1import { DEFAULT_COMPATIBILITY_DATE } from "@alchemy.run/cloudflare-runtime/core/internal/constants";
2import { Credentials } from "@distilled.cloud/cloudflare/Credentials";
3import * as workers from "@distilled.cloud/cloudflare/workers";
4import * as Data from "effect/Data";
5import * as Effect from "effect/Effect";
6import * as HttpClient from "effect/http/HttpClient";
7import { Access } from "./Access.ts";
8import { CloudflareEnvironment } from "./CloudflareEnvironment.ts";
9
10/**
11 * A "remote binding" as understood by the Cloudflare Workers
12 * edge-preview API. We reuse the distilled request schema so the
13 * shape tracks Cloudflare's surface.
14 */
15type EdgeMetadata = NonNullable<
16 NonNullable<workers.CreateScriptEdgePreviewRequest["metadata"]>
17>;
18export type EdgeBinding = NonNullable<
19 NonNullable<EdgeMetadata["bindings"]>
20>[number];
21
22export class EdgeSessionError extends Data.TaggedError("EdgeSessionError")<{
23 readonly message: string;
24 readonly cause?: unknown;
25}> {}
26
27export interface EdgeSessionOptions {
28 /**
29 * Name used as the preview script identifier on Cloudflare's side.
30 * Shows up as the hostname: `{scriptName}.{subdomain}.workers.dev`.
31 */
32 readonly scriptName: string;
33 /** ES-module worker script(s). The first file is the entrypoint. */
34 readonly files: ReadonlyArray<File>;
35 /** Remote bindings attached to the preview worker. */
36 readonly bindings: ReadonlyArray<EdgeBinding>;
37 /** @default "2026-08-31" */
38 readonly compatibilityDate?: string;
39}
40
41/**
42 * Information needed to call a running edge-preview worker. Attach
43 * `headers` (including the `cf-workers-preview-token`) and fetch
44 * `url` or a path under it.
45 */
46export interface EdgeSessionHandle {
47 readonly url: string;
48 readonly headers: Record<string, string>;
49}
50
51const wrap = <A, E, R>(effect: Effect.Effect<A, E, R>, message: string) =>
52 effect.pipe(
53 Effect.mapError((cause) => new EdgeSessionError({ message, cause })),
54 );
55
56/**
57 * Ask Cloudflare for a preview upload token. Some accounts return an
58 * `exchangeUrl` that must be GET'd to swap the session token for a
59 * short-lived upload token; when present we do the swap, falling back
60 * to the original if the exchange fails.
61 */
62const createUploadToken = Effect.gen(function* () {
63 const env = yield* yield* CloudflareEnvironment;
64 const http = yield* HttpClient.HttpClient;
65 const createSubdomainEdgePreviewSession =
66 yield* workers.createSubdomainEdgePreviewSession;
67 const { token, exchangeUrl } = yield* createSubdomainEdgePreviewSession({
68 accountId: env.accountId,
69 });
70 if (!exchangeUrl) return token;
71 const json = yield* http.get(exchangeUrl).pipe(
72 Effect.flatMap((r) => r.json),
73 Effect.timeout(30_000),
74 Effect.catch(() => Effect.succeed(null as unknown)),
75 );
76 if (
77 typeof json === "object" &&
78 json !== null &&
79 "token" in json &&
80 typeof (json as Record<string, unknown>).token === "string"
81 ) {
82 return (json as { token: string }).token;
83 }
84 return token;
85});
86
87const uploadScript = (options: EdgeSessionOptions, uploadToken: string) =>
88 Effect.gen(function* () {
89 const env = yield* yield* CloudflareEnvironment;
90 const createScriptEdgePreview = yield* workers.createScriptEdgePreview;
91 return yield* createScriptEdgePreview({
92 accountId: env.accountId,
93 scriptName: options.scriptName,
94 cfPreviewUploadConfigToken: uploadToken,
95 wranglerSessionConfig: { workersDev: true, minimalMode: true },
96 metadata: {
97 compatibilityDate:
98 options.compatibilityDate ?? DEFAULT_COMPATIBILITY_DATE,
99 bindings: options.bindings as EdgeMetadata["bindings"],
100 mainModule: options.files[0]!.name,
101 },
102 // Distilled's generated schema expects a mutable array; our
103 // public surface keeps `readonly` so callers can pass a frozen
104 // tuple without a cast on their side.
105 files: options.files.slice(),
106 }).pipe(Effect.timeout(30_000));
107 });
108
109const workerHost = (scriptName: string) =>
110 Effect.gen(function* () {
111 const env = yield* yield* CloudflareEnvironment;
112 const { subdomain } = yield* workers.getSubdomain({
113 accountId: env.accountId,
114 });
115 return `${scriptName}.${subdomain}.workers.dev`;
116 });
117
118/**
119 * Upload a short-lived preview worker to Cloudflare's edge and
120 * return its URL plus the headers needed to reach it.
121 *
122 * Preview workers are ephemeral: they live only while a client keeps
123 * sending requests with the returned preview token and expire soon
124 * after. Nothing is deployed or billed.
125 */
126export const createEdgeSession = (
127 options: EdgeSessionOptions,
128): Effect.Effect<
129 EdgeSessionHandle,
130 EdgeSessionError,
131 CloudflareEnvironment | HttpClient.HttpClient | Credentials | Access
132> =>
133 Effect.gen(function* () {
134 const [{ previewToken }, { url, headers }] = yield* Effect.all(
135 [
136 createUploadToken.pipe(Effect.flatMap((t) => uploadScript(options, t))),
137 workerHost(options.scriptName).pipe(
138 Effect.flatMap(
139 Effect.fn(function* (host) {
140 const headers = yield* Access.use((access) =>
141 access.getAccessHeaders(host),
142 );
143 return { url: `https://${host}`, headers };
144 }),
145 ),
146 ),
147 ],
148 { concurrency: "unbounded" },
149 );
150 return {
151 url,
152 headers: { ...headers, "cf-workers-preview-token": previewToken },
153 } satisfies EdgeSessionHandle;
154 }).pipe((e) => wrap(e, "Failed to create edge preview session"));