@sigil-dev/grimoire 0.3.0 → 0.5.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/src/renderer.ts CHANGED
@@ -2,17 +2,34 @@ import { SafeHtml } from "@sigil-dev/runtime";
2
2
  import { isErrorResult } from "./error";
3
3
  import { runWithContext } from "./context";
4
4
  import { collectHead, initHead } from "./head";
5
+ import { runHook } from "./plugins";
5
6
  import { isRedirectResult } from "./redirect";
6
- import type { MatchedRoute } from "./router";
7
- import type { LoadContext } from "./types";
7
+ import { findClosestError, type MatchedRoute } from "./router";
8
+ import type { RouteFile } from "./scanner";
9
+ import type { GrimoirePlugin, LoadContext, Route } from "./types";
8
10
 
9
11
  export type ModuleLoader = (path: string) => Promise<any>;
10
12
 
13
+ async function renderErrorPage(
14
+ errorRoutes: RouteFile[],
15
+ pathname: string,
16
+ status: number,
17
+ message: string,
18
+ ): Promise<Response | null> {
19
+ const errorPage = findClosestError(errorRoutes, pathname);
20
+ if (!errorPage) return null;
21
+ const mod = await import(errorPage.filePath);
22
+ const html = mod.default({ status, message });
23
+ return new Response(html, { status, headers: { "Content-Type": "text/html" } });
24
+ }
25
+
11
26
  export async function renderRoute(
12
27
  matched: MatchedRoute,
13
28
  req: Request,
29
+ errorRoutes: RouteFile[] = [],
14
30
  loadModule: ModuleLoader = (path) => import(path),
15
31
  locals: Record<string, any> = {},
32
+ plugins: GrimoirePlugin[] = [],
16
33
  ): Promise<Response> {
17
34
  return runWithContext(async () => {
18
35
  const context: LoadContext = {
@@ -24,6 +41,15 @@ export async function renderRoute(
24
41
 
25
42
  initHead();
26
43
 
44
+ const route: Route = {
45
+ path: matched.route.path,
46
+ params: matched.params,
47
+ filePath: matched.route.filePath,
48
+ loadPath: matched.pageServer?.filePath,
49
+ layoutPath: matched.layout?.filePath,
50
+ };
51
+ await runHook(plugins, "onRouteLoad", route, context);
52
+
27
53
  let layoutData: unknown;
28
54
  if (matched.layoutServer) {
29
55
  try {
@@ -37,7 +63,10 @@ export async function renderRoute(
37
63
  });
38
64
  }
39
65
  if (isErrorResult(e)) {
40
- return new Response(e.message, { status: e.status });
66
+ return (
67
+ (await renderErrorPage(errorRoutes, context.url.pathname, e.status, e.message)) ??
68
+ new Response(e.message, { status: e.status })
69
+ );
41
70
  }
42
71
  throw e;
43
72
  }
@@ -56,7 +85,10 @@ export async function renderRoute(
56
85
  });
57
86
  }
58
87
  if (isErrorResult(e)) {
59
- return new Response(e.message, { status: e.status });
88
+ return (
89
+ (await renderErrorPage(errorRoutes, context.url.pathname, e.status, e.message)) ??
90
+ new Response(e.message, { status: e.status })
91
+ );
60
92
  }
61
93
  throw e;
62
94
  }
@@ -64,7 +96,7 @@ export async function renderRoute(
64
96
 
65
97
  const pageMod = await import(matched.route.filePath);
66
98
  const pageHtml = pageMod.default({
67
- ...(pageData as Record<string, unknown>),
99
+ data: pageData,
68
100
  params: matched.params,
69
101
  });
70
102
 
@@ -87,7 +119,7 @@ export async function renderRoute(
87
119
  if (matched.layout) {
88
120
  const layoutMod = await import(matched.layout.filePath);
89
121
  bodyHtml = String(layoutMod.default({
90
- ...(layoutData as Record<string, unknown>),
122
+ data: layoutData,
91
123
  children: new SafeHtml(wrappedPage),
92
124
  }));
93
125
  }
package/src/router.ts CHANGED
@@ -52,7 +52,7 @@ export function matchRoute(tree: RouteTree, url: URL): MatchedRoute | null {
52
52
  }
53
53
 
54
54
  export function findClosestError(
55
- tree: RouteTree,
55
+ errors: RouteFile[],
56
56
  pathname: string,
57
57
  ): RouteFile | null {
58
58
  const segments = pathname.split("/").filter(Boolean);
@@ -60,7 +60,7 @@ export function findClosestError(
60
60
  // walk from most specific to root
61
61
  while (segments.length >= 0) {
62
62
  const prefix = `/${segments.join("/")}`;
63
- const error = tree.errors.find(
63
+ const error = errors.find(
64
64
  (e) => e.path === prefix || e.path === prefix + "/",
65
65
  );
66
66
  if (error) return error;
package/src/scanner.ts CHANGED
@@ -93,5 +93,25 @@ export async function scanRoutes(
93
93
  return aScore - bScore;
94
94
  });
95
95
 
96
+ assertNoDuplicatePaths(routes.filter(r => r.type === "page" || r.type === "simple"), "page");
97
+ assertNoDuplicatePaths(routes.filter(r => r.type === "pageServer"), "page server");
98
+ assertNoDuplicatePaths(layouts.filter(l => l.type === "layout"), "layout");
99
+ assertNoDuplicatePaths(layouts.filter(l => l.type === "layoutServer"), "layout server");
100
+ assertNoDuplicatePaths(servers, "API route");
101
+ assertNoDuplicatePaths(errors, "error page");
102
+
96
103
  return { routes, layouts, servers, errors };
97
104
  }
105
+
106
+ function assertNoDuplicatePaths(files: RouteFile[], label: string): void {
107
+ const seen = new Map<string, string>();
108
+ for (const file of files) {
109
+ const existing = seen.get(file.path);
110
+ if (existing) {
111
+ throw new Error(
112
+ `Duplicate ${label} at "${file.path}": "${existing}" and "${file.clientPath}"`,
113
+ );
114
+ }
115
+ seen.set(file.path, file.clientPath);
116
+ }
117
+ }
package/src/server.ts CHANGED
@@ -3,18 +3,12 @@ import { isErrorResult } from "./error";
3
3
  import type { Handle, InitFunction, RequestEvent, ResolveFunction } from "./hooks";
4
4
  import { isRedirectResult } from "./redirect";
5
5
  import { createCookies } from "./cookie-utils";
6
- import { sigil } from "@sigil-dev/compiler/bun";
7
- import { mkdir } from "fs/promises";
8
- import { isAbsolute, join } from "path";
9
- import { generateManifest } from "./manifest-gen";
6
+ import { buildProject } from "./build";
10
7
  import { runHook, runRequestHooks } from "./plugins";
11
8
  import { renderRoute } from "./renderer";
12
9
  import { findClosestError, matchRoute } from "./router";
13
- import { scanRoutes } from "./scanner";
14
10
  import { registerSSRPlugin } from "./ssrPlugin";
15
- import { transformRoutes } from "./transform-routes";
16
- import { generateTypes } from "./typegen";
17
- import type { GrimoireConfig } from "./types";
11
+ import type { GrimoireConfig, WsRouteHandler } from "./types";
18
12
 
19
13
  /**
20
14
  * Try to load hooks.server.ts from the project root.
@@ -22,7 +16,7 @@ import type { GrimoireConfig } from "./types";
22
16
  async function loadHooks(
23
17
  projectRoot: string,
24
18
  ): Promise<{ handle?: Handle; init?: InitFunction }> {
25
- const hooksPath = join(projectRoot, "hooks.server.ts");
19
+ const hooksPath = `${projectRoot}/hooks.server.ts`;
26
20
  try {
27
21
  const mod = await import(hooksPath);
28
22
  return { handle: mod.handle, init: mod.init };
@@ -32,14 +26,19 @@ async function loadHooks(
32
26
  }
33
27
  }
34
28
 
35
- export async function createServer(config: GrimoireConfig = {}) {
29
+ interface _WsInternalData {
30
+ params: Record<string, string>;
31
+ // always present at runtime — optional only to satisfy TypeScript;
32
+ // upgrade block gates on mod.websocket before calling server.upgrade()
33
+ __handler?: WsRouteHandler<any>;
34
+ [key: string]: unknown;
35
+ }
36
36
 
37
- registerSSRPlugin();
37
+ export async function createServer(config: GrimoireConfig = {}) {
38
38
  const {
39
39
  port = 3000,
40
40
  host = "localhost",
41
41
  plugins = [],
42
- routes = "src/routes",
43
42
  } = config;
44
43
 
45
44
  // run config hooks
@@ -48,71 +47,11 @@ export async function createServer(config: GrimoireConfig = {}) {
48
47
  finalConfig = plugin.config?.(finalConfig) ?? finalConfig;
49
48
  }
50
49
 
51
- // scan routes once at startup
52
- const routesDir = isAbsolute(routes)
53
- ? routes.replace(/\0/g, "")
54
- : join(process.cwd(), routes).replace(/\0/g, "");
55
- const tree = await scanRoutes(routesDir, process.cwd());
56
-
57
- await mkdir(join(process.cwd(), ".grimoire"), { recursive: true });
58
-
59
- await generateTypes(tree, {
60
- projectRoot: process.cwd(),
61
- routesDir,
62
- outDir: join(process.cwd(), ".grimoire/types"),
63
- });
50
+ registerSSRPlugin(plugins);
64
51
 
65
- const compiledDir = join(process.cwd(), ".grimoire/compiled");
66
- await mkdir(compiledDir, { recursive: true });
67
-
68
- // Bun 1.3.13 bug: Bun.build's native parser runs before onLoad hooks fire,
69
- // so .tsx files with TS syntax crash the bundler even with a sigil plugin.
70
- // Fix: pre-transform route files to plain JS before Bun.build sees them,
71
- // then generate per-mode manifests pointing at the compiled .js files.
72
- const pageRoutes = tree.routes.filter(
73
- (r) => r.type === "page" || r.type === "simple",
74
- );
75
- const [hydrateFiles, domFiles] = await Promise.all([
76
- transformRoutes(pageRoutes, compiledDir, "hydrate"),
77
- transformRoutes(pageRoutes, compiledDir, "dom"),
78
- ]);
79
-
80
- const hydrateManifest = join(process.cwd(), ".grimoire/_routes.hydrate.js");
81
- const domManifest = join(process.cwd(), ".grimoire/_routes.dom.js");
82
- await Promise.all([
83
- Bun.write(hydrateManifest, generateManifest(pageRoutes, hydrateFiles)),
84
- Bun.write(domManifest, generateManifest(pageRoutes, domFiles)),
85
- ]);
86
-
87
- const makeRoutesPlugin = (manifestPath: string) => ({
88
- name: "grimoire-routes",
89
- setup(build: any) {
90
- build.onResolve({ filter: /^#grimoire-routes$/ }, () => ({
91
- path: manifestPath,
92
- }));
93
- },
94
- });
95
-
96
- const [hydrateResult, domResult] = await Promise.all([
97
- Bun.build({
98
- entrypoints: [join(import.meta.dir, "./hydrate.ts")],
99
- outdir: join(process.cwd(), "public/__grimoire__"),
100
- plugins: [sigil({ mode: "hydrate" }), makeRoutesPlugin(hydrateManifest)],
101
- }),
102
- Bun.build({
103
- entrypoints: [join(import.meta.dir, "./client.ts")],
104
- outdir: join(process.cwd(), "public/__grimoire__"),
105
- plugins: [sigil({ mode: "dom" }), makeRoutesPlugin(domManifest)],
106
- }),
107
- ]);
108
-
109
- if (!hydrateResult.success) {
110
- for (const log of hydrateResult.logs) console.error(log);
111
- throw new Error("Grimoire: hydrate bundle build failed");
112
- }
113
- if (!domResult.success) {
114
- for (const log of domResult.logs) console.error(log);
115
- throw new Error("Grimoire: dom bundle build failed");
52
+ const { result, tree } = await buildProject(finalConfig, plugins);
53
+ if (!result.success) {
54
+ throw new Error("Grimoire: build failed");
116
55
  }
117
56
 
118
57
  // Load hooks.server.ts
@@ -125,11 +64,16 @@ export async function createServer(config: GrimoireConfig = {}) {
125
64
  port,
126
65
  hostname: host,
127
66
  fetch: async (req) => {
67
+ // Shared locals object for this request — same reference across all route handlers.
68
+ // hooks.server.ts handle() runs via resolve() for page routes only.
69
+ // For server/WebSocket routes, auth must use cookies/headers in upgrade().
70
+ const locals: App.Locals = {};
71
+
128
72
  return runRequestHooks(plugins, req, async () => {
129
73
  const url = new URL(req.url);
130
74
 
131
75
  const publicFile = Bun.file(
132
- join(process.cwd(), "public", url.pathname),
76
+ `${process.cwd()}/public${url.pathname}`,
133
77
  );
134
78
  if (await publicFile.exists()) {
135
79
  return new Response(publicFile);
@@ -138,7 +82,7 @@ export async function createServer(config: GrimoireConfig = {}) {
138
82
  const matched = matchRoute(tree, url);
139
83
 
140
84
  if (!matched) {
141
- const error = findClosestError(tree, url.pathname);
85
+ const error = findClosestError(tree.errors, url.pathname);
142
86
  if (error) {
143
87
  const mod = await import(error.filePath);
144
88
  const html = mod.default({ status: 404, message: "Not Found" });
@@ -150,14 +94,45 @@ export async function createServer(config: GrimoireConfig = {}) {
150
94
  return new Response("Not Found", { status: 404 });
151
95
  }
152
96
 
153
- // API routes — no hooks
97
+ // API routes (+server.ts)
154
98
  if (matched.route.type === "server") {
155
99
  const mod = await import(matched.route.filePath);
100
+
101
+ // WebSocket upgrade path
102
+ const isWsUpgrade = req.headers.get("upgrade")?.toLowerCase() === "websocket";
103
+ if (isWsUpgrade && mod.websocket) {
104
+ let extraData: Record<string, unknown> = {};
105
+ if (mod.upgrade) {
106
+ try {
107
+ const result = await mod.upgrade({
108
+ request: req,
109
+ params: matched.params,
110
+ url,
111
+ locals,
112
+ });
113
+ if (result && typeof result === "object") extraData = result;
114
+ } catch {
115
+ return new Response("Upgrade Required", { status: 426 });
116
+ }
117
+ }
118
+ const wsData: _WsInternalData = {
119
+ params: matched.params,
120
+ __handler: mod.websocket,
121
+ ...extraData,
122
+ };
123
+ if (server.upgrade(req, { data: wsData })) {
124
+ // Bun sends the 101 response — return undefined to signal no HTTP response
125
+ return undefined as unknown as Response;
126
+ }
127
+ return new Response("Upgrade Required", { status: 426 });
128
+ }
129
+
130
+ // HTTP method dispatch
156
131
  const handler = mod[req.method];
157
132
  if (!handler) {
158
133
  return new Response("Method Not Allowed", { status: 405 });
159
134
  }
160
- return handler({ request: req, params: matched.params, url });
135
+ return handler({ request: req, params: matched.params, url, locals });
161
136
  }
162
137
 
163
138
  const HTTP_METHODS = [
@@ -179,7 +154,7 @@ export async function createServer(config: GrimoireConfig = {}) {
179
154
  request: req,
180
155
  url,
181
156
  params: matched.params,
182
- locals: {},
157
+ locals,
183
158
  cookies,
184
159
  setHeaders: (headers) => Object.assign(setHeadersMap, headers),
185
160
  };
@@ -214,7 +189,7 @@ export async function createServer(config: GrimoireConfig = {}) {
214
189
  { status: e.status },
215
190
  );
216
191
  }
217
- const errorPage = findClosestError(tree, evt.url.pathname);
192
+ const errorPage = findClosestError(tree.errors, evt.url.pathname);
218
193
  if (errorPage) {
219
194
  const errMod = await import(errorPage.filePath);
220
195
  const html = errMod.default({ status: e.status, message: e.message });
@@ -248,7 +223,7 @@ export async function createServer(config: GrimoireConfig = {}) {
248
223
  }
249
224
 
250
225
  // page routes
251
- const response = await renderRoute(matched, evt.request, undefined, evt.locals);
226
+ const response = await renderRoute(matched, evt.request, tree.errors, undefined, evt.locals, plugins);
252
227
 
253
228
  if (evt.request.headers.get("x-grimoire-navigate") === "1") {
254
229
  return response;
@@ -306,8 +281,33 @@ export async function createServer(config: GrimoireConfig = {}) {
306
281
  return response;
307
282
  });
308
283
  },
284
+ websocket: {
285
+ open(ws: ServerWebSocket<_WsInternalData>) {
286
+ ws.data.__handler?.open?.(ws);
287
+ },
288
+ message(ws: ServerWebSocket<_WsInternalData>, data: string | Buffer) {
289
+ ws.data.__handler?.message?.(ws, data);
290
+ },
291
+ close(ws: ServerWebSocket<_WsInternalData>, code: number, reason?: string) {
292
+ ws.data.__handler?.close?.(ws, code, reason);
293
+ },
294
+ drain(ws: ServerWebSocket<_WsInternalData>) {
295
+ ws.data.__handler?.drain?.(ws);
296
+ },
297
+ },
309
298
  });
310
299
 
300
+ let stopping = false;
301
+ const handleShutdown = async () => {
302
+ if (stopping) return;
303
+ stopping = true;
304
+ await runHook(plugins, "onStop", "shutdown");
305
+ server.stop();
306
+ process.exit(0);
307
+ };
308
+ process.on("SIGINT", handleShutdown);
309
+ process.on("SIGTERM", handleShutdown);
310
+
311
311
  await runHook(plugins, "onStart", {
312
312
  port: server.port,
313
313
  hostname: server.hostname,
package/src/ssrPlugin.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  // packages/grimoire/src/ssr-plugin.ts
2
2
  import { transformSync } from "@babel/core";
3
3
  import sigilPlugin from "@sigil-dev/compiler/babel";
4
+ import type { GrimoirePlugin } from "./types";
4
5
 
5
- export function registerSSRPlugin() {
6
+ export function registerSSRPlugin(plugins: GrimoirePlugin[] = []) {
6
7
  Bun.plugin({
7
8
  name: "sigil-ssr",
8
9
  setup(build) {
@@ -16,8 +17,12 @@ export function registerSSRPlugin() {
16
17
  plugins: [[sigilPlugin, { mode: "ssr" }]],
17
18
  filename: path,
18
19
  });
20
+ let contents = transpiler.transformSync(result?.code ?? "");
21
+ for (const plugin of plugins) {
22
+ if (plugin.transform) contents = (await plugin.transform(contents, path)) ?? contents;
23
+ }
19
24
  return {
20
- contents: transpiler.transformSync(result?.code ?? ""),
25
+ contents,
21
26
  loader: "js" as const,
22
27
  };
23
28
  });
@@ -2,6 +2,7 @@ import { transformSync } from "@babel/core";
2
2
  import sigilPlugin from "@sigil-dev/compiler/babel";
3
3
  import { basename, dirname, join, relative, resolve } from "path";
4
4
  import type { RouteFile } from "./scanner";
5
+ import type { GrimoirePlugin } from "./types";
5
6
 
6
7
  const STYLE_RE = /<style[^>]*>([\s\S]*?)<\/style>/gi;
7
8
 
@@ -56,6 +57,7 @@ export async function transformRoutes(
56
57
  routes: RouteFile[],
57
58
  outDir: string,
58
59
  mode: "hydrate" | "dom",
60
+ plugins: GrimoirePlugin[] = [],
59
61
  ): Promise<Map<string, string>> {
60
62
  const map = new Map<string, string>();
61
63
  const transpiler = new Bun.Transpiler({ loader: "tsx", target: "browser" });
@@ -76,7 +78,10 @@ export async function transformRoutes(
76
78
  ],
77
79
  filename: route.filePath,
78
80
  });
79
- const out = transpiler.transformSync(res?.code ?? "");
81
+ let out = transpiler.transformSync(res?.code ?? "");
82
+ for (const plugin of plugins) {
83
+ if (plugin.transform) out = (await plugin.transform(out, route.filePath)) ?? out;
84
+ }
80
85
  const rel = relative(process.cwd(), route.filePath)
81
86
  .replace(/\.[cm]?[jt]sx?$/, "")
82
87
  .replace(/[^a-zA-Z0-9._-]/g, "_");
package/src/typegen.ts CHANGED
@@ -111,7 +111,7 @@ function generateTypesForGroup(group: RouteGroup, outFileDir: string): string {
111
111
  ` : Record<string, never>;`,
112
112
  "",
113
113
  );
114
- lines.push(`export type PageProps = PageData & { params: Params };`, "");
114
+ lines.push(`export type PageProps = { data: PageData; params: Params };`, "");
115
115
  // PageServerLoad — annotate parameter, NOT return type, to preserve inference
116
116
  lines.push(
117
117
  `/** Annotate the load() parameter only — not the return type — or PageData loses its concrete keys. */`,
@@ -139,7 +139,7 @@ function generateTypesForGroup(group: RouteGroup, outFileDir: string): string {
139
139
  } else {
140
140
  lines.push(
141
141
  `export type PageData = Record<string, never>;`,
142
- `export type PageProps = { params: Params };`,
142
+ `export type PageProps = { data: PageData; params: Params };`,
143
143
  `export type PageServerLoad = never;`,
144
144
  `export type Actions = Record<string, never>;`,
145
145
  "",
@@ -161,7 +161,7 @@ function generateTypesForGroup(group: RouteGroup, outFileDir: string): string {
161
161
  }
162
162
 
163
163
  lines.push(
164
- `export type LayoutProps = LayoutData & { params: Params; children?: unknown };`,
164
+ `export type LayoutProps = { data: LayoutData; params: Params; children?: unknown };`,
165
165
  `export type LayoutServerLoad = (ctx: {`,
166
166
  ` params: Params;`,
167
167
  ` request: Request;`,
@@ -186,6 +186,27 @@ function generateTypesForGroup(group: RouteGroup, outFileDir: string): string {
186
186
  ` locals: App.Locals;`,
187
187
  ` }) => Response | Promise<Response>;`,
188
188
  `};`,
189
+ ``,
190
+ `export type UpgradeContext = {`,
191
+ ` request: Request;`,
192
+ ` params: Params;`,
193
+ ` url: URL;`,
194
+ ` locals: App.Locals;`,
195
+ `};`,
196
+ ``,
197
+ `type _UpgradeReturn = "upgrade" extends keyof _SRV`,
198
+ ` ? _SRV["upgrade"] extends (...args: any[]) => any`,
199
+ ` ? Awaited<ReturnType<_SRV["upgrade"]>>`,
200
+ ` : {}`,
201
+ ` : {};`,
202
+ `export type WsData = { params: Params } & Omit<_UpgradeReturn, "__handler">;`,
203
+ ``,
204
+ `export type WebSocketHandler = {`,
205
+ ` open?(ws: ServerWebSocket<WsData>): void | Promise<void>;`,
206
+ ` message?(ws: ServerWebSocket<WsData>, data: string | Buffer): void | Promise<void>;`,
207
+ ` close?(ws: ServerWebSocket<WsData>, code: number, reason?: string): void | Promise<void>;`,
208
+ ` drain?(ws: ServerWebSocket<WsData>): void | Promise<void>;`,
209
+ `};`,
189
210
  "",
190
211
  );
191
212
  }
@@ -210,6 +231,59 @@ function generateAmbient(): string {
210
231
  " interface Locals extends Record<string, unknown> {}",
211
232
  "}",
212
233
  "",
234
+ "// Available in all route files without import.",
235
+ "// Params default to Record<string,string>; for per-route typed params import from './$types'.",
236
+ "// Note: using these globals widens return types — PageData loses concrete keys.",
237
+ "// For precise data inference annotate load() with PageServerLoad from './$types'.",
238
+ "",
239
+ "type PageServerLoad<",
240
+ " P extends Record<string, string> = Record<string, string>",
241
+ "> = (ctx: {",
242
+ " params: P;",
243
+ " request: Request;",
244
+ " url: URL;",
245
+ " locals: App.Locals;",
246
+ "}) => Record<string, unknown> | Promise<Record<string, unknown>>;",
247
+ "",
248
+ "type LayoutServerLoad<",
249
+ " P extends Record<string, string> = Record<string, string>",
250
+ "> = (ctx: {",
251
+ " params: P;",
252
+ " request: Request;",
253
+ " url: URL;",
254
+ " locals: App.Locals;",
255
+ "}) => Record<string, unknown> | Promise<Record<string, unknown>>;",
256
+ "",
257
+ "type RequestHandler<",
258
+ " P extends Record<string, string> = Record<string, string>",
259
+ "> = (ctx: {",
260
+ " params: P;",
261
+ " request: Request;",
262
+ " url: URL;",
263
+ " locals: App.Locals;",
264
+ "}) => Response | Promise<Response>;",
265
+ "",
266
+ "type PageProps<",
267
+ " D extends Record<string, unknown> = Record<string, unknown>,",
268
+ " P extends Record<string, string> = Record<string, string>",
269
+ "> = { data: D; params: P };",
270
+ "",
271
+ "type LayoutProps<",
272
+ " D extends Record<string, unknown> = Record<string, unknown>,",
273
+ " P extends Record<string, string> = Record<string, string>",
274
+ "> = { data: D; params: P; children?: unknown };",
275
+ "",
276
+ "type ErrorProps = { status: number; message: string };",
277
+ "",
278
+ "type UpgradeContext<",
279
+ " P extends Record<string, string> = Record<string, string>",
280
+ "> = {",
281
+ " params: P;",
282
+ " request: Request;",
283
+ " url: URL;",
284
+ " locals: App.Locals;",
285
+ "};",
286
+ "",
213
287
  ].join("\n");
214
288
  }
215
289
 
package/src/types.ts CHANGED
@@ -48,7 +48,7 @@ export interface GrimoirePlugin {
48
48
 
49
49
  // server lifecycle
50
50
  onStart?(server: Server): void | Promise<void>;
51
- onStop?(): void | Promise<void>;
51
+ onStop?(reason: "shutdown" | "restart"): void | Promise<void>;
52
52
 
53
53
  // request pipeline
54
54
  onRequest?(
@@ -58,6 +58,13 @@ export interface GrimoirePlugin {
58
58
 
59
59
  // route lifecycle
60
60
  onRouteLoad?(route: Route, context: LoadContext): void | Promise<void>;
61
+ /**
62
+ * Called after the page HTML is fully rendered.
63
+ * NOTE: When any plugin implements this hook, the streaming response is
64
+ * buffered into a single string. Streaming is preserved for routes where
65
+ * no plugin uses this hook. To avoid buffering, use onRequest to intercept
66
+ * at the Response level.
67
+ */
61
68
  onRouteRender?(
62
69
  html: string,
63
70
  context: RenderContext,
@@ -69,6 +76,16 @@ export interface GrimoirePlugin {
69
76
 
70
77
  // config
71
78
  config?: (config: GrimoireConfig) => GrimoireConfig | undefined;
79
+
80
+ /**
81
+ * Runs AFTER Sigil/Babel compilation. Receives compiled JS, not source TSX.
82
+ * Suitable for string-level transforms (import rewriting, comment injection, etc.).
83
+ * Return null/undefined to pass through unchanged.
84
+ */
85
+ transform?(
86
+ code: string,
87
+ id: string,
88
+ ): string | null | undefined | Promise<string | null | undefined>;
72
89
  }
73
90
 
74
91
  export interface GrimoireConfig {
@@ -83,3 +100,30 @@ export interface GrimoireConfig {
83
100
  export function defineConfig(config: GrimoireConfig): GrimoireConfig {
84
101
  return config;
85
102
  }
103
+
104
+ /** Generic load type for +page.server.ts. For per-route typed params import PageServerLoad from './$types'. */
105
+ export type PageServerLoad<
106
+ P extends Record<string, string> = Record<string, string>,
107
+ > = (ctx: TypedLoadContext<P>) => Record<string, unknown> | Promise<Record<string, unknown>>;
108
+
109
+ /** Generic load type for +layout.server.ts. For per-route typed params import LayoutServerLoad from './$types'. */
110
+ export type LayoutServerLoad<
111
+ P extends Record<string, string> = Record<string, string>,
112
+ > = (ctx: TypedLoadContext<P>) => Record<string, unknown> | Promise<Record<string, unknown>>;
113
+
114
+ /** Generic handler type for +server.ts API routes. For per-route typed params import RequestHandler from './$types'. */
115
+ export type RequestHandler<
116
+ P extends Record<string, string> = Record<string, string>,
117
+ > = (ctx: TypedLoadContext<P>) => Response | Promise<Response>;
118
+
119
+ /**
120
+ * Shape of the `websocket` export in a +server.ts file.
121
+ * T is the resolved ws.data shape (params + upgrade() return value).
122
+ * For per-route typing import WebSocketHandler from './$types'.
123
+ */
124
+ export interface WsRouteHandler<T = Record<string, unknown>> {
125
+ open?(ws: ServerWebSocket<T>): void | Promise<void>;
126
+ message?(ws: ServerWebSocket<T>, data: string | Buffer): void | Promise<void>;
127
+ close?(ws: ServerWebSocket<T>, code: number, reason?: string): void | Promise<void>;
128
+ drain?(ws: ServerWebSocket<T>): void | Promise<void>;
129
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Verifies that every symbol mentioned in the README is actually exported
3
+ * from the correct import path. If any of these break, the README is lying.
4
+ */
5
+ import { describe, expect, test } from "bun:test";
6
+
7
+ describe("README public API exports", () => {
8
+ test("main package exports: error, fail, redirect, sequence, defineConfig, Head, createServer, TypedLoadContext", async () => {
9
+ const mod = await import("@sigil-dev/grimoire");
10
+ expect(typeof mod.error).toBe("function");
11
+ expect(typeof mod.fail).toBe("function");
12
+ expect(typeof mod.redirect).toBe("function");
13
+ expect(typeof mod.sequence).toBe("function");
14
+ expect(typeof mod.defineConfig).toBe("function");
15
+ expect(typeof mod.Head).toBe("function");
16
+ expect(typeof mod.createServer).toBe("function");
17
+ // isXxx helpers used internally
18
+ expect(typeof mod.isErrorResult).toBe("function");
19
+ expect(typeof mod.isFailResult).toBe("function");
20
+ expect(typeof mod.isRedirectResult).toBe("function");
21
+ });
22
+
23
+ test("@sigil-dev/grimoire/client exports enhance", async () => {
24
+ const mod = await import("@sigil-dev/grimoire/client");
25
+ expect(typeof mod.enhance).toBe("function");
26
+ });
27
+
28
+ test("@sigil-dev/grimoire/hooks exports sequence and createHooks", async () => {
29
+ const mod = await import("@sigil-dev/grimoire/hooks");
30
+ expect(typeof mod.sequence).toBe("function");
31
+ expect(typeof mod.createHooks).toBe("function");
32
+ });
33
+
34
+ test("@sigil-dev/grimoire/headers exports securityHeaders", async () => {
35
+ const mod = await import("@sigil-dev/grimoire/headers");
36
+ expect(typeof mod.securityHeaders).toBe("function");
37
+ });
38
+ });