1/**
2 * Cloudflare dev transport — HOST half.
3 *
4 * Runs in the alchemy / e2e-harness process: opens `cloudflare-runtime`'s
5 * platform proxy (a local workerd instance hosting the configured binding
6 * hooks behind the internal proxy worker) and produces the
7 * {@link NuxtDevPlatform} the framework half injects into `loadNuxt`
8 * overrides — the dev-only nitro plugin (`./plugin.ts`) plus the
9 * `runtimeConfig` connect info. The proxy's entire client state is
10 * `connectInfo: { url, token }` — two plain strings — which is what lets
11 * the nitro dev SSR worker THREAD reconstruct the platform over HTTP
12 * (`platform-proxy/connect`) with live-shared binding state.
13 */
14import { DeployTargetError } from "../../core/index.ts";
15import * as Effect from "effect/Effect";
16import type * as Scope from "effect/Scope";
17import * as NodePath from "node:path";
18import { fileURLToPath } from "node:url";
19import type { NuxtDevPlatform, NuxtDevPlatformContext } from "../Nuxt.ts";
20import { RUNTIME_CONFIG_KEY, type DevConnectInfo } from "./shared.ts";
21
22/**
23 * The runtime-free client subpath of `cloudflare-runtime` the dev plugin is
24 * built on. Resolved HOST-side (this package depends on
25 * `cloudflare-runtime`) to an absolute path the dev worker imports
26 * directly — the worker itself cannot resolve the bare specifier when
27 * `cloudflare-runtime` is only a transitive dependency of the project.
28 */
29export const CLIENT_MODULE_SPECIFIER =
30 "@alchemy.run/cloudflare-runtime/core/platform-proxy/connect";
31
32/** Resolve the absolute path of the shipped dev-only nitro plugin. */
33export const resolveDevPluginPath = (): string => {
34 const packageJson = fileURLToPath(
35 import.meta.resolve("@alchemy.run/frontend-frameworks/package.json"),
36 );
37 return NodePath.join(
38 NodePath.dirname(packageJson),
39 "dist",
40 "nuxt",
41 "dev",
42 "plugin.js",
43 );
44};
45
46/** Resolve {@link CLIENT_MODULE_SPECIFIER} to an absolute file path. */
47export const resolveClientModulePath = (): string =>
48 fileURLToPath(import.meta.resolve(CLIENT_MODULE_SPECIFIER));
49
50/** What the dev platform needs back from an opened proxy. */
51export interface DevProxyHandle {
52 /** Base URL of the proxy workerd instance. */
53 readonly url: string;
54 /** The instance's auth token. */
55 readonly token: string;
56 /** Tear the proxy down. Safe to call multiple times. */
57 readonly dispose: () => Promise<void>;
58}
59
60export interface OpenDevProxyOptions {
61 readonly name: string;
62 readonly compatibilityDate?: string | undefined;
63 readonly compatibilityFlags?: ReadonlyArray<string> | undefined;
64 /** `cloudflare-runtime` binding hooks (opaque through the framework half). */
65 readonly bindings: ReadonlyArray<unknown>;
66 /**
67 * Pre-built `Context<RuntimeServices>` to host the proxy in (opaque
68 * through the framework half). Enables remote()-lowered bindings, which
69 * the proxy's internal local-only layer cannot serve.
70 */
71 readonly services?: unknown;
72}
73
74/** How the platform proxy is opened. A test seam; the default is {@link openPlatformProxy}. */
75export type OpenDevProxy = (
76 options: OpenDevProxyOptions,
77) => Promise<DevProxyHandle>;
78
79/**
80 * The default opener: `cloudflare-runtime`'s `getPlatformProxy` (imported
81 * lazily so production builds never load the runtime machinery). The
82 * instance's `connectInfo` carries everything the dev plugin needs.
83 */
84export const openPlatformProxy: OpenDevProxy = async (options) => {
85 const { getPlatformProxy } =
86 await import("@alchemy.run/cloudflare-runtime/core/platform-proxy");
87 const proxy = await getPlatformProxy({
88 name: options.name,
89 ...(options.compatibilityDate !== undefined
90 ? { compatibilityDate: options.compatibilityDate }
91 : undefined),
92 ...(options.compatibilityFlags !== undefined
93 ? { compatibilityFlags: [...options.compatibilityFlags] }
94 : undefined),
95 bindings: options.bindings as Parameters<
96 typeof getPlatformProxy
97 >[0]["bindings"],
98 ...(options.services !== undefined
99 ? {
100 services: options.services as Parameters<
101 typeof getPlatformProxy
102 >[0]["services"],
103 }
104 : undefined),
105 });
106 return {
107 url: proxy.connectInfo.url,
108 token: proxy.connectInfo.token,
109 dispose: proxy.dispose,
110 };
111};
112
113export interface CloudflareDevPlatformOptions {
114 /** Name of the proxy workerd service. @default "nuxt-dev-platform-proxy" */
115 readonly name?: string | undefined;
116 /** Compatibility date for the binding-proxy workerd instance. */
117 readonly compatibilityDate?: string | undefined;
118 /** Compatibility flags for the binding-proxy workerd instance. */
119 readonly compatibilityFlags?: ReadonlyArray<string> | undefined;
120 /** Override how the proxy is opened. A test seam. */
121 readonly openProxy?: OpenDevProxy | undefined;
122}
123
124/**
125 * Literal env values for the plugin: strings pass through; everything else
126 * (resource bindings) is delivered through the proxy itself.
127 */
128const literalEnv = (
129 env: Record<string, unknown> | undefined,
130): Record<string, string> | undefined => {
131 const out: Record<string, string> = {};
132 for (const [key, value] of Object.entries(env ?? {})) {
133 if (typeof value === "string") {
134 out[key] = value;
135 }
136 }
137 return Object.keys(out).length > 0 ? out : undefined;
138};
139
140/**
141 * The Cloudflare target's dev platform: open the proxy (scoped — closing
142 * the Scope disposes the workerd instance) and hand the framework half the
143 * nitro plugin + `runtimeConfig` connect info to inject.
144 */
145export const makeCloudflareDevPlatform =
146 (options: CloudflareDevPlatformOptions = {}) =>
147 (
148 context: NuxtDevPlatformContext,
149 ): Effect.Effect<NuxtDevPlatform, DeployTargetError, Scope.Scope> =>
150 Effect.gen(function* () {
151 const open = options.openProxy ?? openPlatformProxy;
152 const proxy = yield* Effect.acquireRelease(
153 Effect.tryPromise({
154 try: () =>
155 open({
156 name: options.name ?? "nuxt-dev-platform-proxy",
157 compatibilityDate: options.compatibilityDate,
158 compatibilityFlags: options.compatibilityFlags,
159 bindings: context.bindings ?? [],
160 services: context.services,
161 }),
162 catch: (cause) =>
163 new DeployTargetError({
164 platform: "cloudflare",
165 message: "Failed to open the dev platform proxy",
166 cause,
167 }),
168 }),
169 (handle) => Effect.promise(() => handle.dispose().catch(() => {})),
170 );
171 const clientModule = yield* Effect.try({
172 try: resolveClientModulePath,
173 catch: (cause) =>
174 new DeployTargetError({
175 platform: "cloudflare",
176 message:
177 `Failed to resolve "${CLIENT_MODULE_SPECIFIER}" — the Nuxt dev bridge ` +
178 "requires @alchemy.run/cloudflare-runtime/core (a dependency of this package)",
179 cause,
180 }),
181 });
182 const env = literalEnv(context.env);
183 const connectInfo: DevConnectInfo = {
184 url: proxy.url,
185 token: proxy.token,
186 clientModule,
187 ...(env !== undefined ? { env } : {}),
188 };
189 return {
190 nitroPlugins: [resolveDevPluginPath()],
191 runtimeConfig: { [RUNTIME_CONFIG_KEY]: connectInfo },
192 } satisfies NuxtDevPlatform;
193 });