@sigil-dev/grimoire 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/.grimoire/_routes.dom.js +4 -0
  2. package/.grimoire/_routes.hydrate.js +4 -0
  3. package/.grimoire/_routes.ts +4 -0
  4. package/.grimoire/tsconfig.generated.json +11 -0
  5. package/.grimoire/types/ambient.d.ts +6 -0
  6. package/.grimoire/types/api/hello/$types.d.ts +29 -0
  7. package/README.md +1 -0
  8. package/index.ts +22 -0
  9. package/package.json +36 -0
  10. package/public/__grimoire__/client.js +86 -0
  11. package/public/__grimoire__/hydrate.js +101 -0
  12. package/src/client-router.ts +77 -0
  13. package/src/client.ts +4 -0
  14. package/src/context.ts +10 -0
  15. package/src/cookie-utils.ts +66 -0
  16. package/src/enhance.ts +97 -0
  17. package/src/error.ts +52 -0
  18. package/src/fail.ts +41 -0
  19. package/src/head.ts +27 -0
  20. package/src/headers.ts +114 -0
  21. package/src/hooks.ts +93 -0
  22. package/src/hydrate.ts +22 -0
  23. package/src/manifest-gen.ts +26 -0
  24. package/src/plugins.ts +25 -0
  25. package/src/redirect.ts +35 -0
  26. package/src/renderer.ts +142 -0
  27. package/src/router.ts +94 -0
  28. package/src/scanner.ts +97 -0
  29. package/src/scope.ts +22 -0
  30. package/src/server.ts +318 -0
  31. package/src/ssrPlugin.ts +26 -0
  32. package/src/sync.ts +18 -0
  33. package/src/transform-routes.ts +90 -0
  34. package/src/typegen.ts +263 -0
  35. package/src/types.ts +85 -0
  36. package/src/vite-plugin.ts +72 -0
  37. package/test/context.test.ts +52 -0
  38. package/test/fail.test.ts +46 -0
  39. package/test/headers.test.ts +96 -0
  40. package/test/hydration.test.ts +119 -0
  41. package/test/middleware.test.ts +217 -0
  42. package/test/preload.ts +5 -0
  43. package/test/redirect-error.test.ts +112 -0
  44. package/test/rendering.test.ts +172 -0
  45. package/test/routing.test.ts +45 -0
  46. package/test/scanning.test.ts +55 -0
  47. package/test/scope.test.ts +164 -0
  48. package/test/server.test.ts +30 -0
  49. package/test/streaming.test.ts +132 -0
  50. package/test/transform-routes.test.ts +84 -0
  51. package/test/typegen.test.ts +652 -0
  52. package/tsconfig.json +7 -0
package/src/server.ts ADDED
@@ -0,0 +1,318 @@
1
+ import { isFailResult } from "./fail";
2
+ import { isErrorResult } from "./error";
3
+ import type { Handle, InitFunction, RequestEvent, ResolveFunction } from "./hooks";
4
+ import { isRedirectResult } from "./redirect";
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";
10
+ import { runHook, runRequestHooks } from "./plugins";
11
+ import { renderRoute } from "./renderer";
12
+ import { findClosestError, matchRoute } from "./router";
13
+ import { scanRoutes } from "./scanner";
14
+ import { registerSSRPlugin } from "./ssrPlugin";
15
+ import { transformRoutes } from "./transform-routes";
16
+ import { generateTypes } from "./typegen";
17
+ import type { GrimoireConfig } from "./types";
18
+
19
+ /**
20
+ * Try to load hooks.server.ts from the project root.
21
+ */
22
+ async function loadHooks(
23
+ projectRoot: string,
24
+ ): Promise<{ handle?: Handle; init?: InitFunction }> {
25
+ const hooksPath = join(projectRoot, "hooks.server.ts");
26
+ try {
27
+ const mod = await import(hooksPath);
28
+ return { handle: mod.handle, init: mod.init };
29
+ } catch {
30
+ // no hooks file — that's fine
31
+ return {};
32
+ }
33
+ }
34
+
35
+ export async function createServer(config: GrimoireConfig = {}) {
36
+
37
+ registerSSRPlugin();
38
+ const {
39
+ port = 3000,
40
+ host = "localhost",
41
+ plugins = [],
42
+ routes = "src/routes",
43
+ } = config;
44
+
45
+ // run config hooks
46
+ let finalConfig = config;
47
+ for (const plugin of plugins) {
48
+ finalConfig = plugin.config?.(finalConfig) ?? finalConfig;
49
+ }
50
+
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
+ });
64
+
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");
116
+ }
117
+
118
+ // Load hooks.server.ts
119
+ const { handle: hooksHandle, init: hooksInit } = await loadHooks(process.cwd());
120
+
121
+ // Run init hook if present
122
+ await hooksInit?.();
123
+
124
+ const server = Bun.serve({
125
+ port,
126
+ hostname: host,
127
+ fetch: async (req) => {
128
+ return runRequestHooks(plugins, req, async () => {
129
+ const url = new URL(req.url);
130
+
131
+ const publicFile = Bun.file(
132
+ join(process.cwd(), "public", url.pathname),
133
+ );
134
+ if (await publicFile.exists()) {
135
+ return new Response(publicFile);
136
+ }
137
+
138
+ const matched = matchRoute(tree, url);
139
+
140
+ if (!matched) {
141
+ const error = findClosestError(tree, url.pathname);
142
+ if (error) {
143
+ const mod = await import(error.filePath);
144
+ const html = mod.default({ status: 404, message: "Not Found" });
145
+ return new Response(html, {
146
+ status: 404,
147
+ headers: { "Content-Type": "text/html" },
148
+ });
149
+ }
150
+ return new Response("Not Found", { status: 404 });
151
+ }
152
+
153
+ // API routes — no hooks
154
+ if (matched.route.type === "server") {
155
+ const mod = await import(matched.route.filePath);
156
+ const handler = mod[req.method];
157
+ if (!handler) {
158
+ return new Response("Method Not Allowed", { status: 405 });
159
+ }
160
+ return handler({ request: req, params: matched.params, url });
161
+ }
162
+
163
+ const HTTP_METHODS = [
164
+ "GET",
165
+ "POST",
166
+ "PUT",
167
+ "PATCH",
168
+ "DELETE",
169
+ "HEAD",
170
+ "OPTIONS",
171
+ ] as const;
172
+
173
+ // Build RequestEvent for hooks
174
+ const cookieHeader = req.headers.get("cookie") ?? "";
175
+ const cookies = createCookies(cookieHeader);
176
+ const setHeadersMap: Record<string, string> = {};
177
+
178
+ const event: RequestEvent = {
179
+ request: req,
180
+ url,
181
+ params: matched.params,
182
+ locals: {},
183
+ cookies,
184
+ setHeaders: (headers) => Object.assign(setHeadersMap, headers),
185
+ };
186
+
187
+ // Resolve function: runs the actual page/form action logic
188
+ const resolve: ResolveFunction = async (evt) => {
189
+ // form actions
190
+ if (matched.pageServer && HTTP_METHODS.includes(evt.request.method as any)) {
191
+ const mod = await import(matched.pageServer.filePath);
192
+ const handler = mod[evt.request.method];
193
+ if (handler) {
194
+ let result: any;
195
+ try {
196
+ result = await handler({
197
+ request: evt.request,
198
+ params: matched.params,
199
+ url: evt.url,
200
+ locals: evt.locals,
201
+ });
202
+ } catch (e) {
203
+ if (isRedirectResult(e)) {
204
+ return new Response(null, {
205
+ status: e.status,
206
+ headers: { Location: e.location },
207
+ });
208
+ }
209
+ if (isErrorResult(e)) {
210
+ const isApi = evt.request.headers.get("accept")?.includes("application/json");
211
+ if (isApi) {
212
+ return Response.json(
213
+ { error: true, status: e.status, message: e.message },
214
+ { status: e.status },
215
+ );
216
+ }
217
+ const errorPage = findClosestError(tree, evt.url.pathname);
218
+ if (errorPage) {
219
+ const errMod = await import(errorPage.filePath);
220
+ const html = errMod.default({ status: e.status, message: e.message });
221
+ return new Response(html, {
222
+ status: e.status,
223
+ headers: { "Content-Type": "text/html" },
224
+ });
225
+ }
226
+ return new Response(e.message, { status: e.status });
227
+ }
228
+ throw e;
229
+ }
230
+
231
+ if (isFailResult(result)) {
232
+ return Response.json(
233
+ { fail: true, status: result.status, data: result.data },
234
+ { status: result.status },
235
+ );
236
+ }
237
+
238
+ const redirectTo =
239
+ result?.redirect ?? evt.request.headers.get("referer") ?? "/";
240
+ return new Response(null, {
241
+ status: 303,
242
+ headers: { Location: redirectTo },
243
+ });
244
+ }
245
+ if (evt.request.method !== "GET") {
246
+ return new Response("Method Not Allowed", { status: 405 });
247
+ }
248
+ }
249
+
250
+ // page routes
251
+ const response = await renderRoute(matched, evt.request, undefined, evt.locals);
252
+
253
+ if (evt.request.headers.get("x-grimoire-navigate") === "1") {
254
+ return response;
255
+ }
256
+
257
+ // Streaming SSR: if onRouteRender plugins exist, consume stream
258
+ const hasRenderPlugins = plugins.some((p) => p.onRouteRender);
259
+ if (hasRenderPlugins) {
260
+ let html = await response.text();
261
+ for (const plugin of plugins) {
262
+ html =
263
+ (await plugin.onRouteRender?.(html, {
264
+ route: matched.route,
265
+ request: evt.request,
266
+ params: matched.params,
267
+ data: undefined,
268
+ })) ?? html;
269
+ }
270
+ return new Response(html, {
271
+ headers: { "Content-Type": "text/html" },
272
+ });
273
+ }
274
+
275
+ return response;
276
+ };
277
+
278
+ // If no hooks handle, resolve directly
279
+ if (!hooksHandle) {
280
+ return resolve(event);
281
+ }
282
+
283
+ // Run through hooks chain
284
+ let response = await hooksHandle({
285
+ event,
286
+ resolve,
287
+ });
288
+
289
+ // Apply setHeaders
290
+ const setCookieHeaders = cookies.toHeaders();
291
+ if (setCookieHeaders.length > 0 || Object.keys(setHeadersMap).length > 0) {
292
+ const headers = new Headers(response.headers);
293
+ for (const [k, v] of Object.entries(setHeadersMap)) {
294
+ headers.set(k, v);
295
+ }
296
+ for (const sc of setCookieHeaders) {
297
+ headers.append("Set-Cookie", sc);
298
+ }
299
+ response = new Response(response.body, {
300
+ status: response.status,
301
+ statusText: response.statusText,
302
+ headers,
303
+ });
304
+ }
305
+
306
+ return response;
307
+ });
308
+ },
309
+ });
310
+
311
+ await runHook(plugins, "onStart", {
312
+ port: server.port,
313
+ hostname: server.hostname,
314
+ stop: () => server.stop(),
315
+ });
316
+ console.log(`Grimoire running at http://${host}:${port}`);
317
+ return server;
318
+ }
@@ -0,0 +1,26 @@
1
+ // packages/grimoire/src/ssr-plugin.ts
2
+ import { transformSync } from "@babel/core";
3
+ import sigilPlugin from "@sigil-dev/compiler/babel";
4
+
5
+ export function registerSSRPlugin() {
6
+ Bun.plugin({
7
+ name: "sigil-ssr",
8
+ setup(build) {
9
+ const transpiler = new Bun.Transpiler({ loader: "tsx", target: "bun" });
10
+ build.onLoad({ filter: /\.[jt]sx$/ }, async ({ path }) => {
11
+ const source = await Bun.file(path).text();
12
+ const result = transformSync(source, {
13
+ parserOpts: {
14
+ plugins: [["typescript", { isTSX: true }], "jsx"],
15
+ },
16
+ plugins: [[sigilPlugin, { mode: "ssr" }]],
17
+ filename: path,
18
+ });
19
+ return {
20
+ contents: transpiler.transformSync(result?.code ?? ""),
21
+ loader: "js" as const,
22
+ };
23
+ });
24
+ },
25
+ });
26
+ }
package/src/sync.ts ADDED
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env bun
2
+ import { mkdir } from "node:fs/promises";
3
+ import { isAbsolute, join } from "node:path";
4
+ import { scanRoutes } from "./scanner";
5
+ import { generateTypes } from "./typegen";
6
+
7
+ const routes = process.env.GRIMOIRE_ROUTES ?? "src/routes";
8
+ const routesDir = isAbsolute(routes) ? routes : join(process.cwd(), routes);
9
+
10
+ await mkdir(join(process.cwd(), ".grimoire"), { recursive: true });
11
+ const tree = await scanRoutes(routesDir, process.cwd());
12
+ await generateTypes(tree, {
13
+ projectRoot: process.cwd(),
14
+ routesDir,
15
+ outDir: join(process.cwd(), ".grimoire/types"),
16
+ });
17
+
18
+ console.log("Grimoire: types generated");
@@ -0,0 +1,90 @@
1
+ import { transformSync } from "@babel/core";
2
+ import sigilPlugin from "@sigil-dev/compiler/babel";
3
+ import { basename, dirname, join, relative, resolve } from "path";
4
+ import type { RouteFile } from "./scanner";
5
+
6
+ const STYLE_RE = /<style[^>]*>([\s\S]*?)<\/style>/gi;
7
+
8
+ // Inline the same hash helper used in bun-plugin.ts to avoid importing
9
+ // unexported internals from @sigil-dev/compiler.
10
+ function computeHash(filePath: string): string {
11
+ let h = 0x811c9dc5;
12
+ for (let i = 0; i < filePath.length; i++) {
13
+ h ^= filePath.charCodeAt(i);
14
+ h = (h * 0x01000193) >>> 0;
15
+ }
16
+ return h.toString(36);
17
+ }
18
+
19
+ function rewriteRelativeImports(filePath: string) {
20
+ const basedir = dirname(filePath);
21
+ const rewrite = (source?: { value: string }) => {
22
+ if (!source?.value.startsWith(".")) return;
23
+ source.value = resolve(basedir, source.value);
24
+ };
25
+
26
+ return {
27
+ name: "sigil-rewrite-relative-route-imports",
28
+ visitor: {
29
+ ImportDeclaration(path: any) {
30
+ rewrite(path.node.source);
31
+ },
32
+ ExportNamedDeclaration(path: any) {
33
+ rewrite(path.node.source);
34
+ },
35
+ ExportAllDeclaration(path: any) {
36
+ rewrite(path.node.source);
37
+ },
38
+ CallExpression(path: any) {
39
+ if (path.node.callee.type !== "Import") return;
40
+ rewrite(path.node.arguments[0]);
41
+ },
42
+ },
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Pre-transforms route .tsx/.jsx files to plain JS using the Babel sigil
48
+ * pipeline, writing the output to `outDir`. Returns a Map from the original
49
+ * filePath to the compiled .js path.
50
+ *
51
+ * Workaround for Bun 1.3.13: Bun.build's native parser runs before onLoad
52
+ * hooks fire, crashing on TypeScript syntax. Pre-transforming to JS means
53
+ * Bun.build only ever sees plain JavaScript.
54
+ */
55
+ export async function transformRoutes(
56
+ routes: RouteFile[],
57
+ outDir: string,
58
+ mode: "hydrate" | "dom",
59
+ ): Promise<Map<string, string>> {
60
+ const map = new Map<string, string>();
61
+ const transpiler = new Bun.Transpiler({ loader: "tsx", target: "browser" });
62
+ await Promise.all(
63
+ routes.map(async (route, index) => {
64
+ let code = await Bun.file(route.filePath).text();
65
+ const hash = computeHash(route.filePath);
66
+ // Strip <style> blocks — CSS injection is handled by the sigil plugin
67
+ // inside bun-plugin.ts at runtime; we just need clean JS here.
68
+ code = code.replace(STYLE_RE, "");
69
+ const res = transformSync(code, {
70
+ parserOpts: {
71
+ plugins: [["typescript", { isTSX: true }], "jsx"],
72
+ },
73
+ plugins: [
74
+ [sigilPlugin, { hash, mode }],
75
+ rewriteRelativeImports(route.filePath),
76
+ ],
77
+ filename: route.filePath,
78
+ });
79
+ const out = transpiler.transformSync(res?.code ?? "");
80
+ const rel = relative(process.cwd(), route.filePath)
81
+ .replace(/\.[cm]?[jt]sx?$/, "")
82
+ .replace(/[^a-zA-Z0-9._-]/g, "_");
83
+ const nameBase = rel || basename(route.filePath).replace(/\.[jt]sx$/, "");
84
+ const outPath = join(outDir, `${index}-${nameBase}.${mode}.js`);
85
+ await Bun.write(outPath, out);
86
+ map.set(route.filePath, outPath);
87
+ }),
88
+ );
89
+ return map;
90
+ }