1/**
2 * Dev-only nitro plugin: serves the `cloudflare_module` preset's runtime
3 * contract inside nitro's dev SSR worker thread, wrangler-free.
4 *
5 * Injected by the framework host (`Nuxt.dev()` via the Cloudflare target's
6 * dev platform, `./host.ts`) through `overrides.nitro.plugins`; a project
7 * running under its own `nuxi dev` never loads it. On every request it sets
8 * (the same contract `nitro-cloudflare-dev` provides, and production serves
9 * natively):
10 *
11 * - `event.context.cf` — the static `request.cf` mock;
12 * - `event.context.waitUntil` — accepted and dropped (long-lived dev
13 * process: background work simply runs);
14 * - `event.context.cloudflare = { request, env, context }` — `env` is the
15 * live proxied environment (bindings round-trip to the host's workerd
16 * instance over HTTP, so state is SHARED with the host proxy), `request`
17 * is a synthesized `Request` carrying `cf`, `context` is the no-op
18 * `ExecutionContext` mock.
19 *
20 * The platform is reconstructed by `cloudflare-runtime`'s runtime-free
21 * client (`platform-proxy/connect`, imported by the absolute path the host
22 * resolved into the connect info). The connection is established lazily on
23 * the first request and re-created after a failure, so a nitro dev reload
24 * (worker-thread replacement) reconnects on its own with binding state
25 * intact. When the host proxy is gone, requests fail fast with a
26 * descriptive cause instead of hanging.
27 */
28import type * as ConnectClient from "@alchemy.run/cloudflare-runtime/core/platform-proxy/connect";
29import { defineNitroPlugin, useRuntimeConfig } from "nitropack/runtime";
30import { pathToFileURL } from "node:url";
31import { RUNTIME_CONFIG_KEY, type DevConnectInfo } from "./shared.ts";
32
33/** The runtime-free client module's shape (`platform-proxy/connect`). */
34type ConnectModule = typeof ConnectClient;
35
36interface ConnectedPlatform {
37 readonly module: ConnectModule;
38 readonly env: Record<string, unknown>;
39 readonly cf: Record<string, unknown>;
40}
41
42/** The structural slice of the h3 event the bridge touches. */
43interface DevEventSlice {
44 context: Record<string, unknown>;
45 node?: {
46 req?: {
47 url?: string | undefined;
48 method?: string | undefined;
49 headers?: Record<string, string | Array<string> | undefined>;
50 socket?: { encrypted?: boolean | undefined } | undefined;
51 };
52 };
53}
54
55const isConnectInfo = (value: unknown): value is DevConnectInfo =>
56 typeof value === "object" &&
57 value !== null &&
58 typeof (value as DevConnectInfo).url === "string" &&
59 typeof (value as DevConnectInfo).token === "string" &&
60 typeof (value as DevConnectInfo).clientModule === "string";
61
62/**
63 * Synthesize the per-event `Request` (what the preset's runtime hands the
64 * worker). Body is intentionally omitted: the node request stream belongs
65 * to h3's own body parsing — read bodies through h3 (`readBody`), not
66 * `context.cloudflare.request`.
67 */
68const synthesizeRequest = (
69 event: DevEventSlice,
70 cf: Record<string, unknown>,
71): Request | undefined => {
72 try {
73 const req = event.node?.req;
74 if (req === undefined) return undefined;
75 const host =
76 (typeof req.headers?.host === "string" && req.headers.host) ||
77 "localhost";
78 const protocol = req.socket?.encrypted === true ? "https" : "http";
79 const url = new URL(req.url ?? "/", `${protocol}://${host}`);
80 const headers = new Headers();
81 for (const [name, value] of Object.entries(req.headers ?? {})) {
82 if (typeof value === "string") headers.set(name, value);
83 else if (Array.isArray(value))
84 for (const entry of value) headers.append(name, entry);
85 }
86 const request = new Request(url.toString(), {
87 method: req.method ?? "GET",
88 headers,
89 });
90 Object.defineProperty(request, "cf", { value: cf, configurable: true });
91 return request;
92 } catch {
93 return undefined;
94 }
95};
96
97export default defineNitroPlugin((nitroApp) => {
98 const config = useRuntimeConfig() as Record<string, unknown>;
99 const info = config[RUNTIME_CONFIG_KEY];
100 if (!isConnectInfo(info)) {
101 // Not running under the distilled dev host — stay inert.
102 return;
103 }
104 let connected: Promise<ConnectedPlatform> | undefined;
105 const connect = () =>
106 (connected ??= (async () => {
107 const module = (await import(
108 /* @vite-ignore */ pathToFileURL(info.clientModule).href
109 )) as ConnectModule;
110 const platform = await module.connect({
111 url: info.url,
112 token: info.token,
113 });
114 // Literal env values overlay the proxied bindings (a same-named
115 // literal always wins).
116 const env: Record<string, unknown> = { ...platform.env };
117 for (const [name, value] of Object.entries(info.env ?? {})) {
118 env[name] = value;
119 }
120 return { module, env, cf: platform.cf };
121 })().catch((error: unknown) => {
122 // Reset so the next request retries (host proxy restarts, races).
123 connected = undefined;
124 throw error;
125 }));
126
127 nitroApp.hooks.hook("request", async (event) => {
128 const slice = event as unknown as DevEventSlice;
129 const platform = await connect();
130 const context = new platform.module.ExecutionContext();
131 slice.context.cf = platform.cf;
132 slice.context.waitUntil = context.waitUntil.bind(context);
133 slice.context.cloudflare = {
134 request: synthesizeRequest(slice, platform.cf),
135 env: platform.env,
136 context,
137 };
138 });
139});