@sigil-dev/grimoire 0.4.0 → 0.6.0

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 (73) hide show
  1. package/README.md +174 -174
  2. package/index.ts +47 -23
  3. package/package.json +6 -6
  4. package/src/{enhance.ts → client/enhance.ts} +2 -1
  5. package/src/client/index.ts +5 -0
  6. package/src/{client-router.ts → client/router.ts} +1 -1
  7. package/src/{vite-plugin.ts → integrations/vite.ts} +4 -4
  8. package/src/{hydrate.ts → rendering/hydrate.ts} +2 -2
  9. package/src/{renderer.ts → rendering/index.ts} +39 -15
  10. package/src/{ssrPlugin.ts → rendering/ssrPlugin.ts} +8 -2
  11. package/src/{scanner.ts → routing/scanner.ts} +16 -4
  12. package/src/{transform-routes.ts → routing/transform-routes.ts} +7 -1
  13. package/src/{fail.ts → sentinels/fail.ts} +1 -1
  14. package/src/server/build.ts +90 -0
  15. package/src/{cookie-utils.ts → server/cookie-utils.ts} +66 -66
  16. package/src/server/coordinator.ts +297 -0
  17. package/src/{hooks.ts → server/hooks.ts} +1 -1
  18. package/src/{server.ts → server/index.ts} +153 -105
  19. package/src/server/plugins.ts +119 -0
  20. package/src/server/worker.ts +59 -0
  21. package/src/{typegen.ts → typegen/index.ts} +81 -4
  22. package/src/types.ts +176 -1
  23. package/test/context.test.ts +1 -1
  24. package/test/fail.test.ts +46 -46
  25. package/test/headers.test.ts +100 -96
  26. package/test/hydration.test.ts +1 -1
  27. package/test/middleware.test.ts +221 -217
  28. package/test/preload.ts +1 -1
  29. package/test/redirect-error.test.ts +112 -112
  30. package/test/rendering.test.ts +319 -310
  31. package/test/routing.test.ts +2 -2
  32. package/test/scanning.test.ts +40 -11
  33. package/test/scope.test.ts +25 -10
  34. package/test/server.test.ts +150 -1
  35. package/test/streaming.test.ts +145 -132
  36. package/test/transform-routes.test.ts +2 -2
  37. package/test/typegen.test.ts +10 -8
  38. package/tsconfig.json +3 -1
  39. package/.grimoire/_routes.dom.js +0 -4
  40. package/.grimoire/_routes.hydrate.js +0 -4
  41. package/.grimoire/_routes.ts +0 -4
  42. package/.grimoire/compiled/0-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503001657_protected__page.dom.js +0 -9
  43. package/.grimoire/compiled/0-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503001657_protected__page.hydrate.js +0 -11
  44. package/.grimoire/compiled/0-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503161592_protected__page.dom.js +0 -4
  45. package/.grimoire/compiled/0-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503161592_protected__page.hydrate.js +0 -4
  46. package/.grimoire/compiled/0-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503395872_login.dom.js +0 -4
  47. package/.grimoire/compiled/0-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503395872_login.hydrate.js +0 -4
  48. package/.grimoire/compiled/0-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503614350_login.dom.js +0 -4
  49. package/.grimoire/compiled/0-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503614350_login.hydrate.js +0 -4
  50. package/.grimoire/compiled/1-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503001657_login__page.dom.js +0 -8
  51. package/.grimoire/compiled/1-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503001657_login__page.hydrate.js +0 -9
  52. package/.grimoire/compiled/1-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503161592_login__page.dom.js +0 -4
  53. package/.grimoire/compiled/1-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503161592_login__page.hydrate.js +0 -4
  54. package/.grimoire/compiled/1-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503395872_protected.dom.js +0 -4
  55. package/.grimoire/compiled/1-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503395872_protected.hydrate.js +0 -4
  56. package/.grimoire/compiled/1-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503614350_protected.dom.js +0 -4
  57. package/.grimoire/compiled/1-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503614350_protected.hydrate.js +0 -4
  58. package/.grimoire/tsconfig.generated.json +0 -11
  59. package/.grimoire/types/ambient.d.ts +0 -6
  60. package/.grimoire/types/api/hello/$types.d.ts +0 -29
  61. package/.grimoire/types/api/items/$types.d.ts +0 -29
  62. package/public/__grimoire__/client.js +0 -86
  63. package/public/__grimoire__/hydrate.js +0 -101
  64. package/src/client.ts +0 -4
  65. package/src/plugins.ts +0 -25
  66. package/src/sync.ts +0 -18
  67. /package/src/{scope.ts → client/scope.ts} +0 -0
  68. /package/src/{head.ts → rendering/head.ts} +0 -0
  69. /package/src/{manifest-gen.ts → routing/manifest-gen.ts} +0 -0
  70. /package/src/{router.ts → routing/router.ts} +0 -0
  71. /package/src/{error.ts → sentinels/error.ts} +0 -0
  72. /package/src/{redirect.ts → sentinels/redirect.ts} +0 -0
  73. /package/src/{context.ts → server/context.ts} +0 -0
package/src/types.ts CHANGED
@@ -1,3 +1,14 @@
1
+ import type { ServerWebSocket } from "bun";
2
+ import type { RouteFile, RouteTree } from "./routing/scanner";
3
+
4
+ /// <reference types="bun-types">
5
+ declare global {
6
+ namespace App {
7
+ // augment in your own project
8
+ interface Locals {}
9
+ }
10
+ }
11
+
1
12
  export interface Route {
2
13
  path: string; // /blog/:slug
3
14
  params: Record<string, string>;
@@ -48,7 +59,7 @@ export interface GrimoirePlugin {
48
59
 
49
60
  // server lifecycle
50
61
  onStart?(server: Server): void | Promise<void>;
51
- onStop?(): void | Promise<void>;
62
+ onStop?(reason: "shutdown" | "restart"): void | Promise<void>;
52
63
 
53
64
  // request pipeline
54
65
  onRequest?(
@@ -58,6 +69,13 @@ export interface GrimoirePlugin {
58
69
 
59
70
  // route lifecycle
60
71
  onRouteLoad?(route: Route, context: LoadContext): void | Promise<void>;
72
+ /**
73
+ * Called after the page HTML is fully rendered.
74
+ * NOTE: When any plugin implements this hook, the streaming response is
75
+ * buffered into a single string. Streaming is preserved for routes where
76
+ * no plugin uses this hook. To avoid buffering, use onRequest to intercept
77
+ * at the Response level.
78
+ */
61
79
  onRouteRender?(
62
80
  html: string,
63
81
  context: RenderContext,
@@ -69,6 +87,79 @@ export interface GrimoirePlugin {
69
87
 
70
88
  // config
71
89
  config?: (config: GrimoireConfig) => GrimoireConfig | undefined;
90
+
91
+ // process lifecycle
92
+
93
+ /**
94
+ * Runs AFTER Sigil/Babel compilation. Receives compiled JS, not source TSX.
95
+ * Suitable for string-level transforms (import rewriting, comment injection, etc.).
96
+ * Return null/undefined to pass through unchanged.
97
+ */
98
+ transform?(
99
+ code: string,
100
+ id: string,
101
+ ): string | null | undefined | Promise<string | null | undefined>;
102
+
103
+ /**
104
+ * Fires once after all workers are spawned and ready.
105
+ * Use for coordinator-level setup (e.g. opening a shared store).
106
+ */
107
+ onCoordinatorStart?(ctx: CoordinatorContext): void | Promise<void>;
108
+
109
+ /**
110
+ * Fires for each worker before Bun.spawn is called.
111
+ * Return WorkerEnv to inject env vars or set the worker name.
112
+ * ALL plugins are called and results are merged.
113
+ */
114
+ // biome-ignore lint/suspicious/noConfusingVoidType: doesn't have to return a thing.
115
+ onWorkerSpawn?(
116
+ worker: WorkerDescriptor,
117
+ ): WorkerEnv | void | Promise<WorkerEnv | void>;
118
+
119
+ /**
120
+ * Fires when a worker process is up and accepting requests.
121
+ */
122
+ onWorkerReady?(worker: WorkerDescriptor): void | Promise<void>;
123
+
124
+ /**
125
+ * Fires when a worker process exits.
126
+ * reason "crash" = unexpected exit, "intentional" = coordinator stopped it.
127
+ * Use for cleanup or respawn logic (see plugin-scale).
128
+ */
129
+ onWorkerDeath?(
130
+ worker: WorkerDescriptor,
131
+ reason: "crash" | "intentional",
132
+ ): void | Promise<void>;
133
+
134
+ // ── Locals boundary ────────────────────────────────────────
135
+
136
+ /**
137
+ * Called by coordinator before forwarding a request to a worker.
138
+ * Return a serialized string representation of locals.
139
+ * First plugin to return non-null wins. Default: signed JSON header.
140
+ */
141
+ serializeLocals?(locals: App.Locals): string | Promise<string>;
142
+
143
+ /**
144
+ * Called by worker on receiving a forwarded request.
145
+ * Return the deserialized locals object.
146
+ * First plugin to return non-null wins. Default: verify + JSON.parse.
147
+ */
148
+ deserializeLocals?(raw: string): App.Locals | Promise<App.Locals>;
149
+
150
+ // ── Routing policy ─────────────────────────────────────────
151
+
152
+ /**
153
+ * Called by coordinator to decide which worker handles a request.
154
+ * First plugin to return non-null wins.
155
+ * Default: ws routes → consistent hash, everything else → round robin.
156
+ * routes is the full RouteTree so plugins can make route-aware decisions.
157
+ */
158
+ routeRequest?(
159
+ req: Request,
160
+ workers: WorkerDescriptor[],
161
+ routes: RouteTree,
162
+ ): WorkerDescriptor | Promise<WorkerDescriptor>;
72
163
  }
73
164
 
74
165
  export interface GrimoireConfig {
@@ -78,8 +169,92 @@ export interface GrimoireConfig {
78
169
  routes?: string; // glob, default 'src/routes/**';
79
170
  dev?: boolean;
80
171
  vitePort?: number;
172
+ _skipBuild?: boolean;
81
173
  }
82
174
 
83
175
  export function defineConfig(config: GrimoireConfig): GrimoireConfig {
84
176
  return config;
85
177
  }
178
+
179
+ /** Generic load type for +page.server.ts. For per-route typed params import PageServerLoad from './$types'. */
180
+ export type PageServerLoad<
181
+ P extends Record<string, string> = Record<string, string>,
182
+ > = (
183
+ ctx: TypedLoadContext<P>,
184
+ ) => Record<string, unknown> | Promise<Record<string, unknown>>;
185
+
186
+ /** Generic load type for +layout.server.ts. For per-route typed params import LayoutServerLoad from './$types'. */
187
+ export type LayoutServerLoad<
188
+ P extends Record<string, string> = Record<string, string>,
189
+ > = (
190
+ ctx: TypedLoadContext<P>,
191
+ ) => Record<string, unknown> | Promise<Record<string, unknown>>;
192
+
193
+ /** Generic handler type for +server.ts API routes. For per-route typed params import RequestHandler from './$types'. */
194
+ export type RequestHandler<
195
+ P extends Record<string, string> = Record<string, string>,
196
+ > = (ctx: TypedLoadContext<P>) => Response | Promise<Response>;
197
+
198
+ /**
199
+ * Shape of the `websocket` export in a +server.ts file.
200
+ * T is the resolved ws.data shape (params + upgrade() return value).
201
+ * For per-route typing import WebSocketHandler from './$types'.
202
+ */
203
+ export interface WsRouteHandler<T = Record<string, unknown>> {
204
+ open?(ws: ServerWebSocket<T>): void | Promise<void>;
205
+ message?(ws: ServerWebSocket<T>, data: string | Buffer): void | Promise<void>;
206
+ close?(
207
+ ws: ServerWebSocket<T>,
208
+ code: number,
209
+ reason?: string,
210
+ ): void | Promise<void>;
211
+ drain?(ws: ServerWebSocket<T>): void | Promise<void>;
212
+ }
213
+
214
+ // ***BIG ONE***
215
+
216
+ /**
217
+ * Built-in worker modes understood by the coordinator.
218
+ * Plugins may define custom modes via WorkerMode.
219
+ */
220
+ export type BuiltinWorkerMode = "api" | "frontend" | "ws" | "full";
221
+
222
+ /**
223
+ * Open union — (string & {}) preserves autocomplete for built-in
224
+ * values while allowing plugin-defined modes like "db" or "queue".
225
+ */
226
+ export type WorkerMode = BuiltinWorkerMode | (string & {});
227
+
228
+ /**
229
+ * Describes a single worker process managed by the coordinator.
230
+ * Coordinator derives behavior from routes, not mode string —
231
+ * mode is informational and for plugin use only.
232
+ */
233
+ export interface WorkerDescriptor {
234
+ mode: WorkerMode;
235
+ index: number; // index within this mode (api-0, api-1...)
236
+ globalIndex: number; // index across all workers
237
+ internalUrl: string; // coordinator → worker base URL
238
+ name?: string; // set by namepool or onWorkerSpawn
239
+ routes: RouteFile[]; // route slice assigned by coordinator
240
+ pid?: number; // set after Bun.spawn
241
+ }
242
+
243
+ /**
244
+ * Return value of onWorkerSpawn. All plugins are called and results
245
+ * are merged — env from all plugins is combined, last name wins.
246
+ */
247
+ export interface WorkerEnv {
248
+ env?: Record<string, string>;
249
+ name?: string;
250
+ }
251
+
252
+ /**
253
+ * Passed to onCoordinatorStart once the coordinator is fully initialized.
254
+ */
255
+ export interface CoordinatorContext {
256
+ workers: WorkerDescriptor[];
257
+ port: number;
258
+ secret: string; // ephemeral signing secret, generated per-run
259
+ routes: RouteTree;
260
+ }
@@ -1,7 +1,7 @@
1
1
  // packages/grimoire/src/context.test.ts
2
2
  import { describe, expect, test } from "bun:test";
3
3
  import { createContext, getContext, setContext } from "@sigil-dev/runtime";
4
- import { runWithContext } from "../src/context";
4
+ import { runWithContext } from "../src/server/context";
5
5
 
6
6
  const UserKey = createContext<string>();
7
7
  const ThemeKey = createContext<string>();
package/test/fail.test.ts CHANGED
@@ -1,46 +1,46 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { fail, isFailResult } from "../src/fail";
3
-
4
- describe("fail()", () => {
5
- test("returns a FailResult with status and data", () => {
6
- const result = fail(400, { name: "required" });
7
- expect(result).toEqual({
8
- __fail: true,
9
- status: 400,
10
- data: { name: "required" },
11
- });
12
- });
13
-
14
- test("isFailResult returns true for fail()", () => {
15
- const result = fail(422, { errors: { email: "invalid" } });
16
- expect(isFailResult(result)).toBe(true);
17
- });
18
-
19
- test("isFailResult returns false for plain objects", () => {
20
- expect(isFailResult({ redirect: "/foo" })).toBe(false);
21
- expect(isFailResult(null)).toBe(false);
22
- expect(isFailResult(undefined)).toBe(false);
23
- expect(isFailResult("string")).toBe(false);
24
- });
25
-
26
- test("preserves complex data structures", () => {
27
- const data = {
28
- formData: { email: "test@example.com", name: "" },
29
- errors: {
30
- name: "Name is required",
31
- email: null,
32
- },
33
- timestamp: Date.now(),
34
- };
35
- const result = fail(400, data);
36
- expect(result.data).toEqual(data);
37
- expect(result.status).toBe(400);
38
- });
39
-
40
- test("works with different status codes", () => {
41
- expect(fail(400, {}).status).toBe(400);
42
- expect(fail(403, {}).status).toBe(403);
43
- expect(fail(422, {}).status).toBe(422);
44
- expect(fail(500, {}).status).toBe(500);
45
- });
46
- });
1
+ import { describe, expect, test } from "bun:test";
2
+ import { fail, isFailResult } from "../src/sentinels/fail.ts";
3
+
4
+ describe("fail()", () => {
5
+ test("returns a FailResult with status and data", () => {
6
+ const result = fail(400, { name: "required" });
7
+ expect(result).toEqual({
8
+ __fail: true,
9
+ status: 400,
10
+ data: { name: "required" },
11
+ });
12
+ });
13
+
14
+ test("isFailResult returns true for fail()", () => {
15
+ const result = fail(422, { errors: { email: "invalid" } });
16
+ expect(isFailResult(result)).toBe(true);
17
+ });
18
+
19
+ test("isFailResult returns false for plain objects", () => {
20
+ expect(isFailResult({ redirect: "/foo" })).toBe(false);
21
+ expect(isFailResult(null)).toBe(false);
22
+ expect(isFailResult(undefined)).toBe(false);
23
+ expect(isFailResult("string")).toBe(false);
24
+ });
25
+
26
+ test("preserves complex data structures", () => {
27
+ const data = {
28
+ formData: { email: "test@example.com", name: "" },
29
+ errors: {
30
+ name: "Name is required",
31
+ email: null,
32
+ },
33
+ timestamp: Date.now(),
34
+ };
35
+ const result = fail(400, data);
36
+ expect(result.data).toEqual(data);
37
+ expect(result.status).toBe(400);
38
+ });
39
+
40
+ test("works with different status codes", () => {
41
+ expect(fail(400, {}).status).toBe(400);
42
+ expect(fail(403, {}).status).toBe(403);
43
+ expect(fail(422, {}).status).toBe(422);
44
+ expect(fail(500, {}).status).toBe(500);
45
+ });
46
+ });
@@ -1,96 +1,100 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { securityHeaders } from "../src/headers";
3
-
4
- function fakeRequest(path = "/") {
5
- return new Request(`http://localhost${path}`);
6
- }
7
-
8
- async function runPlugin(
9
- config: Parameters<typeof securityHeaders>[0],
10
- req?: Request,
11
- ): Promise<Headers> {
12
- const plugin = securityHeaders(config);
13
- const res = new Response("<html></html>", {
14
- headers: { "Content-Type": "text/html" },
15
- });
16
- const result = await plugin.onRequest!(req ?? fakeRequest(), async () => res);
17
- return result.headers;
18
- }
19
-
20
- describe("securityHeaders()", () => {
21
- test("applies default headers", async () => {
22
- const headers = await runPlugin({});
23
- expect(headers.get("X-Content-Type-Options")).toBe("nosniff");
24
- expect(headers.get("X-Frame-Options")).toBe("DENY");
25
- expect(headers.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin");
26
- expect(headers.get("Permissions-Policy")).toBe(
27
- "camera=(), microphone=(), geolocation=()",
28
- );
29
- expect(headers.get("X-XSS-Protection")).toBe("0");
30
- });
31
-
32
- test("applies CSP by default", async () => {
33
- const headers = await runPlugin({});
34
- expect(headers.get("Content-Security-Policy")).toContain("default-src 'self'");
35
- });
36
-
37
- test("allows overriding individual headers", async () => {
38
- const headers = await runPlugin({
39
- frameOptions: "SAMEORIGIN",
40
- contentTypeOptions: false,
41
- });
42
- expect(headers.get("X-Frame-Options")).toBe("SAMEORIGIN");
43
- expect(headers.has("X-Content-Type-Options")).toBe(false);
44
- });
45
-
46
- test("disables headers when set to false", async () => {
47
- const headers = await runPlugin({
48
- contentSecurityPolicy: false,
49
- strictTransportSecurity: false,
50
- permissionsPolicy: false,
51
- });
52
- expect(headers.has("Content-Security-Policy")).toBe(false);
53
- expect(headers.has("Strict-Transport-Security")).toBe(false);
54
- expect(headers.has("Permissions-Policy")).toBe(false);
55
- });
56
-
57
- test("applies route overrides", async () => {
58
- const headers = await runPlugin(
59
- {
60
- frameOptions: "DENY",
61
- routes: {
62
- "/admin": { frameOptions: "SAMEORIGIN" },
63
- },
64
- },
65
- fakeRequest("/admin/settings"),
66
- );
67
- expect(headers.get("X-Frame-Options")).toBe("SAMEORIGIN");
68
- });
69
-
70
- test("route override only applies to matching prefix", async () => {
71
- const headers = await runPlugin(
72
- {
73
- frameOptions: "DENY",
74
- routes: {
75
- "/admin": { frameOptions: "SAMEORIGIN" },
76
- },
77
- },
78
- fakeRequest("/dashboard"),
79
- );
80
- expect(headers.get("X-Frame-Options")).toBe("DENY");
81
- });
82
-
83
- test("preserves existing response headers", async () => {
84
- const plugin = securityHeaders({});
85
- const res = new Response("<html></html>", {
86
- headers: {
87
- "Content-Type": "text/html",
88
- "Set-Cookie": "session=abc",
89
- },
90
- });
91
- const result = await plugin.onRequest!(fakeRequest(), async () => res);
92
- expect(result.headers.get("Content-Type")).toBe("text/html");
93
- expect(result.headers.get("Set-Cookie")).toBe("session=abc");
94
- expect(result.headers.get("X-Frame-Options")).toBe("DENY");
95
- });
96
- });
1
+ import { describe, expect, test } from "bun:test";
2
+ import { securityHeaders } from "../src/headers";
3
+
4
+ function fakeRequest(path = "/") {
5
+ return new Request(`http://localhost${path}`);
6
+ }
7
+
8
+ async function runPlugin(
9
+ config: Parameters<typeof securityHeaders>[0],
10
+ req?: Request,
11
+ ): Promise<Headers> {
12
+ const plugin = securityHeaders(config);
13
+ const res = new Response("<html></html>", {
14
+ headers: { "Content-Type": "text/html" },
15
+ });
16
+ const result = await plugin.onRequest!(req ?? fakeRequest(), async () => res);
17
+ return result.headers;
18
+ }
19
+
20
+ describe("securityHeaders()", () => {
21
+ test("applies default headers", async () => {
22
+ const headers = await runPlugin({});
23
+ expect(headers.get("X-Content-Type-Options")).toBe("nosniff");
24
+ expect(headers.get("X-Frame-Options")).toBe("DENY");
25
+ expect(headers.get("Referrer-Policy")).toBe(
26
+ "strict-origin-when-cross-origin",
27
+ );
28
+ expect(headers.get("Permissions-Policy")).toBe(
29
+ "camera=(), microphone=(), geolocation=()",
30
+ );
31
+ expect(headers.get("X-XSS-Protection")).toBe("0");
32
+ });
33
+
34
+ test("applies CSP by default", async () => {
35
+ const headers = await runPlugin({});
36
+ expect(headers.get("Content-Security-Policy")).toContain(
37
+ "default-src 'self'",
38
+ );
39
+ });
40
+
41
+ test("allows overriding individual headers", async () => {
42
+ const headers = await runPlugin({
43
+ frameOptions: "SAMEORIGIN",
44
+ contentTypeOptions: false,
45
+ });
46
+ expect(headers.get("X-Frame-Options")).toBe("SAMEORIGIN");
47
+ expect(headers.has("X-Content-Type-Options")).toBe(false);
48
+ });
49
+
50
+ test("disables headers when set to false", async () => {
51
+ const headers = await runPlugin({
52
+ contentSecurityPolicy: false,
53
+ strictTransportSecurity: false,
54
+ permissionsPolicy: false,
55
+ });
56
+ expect(headers.has("Content-Security-Policy")).toBe(false);
57
+ expect(headers.has("Strict-Transport-Security")).toBe(false);
58
+ expect(headers.has("Permissions-Policy")).toBe(false);
59
+ });
60
+
61
+ test("applies route overrides", async () => {
62
+ const headers = await runPlugin(
63
+ {
64
+ frameOptions: "DENY",
65
+ routes: {
66
+ "/admin": { frameOptions: "SAMEORIGIN" },
67
+ },
68
+ },
69
+ fakeRequest("/admin/settings"),
70
+ );
71
+ expect(headers.get("X-Frame-Options")).toBe("SAMEORIGIN");
72
+ });
73
+
74
+ test("route override only applies to matching prefix", async () => {
75
+ const headers = await runPlugin(
76
+ {
77
+ frameOptions: "DENY",
78
+ routes: {
79
+ "/admin": { frameOptions: "SAMEORIGIN" },
80
+ },
81
+ },
82
+ fakeRequest("/dashboard"),
83
+ );
84
+ expect(headers.get("X-Frame-Options")).toBe("DENY");
85
+ });
86
+
87
+ test("preserves existing response headers", async () => {
88
+ const plugin = securityHeaders({});
89
+ const res = new Response("<html></html>", {
90
+ headers: {
91
+ "Content-Type": "text/html",
92
+ "Set-Cookie": "session=abc",
93
+ },
94
+ });
95
+ const result = await plugin.onRequest!(fakeRequest(), async () => res);
96
+ expect(result.headers.get("Content-Type")).toBe("text/html");
97
+ expect(result.headers.get("Set-Cookie")).toBe("session=abc");
98
+ expect(result.headers.get("X-Frame-Options")).toBe("DENY");
99
+ });
100
+ });
@@ -38,7 +38,7 @@ function runHydrate(routes: Record<string, (props: any) => any>) {
38
38
  }
39
39
  }
40
40
 
41
- // simulate initRouter (same logic as client-router.ts)
41
+ // simulate initRouter (same logic as router.ts)
42
42
  let clickHandlerAttached = false;
43
43
  function initRouter(routes: Record<string, (props: any) => any>) {
44
44
  document.addEventListener("click", () => {});