| 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 | */ |
| 28 | import type * as ConnectClient from "@alchemy.run/cloudflare-runtime/core/platform-proxy/connect"; |
| 29 | import { defineNitroPlugin, useRuntimeConfig } from "nitropack/runtime"; |
| 30 | import { pathToFileURL } from "node:url"; |
| 31 | import { RUNTIME_CONFIG_KEY, type DevConnectInfo } from "./shared.ts"; |
| 32 | |
| 33 | /** The runtime-free client module's shape (`platform-proxy/connect`). */ |
| 34 | type ConnectModule = typeof ConnectClient; |
| 35 | |
| 36 | interface 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. */ |
| 43 | interface 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 | |
| 55 | const 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 | */ |
| 68 | const 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 | |
| 97 | export 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 | }); |