1// Alchemy modifications are licensed under Apache-2.0.
2// This file includes third-party code; see /THIRD_PARTY_LICENSES.md.
3/**
4 * Wrangler-free fork of waku's cloudflare adapter
5 * (upstream `packages/waku/src/adapters/cloudflare.ts` @ waku 1.0.0-beta.7).
6 *
7 * The ONLY functional change: `buildEnhancers` no longer references waku's
8 * cloudflare build-enhancer module, which is the sole code path in waku that
9 * writes wrangler.jsonc / dist/server/wrangler.json /
10 * .wrangler/deploy/config.json. Everything else (Hono app, cloudflare:workers
11 * env access, SSG via the preview server) is behavior-identical.
12 *
13 * Built entirely on public `waku/adapter-builders` + `waku/internals` exports.
14 *
15 * This module is bundled into the Worker by waku's vite pipeline (selected via
16 * the in-memory `Config.unstable_adapter`); it is not Node service code.
17 */
18import type { MiddlewareHandler } from "hono";
19import { bodyLimit } from "hono/body-limit";
20import { Hono } from "hono/tiny";
21import {
22 unstable_createServerEntryAdapter as createServerEntryAdapter,
23 unstable_startPreviewServer as startPreviewServer,
24} from "waku/adapter-builders";
25import {
26 unstable_constants as constants,
27 unstable_consumeMultiplexedStream as consumeMultiplexedStream,
28 unstable_honoMiddleware as honoMiddleware,
29 unstable_produceMultiplexedStream as produceMultiplexedStream,
30} from "waku/internals";
31
32const { DIST_PUBLIC } = constants;
33const { rscMiddleware, middlewareRunner } = honoMiddleware;
34
35const DEFAULT_BODY_LIMIT_MAX_SIZE = 100 * 1024 * 1024;
36
37const DO_NOT_BUNDLE = "";
38
39const PRUNABLE_KEY_PREFIX = "\0__prunable__/";
40
41const emptyStream = () =>
42 new ReadableStream<Uint8Array>({
43 start(controller) {
44 controller.close();
45 },
46 });
47
48function isProductionWorker(req: Request): boolean {
49 // This header seems to only be set for production cloudflare workers
50 return !!req.headers.get("cf-visitor");
51}
52
53function isLoopbackRequest(req: Request): boolean {
54 const { hostname } = new URL(req.url);
55 return (
56 hostname === "localhost" ||
57 hostname === "127.0.0.1" ||
58 hostname === "::1" ||
59 hostname === "[::1]"
60 );
61}
62
63function removeGzipEncoding(res: Response): Response {
64 const contentType = res.headers.get("content-type");
65 if (
66 !contentType ||
67 contentType.includes("text/html") ||
68 contentType.includes("text/plain")
69 ) {
70 const headers = new Headers(res.headers);
71 headers.set("content-encoding", "Identity");
72 return new Response(res.body, {
73 status: res.status,
74 statusText: res.statusText,
75 headers,
76 });
77 }
78 return res;
79}
80
81/** Options accepted by the adapter (identical to upstream's). */
82export interface CloudflareAdapterOptions {
83 static?: boolean;
84 handlers?: Record<string, unknown>;
85 assetsDir?: string;
86 bodyLimit?: Parameters<typeof bodyLimit>[0] | false;
87 middlewareFns?: Array<(opts: { app: Hono }) => MiddlewareHandler>;
88 middlewareModules?: Record<string, () => Promise<unknown>>;
89 internalPathToBuildStaticFiles?: string;
90}
91
92/**
93 * Annotated via an instantiation expression over the public
94 * `createServerEntryAdapter` so the emitted declaration never references
95 * waku's non-exported `lib/types` module (TS2883).
96 */
97const cloudflareAdapter: ReturnType<
98 typeof createServerEntryAdapter<CloudflareAdapterOptions | undefined>
99> = createServerEntryAdapter(
100 (
101 { processRequest, processBuild, setAllEnv, config, notFoundHtml },
102 options?: CloudflareAdapterOptions,
103 ) => {
104 const {
105 bodyLimit: bodyLimitOptions,
106 middlewareFns = [],
107 middlewareModules = {},
108 internalPathToBuildStaticFiles = "__waku_internal_build_static_files",
109 } = options || {};
110 const app = new Hono();
111 app.notFound((c) => {
112 if (notFoundHtml) {
113 return c.html(notFoundHtml, 404);
114 }
115 return c.text("404 Not Found", 404);
116 });
117 if (bodyLimitOptions !== false) {
118 app.use(
119 bodyLimit(bodyLimitOptions ?? { maxSize: DEFAULT_BODY_LIMIT_MAX_SIZE }),
120 );
121 }
122 for (const middlewareFn of middlewareFns) {
123 app.use(middlewareFn({ app }));
124 }
125 app.use(middlewareRunner(middlewareModules as never, { app }));
126 app.use(rscMiddleware({ processRequest }));
127 const buildOptions = {
128 srcDir: config.srcDir,
129 distDir: config.distDir,
130 DIST_PUBLIC,
131 serverless: !options?.static,
132 };
133
134 const buildBody = () =>
135 produceMultiplexedStream(async (emitFile) => {
136 await processBuild({
137 emitFile,
138 unstable_registerPrunableFile: (srcPath) =>
139 emitFile(PRUNABLE_KEY_PREFIX + srcPath, emptyStream()),
140 });
141 });
142
143 const fetchFn = async (req: Request) => {
144 if (
145 new URL(req.url).pathname === `/${internalPathToBuildStaticFiles}` &&
146 isLoopbackRequest(req) &&
147 !isProductionWorker(req)
148 ) {
149 return new Response(buildBody());
150 }
151 let cloudflareContext;
152 try {
153 cloudflareContext = await import(
154 /* @vite-ignore */ DO_NOT_BUNDLE + "cloudflare:workers"
155 );
156 } catch {
157 // Not in a Cloudflare environment
158 }
159 let res: Response | Promise<Response>;
160 if (cloudflareContext) {
161 const { env, waitUntil, passThroughOnException } = cloudflareContext;
162 res = app.fetch(req, env, {
163 waitUntil,
164 passThroughOnException,
165 props: undefined,
166 });
167 } else {
168 res = app.fetch(req);
169 }
170 // Workaround https://github.com/cloudflare/workers-sdk/issues/6577
171 // NOTE: must stay the literal `import.meta.env?.PROD` expression — the
172 // bundler replaces it statically; any other access pattern throws
173 // "[module runner] Dynamic access of import.meta.env" in dev.
174 if (import.meta.env?.PROD && !isProductionWorker(req)) {
175 if ("then" in res) {
176 res = res.then((res) => removeGzipEncoding(res));
177 } else {
178 res = removeGzipEncoding(res);
179 }
180 }
181 return res;
182 };
183
184 return {
185 fetch: fetchFn,
186 build: async (utils) => {
187 const server = await startPreviewServer();
188 // Fallback middleware for the case without a Cloudflare vite plugin
189 // preview server: streams the SSG output directly from Node.
190 server.middlewares.use(async (_req, res, next) => {
191 try {
192 const { Readable } = await import(
193 /* @vite-ignore */ DO_NOT_BUNDLE + "node:stream"
194 );
195 Readable.fromWeb(buildBody() as never).pipe(res as never);
196 } catch (err) {
197 next(err);
198 }
199 });
200 const response = await fetch(
201 server.baseUrl + internalPathToBuildStaticFiles,
202 {
203 headers: { connection: "close" },
204 },
205 );
206 await consumeMultiplexedStream(response.body!, async (key, stream) => {
207 if (key.startsWith(PRUNABLE_KEY_PREFIX)) {
208 utils.unstable_registerPrunableFile(
209 key.slice(PRUNABLE_KEY_PREFIX.length),
210 );
211 return;
212 }
213 await utils.emitFile(key, stream);
214 });
215 // https://github.com/nodejs/node/issues/56645
216 await new Promise((resolve) => setTimeout(resolve, 100));
217 await server.close();
218 },
219 buildOptions,
220 // Upstream lists its build-enhancer module here — the ONLY
221 // wrangler-config writer in waku. Dropped: no wrangler files, ever.
222 buildEnhancers: [],
223 defaultExport: {
224 ...options?.handlers,
225 fetch(req: Request, env: Record<string, unknown>) {
226 setAllEnv(env);
227 return fetchFn(req);
228 },
229 },
230 };
231 },
232);
233
234export default cloudflareAdapter;