@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.
- package/README.md +174 -174
- package/index.ts +47 -23
- package/package.json +6 -6
- package/src/{enhance.ts → client/enhance.ts} +2 -1
- package/src/client/index.ts +5 -0
- package/src/{client-router.ts → client/router.ts} +1 -1
- package/src/{vite-plugin.ts → integrations/vite.ts} +4 -4
- package/src/{hydrate.ts → rendering/hydrate.ts} +2 -2
- package/src/{renderer.ts → rendering/index.ts} +39 -15
- package/src/{ssrPlugin.ts → rendering/ssrPlugin.ts} +8 -2
- package/src/{scanner.ts → routing/scanner.ts} +16 -4
- package/src/{transform-routes.ts → routing/transform-routes.ts} +7 -1
- package/src/{fail.ts → sentinels/fail.ts} +1 -1
- package/src/server/build.ts +90 -0
- package/src/{cookie-utils.ts → server/cookie-utils.ts} +66 -66
- package/src/server/coordinator.ts +297 -0
- package/src/{hooks.ts → server/hooks.ts} +1 -1
- package/src/{server.ts → server/index.ts} +153 -105
- package/src/server/plugins.ts +119 -0
- package/src/server/worker.ts +59 -0
- package/src/{typegen.ts → typegen/index.ts} +81 -4
- package/src/types.ts +176 -1
- package/test/context.test.ts +1 -1
- package/test/fail.test.ts +46 -46
- package/test/headers.test.ts +100 -96
- package/test/hydration.test.ts +1 -1
- package/test/middleware.test.ts +221 -217
- package/test/preload.ts +1 -1
- package/test/redirect-error.test.ts +112 -112
- package/test/rendering.test.ts +319 -310
- package/test/routing.test.ts +2 -2
- package/test/scanning.test.ts +40 -11
- package/test/scope.test.ts +25 -10
- package/test/server.test.ts +150 -1
- package/test/streaming.test.ts +145 -132
- package/test/transform-routes.test.ts +2 -2
- package/test/typegen.test.ts +10 -8
- package/tsconfig.json +3 -1
- package/.grimoire/_routes.dom.js +0 -4
- package/.grimoire/_routes.hydrate.js +0 -4
- package/.grimoire/_routes.ts +0 -4
- package/.grimoire/compiled/0-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503001657_protected__page.dom.js +0 -9
- package/.grimoire/compiled/0-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503001657_protected__page.hydrate.js +0 -11
- package/.grimoire/compiled/0-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503161592_protected__page.dom.js +0 -4
- package/.grimoire/compiled/0-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503161592_protected__page.hydrate.js +0 -4
- package/.grimoire/compiled/0-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503395872_login.dom.js +0 -4
- package/.grimoire/compiled/0-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503395872_login.hydrate.js +0 -4
- package/.grimoire/compiled/0-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503614350_login.dom.js +0 -4
- package/.grimoire/compiled/0-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503614350_login.hydrate.js +0 -4
- package/.grimoire/compiled/1-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503001657_login__page.dom.js +0 -8
- package/.grimoire/compiled/1-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503001657_login__page.hydrate.js +0 -9
- package/.grimoire/compiled/1-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503161592_login__page.dom.js +0 -4
- package/.grimoire/compiled/1-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503161592_login__page.hydrate.js +0 -4
- package/.grimoire/compiled/1-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503395872_protected.dom.js +0 -4
- package/.grimoire/compiled/1-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503395872_protected.hydrate.js +0 -4
- package/.grimoire/compiled/1-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503614350_protected.dom.js +0 -4
- package/.grimoire/compiled/1-C__Users_Cane1712_AppData_Local_Temp_grimoire-actions-1780503614350_protected.hydrate.js +0 -4
- package/.grimoire/tsconfig.generated.json +0 -11
- package/.grimoire/types/ambient.d.ts +0 -6
- package/.grimoire/types/api/hello/$types.d.ts +0 -29
- package/.grimoire/types/api/items/$types.d.ts +0 -29
- package/public/__grimoire__/client.js +0 -86
- package/public/__grimoire__/hydrate.js +0 -101
- package/src/client.ts +0 -4
- package/src/plugins.ts +0 -25
- package/src/sync.ts +0 -18
- /package/src/{scope.ts → client/scope.ts} +0 -0
- /package/src/{head.ts → rendering/head.ts} +0 -0
- /package/src/{manifest-gen.ts → routing/manifest-gen.ts} +0 -0
- /package/src/{router.ts → routing/router.ts} +0 -0
- /package/src/{error.ts → sentinels/error.ts} +0 -0
- /package/src/{redirect.ts → sentinels/redirect.ts} +0 -0
- /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
|
+
}
|
package/test/context.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/test/headers.test.ts
CHANGED
|
@@ -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(
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
+
});
|
package/test/hydration.test.ts
CHANGED
|
@@ -38,7 +38,7 @@ function runHydrate(routes: Record<string, (props: any) => any>) {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
// simulate initRouter (same logic as
|
|
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", () => {});
|