@tschk/moonshine-framework 0.3.7 → 0.4.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/package.json +2 -2
- package/src/adapter.ts +21 -0
- package/src/index.ts +1 -0
- package/src/serialize.ts +108 -0
- package/test/adapter.test.ts +26 -0
- package/test/config.test.ts +45 -0
- package/test/serialize.test.ts +69 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tschk/moonshine-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"test": "bun test"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@tschk/moonshine": "^0.
|
|
18
|
+
"@tschk/moonshine": "^0.4.1"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@types/bun": "1.3.14",
|
package/src/adapter.ts
CHANGED
|
@@ -8,6 +8,27 @@ export type DeploymentAdapter = {
|
|
|
8
8
|
build(manifest: MoonshineManifest, outDir: string): Promise<void>;
|
|
9
9
|
};
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* A running instance of an adapter, used to exercise it end to end.
|
|
13
|
+
*
|
|
14
|
+
* This lives here rather than in the conformance suite because every deploy
|
|
15
|
+
* package exports a harness as part of its public API (`nodeHarness`,
|
|
16
|
+
* `bunHarness`, ...). With the type owned by the test suite, each of those
|
|
17
|
+
* packages had to declare the suite — fixtures and all — as a production
|
|
18
|
+
* `dependency` purely to name a type, so installing an adapter installed its
|
|
19
|
+
* own test tooling. The suite still owns the tests; the framework owns the
|
|
20
|
+
* shape the adapters implement.
|
|
21
|
+
*/
|
|
22
|
+
export type Harness = {
|
|
23
|
+
start(): Promise<URL>;
|
|
24
|
+
stop(): Promise<void>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type HarnessFactory = (
|
|
28
|
+
fetch: (request: Request) => Promise<Response>,
|
|
29
|
+
options: { manifest: MoonshineManifest; staticDir: string },
|
|
30
|
+
) => Harness;
|
|
31
|
+
|
|
11
32
|
export function defineRoute<T extends RouteDefinition>(route: T): T {
|
|
12
33
|
return route;
|
|
13
34
|
}
|
package/src/index.ts
CHANGED
package/src/serialize.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
const ESCAPE_PATTERN = /[<>&\u2028\u2029]/g;
|
|
2
|
+
|
|
3
|
+
function escapeJsonChar(char: string): string {
|
|
4
|
+
return `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* HTML-safe JSON for anything embedded in a document: server loader data and
|
|
9
|
+
* island props both land inside `<script>`, so one implementation escapes for
|
|
10
|
+
* both. Two copies drift, and a copy that misses an escape vector is an XSS.
|
|
11
|
+
*/
|
|
12
|
+
export function serializeData(value: unknown): string {
|
|
13
|
+
// Ancestor chain, not a visited set: a value repeated in sibling positions
|
|
14
|
+
// is shared, not cyclic, and must still serialize.
|
|
15
|
+
const ancestors = new Set<unknown>();
|
|
16
|
+
|
|
17
|
+
function visit(v: unknown): string {
|
|
18
|
+
if (v === undefined || v === null) return "null";
|
|
19
|
+
|
|
20
|
+
const type = typeof v;
|
|
21
|
+
|
|
22
|
+
if (type === "boolean" || type === "number") {
|
|
23
|
+
if (Number.isNaN(v as number) || !Number.isFinite(v as number)) {
|
|
24
|
+
return "null";
|
|
25
|
+
}
|
|
26
|
+
return String(v);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (type === "string") {
|
|
30
|
+
const raw = JSON.stringify(v);
|
|
31
|
+
if (raw === undefined)
|
|
32
|
+
throw new TypeError("serializeData: cannot serialize");
|
|
33
|
+
return raw;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (type === "bigint" || type === "function" || type === "symbol") {
|
|
37
|
+
throw new TypeError(`serializeData: cannot serialize ${type}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (ancestors.has(v))
|
|
41
|
+
throw new TypeError("serializeData: cyclic reference");
|
|
42
|
+
|
|
43
|
+
if (Array.isArray(v)) {
|
|
44
|
+
ancestors.add(v);
|
|
45
|
+
try {
|
|
46
|
+
const parts: string[] = [];
|
|
47
|
+
for (const item of v) {
|
|
48
|
+
parts.push(visit(item));
|
|
49
|
+
}
|
|
50
|
+
return `[${parts.join(",")}]`;
|
|
51
|
+
} finally {
|
|
52
|
+
ancestors.delete(v);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const proto = Object.prototype.toString.call(v);
|
|
57
|
+
|
|
58
|
+
if (proto === "[object Date]" || proto === "[object RegExp]") {
|
|
59
|
+
ancestors.add(v);
|
|
60
|
+
try {
|
|
61
|
+
const raw = JSON.stringify(v);
|
|
62
|
+
if (raw === undefined)
|
|
63
|
+
throw new TypeError("serializeData: cannot serialize");
|
|
64
|
+
return raw;
|
|
65
|
+
} finally {
|
|
66
|
+
ancestors.delete(v);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// JSON.stringify skips symbol keys silently. A prop the server drops but
|
|
71
|
+
// the client expects is a hydration mismatch, so refuse the value instead.
|
|
72
|
+
const symbols = Object.getOwnPropertySymbols(v);
|
|
73
|
+
if (symbols.length) {
|
|
74
|
+
throw new TypeError("serializeData: cannot serialize symbol");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
ancestors.add(v);
|
|
78
|
+
try {
|
|
79
|
+
const parts: string[] = [];
|
|
80
|
+
const keys = Object.keys(v as Record<string, unknown>);
|
|
81
|
+
for (const key of keys) {
|
|
82
|
+
let val = undefined;
|
|
83
|
+
try {
|
|
84
|
+
val = (v as Record<string, unknown>)[key];
|
|
85
|
+
} catch {
|
|
86
|
+
// Throwing getter -> undefined
|
|
87
|
+
val = undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (typeof val === "function" || typeof val === "symbol") {
|
|
91
|
+
throw new TypeError(
|
|
92
|
+
"serializeData: cannot serialize function/symbol",
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
// An undefined property is absent, not null: JSON semantics, and what
|
|
96
|
+
// the client's own JSON.parse round-trip would produce.
|
|
97
|
+
if (val === undefined) continue;
|
|
98
|
+
parts.push(`${visit(key)}:${visit(val)}`);
|
|
99
|
+
}
|
|
100
|
+
return `{${parts.join(",")}}`;
|
|
101
|
+
} finally {
|
|
102
|
+
ancestors.delete(v);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const raw = visit(value);
|
|
107
|
+
return raw.replace(ESCAPE_PATTERN, escapeJsonChar);
|
|
108
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
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
|
+
});
|