@tschk/moonshine-adapter-conformance 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 ADDED
@@ -0,0 +1,14 @@
1
+ # `@tschk/moonshine-adapter-conformance`
2
+
3
+ Reusable deployment contract suite for Moonshine adapters.
4
+
5
+ ```ts
6
+ import { adapterConformance } from "@tschk/moonshine-adapter-conformance";
7
+ ```
8
+
9
+ Adapters implement `DeploymentAdapter` from `@tschk/moonshine-framework` and
10
+ pass the same conformance tests for request handling, static assets,
11
+ streaming, and edge behavior.
12
+
13
+ See [docs/DEPLOYMENT.md](../../docs/DEPLOYMENT.md) and
14
+ [DESIGN.md](../../DESIGN.md).
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@tschk/moonshine-adapter-conformance",
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-react": "^0.3.1",
21
+ "react": "^19.1.0",
22
+ "react-dom": "^19.1.0"
23
+ },
24
+ "devDependencies": {
25
+ "@types/bun": "1.3.14",
26
+ "@types/react": "^19.1.0",
27
+ "@types/react-dom": "^19.1.0",
28
+ "typescript": "7.0.2"
29
+ },
30
+ "files": [
31
+ "src"
32
+ ]
33
+ }
@@ -0,0 +1,21 @@
1
+ export default function Page({
2
+ params,
3
+ data,
4
+ }: {
5
+ params: Record<string, string>;
6
+ data: unknown;
7
+ }) {
8
+ const d = (data ?? {}) as Record<string, string>;
9
+ return (
10
+ <main data-moonshine-mode={d.mode ?? "default"}>
11
+ <h1>{d.title ?? "Moonshine"}</h1>
12
+ {d.message ? <p>{d.message}</p> : null}
13
+ {Object.entries(params).map(([k, v]) => (
14
+ <dl key={k}>
15
+ <dt>{k}</dt>
16
+ <dd>{v}</dd>
17
+ </dl>
18
+ ))}
19
+ </main>
20
+ );
21
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { adapterConformance } from "./suite.js";
2
+ export type { Harness, HarnessFactory } from "./suite.js";
package/src/suite.ts ADDED
@@ -0,0 +1,409 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ rmSync,
8
+ writeFileSync,
9
+ } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { resolve } from "node:path";
12
+ import {
13
+ MANIFEST_VERSION,
14
+ type DeploymentAdapter,
15
+ type MoonshineManifest,
16
+ type RenderContext,
17
+ type RouteArtifact,
18
+ } from "@tschk/moonshine-framework";
19
+ import {
20
+ createRequestHandler,
21
+ json,
22
+ redirect,
23
+ type RouteModule,
24
+ } from "@tschk/moonshine-server";
25
+ import { reactRenderer, type Renderer } from "@tschk/moonshine-react";
26
+
27
+ export type Harness = {
28
+ start(): Promise<URL>;
29
+ stop(): Promise<void>;
30
+ };
31
+
32
+ export type HarnessFactory = (
33
+ fetch: (request: Request) => Promise<Response>,
34
+ options: { manifest: MoonshineManifest; staticDir: string },
35
+ ) => Harness;
36
+
37
+ const pageFile = resolve(import.meta.dir, "fixtures", "Page.tsx");
38
+
39
+ function getRouteData(context: RenderContext): unknown {
40
+ const data = context.data as Record<string, unknown> | undefined;
41
+ if (data && context.route.id in data) return data[context.route.id];
42
+ return context.data;
43
+ }
44
+
45
+ const conformanceRenderer: Renderer = {
46
+ name: "conformance",
47
+ async prerender(context) {
48
+ if (context.route.id === "stream") return "stream";
49
+ return reactRenderer.prerender({
50
+ ...context,
51
+ data: getRouteData(context),
52
+ });
53
+ },
54
+ async render(context) {
55
+ if (context.route.id === "stream") {
56
+ const encoder = new TextEncoder();
57
+ const stream = new ReadableStream({
58
+ start(controller) {
59
+ controller.enqueue(encoder.encode("chunk1\n"));
60
+ const t = setTimeout(() => {
61
+ controller.enqueue(encoder.encode("chunk2\n"));
62
+ controller.close();
63
+ }, 50);
64
+ context.signal.addEventListener("abort", () => {
65
+ clearTimeout(t);
66
+ try {
67
+ controller.error(new Error("aborted"));
68
+ } catch {}
69
+ });
70
+ },
71
+ });
72
+ return new Response(stream, {
73
+ headers: { "content-type": "text/plain; charset=utf-8" },
74
+ });
75
+ }
76
+ return reactRenderer.render({
77
+ ...context,
78
+ data: getRouteData(context),
79
+ });
80
+ },
81
+ };
82
+
83
+ export function adapterConformance(
84
+ adapter: DeploymentAdapter,
85
+ createHarness: HarnessFactory,
86
+ ): void {
87
+ describe(`${adapter.name} adapter conformance`, () => {
88
+ const tmpDir = mkdtempSync(resolve(tmpdir(), "ms-conf-"));
89
+ const staticDir = resolve(tmpDir, "public");
90
+ const assetFile = resolve(staticDir, "app.css");
91
+ const css = "body { background: #000; }";
92
+ mkdirSync(staticDir, { recursive: true });
93
+ writeFileSync(assetFile, css);
94
+
95
+ let slowAborted = false;
96
+
97
+ const routes: RouteArtifact[] = [
98
+ {
99
+ id: "home",
100
+ path: "/",
101
+ file: pageFile,
102
+ mode: "static",
103
+ runtime: adapter.runtimes[0]!,
104
+ decision: "fixture",
105
+ clientEntries: [],
106
+ },
107
+ {
108
+ id: "about",
109
+ path: "/about",
110
+ file: pageFile,
111
+ mode: "static",
112
+ runtime: adapter.runtimes[0]!,
113
+ decision: "fixture",
114
+ clientEntries: [],
115
+ },
116
+ {
117
+ id: "hello",
118
+ path: "/hello/:name",
119
+ file: pageFile,
120
+ mode: "ssr",
121
+ runtime: adapter.runtimes[0]!,
122
+ decision: "fixture",
123
+ clientEntries: [],
124
+ },
125
+ {
126
+ id: "dashboard",
127
+ path: "/dashboard/*",
128
+ file: pageFile,
129
+ mode: "spa",
130
+ runtime: adapter.runtimes[0]!,
131
+ decision: "fixture",
132
+ clientEntries: ["/client.js"],
133
+ },
134
+ {
135
+ id: "api-method",
136
+ path: "/api/method",
137
+ file: pageFile,
138
+ mode: "api",
139
+ runtime: adapter.runtimes[0]!,
140
+ decision: "fixture",
141
+ clientEntries: [],
142
+ },
143
+ {
144
+ id: "old",
145
+ path: "/old",
146
+ file: pageFile,
147
+ mode: "ssr",
148
+ runtime: adapter.runtimes[0]!,
149
+ decision: "fixture",
150
+ clientEntries: [],
151
+ },
152
+ {
153
+ id: "headers",
154
+ path: "/headers",
155
+ file: pageFile,
156
+ mode: "ssr",
157
+ runtime: adapter.runtimes[0]!,
158
+ decision: "fixture",
159
+ clientEntries: [],
160
+ headers: { "x-moonshine": "ok" },
161
+ },
162
+ {
163
+ id: "stream",
164
+ path: "/stream",
165
+ file: pageFile,
166
+ mode: "ssr",
167
+ runtime: adapter.runtimes[0]!,
168
+ decision: "fixture",
169
+ clientEntries: [],
170
+ },
171
+ {
172
+ id: "slow",
173
+ path: "/slow",
174
+ file: pageFile,
175
+ mode: "ssr",
176
+ runtime: adapter.runtimes[0]!,
177
+ decision: "fixture",
178
+ clientEntries: [],
179
+ },
180
+ ];
181
+
182
+ const manifest: MoonshineManifest = {
183
+ version: MANIFEST_VERSION,
184
+ frameworkVersion: "0.2.0",
185
+ routes,
186
+ assets: [
187
+ {
188
+ file: assetFile,
189
+ path: "/app.css",
190
+ integrity: "sha256-0000000000000000000000000000000000000000000=",
191
+ },
192
+ ],
193
+ entries: { server: "server.js", client: "client.js" },
194
+ capabilities: adapter.capabilities,
195
+ };
196
+
197
+ const modules: Record<string, RouteModule> = {
198
+ home: { loader: () => ({ title: "Home", mode: "static" }) },
199
+ about: { loader: () => ({ title: "About", mode: "static" }) },
200
+ hello: {
201
+ loader: (ctx) => ({
202
+ title: `Hello ${ctx.params.name}`,
203
+ mode: "ssr",
204
+ message: ctx.params.name,
205
+ }),
206
+ },
207
+ dashboard: {},
208
+ "api-method": {
209
+ loader: (ctx) => ({
210
+ method: ctx.request.method,
211
+ header: ctx.request.headers.get("x-test-header"),
212
+ }),
213
+ action: async (ctx) => {
214
+ const body = await new Response(ctx.request.body).text();
215
+ return json({ method: ctx.request.method, body });
216
+ },
217
+ },
218
+ old: {
219
+ loader: () => {
220
+ throw redirect("/new");
221
+ },
222
+ },
223
+ headers: { loader: () => ({ title: "Headers", mode: "ssr" }) },
224
+ stream: { loader: () => ({ title: "Stream", mode: "ssr" }) },
225
+ slow: {
226
+ loader: async (ctx) => {
227
+ await new Promise<void>((resolve) => {
228
+ const id = setInterval(() => {
229
+ if (ctx.signal.aborted) {
230
+ slowAborted = true;
231
+ clearInterval(id);
232
+ resolve();
233
+ }
234
+ }, 20);
235
+ setTimeout(() => {
236
+ clearInterval(id);
237
+ resolve();
238
+ }, 500);
239
+ });
240
+ return { title: "Slow" };
241
+ },
242
+ },
243
+ };
244
+
245
+ const handler = createRequestHandler({
246
+ manifest,
247
+ modules,
248
+ renderer: conformanceRenderer,
249
+ });
250
+
251
+ const harness = createHarness(handler, { manifest, staticDir });
252
+ let url: URL;
253
+
254
+ beforeAll(async () => {
255
+ url = await harness.start();
256
+ });
257
+
258
+ afterAll(async () => {
259
+ await harness.stop();
260
+ rmSync(tmpDir, { recursive: true, force: true });
261
+ });
262
+
263
+ function buildUrl(path: string): string {
264
+ return new URL(path, url).href;
265
+ }
266
+
267
+ test("serves manifest assets", async () => {
268
+ const res = await fetch(new Request(buildUrl("/app.css")));
269
+ expect(res.status).toBe(200);
270
+ expect(res.headers.get("content-type")).toContain("text/css");
271
+ expect(await res.text()).toBe(css);
272
+ });
273
+
274
+ test("renders static HTML", async () => {
275
+ const res = await fetch(new Request(buildUrl("/about")));
276
+ expect(res.status).toBe(200);
277
+ expect(res.headers.get("content-type")).toContain("text/html");
278
+ const text = await res.text();
279
+ expect(text).toContain('data-moonshine-mode="static"');
280
+ expect(text).toContain("About");
281
+ });
282
+
283
+ test("renders SSR with loader data", async () => {
284
+ const res = await fetch(new Request(buildUrl("/hello/world")));
285
+ expect(res.status).toBe(200);
286
+ expect(res.headers.get("content-type")).toContain("text/html");
287
+ const text = await res.text();
288
+ expect(text).toContain('data-moonshine-mode="ssr"');
289
+ expect(text).toContain("Hello world");
290
+ expect(text).toContain("<dd>world</dd>");
291
+ });
292
+
293
+ test("streams response chunks", async () => {
294
+ const res = await fetch(new Request(buildUrl("/stream")));
295
+ expect(res.status).toBe(200);
296
+ expect(res.headers.get("content-type")).toContain("text/plain");
297
+ const reader = res.body!.getReader();
298
+ const decoder = new TextDecoder();
299
+ const chunks: string[] = [];
300
+ while (true) {
301
+ const { done, value } = await reader.read();
302
+ if (done) break;
303
+ chunks.push(decoder.decode(value, { stream: true }));
304
+ }
305
+ const text = chunks.join("");
306
+ expect(text).toContain("chunk1");
307
+ expect(text).toContain("chunk2");
308
+ });
309
+
310
+ test("returns SPA shell and splat fallback", async () => {
311
+ for (const path of ["/dashboard", "/dashboard/foo/bar"]) {
312
+ const res = await fetch(new Request(buildUrl(path)));
313
+ expect(res.status).toBe(200);
314
+ const text = await res.text();
315
+ expect(text).toContain('data-moonshine-app="dashboard"');
316
+ expect(text).toContain(
317
+ '<script type="module" src="/client.js"></script>',
318
+ );
319
+ }
320
+ });
321
+
322
+ test("handles API GET", async () => {
323
+ const res = await fetch(
324
+ new Request(buildUrl("/api/method"), {
325
+ headers: { "x-test-header": "42" },
326
+ }),
327
+ );
328
+ expect(res.status).toBe(200);
329
+ expect(res.headers.get("content-type")).toContain("application/json");
330
+ const data = (await res.json()) as Record<string, unknown>;
331
+ expect((data["api-method"] as Record<string, string>).method).toBe("GET");
332
+ expect((data["api-method"] as Record<string, string>).header).toBe("42");
333
+ });
334
+
335
+ test("handles API POST", async () => {
336
+ const res = await fetch(
337
+ new Request(buildUrl("/api/method"), {
338
+ method: "POST",
339
+ headers: { "content-type": "text/plain" },
340
+ body: "payload",
341
+ }),
342
+ );
343
+ expect(res.status).toBe(200);
344
+ const data = (await res.json()) as Record<string, string>;
345
+ expect(data.method).toBe("POST");
346
+ expect(data.body).toBe("payload");
347
+ });
348
+
349
+ test("returns 404 for missing routes", async () => {
350
+ const res = await fetch(new Request(buildUrl("/not-found")));
351
+ expect(res.status).toBe(404);
352
+ });
353
+
354
+ test("preserves redirects", async () => {
355
+ const res = await fetch(
356
+ new Request(buildUrl("/old"), { redirect: "manual" }),
357
+ );
358
+ expect(res.status).toBe(302);
359
+ expect(res.headers.get("location")).toBe("/new");
360
+ });
361
+
362
+ test("forwards request and response headers", async () => {
363
+ const res = await fetch(new Request(buildUrl("/headers")));
364
+ expect(res.status).toBe(200);
365
+ expect(res.headers.get("x-moonshine")).toBe("ok");
366
+ });
367
+
368
+ test("forwards request cancellation", async () => {
369
+ const controller = new AbortController();
370
+ const req = new Request(buildUrl("/slow"), {
371
+ signal: controller.signal,
372
+ });
373
+ const promise = fetch(req);
374
+ await new Promise((r) => setTimeout(r, 100));
375
+ controller.abort();
376
+ await expect(promise).rejects.toThrow();
377
+ await new Promise((r) => setTimeout(r, 600));
378
+ expect(slowAborted).toBe(true);
379
+ const res = await fetch(new Request(buildUrl("/")));
380
+ expect(res.status).toBe(200);
381
+ });
382
+
383
+ test("builds deploy output", async () => {
384
+ const buildDir = mkdtempSync(
385
+ resolve(tmpdir(), `ms-${adapter.name}-build-`),
386
+ );
387
+ const buildManifest: MoonshineManifest = {
388
+ ...manifest,
389
+ assets: manifest.assets.map((a) => ({ ...a })),
390
+ };
391
+ await adapter.build(buildManifest, buildDir);
392
+ expect(existsSync(resolve(buildDir, "app.css"))).toBe(true);
393
+ expect(existsSync(resolve(buildDir, "server.ts"))).toBe(true);
394
+ expect(existsSync(resolve(buildDir, "manifest.json"))).toBe(true);
395
+ const built = readFileSync(resolve(buildDir, "app.css"), "utf8");
396
+ expect(built).toBe(css);
397
+ rmSync(buildDir, { recursive: true, force: true });
398
+ });
399
+
400
+ test("stops gracefully", async () => {
401
+ const h = createHarness(handler, { manifest, staticDir });
402
+ const u = await h.start();
403
+ const res = await fetch(new Request(new URL("/", u).href));
404
+ expect(res.status).toBe(200);
405
+ await res.text();
406
+ await h.stop();
407
+ });
408
+ });
409
+ }