@sigil-dev/grimoire 0.3.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 (52) hide show
  1. package/.grimoire/_routes.dom.js +4 -0
  2. package/.grimoire/_routes.hydrate.js +4 -0
  3. package/.grimoire/_routes.ts +4 -0
  4. package/.grimoire/tsconfig.generated.json +11 -0
  5. package/.grimoire/types/ambient.d.ts +6 -0
  6. package/.grimoire/types/api/hello/$types.d.ts +29 -0
  7. package/README.md +1 -0
  8. package/index.ts +22 -0
  9. package/package.json +36 -0
  10. package/public/__grimoire__/client.js +86 -0
  11. package/public/__grimoire__/hydrate.js +101 -0
  12. package/src/client-router.ts +77 -0
  13. package/src/client.ts +4 -0
  14. package/src/context.ts +10 -0
  15. package/src/cookie-utils.ts +66 -0
  16. package/src/enhance.ts +97 -0
  17. package/src/error.ts +52 -0
  18. package/src/fail.ts +41 -0
  19. package/src/head.ts +27 -0
  20. package/src/headers.ts +114 -0
  21. package/src/hooks.ts +93 -0
  22. package/src/hydrate.ts +22 -0
  23. package/src/manifest-gen.ts +26 -0
  24. package/src/plugins.ts +25 -0
  25. package/src/redirect.ts +35 -0
  26. package/src/renderer.ts +142 -0
  27. package/src/router.ts +94 -0
  28. package/src/scanner.ts +97 -0
  29. package/src/scope.ts +22 -0
  30. package/src/server.ts +318 -0
  31. package/src/ssrPlugin.ts +26 -0
  32. package/src/sync.ts +18 -0
  33. package/src/transform-routes.ts +90 -0
  34. package/src/typegen.ts +263 -0
  35. package/src/types.ts +85 -0
  36. package/src/vite-plugin.ts +72 -0
  37. package/test/context.test.ts +52 -0
  38. package/test/fail.test.ts +46 -0
  39. package/test/headers.test.ts +96 -0
  40. package/test/hydration.test.ts +119 -0
  41. package/test/middleware.test.ts +217 -0
  42. package/test/preload.ts +5 -0
  43. package/test/redirect-error.test.ts +112 -0
  44. package/test/rendering.test.ts +172 -0
  45. package/test/routing.test.ts +45 -0
  46. package/test/scanning.test.ts +55 -0
  47. package/test/scope.test.ts +164 -0
  48. package/test/server.test.ts +30 -0
  49. package/test/streaming.test.ts +132 -0
  50. package/test/transform-routes.test.ts +84 -0
  51. package/test/typegen.test.ts +652 -0
  52. package/tsconfig.json +7 -0
package/src/typegen.ts ADDED
@@ -0,0 +1,263 @@
1
+ import { mkdir, rm, writeFile } from "node:fs/promises";
2
+ import { dirname, isAbsolute, join, relative } from "node:path";
3
+ import type { RouteFile, RouteTree } from "./scanner";
4
+
5
+ export interface TypegenConfig {
6
+ projectRoot: string;
7
+ routesDir: string;
8
+ outDir: string; // absolute path to .grimoire/types
9
+ }
10
+
11
+ export interface RouteGroup {
12
+ dir: string;
13
+ paramNames: string[];
14
+ page?: RouteFile;
15
+ pageServer?: RouteFile;
16
+ layout?: RouteFile;
17
+ layoutServer?: RouteFile;
18
+ server?: RouteFile;
19
+ error?: RouteFile;
20
+ }
21
+
22
+ // On Windows, relative() returns the absolute `to` path when src and dest are on
23
+ // different drives. Detect that and fall back to routes-dir-relative so join()
24
+ // doesn't produce an invalid double-rooted path like "D:\out\C:\Users\...".
25
+ function safeRelativeDir(
26
+ projectRoot: string,
27
+ routesDir: string,
28
+ groupDir: string,
29
+ ): string {
30
+ const rel = relative(projectRoot, groupDir);
31
+ return isAbsolute(rel) ? relative(routesDir, groupDir) : rel;
32
+ }
33
+
34
+ export function groupByDirectory(tree: RouteTree): Map<string, RouteGroup> {
35
+ const groups = new Map<string, RouteGroup>();
36
+
37
+ // Only process + convention files — simple files don't get $types
38
+ const files: RouteFile[] = [
39
+ ...tree.routes.filter((f) => f.type !== "simple"),
40
+ ...tree.layouts,
41
+ ...tree.servers,
42
+ ...tree.errors,
43
+ ];
44
+
45
+ for (const file of files) {
46
+ const dir = dirname(file.filePath);
47
+ if (!groups.has(dir)) {
48
+ groups.set(dir, { dir, paramNames: file.paramNames });
49
+ }
50
+ const g = groups.get(dir)!;
51
+ if (file.paramNames.length > g.paramNames.length) {
52
+ g.paramNames = file.paramNames;
53
+ }
54
+ switch (file.type) {
55
+ case "page":
56
+ g.page = file;
57
+ break;
58
+ case "pageServer":
59
+ g.pageServer = file;
60
+ break;
61
+ case "layout":
62
+ g.layout = file;
63
+ break;
64
+ case "layoutServer":
65
+ g.layoutServer = file;
66
+ break;
67
+ case "server":
68
+ g.server = file;
69
+ break;
70
+ case "error":
71
+ g.error = file;
72
+ break;
73
+ }
74
+ }
75
+
76
+ return groups;
77
+ }
78
+
79
+ export function toImportPath(from: string, to: string): string {
80
+ return relative(from, to)
81
+ .replace(/\\/g, "/")
82
+ .replace(/\.tsx?$/, ".js");
83
+ }
84
+
85
+ export function buildParams(paramNames: string[]): string {
86
+ if (paramNames.length === 0) return "{}";
87
+ const props = paramNames.map((p) => ` ${p}: string;`).join("\n");
88
+ return `{\n${props}\n}`;
89
+ }
90
+
91
+ function generateTypesForGroup(group: RouteGroup, outFileDir: string): string {
92
+ const lines: string[] = [
93
+ "// Auto-generated by @sigil-dev/grimoire — do not edit",
94
+ "",
95
+ ];
96
+
97
+ const paramsType = buildParams(group.paramNames);
98
+
99
+ // --- Params ---
100
+ lines.push(`export type Params = ${paramsType};`, "");
101
+
102
+ // --- PageData from +page.server.ts ---
103
+ if (group.pageServer) {
104
+ const imp = toImportPath(outFileDir, group.pageServer.filePath);
105
+ // Use `typeof import(...)` (not `import type * as NS`) so the module type
106
+ // can be used in `keyof` and indexed access without TS2709.
107
+ lines.push(
108
+ `type _PS = typeof import("${imp}");`,
109
+ `export type PageData = "load" extends keyof _PS`,
110
+ ` ? Awaited<ReturnType<_PS["load"]>>`,
111
+ ` : Record<string, never>;`,
112
+ "",
113
+ );
114
+ lines.push(`export type PageProps = PageData & { params: Params };`, "");
115
+ // PageServerLoad — annotate parameter, NOT return type, to preserve inference
116
+ lines.push(
117
+ `/** Annotate the load() parameter only — not the return type — or PageData loses its concrete keys. */`,
118
+ `export type PageServerLoad = (ctx: {`,
119
+ ` params: Params;`,
120
+ ` request: Request;`,
121
+ ` url: URL;`,
122
+ ` locals: App.Locals;`,
123
+ `}) => Record<string, unknown> | Promise<Record<string, unknown>>;`,
124
+ "",
125
+ );
126
+ // Actions — all exports except load and default
127
+ lines.push(
128
+ `type _ActionKeys = Exclude<keyof _PS, "load" | "default">;`,
129
+ `export type Actions = {`,
130
+ ` [K in _ActionKeys]: (ctx: {`,
131
+ ` params: Params;`,
132
+ ` request: Request;`,
133
+ ` url: URL;`,
134
+ ` locals: App.Locals;`,
135
+ ` }) => unknown | Promise<unknown>;`,
136
+ `};`,
137
+ "",
138
+ );
139
+ } else {
140
+ lines.push(
141
+ `export type PageData = Record<string, never>;`,
142
+ `export type PageProps = { params: Params };`,
143
+ `export type PageServerLoad = never;`,
144
+ `export type Actions = Record<string, never>;`,
145
+ "",
146
+ );
147
+ }
148
+
149
+ // --- LayoutData from +layout.server.ts ---
150
+ if (group.layoutServer) {
151
+ const imp = toImportPath(outFileDir, group.layoutServer.filePath);
152
+ lines.push(
153
+ `type _LS = typeof import("${imp}");`,
154
+ `export type LayoutData = "load" extends keyof _LS`,
155
+ ` ? Awaited<ReturnType<_LS["load"]>>`,
156
+ ` : Record<string, never>;`,
157
+ "",
158
+ );
159
+ } else {
160
+ lines.push(`export type LayoutData = Record<string, never>;`);
161
+ }
162
+
163
+ lines.push(
164
+ `export type LayoutProps = LayoutData & { params: Params; children?: unknown };`,
165
+ `export type LayoutServerLoad = (ctx: {`,
166
+ ` params: Params;`,
167
+ ` request: Request;`,
168
+ ` url: URL;`,
169
+ ` locals: App.Locals;`,
170
+ `}) => Record<string, unknown> | Promise<Record<string, unknown>>;`,
171
+ "",
172
+ );
173
+
174
+ // --- +server.ts API route handlers ---
175
+ if (group.server) {
176
+ const imp = toImportPath(outFileDir, group.server.filePath);
177
+ lines.push(
178
+ `type _SRV = typeof import("${imp}");`,
179
+ `type _HttpMethods = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";`,
180
+ `type _ServerKeys = Extract<keyof _SRV, _HttpMethods>;`,
181
+ `export type ServerHandlers = {`,
182
+ ` [K in _ServerKeys]: (ctx: {`,
183
+ ` params: Params;`,
184
+ ` request: Request;`,
185
+ ` url: URL;`,
186
+ ` locals: App.Locals;`,
187
+ ` }) => Response | Promise<Response>;`,
188
+ `};`,
189
+ "",
190
+ );
191
+ }
192
+
193
+ // --- +error.tsx ---
194
+ if (group.error) {
195
+ lines.push(
196
+ `export type ErrorProps = { status: number; message: string };`,
197
+ "",
198
+ );
199
+ }
200
+
201
+ return lines.join("\n");
202
+ }
203
+
204
+ function generateAmbient(): string {
205
+ return [
206
+ "// Auto-generated by @sigil-dev/grimoire — do not edit",
207
+ "",
208
+ "declare namespace App {",
209
+ " // Extend this interface in your app's src/app.d.ts to add typed locals",
210
+ " interface Locals extends Record<string, unknown> {}",
211
+ "}",
212
+ "",
213
+ ].join("\n");
214
+ }
215
+
216
+ function generateTsConfig(): string {
217
+ return JSON.stringify(
218
+ {
219
+ compilerOptions: {
220
+ // Merges ".." (project root) and "./types" (.grimoire/types) into one
221
+ // virtual root so `import from './$types'` resolves without path aliases.
222
+ rootDirs: ["..", "./types"],
223
+ },
224
+ include: ["types/**/*.d.ts"],
225
+ },
226
+ null,
227
+ 2,
228
+ );
229
+ }
230
+
231
+ export async function generateTypes(
232
+ tree: RouteTree,
233
+ config: TypegenConfig,
234
+ ): Promise<void> {
235
+ const { projectRoot, outDir } = config;
236
+
237
+ await rm(outDir, { recursive: true, force: true });
238
+ await mkdir(outDir, { recursive: true });
239
+
240
+ const groups = groupByDirectory(tree);
241
+
242
+ for (const group of groups.values()) {
243
+ const routeRelDir = safeRelativeDir(
244
+ projectRoot,
245
+ config.routesDir,
246
+ group.dir,
247
+ );
248
+ const outFileDir = join(outDir, routeRelDir);
249
+ await mkdir(outFileDir, { recursive: true });
250
+
251
+ const content = generateTypesForGroup(group, outFileDir);
252
+ await writeFile(join(outFileDir, "$types.d.ts"), content, "utf-8");
253
+ }
254
+
255
+ await writeFile(join(outDir, "ambient.d.ts"), generateAmbient(), "utf-8");
256
+
257
+ const grimoireDir = dirname(outDir);
258
+ await writeFile(
259
+ join(grimoireDir, "tsconfig.generated.json"),
260
+ generateTsConfig(),
261
+ "utf-8",
262
+ );
263
+ }
package/src/types.ts ADDED
@@ -0,0 +1,85 @@
1
+ export interface Route {
2
+ path: string; // /blog/:slug
3
+ params: Record<string, string>;
4
+ filePath: string; // absolute path to +page.tsx
5
+ loadPath?: string; // absolute path to +page.ts
6
+ serverPath?: string; // absolute path to +server.ts
7
+ layoutPath?: string; // absolute path to +layout.tsx
8
+ }
9
+
10
+ export interface RouteInfo {
11
+ path: string; // /blog/:slug
12
+ filePath: string; // absolute path
13
+ type: string; // page, layout etc
14
+ }
15
+
16
+ export interface LoadContext {
17
+ request: Request;
18
+ params: Record<string, string>;
19
+ url: URL;
20
+ locals: Record<string, unknown>; // set by plugins/hooks
21
+ }
22
+
23
+ export interface TypedLoadContext<
24
+ P extends Record<string, string> = Record<string, string>,
25
+ > {
26
+ request: Request;
27
+ params: P;
28
+ url: URL;
29
+ locals: App.Locals;
30
+ }
31
+
32
+ export interface RenderContext {
33
+ route: RouteInfo;
34
+ request: Request;
35
+ params: Record<string, string>;
36
+ data: unknown; // return value of load()
37
+ }
38
+
39
+ export interface BuildResult {
40
+ success: boolean;
41
+ outputs: string[];
42
+ errors: string[];
43
+ }
44
+ export type Server = { port: number; hostname: string; stop(): void };
45
+
46
+ export interface GrimoirePlugin {
47
+ name: string;
48
+
49
+ // server lifecycle
50
+ onStart?(server: Server): void | Promise<void>;
51
+ onStop?(): void | Promise<void>;
52
+
53
+ // request pipeline
54
+ onRequest?(
55
+ req: Request,
56
+ next: () => Promise<Response>,
57
+ ): Response | Promise<Response>;
58
+
59
+ // route lifecycle
60
+ onRouteLoad?(route: Route, context: LoadContext): void | Promise<void>;
61
+ onRouteRender?(
62
+ html: string,
63
+ context: RenderContext,
64
+ ): string | Promise<string>;
65
+
66
+ // build
67
+ onBuildStart?(): void | Promise<void>;
68
+ onBuildEnd?(result: BuildResult): void | Promise<void>;
69
+
70
+ // config
71
+ config?: (config: GrimoireConfig) => GrimoireConfig | undefined;
72
+ }
73
+
74
+ export interface GrimoireConfig {
75
+ port?: number;
76
+ host?: string;
77
+ plugins?: GrimoirePlugin[];
78
+ routes?: string; // glob, default 'src/routes/**';
79
+ dev?: boolean;
80
+ vitePort?: number;
81
+ }
82
+
83
+ export function defineConfig(config: GrimoireConfig): GrimoireConfig {
84
+ return config;
85
+ }
@@ -0,0 +1,72 @@
1
+ import { isAbsolute, join, resolve } from "node:path";
2
+ import type { Plugin } from "vite";
3
+ import { renderRoute } from "./renderer";
4
+ import { matchRoute } from "./router";
5
+ import { scanRoutes } from "./scanner";
6
+
7
+ const CLIENT_ENTRY = resolve(import.meta.dir, "./client.ts");
8
+
9
+ export function grimoire(options: { routes?: string } = {}): Plugin {
10
+ let isBuild = false;
11
+
12
+ return {
13
+ name: "grimoire",
14
+
15
+ configResolved(config) {
16
+ isBuild = config.command === "build";
17
+ },
18
+
19
+ configureServer(vite) {
20
+ const routesDir = isAbsolute(options.routes ?? "src/routes")
21
+ ? options.routes!
22
+ : join(process.cwd(), options.routes ?? "src/routes");
23
+
24
+ // client entry
25
+ vite.middlewares.use("/__grimoire__/client.js", async (req, res) => {
26
+ const result = await vite.transformRequest(CLIENT_ENTRY);
27
+ if (!result) {
28
+ res.statusCode = 404;
29
+ res.end();
30
+ return;
31
+ }
32
+ res.setHeader("Content-Type", "application/javascript");
33
+ res.end(result.code);
34
+ });
35
+
36
+ // page routes
37
+ vite.middlewares.use(async (req, res, next) => {
38
+ const url = new URL(req.url!, "http://localhost");
39
+ if (url.pathname.startsWith("/__") || url.pathname.includes(".")) {
40
+ return next();
41
+ }
42
+ try {
43
+ const tree = await scanRoutes(routesDir, process.cwd());
44
+ const matched = matchRoute(tree, url);
45
+ if (!matched) return next();
46
+
47
+ const response = await renderRoute(
48
+ matched,
49
+ new Request(`http://localhost${req.url}`),
50
+ (path) => vite.ssrLoadModule(path), // Vite transforms SSR files correctly
51
+ );
52
+
53
+ const html = await response.text();
54
+ res.setHeader("Content-Type", "text/html");
55
+ res.end(html);
56
+ } catch (e) {
57
+ vite.ssrFixStacktrace(e as Error);
58
+ next(e);
59
+ }
60
+ });
61
+ },
62
+
63
+ buildStart() {
64
+ if (!isBuild) return;
65
+ this.emitFile({
66
+ type: "chunk",
67
+ id: CLIENT_ENTRY,
68
+ fileName: "client.js",
69
+ });
70
+ },
71
+ };
72
+ }
@@ -0,0 +1,52 @@
1
+ // packages/grimoire/src/context.test.ts
2
+ import { describe, expect, test } from "bun:test";
3
+ import { createContext, getContext, setContext } from "@sigil-dev/runtime";
4
+ import { runWithContext } from "../src/context";
5
+
6
+ const UserKey = createContext<string>();
7
+ const ThemeKey = createContext<string>();
8
+
9
+ // 1. Basic: does it work at all
10
+ test("getContext returns value set in same context", async () => {
11
+ await runWithContext(async () => {
12
+ setContext(UserKey, "cane");
13
+ expect(getContext(UserKey)).toBe("cane");
14
+ });
15
+ });
16
+
17
+ // 2. Isolation: the whole point of AsyncLocalStorage
18
+ test("concurrent requests do not bleed context", async () => {
19
+ let resolveA!: () => void;
20
+ let resolveB!: () => void;
21
+
22
+ const barrier = {
23
+ a: new Promise<void>((r) => (resolveA = r)),
24
+ b: new Promise<void>((r) => (resolveB = r)),
25
+ };
26
+
27
+ const requestA = runWithContext(async () => {
28
+ setContext(UserKey, "request-A");
29
+ await barrier.a; // suspend, let B run
30
+ // if context bleeds, this returns "request-B"
31
+ return getContext(UserKey);
32
+ });
33
+
34
+ const requestB = runWithContext(async () => {
35
+ setContext(UserKey, "request-B");
36
+ resolveA(); // wake A up
37
+ await barrier.b;
38
+ return getContext(UserKey);
39
+ });
40
+
41
+ resolveB();
42
+ const [a, b] = await Promise.all([requestA, requestB]);
43
+
44
+ expect(a).toBe("request-A"); // fails if bleed
45
+ expect(b).toBe("request-B");
46
+ });
47
+
48
+ // 3. Outside context: should not throw, returns undefined
49
+ test("getContext outside runWithContext returns undefined gracefully", () => {
50
+ const val = getContext(UserKey);
51
+ expect(val).toBeUndefined();
52
+ });
@@ -0,0 +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
+ });
@@ -0,0 +1,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("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
+ });