@tschk/moonshine-deploy-vercel 0.4.0 → 0.4.4

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 +14 -0
  2. package/package.json +15 -8
  3. package/src/index.ts +204 -116
package/README.md CHANGED
@@ -18,3 +18,17 @@ route becomes a separate function; `revalidation` is not supported.
18
18
 
19
19
  See [docs/DEPLOYMENT.md](../../docs/DEPLOYMENT.md) and
20
20
  [docs/MANIFEST.md](../../docs/MANIFEST.md).
21
+
22
+ Native manifests support only the Node runtime. The adapter bundles one standalone
23
+ Node 22 function (`index.mjs`) containing the native renderer and route modules.
24
+ Build Output filesystem routing serves public client assets first, then sends
25
+ requests through the complete loader/action/middleware pipeline, including routes
26
+ marked static. No React or Solid runtime is included. For native builds, only files within the build's `public` directory can enter
27
+ Vercel static output. Legacy explicit external asset registrations remain supported,
28
+ while the build's `dist` directory and registered server entry are excluded.
29
+
30
+ Native Edge, request context, islands/hydration, and cache/revalidation options are
31
+ rejected. Local acceptance runs the relocated function under actual Node HTTP with
32
+ a small filesystem-routing harness; this does not emulate Vercel infrastructure
33
+ or establish a public deployment. Legacy rendering requires the optional
34
+ `@tschk/moonshine-react` peer.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tschk/moonshine-deploy-vercel",
3
- "version": "0.4.0",
3
+ "version": "0.4.4",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -15,18 +15,25 @@
15
15
  "test": "bun test"
16
16
  },
17
17
  "dependencies": {
18
- "@tschk/moonshine-compiler": "^0.3.6",
19
- "@tschk/moonshine-framework": "^0.3.6",
20
- "@tschk/moonshine-react": "^0.3.6",
21
- "@tschk/moonshine-router": "^0.3.6",
22
- "@tschk/moonshine-server": "^0.3.6"
18
+ "@tschk/moonshine-framework": "^0.4.4",
19
+ "@tschk/moonshine-router": "^0.4.4",
20
+ "@tschk/moonshine-server": "^0.4.4",
21
+ "@tschk/moonshine-deploy-node": "^0.4.4"
23
22
  },
24
23
  "devDependencies": {
25
24
  "@types/bun": "1.3.14",
26
25
  "typescript": "7.0.2",
27
- "@tschk/moonshine-adapter-conformance": "^0.3.6"
26
+ "@tschk/moonshine-adapter-conformance": "^0.4.4"
28
27
  },
29
28
  "files": [
30
29
  "src"
31
- ]
30
+ ],
31
+ "peerDependencies": {
32
+ "@tschk/moonshine-react": "^0.4.4"
33
+ },
34
+ "peerDependenciesMeta": {
35
+ "@tschk/moonshine-react": {
36
+ "optional": true
37
+ }
38
+ }
32
39
  }
package/src/index.ts CHANGED
@@ -1,13 +1,19 @@
1
- import { copyFile, mkdir, writeFile } from "node:fs/promises";
2
- import { dirname, isAbsolute, resolve } from "node:path";
1
+ import { copyFile, mkdir, realpath, rm, writeFile } from "node:fs/promises";
2
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
3
+ import { fileURLToPath } from "node:url";
3
4
  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
- Harness,
10
- HarnessFactory,
5
+
6
+ import {
7
+ type DeploymentAdapter,
8
+ type MoonshineManifest,
9
+ type RenderContext,
10
+ type RouteArtifact,
11
+ type Harness,
12
+ type HarnessFactory,
13
+ resolveManifestFiles,
14
+ copyAssets,
15
+ validateRuntime,
16
+ validateCapabilities,
11
17
  } from "@tschk/moonshine-framework";
12
18
 
13
19
  type VercelRoute = {
@@ -20,92 +26,6 @@ type VercelConfig = {
20
26
  routes: VercelRoute[];
21
27
  };
22
28
 
23
- function resolveManifestFiles(
24
- manifest: MoonshineManifest,
25
- projectRoot: string,
26
- ): MoonshineManifest {
27
- return {
28
- ...manifest,
29
- routes: manifest.routes.map((route) => ({
30
- ...route,
31
- file: isAbsolute(route.file)
32
- ? route.file
33
- : resolve(projectRoot, route.file),
34
- ...(route.dataFile && {
35
- dataFile: isAbsolute(route.dataFile)
36
- ? route.dataFile
37
- : resolve(projectRoot, route.dataFile),
38
- }),
39
- ...(route.layouts && {
40
- layouts: route.layouts.map((layout) =>
41
- isAbsolute(layout) ? layout : resolve(projectRoot, layout),
42
- ),
43
- }),
44
- ...(route.middleware && {
45
- middleware: route.middleware.map((middleware) =>
46
- isAbsolute(middleware)
47
- ? middleware
48
- : resolve(projectRoot, middleware),
49
- ),
50
- }),
51
- ...(route.errorBoundary && {
52
- errorBoundary: isAbsolute(route.errorBoundary)
53
- ? route.errorBoundary
54
- : resolve(projectRoot, route.errorBoundary),
55
- }),
56
- })),
57
- };
58
- }
59
-
60
- async function copyAssets(
61
- assets: MoonshineManifest["assets"],
62
- outDir: string,
63
- ): Promise<void> {
64
- await Promise.all(
65
- assets.map(async (asset) => {
66
- const source = isAbsolute(asset.file)
67
- ? asset.file
68
- : resolve(outDir, asset.file);
69
- const target = resolve(
70
- outDir,
71
- (asset.path ?? asset.file).replace(/^\/+/, ""),
72
- );
73
- await mkdir(dirname(target), { recursive: true });
74
- if (source !== target) {
75
- await copyFile(source, target);
76
- }
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
29
  function routeToRegex(path: string): string {
110
30
  return `^${path
111
31
  .replace(/\//g, "\\/")
@@ -132,7 +52,7 @@ async function mapConcurrent<T, R>(
132
52
  fn: (item: T) => Promise<R>,
133
53
  concurrency: number,
134
54
  ): Promise<R[]> {
135
- const results = new Array<R>(items.length);
55
+ const results: R[] = Array.from({ length: items.length });
136
56
  const errors: unknown[] = [];
137
57
  let index = 0;
138
58
 
@@ -163,18 +83,189 @@ async function prerenderRoute(route: RouteArtifact): Promise<string> {
163
83
  data: {},
164
84
  signal: new AbortController().signal,
165
85
  };
86
+ const { reactRenderer } = await import("@tschk/moonshine-react");
166
87
  return reactRenderer.prerender(context);
167
88
  }
168
89
 
90
+ function validateAssetPath(path: string): void {
91
+ let decoded = path;
92
+ try {
93
+ decoded = decodeURIComponent(path);
94
+ } catch {
95
+ /* Literal percent filenames are valid. */
96
+ }
97
+ for (const value of [path, decoded]) {
98
+ if (
99
+ !value.startsWith("/") ||
100
+ value.includes("\\") ||
101
+ Array.from(value).some(
102
+ (char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127,
103
+ ) ||
104
+ value.split("/").includes("..")
105
+ )
106
+ throw new Error(`Invalid public asset path: ${path}`);
107
+ }
108
+ }
109
+
110
+ /** Native output exposes only public files; legacy registries exclude server bundles. */
111
+ async function copyPublicAssets(
112
+ manifest: MoonshineManifest,
113
+ outDir: string,
114
+ staticDir: string,
115
+ ) {
116
+ await Promise.all(
117
+ manifest.assets.map(async (asset) => {
118
+ const source = await realpath(
119
+ isAbsolute(asset.file) ? asset.file : resolve(outDir, asset.file),
120
+ );
121
+ const directory = resolve(
122
+ outDir,
123
+ manifest.renderer === "native" ? "public" : "dist",
124
+ );
125
+ const canonicalDirectory = await realpath(directory).catch(
126
+ () => directory,
127
+ );
128
+ const sourcePath = relative(canonicalDirectory, source);
129
+ const withinDirectory =
130
+ sourcePath !== ".." &&
131
+ !sourcePath.startsWith(`..${sep}`) &&
132
+ !isAbsolute(sourcePath);
133
+ if (manifest.renderer === "native" ? !withinDirectory : withinDirectory)
134
+ return;
135
+ if (manifest.renderer !== "native" && manifest.entries.server) {
136
+ const entry = resolve(outDir, manifest.entries.server);
137
+ if (source === (await realpath(entry).catch(() => entry))) return;
138
+ }
139
+ validateAssetPath(asset.path);
140
+ const publicDirectory = resolve(outDir, "public");
141
+ const canonicalPublic = await realpath(publicDirectory).catch(
142
+ () => publicDirectory,
143
+ );
144
+ const publicPath = relative(canonicalPublic, source);
145
+ const withinPublic =
146
+ publicPath !== ".." &&
147
+ !publicPath.startsWith(`..${sep}`) &&
148
+ !isAbsolute(publicPath);
149
+ const canonicalUrl =
150
+ "/" + publicPath.split(sep).map(encodeURIComponent).join("/");
151
+ const preservePublicFilename =
152
+ withinPublic &&
153
+ (manifest.renderer === "native" || asset.path === canonicalUrl);
154
+ const target = resolve(
155
+ staticDir,
156
+ preservePublicFilename ? publicPath : asset.path.replace(/^\/+/, ""),
157
+ );
158
+ const targetPath = relative(staticDir, target);
159
+ if (
160
+ !targetPath ||
161
+ targetPath === ".." ||
162
+ targetPath.startsWith(`..${sep}`) ||
163
+ isAbsolute(targetPath)
164
+ )
165
+ throw new Error(`Invalid public asset path: ${asset.path}`);
166
+ await mkdir(dirname(target), { recursive: true });
167
+ if (source !== target) await copyFile(source, target);
168
+ }),
169
+ );
170
+ }
171
+
172
+ async function buildNativeFunction(
173
+ manifest: MoonshineManifest,
174
+ outDir: string,
175
+ ) {
176
+ const output = resolve(outDir, ".vercel/output");
177
+ const funcDir = resolve(output, "functions/moonshine.func");
178
+ const staticDir = resolve(output, "static");
179
+ await mkdir(funcDir, { recursive: true });
180
+ await mkdir(staticDir, { recursive: true });
181
+ await writeFile(
182
+ resolve(outDir, "manifest.json"),
183
+ JSON.stringify(manifest) + "\n",
184
+ );
185
+ await copyPublicAssets(manifest, outDir, staticDir);
186
+ const entryPath = resolve(outDir, "vercel-native.ts");
187
+ await writeFile(
188
+ entryPath,
189
+ `import { createRequestHandler } from "@tschk/moonshine-server";
190
+ import { createNodeHandler } from ${JSON.stringify(fileURLToPath(import.meta.resolve("@tschk/moonshine-deploy-node")))};
191
+ import { modules, renderer } from "./dist/server.js";
192
+ import manifest from "./manifest.json" with { type: "json" };
193
+ const fetch = createRequestHandler({ manifest, modules, renderer });
194
+ export default createNodeHandler({ fetch });
195
+ `,
196
+ );
197
+ const result = await Bun.build({
198
+ entrypoints: [entryPath],
199
+ outdir: funcDir,
200
+ naming: "index.mjs",
201
+ target: "node",
202
+ packages: "bundle",
203
+ });
204
+ if (!result.success)
205
+ throw new AggregateError(
206
+ result.logs,
207
+ "Native Vercel Node function bundle failed",
208
+ );
209
+ await writeFile(
210
+ resolve(funcDir, ".vc-config.json"),
211
+ JSON.stringify(
212
+ {
213
+ runtime: "nodejs22.x",
214
+ handler: "index.mjs",
215
+ launcherType: "Nodejs",
216
+ supportsResponseStreaming: true,
217
+ },
218
+ null,
219
+ 2,
220
+ ) + "\n",
221
+ );
222
+ await writeFile(
223
+ resolve(output, "config.json"),
224
+ JSON.stringify(
225
+ {
226
+ version: 3,
227
+ routes: [{ handle: "filesystem" }, { src: "/.*", dest: "/moonshine" }],
228
+ },
229
+ null,
230
+ 2,
231
+ ) + "\n",
232
+ );
233
+ }
234
+
169
235
  export const vercelAdapter: DeploymentAdapter = {
170
236
  name: "vercel",
171
237
  runtimes: ["node", "vercel-edge"],
172
238
  capabilities: ["streaming", "islands", "edge"],
173
239
  build: async (manifest, outDir) => {
240
+ if (manifest.renderer === "native") {
241
+ if (manifest.routes.some((route) => route.runtime !== "node"))
242
+ throw new Error("Native Vercel supports only the Node runtime");
243
+ if (manifest.requestContext)
244
+ throw new Error("Native Vercel request context is not supported");
245
+ if (manifest.routes.some((route) => route.mode === "island"))
246
+ throw new Error("Native island hydration is not implemented");
247
+ if (
248
+ manifest.routes.some((route) => route.cache) ||
249
+ manifest.capabilities.includes("revalidation")
250
+ )
251
+ throw new Error(
252
+ "Native Vercel caching/revalidation is not implemented",
253
+ );
254
+ }
174
255
  validateRuntime(vercelAdapter, manifest);
175
256
  validateCapabilities(vercelAdapter, manifest);
176
257
 
177
258
  await mkdir(outDir, { recursive: true });
259
+ await rm(resolve(outDir, ".vercel/output"), {
260
+ recursive: true,
261
+ force: true,
262
+ });
263
+ if (manifest.renderer === "native") {
264
+ await buildNativeFunction(manifest, outDir);
265
+ return;
266
+ }
267
+ for (const asset of manifest.assets) validateAssetPath(asset.path);
268
+ await copyAssets(manifest.assets, outDir);
178
269
  const projectRoot = resolve(outDir, "..");
179
270
  const resolvedManifest = resolveManifestFiles(manifest, projectRoot);
180
271
 
@@ -182,7 +273,6 @@ export const vercelAdapter: DeploymentAdapter = {
182
273
  resolve(outDir, "manifest.json"),
183
274
  JSON.stringify(resolvedManifest, null, 2) + "\n",
184
275
  );
185
- await copyAssets(manifest.assets, outDir);
186
276
 
187
277
  const vercelOutput = resolve(outDir, ".vercel/output");
188
278
  const staticDir = resolve(vercelOutput, "static");
@@ -196,9 +286,15 @@ export const vercelAdapter: DeploymentAdapter = {
196
286
  const routesConfig = await mapConcurrent(
197
287
  resolvedManifest.routes,
198
288
  async (route) => {
199
- if (route.mode === "static" && !route.path.includes("*")) {
200
- const html = await prerenderRoute(route);
289
+ if (
290
+ route.mode === "static" &&
291
+ !route.path.includes("*") &&
292
+ !route.path.includes(":")
293
+ ) {
294
+ // Allocate the output slug before awaiting so concurrent workers
295
+ // cannot race on `usedSlugs`.
201
296
  const slug = staticSlug(route.path, usedSlugs);
297
+ const html = await prerenderRoute(route);
202
298
  await writeFile(resolve(staticDir, `${slug}.html`), html);
203
299
  return {
204
300
  src: routeToRegex(route.path),
@@ -242,21 +338,7 @@ export default { fetch };
242
338
  );
243
339
  config.routes.push(...routesConfig);
244
340
 
245
- await Promise.all(
246
- resolvedManifest.assets.map(async (asset) => {
247
- const source = isAbsolute(asset.file)
248
- ? asset.file
249
- : resolve(outDir, asset.file);
250
- const target = resolve(
251
- staticDir,
252
- (asset.path ?? asset.file).replace(/^\/+/, ""),
253
- );
254
- await mkdir(dirname(target), { recursive: true });
255
- if (source !== target) {
256
- await copyFile(source, target);
257
- }
258
- }),
259
- );
341
+ await copyPublicAssets(resolvedManifest, outDir, staticDir);
260
342
 
261
343
  await writeFile(
262
344
  resolve(vercelOutput, "manifest.json"),
@@ -278,7 +360,8 @@ const fetch = createRequestHandler({
278
360
  staticDir: join(import.meta.dir, ".vercel/output/static"),
279
361
  });
280
362
 
281
- Bun.serve({ port: Number(process.env.PORT) || 0, fetch });
363
+ const envPort = Number(process.env.PORT);
364
+ Bun.serve({ port: Number.isInteger(envPort) && envPort >= 0 && envPort <= 65535 ? envPort : 0, fetch });
282
365
  `;
283
366
  await writeFile(resolve(outDir, "server.ts"), serverEntry);
284
367
  },
@@ -294,7 +377,12 @@ export const vercelHarness: HarnessFactory = (fetch, options) => {
294
377
  (request.method === "GET" || request.method === "HEAD") &&
295
378
  pathname !== "/"
296
379
  ) {
297
- const res = await tryServeStatic(options.staticDir, pathname);
380
+ const res = await tryServeStatic(
381
+ options.staticDir,
382
+ pathname,
383
+ undefined,
384
+ request.method,
385
+ );
298
386
  if (res) return res;
299
387
  }
300
388
  return fetch(request);