@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.
- package/README.md +20 -0
- package/package.json +32 -0
- 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
|
+
};
|