@tschk/moonshine-deploy-vercel 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 +262 -0
package/README.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# `@tschk/moonshine-deploy-vercel`
|
|
2
|
+
|
|
3
|
+
Vercel deployment adapter for Moonshine.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { vercelAdapter } from "@tschk/moonshine-deploy-vercel";
|
|
7
|
+
|
|
8
|
+
await vercelAdapter.build(manifest, ".moonshine");
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
- Target runtimes: `node`, `vercel-edge`.
|
|
12
|
+
- Capabilities: `streaming`, `islands`, `edge`.
|
|
13
|
+
- Output: `.vercel/output` with static HTML, function directories, and
|
|
14
|
+
`config.json` route regexes.
|
|
15
|
+
|
|
16
|
+
Limitations: static catch-all routes are not pre-rendered; each non-static
|
|
17
|
+
route becomes a separate function; `revalidation` is not supported.
|
|
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-vercel",
|
|
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,262 @@
|
|
|
1
|
+
import { copyFile, mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, isAbsolute, resolve } from "node:path";
|
|
3
|
+
import { tryServeStatic } from "@tschk/moonshine-server";
|
|
4
|
+
import { reactRenderer, type RenderContext } from "@tschk/moonshine-react";
|
|
5
|
+
import type {
|
|
6
|
+
DeploymentAdapter,
|
|
7
|
+
MoonshineManifest,
|
|
8
|
+
RouteArtifact,
|
|
9
|
+
} from "@tschk/moonshine-framework";
|
|
10
|
+
import type {
|
|
11
|
+
Harness,
|
|
12
|
+
HarnessFactory,
|
|
13
|
+
} from "@tschk/moonshine-adapter-conformance";
|
|
14
|
+
|
|
15
|
+
type VercelRoute = {
|
|
16
|
+
src: string;
|
|
17
|
+
dest: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type VercelConfig = {
|
|
21
|
+
version: 3;
|
|
22
|
+
routes: VercelRoute[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function resolveManifestFiles(
|
|
26
|
+
manifest: MoonshineManifest,
|
|
27
|
+
projectRoot: string,
|
|
28
|
+
): MoonshineManifest {
|
|
29
|
+
return {
|
|
30
|
+
...manifest,
|
|
31
|
+
routes: manifest.routes.map((route) => ({
|
|
32
|
+
...route,
|
|
33
|
+
file: isAbsolute(route.file)
|
|
34
|
+
? route.file
|
|
35
|
+
: resolve(projectRoot, route.file),
|
|
36
|
+
...(route.dataFile && {
|
|
37
|
+
dataFile: isAbsolute(route.dataFile)
|
|
38
|
+
? route.dataFile
|
|
39
|
+
: resolve(projectRoot, route.dataFile),
|
|
40
|
+
}),
|
|
41
|
+
...(route.layouts && {
|
|
42
|
+
layouts: route.layouts.map((layout) =>
|
|
43
|
+
isAbsolute(layout) ? layout : resolve(projectRoot, layout),
|
|
44
|
+
),
|
|
45
|
+
}),
|
|
46
|
+
...(route.middleware && {
|
|
47
|
+
middleware: route.middleware.map((middleware) =>
|
|
48
|
+
isAbsolute(middleware)
|
|
49
|
+
? middleware
|
|
50
|
+
: resolve(projectRoot, middleware),
|
|
51
|
+
),
|
|
52
|
+
}),
|
|
53
|
+
...(route.errorBoundary && {
|
|
54
|
+
errorBoundary: isAbsolute(route.errorBoundary)
|
|
55
|
+
? route.errorBoundary
|
|
56
|
+
: resolve(projectRoot, route.errorBoundary),
|
|
57
|
+
}),
|
|
58
|
+
})),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function copyAssets(
|
|
63
|
+
assets: MoonshineManifest["assets"],
|
|
64
|
+
outDir: string,
|
|
65
|
+
): Promise<void> {
|
|
66
|
+
for (const asset of assets) {
|
|
67
|
+
const source = isAbsolute(asset.file)
|
|
68
|
+
? asset.file
|
|
69
|
+
: resolve(outDir, asset.file);
|
|
70
|
+
const target = resolve(
|
|
71
|
+
outDir,
|
|
72
|
+
(asset.path ?? asset.file).replace(/^\/+/, ""),
|
|
73
|
+
);
|
|
74
|
+
await mkdir(dirname(target), { recursive: true });
|
|
75
|
+
if (source !== target) {
|
|
76
|
+
await copyFile(source, target);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function validateRuntime(
|
|
82
|
+
adapter: DeploymentAdapter,
|
|
83
|
+
manifest: MoonshineManifest,
|
|
84
|
+
): void {
|
|
85
|
+
const errors: string[] = [];
|
|
86
|
+
for (const route of manifest.routes) {
|
|
87
|
+
if (!adapter.runtimes.includes(route.runtime)) {
|
|
88
|
+
errors.push(
|
|
89
|
+
`${adapter.name} adapter does not support route "${route.path}" with runtime "${route.runtime}"; supported runtimes: ${adapter.runtimes.join(", ")}`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (errors.length) throw new Error(errors.join("\n"));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function validateCapabilities(
|
|
97
|
+
adapter: DeploymentAdapter,
|
|
98
|
+
manifest: MoonshineManifest,
|
|
99
|
+
): void {
|
|
100
|
+
for (const capability of manifest.capabilities) {
|
|
101
|
+
if (!adapter.capabilities.includes(capability)) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`${adapter.name} adapter does not support capability "${capability}"; supported capabilities: ${adapter.capabilities.join(", ")}`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function routeToRegex(path: string): string {
|
|
110
|
+
return `^${path
|
|
111
|
+
.replace(/\//g, "\\/")
|
|
112
|
+
.replace(/\*/g, ".*")
|
|
113
|
+
.replace(/:([^/]+?)\?/g, "(?<$1>[^/]*)")
|
|
114
|
+
.replace(/:([^/]+)/g, "(?<$1>[^/]+)")}$`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function staticSlug(path: string): string {
|
|
118
|
+
if (path === "/") return "index";
|
|
119
|
+
return path.replace(/^\/+/, "").replace(/\//g, "-");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function prerenderRoute(route: RouteArtifact): Promise<string> {
|
|
123
|
+
const context: RenderContext = {
|
|
124
|
+
request: new Request(`http://localhost${route.path}`),
|
|
125
|
+
route,
|
|
126
|
+
params: {},
|
|
127
|
+
data: {},
|
|
128
|
+
signal: new AbortController().signal,
|
|
129
|
+
};
|
|
130
|
+
return reactRenderer.prerender(context);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const vercelAdapter: DeploymentAdapter = {
|
|
134
|
+
name: "vercel",
|
|
135
|
+
runtimes: ["node", "vercel-edge"],
|
|
136
|
+
capabilities: ["streaming", "islands", "edge"],
|
|
137
|
+
build: async (manifest, outDir) => {
|
|
138
|
+
validateRuntime(vercelAdapter, manifest);
|
|
139
|
+
validateCapabilities(vercelAdapter, manifest);
|
|
140
|
+
|
|
141
|
+
await mkdir(outDir, { recursive: true });
|
|
142
|
+
const projectRoot = resolve(outDir, "..");
|
|
143
|
+
const resolvedManifest = resolveManifestFiles(manifest, projectRoot);
|
|
144
|
+
|
|
145
|
+
await writeFile(
|
|
146
|
+
resolve(outDir, "manifest.json"),
|
|
147
|
+
JSON.stringify(resolvedManifest, null, 2) + "\n",
|
|
148
|
+
);
|
|
149
|
+
await copyAssets(manifest.assets, outDir);
|
|
150
|
+
|
|
151
|
+
const vercelOutput = resolve(outDir, ".vercel/output");
|
|
152
|
+
const staticDir = resolve(vercelOutput, "static");
|
|
153
|
+
const functionsDir = resolve(vercelOutput, "functions");
|
|
154
|
+
await mkdir(staticDir, { recursive: true });
|
|
155
|
+
await mkdir(functionsDir, { recursive: true });
|
|
156
|
+
|
|
157
|
+
const config: VercelConfig = { version: 3, routes: [] };
|
|
158
|
+
|
|
159
|
+
for (const route of resolvedManifest.routes) {
|
|
160
|
+
if (route.mode === "static" && !route.path.includes("*")) {
|
|
161
|
+
const html = await prerenderRoute(route);
|
|
162
|
+
const slug = staticSlug(route.path);
|
|
163
|
+
await writeFile(resolve(staticDir, `${slug}.html`), html);
|
|
164
|
+
config.routes.push({
|
|
165
|
+
src: routeToRegex(route.path),
|
|
166
|
+
dest: `/${slug}.html`,
|
|
167
|
+
});
|
|
168
|
+
} else {
|
|
169
|
+
const funcDir = resolve(functionsDir, `${route.id}.func`);
|
|
170
|
+
await mkdir(funcDir, { recursive: true });
|
|
171
|
+
|
|
172
|
+
const runtime = route.runtime === "vercel-edge" ? "edge" : "nodejs20.x";
|
|
173
|
+
const vcConfig = { runtime, handler: "index.ts" };
|
|
174
|
+
if (runtime !== "edge") {
|
|
175
|
+
(vcConfig as Record<string, string>).launcherType = "Nodejs";
|
|
176
|
+
}
|
|
177
|
+
await writeFile(
|
|
178
|
+
resolve(funcDir, ".vc-config.json"),
|
|
179
|
+
JSON.stringify(vcConfig, null, 2) + "\n",
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const functionEntry = `import { createRequestHandler } from "@tschk/moonshine-server";
|
|
183
|
+
import { reactRenderer } from "@tschk/moonshine-react";
|
|
184
|
+
import manifest from "../../manifest.json" with { type: "json" };
|
|
185
|
+
|
|
186
|
+
const fetch = createRequestHandler({ manifest, renderer: reactRenderer });
|
|
187
|
+
|
|
188
|
+
export default { fetch };
|
|
189
|
+
`;
|
|
190
|
+
await writeFile(resolve(funcDir, "index.ts"), functionEntry);
|
|
191
|
+
|
|
192
|
+
config.routes.push({
|
|
193
|
+
src: routeToRegex(route.path),
|
|
194
|
+
dest: `/${route.id}`,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
for (const asset of resolvedManifest.assets) {
|
|
200
|
+
const source = isAbsolute(asset.file)
|
|
201
|
+
? asset.file
|
|
202
|
+
: resolve(outDir, asset.file);
|
|
203
|
+
const target = resolve(
|
|
204
|
+
staticDir,
|
|
205
|
+
(asset.path ?? asset.file).replace(/^\/+/, ""),
|
|
206
|
+
);
|
|
207
|
+
await mkdir(dirname(target), { recursive: true });
|
|
208
|
+
if (source !== target) {
|
|
209
|
+
await copyFile(source, target);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
await writeFile(
|
|
214
|
+
resolve(vercelOutput, "manifest.json"),
|
|
215
|
+
JSON.stringify(resolvedManifest, null, 2) + "\n",
|
|
216
|
+
);
|
|
217
|
+
await writeFile(
|
|
218
|
+
resolve(vercelOutput, "config.json"),
|
|
219
|
+
JSON.stringify(config, null, 2) + "\n",
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
const serverEntry = `import { createRequestHandler } from "@tschk/moonshine-server";
|
|
223
|
+
import { reactRenderer } from "@tschk/moonshine-react";
|
|
224
|
+
import { join } from "node:path";
|
|
225
|
+
import manifest from "./manifest.json" with { type: "json" };
|
|
226
|
+
|
|
227
|
+
const fetch = createRequestHandler({
|
|
228
|
+
manifest,
|
|
229
|
+
renderer: reactRenderer,
|
|
230
|
+
staticDir: join(import.meta.dir, ".vercel/output/static"),
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
Bun.serve({ port: Number(process.env.PORT) || 0, fetch });
|
|
234
|
+
`;
|
|
235
|
+
await writeFile(resolve(outDir, "server.ts"), serverEntry);
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
export const vercelHarness: HarnessFactory = (fetch, options) => {
|
|
240
|
+
const server = Bun.serve({
|
|
241
|
+
port: 0,
|
|
242
|
+
fetch: async (request) => {
|
|
243
|
+
const url = new URL(request.url);
|
|
244
|
+
const pathname = url.pathname.replace(/\/+$/, "") || "/";
|
|
245
|
+
if (
|
|
246
|
+
(request.method === "GET" || request.method === "HEAD") &&
|
|
247
|
+
pathname !== "/"
|
|
248
|
+
) {
|
|
249
|
+
const res = await tryServeStatic(options.staticDir, pathname);
|
|
250
|
+
if (res) return res;
|
|
251
|
+
}
|
|
252
|
+
return fetch(request);
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
start: async () => server.url,
|
|
258
|
+
stop: async () => {
|
|
259
|
+
server.stop(true);
|
|
260
|
+
},
|
|
261
|
+
} as Harness;
|
|
262
|
+
};
|