@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.
Files changed (51) hide show
  1. package/README.md +174 -174
  2. package/index.ts +34 -16
  3. package/package.json +34 -34
  4. package/src/{enhance.ts → client/enhance.ts} +2 -1
  5. package/src/client/index.ts +5 -0
  6. package/src/{client-router.ts → client/router.ts} +1 -1
  7. package/src/{vite-plugin.ts → integrations/vite.ts} +4 -4
  8. package/src/{hydrate.ts → rendering/hydrate.ts} +2 -2
  9. package/src/{renderer.ts → rendering/index.ts} +29 -16
  10. package/src/{ssrPlugin.ts → rendering/ssrPlugin.ts} +3 -2
  11. package/src/{scanner.ts → routing/scanner.ts} +16 -4
  12. package/src/{transform-routes.ts → routing/transform-routes.ts} +3 -2
  13. package/src/{fail.ts → sentinels/fail.ts} +1 -1
  14. package/src/{build.ts → server/build.ts} +12 -10
  15. package/src/server/coordinator.ts +297 -0
  16. package/src/{hooks.ts → server/hooks.ts} +1 -1
  17. package/src/{server.ts → server/index.ts} +85 -37
  18. package/src/server/plugins.ts +119 -0
  19. package/src/server/worker.ts +59 -0
  20. package/src/{typegen.ts → typegen/index.ts} +5 -2
  21. package/src/types.ts +134 -3
  22. package/test/context.test.ts +1 -1
  23. package/test/fail.test.ts +1 -1
  24. package/test/headers.test.ts +6 -2
  25. package/test/hydration.test.ts +1 -1
  26. package/test/middleware.test.ts +8 -4
  27. package/test/preload.ts +1 -1
  28. package/test/redirect-error.test.ts +2 -2
  29. package/test/rendering.test.ts +15 -6
  30. package/test/routing.test.ts +2 -2
  31. package/test/scanning.test.ts +40 -11
  32. package/test/scope.test.ts +25 -10
  33. package/test/server.test.ts +39 -10
  34. package/test/streaming.test.ts +21 -8
  35. package/test/transform-routes.test.ts +2 -2
  36. package/test/typegen.test.ts +5 -3
  37. package/tsconfig.json +3 -1
  38. package/.grimoire/_routes.ts +0 -4
  39. package/public/__grimoire__/client.js +0 -55
  40. package/public/__grimoire__/hydrate.js +0 -63
  41. package/src/client.ts +0 -4
  42. package/src/plugins.ts +0 -39
  43. package/src/sync.ts +0 -18
  44. /package/src/{scope.ts → client/scope.ts} +0 -0
  45. /package/src/{head.ts → rendering/head.ts} +0 -0
  46. /package/src/{manifest-gen.ts → routing/manifest-gen.ts} +0 -0
  47. /package/src/{router.ts → routing/router.ts} +0 -0
  48. /package/src/{error.ts → sentinels/error.ts} +0 -0
  49. /package/src/{redirect.ts → sentinels/redirect.ts} +0 -0
  50. /package/src/{context.ts → server/context.ts} +0 -0
  51. /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 "./scanner";
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(`export type PageProps = { data: PageData; params: Params };`, "");
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
- > = (ctx: TypedLoadContext<P>) => Record<string, unknown> | Promise<Record<string, unknown>>;
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
- > = (ctx: TypedLoadContext<P>) => Record<string, unknown> | Promise<Record<string, unknown>>;
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?(ws: ServerWebSocket<T>, code: number, reason?: string): void | Promise<void>;
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
+ }
@@ -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,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { fail, isFailResult } from "../src/fail";
2
+ import { fail, isFailResult } from "../src/sentinels/fail.ts";
3
3
 
4
4
  describe("fail()", () => {
5
5
  test("returns a FailResult with status and data", () => {
@@ -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("strict-origin-when-cross-origin");
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("default-src 'self'");
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 () => {
@@ -38,7 +38,7 @@ function runHydrate(routes: Record<string, (props: any) => any>) {
38
38
  }
39
39
  }
40
40
 
41
- // simulate initRouter (same logic as client-router.ts)
41
+ // simulate initRouter (same logic as router.ts)
42
42
  let clickHandlerAttached = false;
43
43
  function initRouter(routes: Record<string, (props: any) => any>) {
44
44
  document.addEventListener("click", () => {});
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { sequence, createHooks } from "../src/hooks";
3
- import { createCookies } from "../src/cookie-utils";
4
- import type { Handle, RequestEvent } from "../src/hooks";
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(a, b, c)({
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,5 +1,5 @@
1
1
  import { sigil } from "@sigil-dev/compiler/bun";
2
- import { registerSSRPlugin } from "../src/ssrPlugin";
2
+ import { registerSSRPlugin } from "../src/rendering/ssrPlugin";
3
3
 
4
4
  registerSSRPlugin();
5
5
  Bun.plugin(sigil({ mode: "ssr" }));
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { redirect, isRedirectResult } from "../src/redirect";
3
- import { error, isErrorResult } from "../src/error";
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", () => {
@@ -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/renderer";
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(matched!, new Request("http://localhost/blog"));
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(matched!, new Request("http://localhost/redirect-page"));
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(matched!, new Request("http://localhost/dashboard"));
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"');
@@ -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,
@@ -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("/app/routes/about/index.tsx", "/app/routes");
61
- const page = filePathToRoutePath("/app/routes/about/+page.tsx", "/app/routes");
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("/app/routes/api/items/+server.ts", "/app/routes");
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("/app/routes/blog/+error.tsx", "/app/routes");
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(join(dir, "about", "index.tsx"), "export default () => null");
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(join(dir, "blog", "+page.tsx"), "export default () => null");
105
- await writeFile(join(dir, "blog", "+page.server.ts"), "export async function load() {}");
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(join(dir, "api2", "+server.ts"), "export async function GET() {}");
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(join(dir, "contact", "+page.tsx"), "export default () => null");
138
- await expect(scanRoutes(dir)).rejects.toThrow('Duplicate page at "/contact"');
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
  });