@tschk/moonshine-framework 0.4.1 → 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.
package/README.md CHANGED
@@ -19,6 +19,12 @@ const route = defineRoute({
19
19
  const config = defineConfig({ runtime: "bun", mode: "auto" });
20
20
  ```
21
21
 
22
+ Native manifests support Bun, Node, Cloudflare, and Vercel Node functions. Route
23
+ runtimes match their adapter, with Vercel native functions using `node`. Vercel
24
+ Edge remains unsupported. Cloudflare and Vercel reject request context, islands,
25
+ and cache/revalidation options; see the [Cloudflare](../deploy-cloudflare/README.md)
26
+ and [Vercel](../deploy-vercel/README.md) adapter limits.
27
+
22
28
  Exports include:
23
29
 
24
30
  - `RouteDefinition`, `RenderMode`, `RuntimeTarget`
@@ -28,3 +34,33 @@ Exports include:
28
34
 
29
35
  See [docs/MANIFEST.md](../../docs/MANIFEST.md) and
30
36
  [DESIGN.md](../../DESIGN.md).
37
+
38
+ Server request context is opt-in with `requestContext: true` and requires matching
39
+ Bun or Node deployment adapters and route runtimes. Import `runWithRequest` and
40
+ `getRequest` from `@tschk/moonshine-framework/request`; this server-only subpath
41
+ uses AsyncLocalStorage. The root export contains no request-context runtime.
42
+ `RequestScope` is available as a root type for pipeline integration.
43
+
44
+ Native page and layout server modules may export `metadata`, typed as `Metadata`:
45
+
46
+ ```ts
47
+ import type { Metadata } from "@tschk/moonshine-framework";
48
+
49
+ export const metadata: Metadata = ({ data }) => ({
50
+ title: (data as { title: string }).title,
51
+ description: "A server-rendered page",
52
+ lang: "en",
53
+ });
54
+ ```
55
+
56
+ `DocumentMetadata` supports only `title`, `description`, and `lang`. Outer layouts,
57
+ inner layouts, then the page override individual fields. Undefined inherits; null
58
+ clears an inherited field (including the default `lang="en"`). Values are escaped
59
+ in the document head and HTML language attribute. Callbacks must be synchronous,
60
+ pure functions of the render context, which contains already-loaded route data;
61
+ Promises and invalid field types are rejected. No arbitrary head HTML is accepted.
62
+
63
+ Metadata applies to native SSR, static output, and SPA document shells without
64
+ mounting the SPA server component. API responses skip it. This adds no client head
65
+ manager: subsequent client-side navigation must update document metadata explicitly.
66
+ Legacy React rendering does not consume this export.
package/package.json CHANGED
@@ -1,13 +1,17 @@
1
1
  {
2
2
  "name": "@tschk/moonshine-framework",
3
- "version": "0.4.1",
3
+ "version": "0.4.4",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
7
7
  "types": "./src/index.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": "./src/index.ts",
10
+ "./request": "./src/request.ts"
10
11
  },
12
+ "files": [
13
+ "src"
14
+ ],
11
15
  "scripts": {
12
16
  "format": "bunx prettier --check .",
13
17
  "lint": "bunx oxlint .",
@@ -15,7 +19,7 @@
15
19
  "test": "bun test"
16
20
  },
17
21
  "dependencies": {
18
- "@tschk/moonshine": "^0.4.1"
22
+ "@tschk/moonshine": "^0.4.4"
19
23
  },
20
24
  "devDependencies": {
21
25
  "@types/bun": "1.3.14",
package/src/build.ts ADDED
@@ -0,0 +1,136 @@
1
+ import { copyFile, mkdir, realpath } from "node:fs/promises";
2
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
3
+ import type { DeploymentAdapter } from "./adapter";
4
+ import type { MoonshineManifest } from "./manifest";
5
+
6
+ export function resolveManifestFiles(
7
+ manifest: MoonshineManifest,
8
+ projectRoot: string,
9
+ ): MoonshineManifest {
10
+ return {
11
+ ...manifest,
12
+ routes: manifest.routes.map((route) => ({
13
+ ...route,
14
+ file: isAbsolute(route.file)
15
+ ? route.file
16
+ : resolve(projectRoot, route.file),
17
+ ...(route.dataFile && {
18
+ dataFile: isAbsolute(route.dataFile)
19
+ ? route.dataFile
20
+ : resolve(projectRoot, route.dataFile),
21
+ }),
22
+ ...(route.clientFile && {
23
+ clientFile: isAbsolute(route.clientFile)
24
+ ? route.clientFile
25
+ : resolve(projectRoot, route.clientFile),
26
+ }),
27
+ ...(route.layouts && {
28
+ layouts: route.layouts.map((layout) =>
29
+ isAbsolute(layout) ? layout : resolve(projectRoot, layout),
30
+ ),
31
+ }),
32
+ ...(route.middleware && {
33
+ middleware: route.middleware.map((middleware) =>
34
+ isAbsolute(middleware)
35
+ ? middleware
36
+ : resolve(projectRoot, middleware),
37
+ ),
38
+ }),
39
+ ...(route.errorBoundary && {
40
+ errorBoundary: isAbsolute(route.errorBoundary)
41
+ ? route.errorBoundary
42
+ : resolve(projectRoot, route.errorBoundary),
43
+ }),
44
+ })),
45
+ };
46
+ }
47
+
48
+ function within(root: string, file: string): boolean {
49
+ const path = relative(root, file);
50
+ return path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path);
51
+ }
52
+
53
+ export async function copyAsset(
54
+ asset: MoonshineManifest["assets"][number],
55
+ outDir: string,
56
+ ): Promise<string> {
57
+ const source = isAbsolute(asset.file)
58
+ ? asset.file
59
+ : resolve(outDir, asset.file);
60
+ const publicRoot = resolve(outDir, "public");
61
+ const [realSource, realPublic] = await Promise.all([
62
+ realpath(source),
63
+ realpath(publicRoot).catch(() => publicRoot),
64
+ ]);
65
+ if (within(publicRoot, source) && !within(realPublic, realSource))
66
+ throw new Error(`Public asset escapes its directory: ${asset.file}`);
67
+ if (within(realPublic, realSource)) {
68
+ const stored = within(publicRoot, source)
69
+ ? relative(publicRoot, source)
70
+ : relative(realPublic, realSource);
71
+ return `public/${stored.split(sep).join("/")}`;
72
+ }
73
+ const target = resolve(
74
+ outDir,
75
+ (asset.path ?? asset.file).replace(/^\/+/, ""),
76
+ );
77
+ await mkdir(dirname(target), { recursive: true });
78
+ if (source !== target) {
79
+ await copyFile(source, target);
80
+ }
81
+ return (asset.path ?? asset.file).replace(/^\/+/, "");
82
+ }
83
+
84
+ export async function copyAssets(
85
+ assets: MoonshineManifest["assets"],
86
+ outDir: string,
87
+ ): Promise<void> {
88
+ await Promise.all(assets.map((asset) => copyAsset(asset, outDir)));
89
+ }
90
+
91
+ export function validateRuntime(
92
+ adapter: DeploymentAdapter,
93
+ manifest: MoonshineManifest,
94
+ ): void {
95
+ const nativeRuntime = adapter.name === "vercel" ? "node" : adapter.name;
96
+ if (
97
+ manifest.renderer === "native" &&
98
+ (!["bun", "node", "cloudflare", "vercel"].includes(adapter.name) ||
99
+ manifest.routes.some((route) => route.runtime !== nativeRuntime))
100
+ ) {
101
+ throw new Error(
102
+ "Native renderer requires matching Bun, Node or Cloudflare runtimes; Vercel native functions require Node",
103
+ );
104
+ }
105
+ if (
106
+ manifest.requestContext &&
107
+ (!["bun", "node"].includes(adapter.name) ||
108
+ manifest.routes.some((route) => route.runtime !== adapter.name))
109
+ ) {
110
+ throw new Error(
111
+ "Request context supports only matching Bun or Node deployment adapters/runtimes",
112
+ );
113
+ }
114
+ const errors: string[] = [];
115
+ for (const route of manifest.routes) {
116
+ if (!adapter.runtimes.includes(route.runtime)) {
117
+ errors.push(
118
+ `${adapter.name} adapter does not support route "${route.path}" with runtime "${route.runtime}"; supported runtimes: ${adapter.runtimes.join(", ")}`,
119
+ );
120
+ }
121
+ }
122
+ if (errors.length) throw new Error(errors.join("\n"));
123
+ }
124
+
125
+ export function validateCapabilities(
126
+ adapter: DeploymentAdapter,
127
+ manifest: MoonshineManifest,
128
+ ): void {
129
+ for (const capability of manifest.capabilities) {
130
+ if (!adapter.capabilities.includes(capability)) {
131
+ throw new Error(
132
+ `${adapter.name} adapter does not support capability "${capability}"; supported capabilities: ${adapter.capabilities.join(", ")}`,
133
+ );
134
+ }
135
+ }
136
+ }
package/src/config.ts CHANGED
@@ -3,6 +3,7 @@ import type { RenderMode, RuntimeTarget } from "./routes";
3
3
  export type MoonshineConfig = {
4
4
  mode?: RenderMode;
5
5
  runtime?: RuntimeTarget;
6
+ requestContext?: boolean;
6
7
  };
7
8
 
8
9
  export function defineConfig<T extends MoonshineConfig>(config: T): T {
package/src/index.ts CHANGED
@@ -4,3 +4,4 @@ export * from "./manifest";
4
4
  export * from "./renderer";
5
5
  export * from "./routes";
6
6
  export * from "./serialize";
7
+ export * from "./build";
package/src/manifest.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { RenderMode, RouteDefinition, RuntimeTarget } from "./routes";
2
+ import type { RendererName } from "./renderer";
2
3
 
3
4
  export const MANIFEST_VERSION = 1 as const;
4
5
 
@@ -14,6 +15,7 @@ export type RouteArtifact = RouteDefinition & {
14
15
  decision: string;
15
16
  serverEntry?: string;
16
17
  clientEntries: string[];
18
+ clientStyles?: string[];
17
19
  staticOutput?: string;
18
20
  cache?: { control?: string; revalidate?: number };
19
21
  headers?: Record<string, string>;
@@ -22,6 +24,8 @@ export type RouteArtifact = RouteDefinition & {
22
24
  export type MoonshineManifest = {
23
25
  version: typeof MANIFEST_VERSION;
24
26
  frameworkVersion: string;
27
+ renderer?: RendererName;
28
+ requestContext?: boolean;
25
29
  routes: RouteArtifact[];
26
30
  assets: Array<{ path: string; file: string; integrity?: string }>;
27
31
  entries: { server?: string; client?: string };
package/src/renderer.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { RouteArtifact } from "./manifest";
2
2
 
3
+ export type RendererName = "react" | "solid" | "crepus" | "native";
4
+
3
5
  export type RenderContext = {
4
6
  request: Request;
5
7
  route: RouteArtifact;
@@ -8,8 +10,22 @@ export type RenderContext = {
8
10
  signal: AbortSignal;
9
11
  };
10
12
 
13
+ /** Native document fields. Undefined inherits; null removes an inherited field. */
14
+ export type DocumentMetadata = {
15
+ title?: string | null;
16
+ description?: string | null;
17
+ lang?: string | null;
18
+ };
19
+
20
+ /** Synchronous server-module metadata, evaluated with loaded route data. */
21
+ export type Metadata =
22
+ DocumentMetadata | ((context: RenderContext) => DocumentMetadata);
23
+
11
24
  export type Renderer = {
12
25
  name: string;
13
26
  render(context: RenderContext): Promise<Response>;
14
27
  prerender(context: RenderContext): Promise<string>;
15
28
  };
29
+
30
+ /** Runs the entire request pipeline in an optional server context. */
31
+ export type RequestScope = <T>(request: Request, fn: () => T) => T;
package/src/request.ts ADDED
@@ -0,0 +1,12 @@
1
+ /** Opt-in server request context; deliberately absent from the root barrel. */
2
+ import { AsyncLocalStorage } from "node:async_hooks";
3
+ import type { RequestScope } from "./renderer";
4
+
5
+ const requests = new AsyncLocalStorage<Request>();
6
+
7
+ export const runWithRequest: RequestScope = (request, fn) =>
8
+ requests.run(request, fn);
9
+
10
+ export function getRequest(): Request | undefined {
11
+ return requests.getStore();
12
+ }
package/src/routes.ts CHANGED
@@ -5,6 +5,8 @@ export type RouteDefinition = {
5
5
  id: string;
6
6
  path: string;
7
7
  file: string;
8
+ /** Explicit browser-only page entry for native SPA rendering. */
9
+ clientFile?: string;
8
10
  mode?: RenderMode;
9
11
  runtime?: RuntimeTarget;
10
12
  layouts?: string[];
@@ -1,26 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { defineRoute } from "../src/adapter";
3
- import type { RouteDefinition } from "../src/routes";
4
-
5
- describe("defineRoute", () => {
6
- test("returns the exact route definition provided", () => {
7
- const route: RouteDefinition = {
8
- id: "test-route",
9
- path: "/test",
10
- file: "test.tsx",
11
- mode: "ssr",
12
- runtime: "bun",
13
- };
14
-
15
- const result = defineRoute(route);
16
-
17
- expect(result).toBe(route);
18
- expect(result).toEqual({
19
- id: "test-route",
20
- path: "/test",
21
- file: "test.tsx",
22
- mode: "ssr",
23
- runtime: "bun",
24
- });
25
- });
26
- });
@@ -1,45 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { defineConfig, type MoonshineConfig } from "../src/config";
3
-
4
- describe("defineConfig", () => {
5
- test("returns the exact same object reference", () => {
6
- const config: MoonshineConfig = {};
7
- const result = defineConfig(config);
8
- expect(result).toBe(config);
9
- });
10
-
11
- test("handles empty configuration", () => {
12
- const result = defineConfig({});
13
- expect(result).toEqual({});
14
- });
15
-
16
- test("handles configuration with mode", () => {
17
- const result = defineConfig({ mode: "island" });
18
- expect(result).toEqual({ mode: "island" });
19
- });
20
-
21
- test("handles configuration with runtime", () => {
22
- const result = defineConfig({ runtime: "bun" });
23
- expect(result).toEqual({ runtime: "bun" });
24
- });
25
-
26
- test("handles full configuration", () => {
27
- const config = {
28
- mode: "ssr" as const,
29
- runtime: "cloudflare" as const,
30
- };
31
- const result = defineConfig(config);
32
- expect(result).toEqual(config);
33
- });
34
-
35
- test("preserves additional properties (type safety with generic T)", () => {
36
- const config = {
37
- mode: "spa" as const,
38
- customPlugin: true,
39
- };
40
- const result = defineConfig(config);
41
- expect(result).toEqual(config);
42
- // TypeScript check: result.customPlugin should exist
43
- expect(result.customPlugin).toBe(true);
44
- });
45
- });
@@ -1,13 +0,0 @@
1
- import { expect, test } from "bun:test";
2
- import { defineConfig, defineRoute, MANIFEST_VERSION } from "../src";
3
-
4
- test("contracts preserve exact input and version one", () => {
5
- const route = defineRoute({
6
- id: "home",
7
- path: "/",
8
- file: "src/routes/index.tsx",
9
- });
10
- expect(route.path).toBe("/");
11
- expect(defineConfig({ mode: "auto" }).mode).toBe("auto");
12
- expect(MANIFEST_VERSION).toBe(1);
13
- });
@@ -1,69 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { serializeData } from "../src/serialize";
3
-
4
- // The single HTML-safe JSON serializer: the server data payload and the island
5
- // props script are the same XSS-prevention primitive, so they share this one
6
- // implementation rather than drifting apart in two packages.
7
- describe("serializeData", () => {
8
- test("escapes <, >, &, U+2028, and U+2029", () => {
9
- const out = serializeData({
10
- html: "<script>alert('&

')</script>",
11
- });
12
- expect(out).not.toContain("<");
13
- expect(out).not.toContain(">");
14
- expect(out).not.toContain("&");
15
- expect(out).not.toContain("
");
16
- expect(out).not.toContain("
");
17
- expect(out).toContain("\\u003c");
18
- expect(out).toContain("\\u003e");
19
- expect(out).toContain("\\u0026");
20
- expect(out).toContain("\\u2028");
21
- expect(out).toContain("\\u2029");
22
- });
23
-
24
- test("omits undefined properties instead of nulling them", () => {
25
- expect(serializeData({ a: 1, b: undefined })).toBe('{"a":1}');
26
- expect(serializeData([1, undefined])).toBe("[1,null]");
27
- });
28
-
29
- test("rejects symbol-keyed properties rather than dropping them", () => {
30
- expect(() => serializeData({ [Symbol("k")]: 1 })).toThrow(/symbol/);
31
- });
32
-
33
- test("serializes NaN and Infinity as null", () => {
34
- expect(serializeData({ a: NaN, b: Infinity, c: -Infinity })).toBe(
35
- '{"a":null,"b":null,"c":null}',
36
- );
37
- });
38
-
39
- test("allows shared references and rejects only true cycles", () => {
40
- const shared = { id: 1 };
41
- expect(serializeData({ a: shared, b: shared })).toBe(
42
- '{"a":{"id":1},"b":{"id":1}}',
43
- );
44
- expect(serializeData([shared, shared])).toBe('[{"id":1},{"id":1}]');
45
-
46
- const cyclic: Record<string, unknown> = {};
47
- cyclic.self = cyclic;
48
- expect(() => serializeData(cyclic)).toThrow(/cyclic/);
49
-
50
- const arr: unknown[] = [];
51
- arr.push(arr);
52
- expect(() => serializeData(arr)).toThrow(/cyclic/);
53
- });
54
-
55
- test("rejects functions, symbols, and bigints", () => {
56
- expect(() => serializeData({ f: () => 1 })).toThrow(/function/);
57
- expect(() => serializeData({ s: Symbol("x") })).toThrow(/symbol/);
58
- expect(() => serializeData({ n: 1n })).toThrow(/bigint/);
59
- });
60
-
61
- test("omits properties with throwing getters", () => {
62
- const obj = {
63
- get thrower() {
64
- throw new Error("Getter threw!");
65
- },
66
- };
67
- expect(serializeData(obj)).toBe("{}");
68
- });
69
- });
package/tsconfig.json DELETED
@@ -1,4 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.base.json",
3
- "include": ["src/**/*", "test/**/*"]
4
- }