@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.
- package/.grimoire/_routes.dom.js +4 -0
- package/.grimoire/_routes.hydrate.js +4 -0
- package/.grimoire/_routes.ts +4 -0
- package/.grimoire/tsconfig.generated.json +11 -0
- package/.grimoire/types/ambient.d.ts +6 -0
- package/.grimoire/types/api/hello/$types.d.ts +29 -0
- package/README.md +1 -0
- package/index.ts +22 -0
- package/package.json +36 -0
- package/public/__grimoire__/client.js +86 -0
- package/public/__grimoire__/hydrate.js +101 -0
- package/src/client-router.ts +77 -0
- package/src/client.ts +4 -0
- package/src/context.ts +10 -0
- package/src/cookie-utils.ts +66 -0
- package/src/enhance.ts +97 -0
- package/src/error.ts +52 -0
- package/src/fail.ts +41 -0
- package/src/head.ts +27 -0
- package/src/headers.ts +114 -0
- package/src/hooks.ts +93 -0
- package/src/hydrate.ts +22 -0
- package/src/manifest-gen.ts +26 -0
- package/src/plugins.ts +25 -0
- package/src/redirect.ts +35 -0
- package/src/renderer.ts +142 -0
- package/src/router.ts +94 -0
- package/src/scanner.ts +97 -0
- package/src/scope.ts +22 -0
- package/src/server.ts +318 -0
- package/src/ssrPlugin.ts +26 -0
- package/src/sync.ts +18 -0
- package/src/transform-routes.ts +90 -0
- package/src/typegen.ts +263 -0
- package/src/types.ts +85 -0
- package/src/vite-plugin.ts +72 -0
- package/test/context.test.ts +52 -0
- package/test/fail.test.ts +46 -0
- package/test/headers.test.ts +96 -0
- package/test/hydration.test.ts +119 -0
- package/test/middleware.test.ts +217 -0
- package/test/preload.ts +5 -0
- package/test/redirect-error.test.ts +112 -0
- package/test/rendering.test.ts +172 -0
- package/test/routing.test.ts +45 -0
- package/test/scanning.test.ts +55 -0
- package/test/scope.test.ts +164 -0
- package/test/server.test.ts +30 -0
- package/test/streaming.test.ts +132 -0
- package/test/transform-routes.test.ts +84 -0
- package/test/typegen.test.ts +652 -0
- 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
|
+
});
|