@tschk/moonshine-deploy-cloudflare 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +20 -0
  2. package/package.json +32 -0
  3. package/src/index.ts +214 -0
package/README.md ADDED
@@ -0,0 +1,20 @@
1
+ # `@tschk/moonshine-deploy-cloudflare`
2
+
3
+ Cloudflare Workers deployment adapter for Moonshine.
4
+
5
+ ```ts
6
+ import { cloudflareAdapter } from "@tschk/moonshine-deploy-cloudflare";
7
+
8
+ await cloudflareAdapter.build(manifest, ".moonshine");
9
+ ```
10
+
11
+ - Target runtime: `cloudflare`.
12
+ - Capabilities: `streaming`, `islands`, `edge`, `revalidation`.
13
+ - Output: `server.ts` worker that uses the Cloudflare Cache API and an `ASSETS`
14
+ binding for static files.
15
+
16
+ Limitations: requires an `ASSETS` binding for static files; worker size and
17
+ Cache API limits apply.
18
+
19
+ See [docs/DEPLOYMENT.md](../../docs/DEPLOYMENT.md) and
20
+ [docs/MANIFEST.md](../../docs/MANIFEST.md).
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@tschk/moonshine-deploy-cloudflare",
3
+ "version": "0.3.1",
4
+ "license": "ISC",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "scripts": {
12
+ "format": "bunx prettier --check .",
13
+ "lint": "bunx oxlint .",
14
+ "typecheck": "bunx tsc --noEmit",
15
+ "test": "bun test"
16
+ },
17
+ "dependencies": {
18
+ "@tschk/moonshine-framework": "^0.3.1",
19
+ "@tschk/moonshine-server": "^0.3.1",
20
+ "@tschk/moonshine-router": "^0.3.1",
21
+ "@tschk/moonshine-compiler": "^0.3.1",
22
+ "@tschk/moonshine-react": "^0.3.1"
23
+ },
24
+ "devDependencies": {
25
+ "@types/bun": "1.3.14",
26
+ "@tschk/moonshine-adapter-conformance": "^0.3.1",
27
+ "typescript": "7.0.2"
28
+ },
29
+ "files": [
30
+ "src"
31
+ ]
32
+ }
package/src/index.ts ADDED
@@ -0,0 +1,214 @@
1
+ import { copyFile, mkdir, writeFile } from "node:fs/promises";
2
+ import { dirname, isAbsolute, resolve } from "node:path";
3
+ import { createRequestHandler, tryServeStatic } from "@tschk/moonshine-server";
4
+ import { matchRoutes, createRouteGraph } from "@tschk/moonshine-router";
5
+ import { reactRenderer } from "@tschk/moonshine-react";
6
+ import type {
7
+ DeploymentAdapter,
8
+ MoonshineManifest,
9
+ } from "@tschk/moonshine-framework";
10
+ import type {
11
+ Harness,
12
+ HarnessFactory,
13
+ } from "@tschk/moonshine-adapter-conformance";
14
+
15
+ export type CloudflareEnv = {
16
+ ASSETS?: { fetch: (request: Request) => Promise<Response> };
17
+ };
18
+
19
+ export type CloudflareContext = {
20
+ waitUntil: (promise: Promise<unknown>) => void;
21
+ };
22
+
23
+ export async function cloudflareFetch(
24
+ request: Request,
25
+ env: CloudflareEnv,
26
+ ctx: CloudflareContext,
27
+ manifest: MoonshineManifest,
28
+ ): Promise<Response> {
29
+ const url = new URL(request.url);
30
+ const pathname = url.pathname.replace(/\/+$/, "") || "/";
31
+
32
+ if (
33
+ env.ASSETS &&
34
+ (request.method === "GET" || request.method === "HEAD") &&
35
+ pathname !== "/"
36
+ ) {
37
+ const assetRes = await env.ASSETS.fetch(request);
38
+ if (assetRes.status !== 404) return assetRes;
39
+ }
40
+
41
+ const graph = createRouteGraph(manifest.routes);
42
+ const match = matchRoutes(graph, pathname);
43
+ const handler = createRequestHandler({ manifest, renderer: reactRenderer });
44
+ const cache =
45
+ typeof caches !== "undefined"
46
+ ? (caches as { default?: Cache }).default
47
+ : undefined;
48
+
49
+ if (match && match.route.cache && cache) {
50
+ const cached = await cache.match(request);
51
+ if (cached) return cached;
52
+ const response = await handler(request);
53
+ if (
54
+ response.status >= 200 &&
55
+ response.status < 400 &&
56
+ (request.method === "GET" || request.method === "HEAD")
57
+ ) {
58
+ ctx.waitUntil(
59
+ (async () => {
60
+ try {
61
+ await cache.put(request.clone(), response.clone());
62
+ } catch {}
63
+ })(),
64
+ );
65
+ }
66
+ return response;
67
+ }
68
+
69
+ return handler(request);
70
+ }
71
+
72
+ export default cloudflareFetch;
73
+
74
+ function resolveManifestFiles(
75
+ manifest: MoonshineManifest,
76
+ projectRoot: string,
77
+ ): MoonshineManifest {
78
+ return {
79
+ ...manifest,
80
+ routes: manifest.routes.map((route) => ({
81
+ ...route,
82
+ file: isAbsolute(route.file)
83
+ ? route.file
84
+ : resolve(projectRoot, route.file),
85
+ ...(route.dataFile && {
86
+ dataFile: isAbsolute(route.dataFile)
87
+ ? route.dataFile
88
+ : resolve(projectRoot, route.dataFile),
89
+ }),
90
+ ...(route.layouts && {
91
+ layouts: route.layouts.map((layout) =>
92
+ isAbsolute(layout) ? layout : resolve(projectRoot, layout),
93
+ ),
94
+ }),
95
+ ...(route.middleware && {
96
+ middleware: route.middleware.map((middleware) =>
97
+ isAbsolute(middleware)
98
+ ? middleware
99
+ : resolve(projectRoot, middleware),
100
+ ),
101
+ }),
102
+ ...(route.errorBoundary && {
103
+ errorBoundary: isAbsolute(route.errorBoundary)
104
+ ? route.errorBoundary
105
+ : resolve(projectRoot, route.errorBoundary),
106
+ }),
107
+ })),
108
+ };
109
+ }
110
+
111
+ async function copyAssets(
112
+ assets: MoonshineManifest["assets"],
113
+ outDir: string,
114
+ ): Promise<void> {
115
+ for (const asset of assets) {
116
+ const source = isAbsolute(asset.file)
117
+ ? asset.file
118
+ : resolve(outDir, asset.file);
119
+ const target = resolve(
120
+ outDir,
121
+ (asset.path ?? asset.file).replace(/^\/+/, ""),
122
+ );
123
+ await mkdir(dirname(target), { recursive: true });
124
+ if (source !== target) {
125
+ await copyFile(source, target);
126
+ }
127
+ }
128
+ }
129
+
130
+ function validateRuntime(
131
+ adapter: DeploymentAdapter,
132
+ manifest: MoonshineManifest,
133
+ ): void {
134
+ const errors: string[] = [];
135
+ for (const route of manifest.routes) {
136
+ if (!adapter.runtimes.includes(route.runtime)) {
137
+ errors.push(
138
+ `${adapter.name} adapter does not support route "${route.path}" with runtime "${route.runtime}"; supported runtimes: ${adapter.runtimes.join(", ")}`,
139
+ );
140
+ }
141
+ }
142
+ if (errors.length) throw new Error(errors.join("\n"));
143
+ }
144
+
145
+ function validateCapabilities(
146
+ adapter: DeploymentAdapter,
147
+ manifest: MoonshineManifest,
148
+ ): void {
149
+ for (const capability of manifest.capabilities) {
150
+ if (!adapter.capabilities.includes(capability)) {
151
+ throw new Error(
152
+ `${adapter.name} adapter does not support capability "${capability}"; supported capabilities: ${adapter.capabilities.join(", ")}`,
153
+ );
154
+ }
155
+ }
156
+ }
157
+
158
+ export const cloudflareAdapter: DeploymentAdapter = {
159
+ name: "cloudflare",
160
+ runtimes: ["cloudflare"],
161
+ capabilities: ["streaming", "islands", "edge", "revalidation"],
162
+ build: async (manifest, outDir) => {
163
+ validateRuntime(cloudflareAdapter, manifest);
164
+ validateCapabilities(cloudflareAdapter, manifest);
165
+
166
+ await mkdir(outDir, { recursive: true });
167
+ const projectRoot = resolve(outDir, "..");
168
+ const resolvedManifest = resolveManifestFiles(manifest, projectRoot);
169
+
170
+ await writeFile(
171
+ resolve(outDir, "manifest.json"),
172
+ JSON.stringify(resolvedManifest, null, 2) + "\n",
173
+ );
174
+ await copyAssets(manifest.assets, outDir);
175
+
176
+ const serverEntry = `import cloudflareFetch from "@tschk/moonshine-deploy-cloudflare";
177
+ import manifest from "./manifest.json" with { type: "json" };
178
+
179
+ type Env = { ASSETS?: { fetch: (request: Request) => Promise<Response> } };
180
+
181
+ export default {
182
+ async fetch(request: Request, env: Env, ctx: { waitUntil: (promise: Promise<unknown>) => void }) {
183
+ return cloudflareFetch(request, env, ctx, manifest);
184
+ },
185
+ };
186
+ `;
187
+ await writeFile(resolve(outDir, "server.ts"), serverEntry);
188
+ },
189
+ };
190
+
191
+ export const cloudflareHarness: HarnessFactory = (fetch, options) => {
192
+ const server = Bun.serve({
193
+ port: 0,
194
+ fetch: async (request) => {
195
+ const url = new URL(request.url);
196
+ const pathname = url.pathname.replace(/\/+$/, "") || "/";
197
+ if (
198
+ (request.method === "GET" || request.method === "HEAD") &&
199
+ pathname !== "/"
200
+ ) {
201
+ const res = await tryServeStatic(options.staticDir, pathname);
202
+ if (res) return res;
203
+ }
204
+ return fetch(request);
205
+ },
206
+ });
207
+
208
+ return {
209
+ start: async () => server.url,
210
+ stop: async () => {
211
+ server.stop(true);
212
+ },
213
+ } as Harness;
214
+ };