@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/README.md +174 -1
- package/index.ts +29 -22
- package/package.json +1 -1
- package/public/__grimoire__/client.js +5 -36
- package/public/__grimoire__/hydrate.js +7 -45
- package/src/build.ts +88 -0
- package/src/client-router.ts +1 -1
- package/src/cookie-utils.ts +66 -66
- package/src/enhance.ts +1 -1
- package/src/hydrate.ts +1 -1
- package/src/plugins.ts +19 -5
- package/src/renderer.ts +38 -6
- package/src/router.ts +2 -2
- package/src/scanner.ts +20 -0
- package/src/server.ts +83 -83
- package/src/ssrPlugin.ts +7 -2
- package/src/transform-routes.ts +6 -1
- package/src/typegen.ts +77 -3
- package/src/types.ts +45 -1
- package/test/exports.test.ts +38 -0
- package/test/fail.test.ts +46 -46
- package/test/headers.test.ts +96 -96
- package/test/middleware.test.ts +217 -217
- package/test/redirect-error.test.ts +112 -112
- package/test/rendering.test.ts +310 -172
- package/test/scanning.test.ts +87 -2
- package/test/server.test.ts +178 -8
- package/test/streaming.test.ts +132 -132
- package/test/typegen.test.ts +6 -6
- package/.grimoire/_routes.dom.js +0 -4
- package/.grimoire/_routes.hydrate.js +0 -4
- package/.grimoire/tsconfig.generated.json +0 -11
- package/.grimoire/types/ambient.d.ts +0 -6
- package/.grimoire/types/api/hello/$types.d.ts +0 -29
package/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
|
|
7
|
-
import type {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 {
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
66
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
25
|
+
contents,
|
|
21
26
|
loader: "js" as const,
|
|
22
27
|
};
|
|
23
28
|
});
|
package/src/transform-routes.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
+
});
|