@sigil-dev/grimoire 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +174 -174
- package/index.ts +34 -16
- package/package.json +34 -34
- 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} +29 -16
- package/src/{ssrPlugin.ts → rendering/ssrPlugin.ts} +3 -2
- package/src/{scanner.ts → routing/scanner.ts} +16 -4
- package/src/{transform-routes.ts → routing/transform-routes.ts} +3 -2
- package/src/{fail.ts → sentinels/fail.ts} +1 -1
- package/src/{build.ts → server/build.ts} +12 -10
- package/src/server/coordinator.ts +297 -0
- package/src/{hooks.ts → server/hooks.ts} +1 -1
- package/src/{server.ts → server/index.ts} +85 -37
- package/src/server/plugins.ts +119 -0
- package/src/server/worker.ts +59 -0
- package/src/{typegen.ts → typegen/index.ts} +5 -2
- package/src/types.ts +134 -3
- package/test/context.test.ts +1 -1
- package/test/fail.test.ts +1 -1
- package/test/headers.test.ts +6 -2
- package/test/hydration.test.ts +1 -1
- package/test/middleware.test.ts +8 -4
- package/test/preload.ts +1 -1
- package/test/redirect-error.test.ts +2 -2
- package/test/rendering.test.ts +15 -6
- 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 +39 -10
- package/test/streaming.test.ts +21 -8
- package/test/transform-routes.test.ts +2 -2
- package/test/typegen.test.ts +5 -3
- package/tsconfig.json +3 -1
- package/.grimoire/_routes.ts +0 -4
- package/public/__grimoire__/client.js +0 -55
- package/public/__grimoire__/hydrate.js +0 -63
- package/src/client.ts +0 -4
- package/src/plugins.ts +0 -39
- 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/{cookie-utils.ts → server/cookie-utils.ts} +0 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { RouteTree } from "../routing/scanner";
|
|
2
|
+
import type {
|
|
3
|
+
BuildResult,
|
|
4
|
+
CoordinatorContext,
|
|
5
|
+
GrimoirePlugin,
|
|
6
|
+
LoadContext,
|
|
7
|
+
Route,
|
|
8
|
+
Server,
|
|
9
|
+
WorkerDescriptor,
|
|
10
|
+
WorkerEnv,
|
|
11
|
+
} from "../types";
|
|
12
|
+
|
|
13
|
+
// Fire-and-forget hooks routed through runHook.
|
|
14
|
+
// onRequest and onRouteRender are intentionally excluded: they have distinct
|
|
15
|
+
// calling conventions (middleware chain / inline transform loop) handled in index.ts.
|
|
16
|
+
// Keep HookArgs in sync with GrimoirePlugin in types.ts.
|
|
17
|
+
type HookArgs = {
|
|
18
|
+
onStart: [server: Server];
|
|
19
|
+
onStop: [reason: "shutdown" | "restart"];
|
|
20
|
+
onBuildStart: [];
|
|
21
|
+
onBuildEnd: [result: BuildResult];
|
|
22
|
+
onRouteLoad: [route: Route, context: LoadContext];
|
|
23
|
+
onCoordinatorStart: [ctx: CoordinatorContext];
|
|
24
|
+
onWorkerReady: [worker: WorkerDescriptor];
|
|
25
|
+
onWorkerDeath: [worker: WorkerDescriptor, reason: "crash" | "intentional"];
|
|
26
|
+
};
|
|
27
|
+
type FireAndForgetHook = keyof HookArgs;
|
|
28
|
+
|
|
29
|
+
export async function runHook<K extends FireAndForgetHook>(
|
|
30
|
+
plugins: GrimoirePlugin[],
|
|
31
|
+
hook: K,
|
|
32
|
+
...args: HookArgs[K]
|
|
33
|
+
): Promise<void> {
|
|
34
|
+
for (const plugin of plugins) {
|
|
35
|
+
const fn = plugin[hook] as ((...a: any[]) => any) | undefined;
|
|
36
|
+
await fn?.(...args);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function runRequestHooks(
|
|
41
|
+
plugins: GrimoirePlugin[],
|
|
42
|
+
req: Request,
|
|
43
|
+
final: () => Promise<Response>,
|
|
44
|
+
): Promise<Response> {
|
|
45
|
+
const chain = plugins
|
|
46
|
+
.filter((p) => p.onRequest)
|
|
47
|
+
.reduceRight(
|
|
48
|
+
(next, plugin) => async () => plugin.onRequest!(req, next),
|
|
49
|
+
final,
|
|
50
|
+
);
|
|
51
|
+
return chain();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Calls onWorkerSpawn on ALL plugins and merges results.
|
|
56
|
+
* env is combined (later plugins override earlier on conflict).
|
|
57
|
+
* name is last-write-wins.
|
|
58
|
+
*/
|
|
59
|
+
export async function runWorkerSpawn(
|
|
60
|
+
plugins: GrimoirePlugin[],
|
|
61
|
+
worker: WorkerDescriptor,
|
|
62
|
+
): Promise<WorkerEnv> {
|
|
63
|
+
const merged: WorkerEnv = { env: {} };
|
|
64
|
+
for (const plugin of plugins) {
|
|
65
|
+
const result = await plugin.onWorkerSpawn?.(worker);
|
|
66
|
+
//@ts-expect-error WTF TS? its result? meaning its not gonna do anything if null.
|
|
67
|
+
if (result?.env) Object.assign(merged.env!, result.env);
|
|
68
|
+
//@ts-expect-error WTF TS? its result? meaning its not gonna do anything if null.
|
|
69
|
+
if (result?.name) merged.name = result.name;
|
|
70
|
+
}
|
|
71
|
+
return merged;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* First plugin to return a non-null string wins.
|
|
76
|
+
* Returns null if no plugin handles it — coordinator uses default.
|
|
77
|
+
*/
|
|
78
|
+
export async function runSerializeLocals(
|
|
79
|
+
plugins: GrimoirePlugin[],
|
|
80
|
+
locals: App.Locals,
|
|
81
|
+
): Promise<string | null> {
|
|
82
|
+
for (const plugin of plugins) {
|
|
83
|
+
const result = await plugin.serializeLocals?.(locals);
|
|
84
|
+
if (result != null) return result;
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* First plugin to return non-null wins.
|
|
91
|
+
* Returns null if no plugin handles it — worker uses default.
|
|
92
|
+
*/
|
|
93
|
+
export async function runDeserializeLocals(
|
|
94
|
+
plugins: GrimoirePlugin[],
|
|
95
|
+
raw: string,
|
|
96
|
+
): Promise<App.Locals | null> {
|
|
97
|
+
for (const plugin of plugins) {
|
|
98
|
+
const result = await plugin.deserializeLocals?.(raw);
|
|
99
|
+
if (result != null) return result;
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* First plugin to return a WorkerDescriptor wins.
|
|
106
|
+
* Returns null if no plugin handles it — coordinator uses default routing.
|
|
107
|
+
*/
|
|
108
|
+
export async function runRouteRequest(
|
|
109
|
+
plugins: GrimoirePlugin[],
|
|
110
|
+
req: Request,
|
|
111
|
+
workers: WorkerDescriptor[],
|
|
112
|
+
routes: RouteTree,
|
|
113
|
+
): Promise<WorkerDescriptor | null> {
|
|
114
|
+
for (const plugin of plugins) {
|
|
115
|
+
const result = await plugin.routeRequest?.(req, workers, routes);
|
|
116
|
+
if (result != null) return result;
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import type { GrimoireConfig, GrimoirePlugin, WorkerMode } from "../types";
|
|
3
|
+
import { createServer } from "./index";
|
|
4
|
+
import { runDeserializeLocals } from "./plugins";
|
|
5
|
+
|
|
6
|
+
export interface WorkerOptions {
|
|
7
|
+
config: GrimoireConfig;
|
|
8
|
+
plugins: GrimoirePlugin[];
|
|
9
|
+
secret: string;
|
|
10
|
+
mode: WorkerMode;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function startWorker(options: WorkerOptions) {
|
|
14
|
+
const { secret, plugins } = options;
|
|
15
|
+
|
|
16
|
+
const workerAuthPlugin: GrimoirePlugin = {
|
|
17
|
+
name: "__grimoire-worker-auth",
|
|
18
|
+
async onRequest(req, next) {
|
|
19
|
+
if (req.headers.get("X-Grimoire-Internal") !== secret) {
|
|
20
|
+
return new Response("Forbidden", { status: 403 });
|
|
21
|
+
}
|
|
22
|
+
return next();
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
return createServer({
|
|
27
|
+
...options.config,
|
|
28
|
+
plugins: [workerAuthPlugin, ...plugins],
|
|
29
|
+
_skipBuild: true,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// bootstrap when spawned by coordinator
|
|
34
|
+
if (process.env.GRIMOIRE_INTERNAL_SECRET) {
|
|
35
|
+
const secret = process.env.GRIMOIRE_INTERNAL_SECRET;
|
|
36
|
+
const port = Number(process.env.GRIMOIRE_WORKER_PORT ?? 3001);
|
|
37
|
+
const cwd = process.cwd();
|
|
38
|
+
|
|
39
|
+
let config: GrimoireConfig = {};
|
|
40
|
+
try {
|
|
41
|
+
const mod = await import(join(cwd, "sigil.config.ts"));
|
|
42
|
+
config = mod.default ?? {};
|
|
43
|
+
} catch {}
|
|
44
|
+
|
|
45
|
+
let finalConfig: GrimoireConfig = { ...config, port, host: "127.0.0.1" };
|
|
46
|
+
const plugins = finalConfig.plugins ?? [];
|
|
47
|
+
for (const plugin of plugins) {
|
|
48
|
+
finalConfig = plugin.config?.(finalConfig) ?? finalConfig;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
await startWorker({
|
|
52
|
+
config: finalConfig,
|
|
53
|
+
plugins,
|
|
54
|
+
secret,
|
|
55
|
+
mode: (process.env.GRIMOIRE_MODE ?? "full") as WorkerMode,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
process.send?.({ ready: true });
|
|
59
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
2
2
|
import { dirname, isAbsolute, join, relative } from "node:path";
|
|
3
|
-
import type { RouteFile, RouteTree } from "
|
|
3
|
+
import type { RouteFile, RouteTree } from "../routing/scanner.ts";
|
|
4
4
|
|
|
5
5
|
export interface TypegenConfig {
|
|
6
6
|
projectRoot: string;
|
|
@@ -111,7 +111,10 @@ function generateTypesForGroup(group: RouteGroup, outFileDir: string): string {
|
|
|
111
111
|
` : Record<string, never>;`,
|
|
112
112
|
"",
|
|
113
113
|
);
|
|
114
|
-
lines.push(
|
|
114
|
+
lines.push(
|
|
115
|
+
`export type PageProps = { data: PageData; params: Params };`,
|
|
116
|
+
"",
|
|
117
|
+
);
|
|
115
118
|
// PageServerLoad — annotate parameter, NOT return type, to preserve inference
|
|
116
119
|
lines.push(
|
|
117
120
|
`/** Annotate the load() parameter only — not the return type — or PageData loses its concrete keys. */`,
|
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>;
|
|
@@ -77,6 +88,8 @@ export interface GrimoirePlugin {
|
|
|
77
88
|
// config
|
|
78
89
|
config?: (config: GrimoireConfig) => GrimoireConfig | undefined;
|
|
79
90
|
|
|
91
|
+
// process lifecycle
|
|
92
|
+
|
|
80
93
|
/**
|
|
81
94
|
* Runs AFTER Sigil/Babel compilation. Receives compiled JS, not source TSX.
|
|
82
95
|
* Suitable for string-level transforms (import rewriting, comment injection, etc.).
|
|
@@ -86,6 +99,67 @@ export interface GrimoirePlugin {
|
|
|
86
99
|
code: string,
|
|
87
100
|
id: string,
|
|
88
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>;
|
|
89
163
|
}
|
|
90
164
|
|
|
91
165
|
export interface GrimoireConfig {
|
|
@@ -95,6 +169,7 @@ export interface GrimoireConfig {
|
|
|
95
169
|
routes?: string; // glob, default 'src/routes/**';
|
|
96
170
|
dev?: boolean;
|
|
97
171
|
vitePort?: number;
|
|
172
|
+
_skipBuild?: boolean;
|
|
98
173
|
}
|
|
99
174
|
|
|
100
175
|
export function defineConfig(config: GrimoireConfig): GrimoireConfig {
|
|
@@ -104,12 +179,16 @@ export function defineConfig(config: GrimoireConfig): GrimoireConfig {
|
|
|
104
179
|
/** Generic load type for +page.server.ts. For per-route typed params import PageServerLoad from './$types'. */
|
|
105
180
|
export type PageServerLoad<
|
|
106
181
|
P extends Record<string, string> = Record<string, string>,
|
|
107
|
-
> = (
|
|
182
|
+
> = (
|
|
183
|
+
ctx: TypedLoadContext<P>,
|
|
184
|
+
) => Record<string, unknown> | Promise<Record<string, unknown>>;
|
|
108
185
|
|
|
109
186
|
/** Generic load type for +layout.server.ts. For per-route typed params import LayoutServerLoad from './$types'. */
|
|
110
187
|
export type LayoutServerLoad<
|
|
111
188
|
P extends Record<string, string> = Record<string, string>,
|
|
112
|
-
> = (
|
|
189
|
+
> = (
|
|
190
|
+
ctx: TypedLoadContext<P>,
|
|
191
|
+
) => Record<string, unknown> | Promise<Record<string, unknown>>;
|
|
113
192
|
|
|
114
193
|
/** Generic handler type for +server.ts API routes. For per-route typed params import RequestHandler from './$types'. */
|
|
115
194
|
export type RequestHandler<
|
|
@@ -124,6 +203,58 @@ export type RequestHandler<
|
|
|
124
203
|
export interface WsRouteHandler<T = Record<string, unknown>> {
|
|
125
204
|
open?(ws: ServerWebSocket<T>): void | Promise<void>;
|
|
126
205
|
message?(ws: ServerWebSocket<T>, data: string | Buffer): void | Promise<void>;
|
|
127
|
-
close?(
|
|
206
|
+
close?(
|
|
207
|
+
ws: ServerWebSocket<T>,
|
|
208
|
+
code: number,
|
|
209
|
+
reason?: string,
|
|
210
|
+
): void | Promise<void>;
|
|
128
211
|
drain?(ws: ServerWebSocket<T>): void | Promise<void>;
|
|
129
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
package/test/headers.test.ts
CHANGED
|
@@ -22,7 +22,9 @@ describe("securityHeaders()", () => {
|
|
|
22
22
|
const headers = await runPlugin({});
|
|
23
23
|
expect(headers.get("X-Content-Type-Options")).toBe("nosniff");
|
|
24
24
|
expect(headers.get("X-Frame-Options")).toBe("DENY");
|
|
25
|
-
expect(headers.get("Referrer-Policy")).toBe(
|
|
25
|
+
expect(headers.get("Referrer-Policy")).toBe(
|
|
26
|
+
"strict-origin-when-cross-origin",
|
|
27
|
+
);
|
|
26
28
|
expect(headers.get("Permissions-Policy")).toBe(
|
|
27
29
|
"camera=(), microphone=(), geolocation=()",
|
|
28
30
|
);
|
|
@@ -31,7 +33,9 @@ describe("securityHeaders()", () => {
|
|
|
31
33
|
|
|
32
34
|
test("applies CSP by default", async () => {
|
|
33
35
|
const headers = await runPlugin({});
|
|
34
|
-
expect(headers.get("Content-Security-Policy")).toContain(
|
|
36
|
+
expect(headers.get("Content-Security-Policy")).toContain(
|
|
37
|
+
"default-src 'self'",
|
|
38
|
+
);
|
|
35
39
|
});
|
|
36
40
|
|
|
37
41
|
test("allows overriding individual headers", async () => {
|
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", () => {});
|
package/test/middleware.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
2
|
+
import { createCookies } from "../src/server/cookie-utils";
|
|
3
|
+
import type { Handle, RequestEvent } from "../src/server/hooks";
|
|
4
|
+
import { createHooks, sequence } from "../src/server/hooks";
|
|
5
5
|
|
|
6
6
|
function fakeEvent(overrides?: Partial<RequestEvent>): RequestEvent {
|
|
7
7
|
return {
|
|
@@ -147,7 +147,11 @@ describe("sequence()", () => {
|
|
|
147
147
|
return resolve(event);
|
|
148
148
|
};
|
|
149
149
|
|
|
150
|
-
await sequence(
|
|
150
|
+
await sequence(
|
|
151
|
+
a,
|
|
152
|
+
b,
|
|
153
|
+
c,
|
|
154
|
+
)({
|
|
151
155
|
event: fakeEvent(),
|
|
152
156
|
resolve: async () => new Response("OK"),
|
|
153
157
|
});
|
package/test/preload.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { error, isErrorResult } from "../src/sentinels/error.ts";
|
|
3
|
+
import { isRedirectResult, redirect } from "../src/sentinels/redirect.ts";
|
|
4
4
|
|
|
5
5
|
describe("redirect()", () => {
|
|
6
6
|
test("throws a RedirectResult", () => {
|
package/test/rendering.test.ts
CHANGED
|
@@ -2,9 +2,9 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdir, rm, writeFile } from "fs/promises";
|
|
3
3
|
import { tmpdir as osTmpDir } from "os";
|
|
4
4
|
import { join } from "path";
|
|
5
|
-
import { renderRoute } from "../src/
|
|
6
|
-
import { matchRoute } from "../src/router";
|
|
7
|
-
import { scanRoutes } from "../src/scanner";
|
|
5
|
+
import { renderRoute } from "../src/rendering";
|
|
6
|
+
import { matchRoute } from "../src/routing/router";
|
|
7
|
+
import { scanRoutes } from "../src/routing/scanner";
|
|
8
8
|
|
|
9
9
|
/** Read a streaming Response body into a single string */
|
|
10
10
|
async function readStream(res: Response): Promise<string> {
|
|
@@ -235,7 +235,10 @@ describe("renderRoute", () => {
|
|
|
235
235
|
test("load return value is passed as the data prop (SvelteKit convention)", async () => {
|
|
236
236
|
const tree = await scanRoutes(tmpDir);
|
|
237
237
|
const matched = matchRoute(tree, new URL("http://localhost/blog"));
|
|
238
|
-
const res = await renderRoute(
|
|
238
|
+
const res = await renderRoute(
|
|
239
|
+
matched!,
|
|
240
|
+
new Request("http://localhost/blog"),
|
|
241
|
+
);
|
|
239
242
|
const html = await readStream(res);
|
|
240
243
|
// blog/+page.server.ts returns { title: "My Blog" }
|
|
241
244
|
// blog/+page.tsx receives { data: { title: "My Blog" } } — not spread
|
|
@@ -251,7 +254,10 @@ describe("renderRoute", () => {
|
|
|
251
254
|
test("redirect() from load returns 3xx with Location header", async () => {
|
|
252
255
|
const tree = await scanRoutes(tmpDir);
|
|
253
256
|
const matched = matchRoute(tree, new URL("http://localhost/redirect-page"));
|
|
254
|
-
const res = await renderRoute(
|
|
257
|
+
const res = await renderRoute(
|
|
258
|
+
matched!,
|
|
259
|
+
new Request("http://localhost/redirect-page"),
|
|
260
|
+
);
|
|
255
261
|
expect(res.status).toBe(302);
|
|
256
262
|
expect(res.headers.get("Location")).toBe("/login");
|
|
257
263
|
});
|
|
@@ -301,7 +307,10 @@ describe("renderRoute", () => {
|
|
|
301
307
|
test("+layout.server.ts load data is passed to the layout component", async () => {
|
|
302
308
|
const tree = await scanRoutes(tmpDir);
|
|
303
309
|
const matched = matchRoute(tree, new URL("http://localhost/dashboard"));
|
|
304
|
-
const res = await renderRoute(
|
|
310
|
+
const res = await renderRoute(
|
|
311
|
+
matched!,
|
|
312
|
+
new Request("http://localhost/dashboard"),
|
|
313
|
+
);
|
|
305
314
|
const html = await readStream(res);
|
|
306
315
|
expect(html).toContain('class="dash"');
|
|
307
316
|
expect(html).toContain('data-user="admin"');
|
package/test/routing.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { matchRoute } from "../src/router";
|
|
3
|
-
import type { RouteFile, RouteTree } from "../src/scanner";
|
|
2
|
+
import { matchRoute } from "../src/routing/router";
|
|
3
|
+
import type { RouteFile, RouteTree } from "../src/routing/scanner";
|
|
4
4
|
|
|
5
5
|
const makeRoute = (path: string, type: RouteFile["type"]): RouteFile => ({
|
|
6
6
|
path,
|
package/test/scanning.test.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdir, rm, writeFile } from "fs/promises";
|
|
3
3
|
import { tmpdir } from "os";
|
|
4
4
|
import { join } from "path";
|
|
5
|
-
import { filePathToRoutePath, scanRoutes } from "../src/scanner";
|
|
5
|
+
import { filePathToRoutePath, scanRoutes } from "../src/routing/scanner";
|
|
6
6
|
|
|
7
7
|
describe("File scanning", () => {
|
|
8
8
|
test("index.tsx → /", () => {
|
|
@@ -57,8 +57,14 @@ describe("File scanning", () => {
|
|
|
57
57
|
});
|
|
58
58
|
|
|
59
59
|
test("about/index.tsx, about/+page.tsx, and about.tsx all resolve to /about", () => {
|
|
60
|
-
const index = filePathToRoutePath(
|
|
61
|
-
|
|
60
|
+
const index = filePathToRoutePath(
|
|
61
|
+
"/app/routes/about/index.tsx",
|
|
62
|
+
"/app/routes",
|
|
63
|
+
);
|
|
64
|
+
const page = filePathToRoutePath(
|
|
65
|
+
"/app/routes/about/+page.tsx",
|
|
66
|
+
"/app/routes",
|
|
67
|
+
);
|
|
62
68
|
const bare = filePathToRoutePath("/app/routes/about.tsx", "/app/routes");
|
|
63
69
|
expect(index.pattern).toBe("/about");
|
|
64
70
|
expect(page.pattern).toBe("/about");
|
|
@@ -66,13 +72,19 @@ describe("File scanning", () => {
|
|
|
66
72
|
});
|
|
67
73
|
|
|
68
74
|
test("+server.ts resolves to the same path as +page.tsx", () => {
|
|
69
|
-
const server = filePathToRoutePath(
|
|
75
|
+
const server = filePathToRoutePath(
|
|
76
|
+
"/app/routes/api/items/+server.ts",
|
|
77
|
+
"/app/routes",
|
|
78
|
+
);
|
|
70
79
|
expect(server.pattern).toBe("/api/items");
|
|
71
80
|
});
|
|
72
81
|
|
|
73
82
|
test("+error.tsx resolves to its directory path", () => {
|
|
74
83
|
const root = filePathToRoutePath("/app/routes/+error.tsx", "/app/routes");
|
|
75
|
-
const nested = filePathToRoutePath(
|
|
84
|
+
const nested = filePathToRoutePath(
|
|
85
|
+
"/app/routes/blog/+error.tsx",
|
|
86
|
+
"/app/routes",
|
|
87
|
+
);
|
|
76
88
|
expect(root.pattern).toBe("/");
|
|
77
89
|
expect(nested.pattern).toBe("/blog");
|
|
78
90
|
});
|
|
@@ -94,15 +106,24 @@ describe("Duplicate route detection", () => {
|
|
|
94
106
|
const dir = join(tmpDir, "dup-pages");
|
|
95
107
|
await mkdir(join(dir, "about"), { recursive: true });
|
|
96
108
|
await writeFile(join(dir, "about.tsx"), "export default () => null");
|
|
97
|
-
await writeFile(
|
|
109
|
+
await writeFile(
|
|
110
|
+
join(dir, "about", "index.tsx"),
|
|
111
|
+
"export default () => null",
|
|
112
|
+
);
|
|
98
113
|
await expect(scanRoutes(dir)).rejects.toThrow('Duplicate page at "/about"');
|
|
99
114
|
});
|
|
100
115
|
|
|
101
116
|
test("+page.tsx and +page.server.ts at the same path do NOT conflict", async () => {
|
|
102
117
|
const dir = join(tmpDir, "no-conflict");
|
|
103
118
|
await mkdir(join(dir, "blog"), { recursive: true });
|
|
104
|
-
await writeFile(
|
|
105
|
-
|
|
119
|
+
await writeFile(
|
|
120
|
+
join(dir, "blog", "+page.tsx"),
|
|
121
|
+
"export default () => null",
|
|
122
|
+
);
|
|
123
|
+
await writeFile(
|
|
124
|
+
join(dir, "blog", "+page.server.ts"),
|
|
125
|
+
"export async function load() {}",
|
|
126
|
+
);
|
|
106
127
|
await expect(scanRoutes(dir)).resolves.toBeDefined();
|
|
107
128
|
});
|
|
108
129
|
|
|
@@ -112,7 +133,10 @@ describe("Duplicate route detection", () => {
|
|
|
112
133
|
// Bun glob finds all files; we simulate two +server files at same path via subdirs
|
|
113
134
|
// Use two different nesting depths that produce the same URL
|
|
114
135
|
await mkdir(join(dir, "api2"), { recursive: true });
|
|
115
|
-
await writeFile(
|
|
136
|
+
await writeFile(
|
|
137
|
+
join(dir, "api2", "+server.ts"),
|
|
138
|
+
"export async function GET() {}",
|
|
139
|
+
);
|
|
116
140
|
await mkdir(join(dir, "api2", "index"), { recursive: true });
|
|
117
141
|
// Can't easily duplicate +server.ts via nesting — test the named-file vs +server collision instead
|
|
118
142
|
// Two +server.ts under different folder structures that map to same URL isn't possible without
|
|
@@ -134,7 +158,12 @@ describe("Duplicate route detection", () => {
|
|
|
134
158
|
const dir = join(tmpDir, "dup-mixed");
|
|
135
159
|
await mkdir(join(dir, "contact"), { recursive: true });
|
|
136
160
|
await writeFile(join(dir, "contact.tsx"), "export default () => null");
|
|
137
|
-
await writeFile(
|
|
138
|
-
|
|
161
|
+
await writeFile(
|
|
162
|
+
join(dir, "contact", "+page.tsx"),
|
|
163
|
+
"export default () => null",
|
|
164
|
+
);
|
|
165
|
+
await expect(scanRoutes(dir)).rejects.toThrow(
|
|
166
|
+
'Duplicate page at "/contact"',
|
|
167
|
+
);
|
|
139
168
|
});
|
|
140
169
|
});
|