@teyik0/furin 0.1.0-alpha.3

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 (71) hide show
  1. package/dist/adapter/bun.d.ts +3 -0
  2. package/dist/build/client.d.ts +14 -0
  3. package/dist/build/compile-entry.d.ts +22 -0
  4. package/dist/build/entry-template.d.ts +13 -0
  5. package/dist/build/hydrate.d.ts +20 -0
  6. package/dist/build/index.d.ts +7 -0
  7. package/dist/build/index.js +2212 -0
  8. package/dist/build/route-types.d.ts +20 -0
  9. package/dist/build/scan-server.d.ts +8 -0
  10. package/dist/build/server-routes-entry.d.ts +22 -0
  11. package/dist/build/shared.d.ts +12 -0
  12. package/dist/build/types.d.ts +53 -0
  13. package/dist/cli/config.d.ts +9 -0
  14. package/dist/cli/index.d.ts +1 -0
  15. package/dist/cli/index.js +2240 -0
  16. package/dist/client.d.ts +158 -0
  17. package/dist/client.js +20 -0
  18. package/dist/config.d.ts +16 -0
  19. package/dist/config.js +23 -0
  20. package/dist/furin.d.ts +45 -0
  21. package/dist/furin.js +937 -0
  22. package/dist/internal.d.ts +18 -0
  23. package/dist/link.d.ts +119 -0
  24. package/dist/link.js +281 -0
  25. package/dist/plugin/index.d.ts +20 -0
  26. package/dist/plugin/index.js +1408 -0
  27. package/dist/plugin/transform-client.d.ts +9 -0
  28. package/dist/render/assemble.d.ts +13 -0
  29. package/dist/render/cache.d.ts +7 -0
  30. package/dist/render/element.d.ts +4 -0
  31. package/dist/render/index.d.ts +26 -0
  32. package/dist/render/loaders.d.ts +12 -0
  33. package/dist/render/shell.d.ts +17 -0
  34. package/dist/render/template.d.ts +4 -0
  35. package/dist/router.d.ts +32 -0
  36. package/dist/router.js +575 -0
  37. package/dist/runtime-env.d.ts +3 -0
  38. package/dist/tsconfig.dts.tsbuildinfo +1 -0
  39. package/dist/utils.d.ts +6 -0
  40. package/package.json +74 -0
  41. package/src/adapter/README.md +13 -0
  42. package/src/adapter/bun.ts +119 -0
  43. package/src/build/client.ts +110 -0
  44. package/src/build/compile-entry.ts +99 -0
  45. package/src/build/entry-template.ts +62 -0
  46. package/src/build/hydrate.ts +106 -0
  47. package/src/build/index.ts +120 -0
  48. package/src/build/route-types.ts +88 -0
  49. package/src/build/scan-server.ts +88 -0
  50. package/src/build/server-routes-entry.ts +38 -0
  51. package/src/build/shared.ts +80 -0
  52. package/src/build/types.ts +60 -0
  53. package/src/cli/config.ts +68 -0
  54. package/src/cli/index.ts +106 -0
  55. package/src/client.ts +237 -0
  56. package/src/config.ts +31 -0
  57. package/src/furin.ts +251 -0
  58. package/src/internal.ts +36 -0
  59. package/src/link.tsx +480 -0
  60. package/src/plugin/index.ts +80 -0
  61. package/src/plugin/transform-client.ts +372 -0
  62. package/src/render/assemble.ts +57 -0
  63. package/src/render/cache.ts +9 -0
  64. package/src/render/element.tsx +28 -0
  65. package/src/render/index.ts +312 -0
  66. package/src/render/loaders.ts +67 -0
  67. package/src/render/shell.ts +128 -0
  68. package/src/render/template.ts +54 -0
  69. package/src/router.ts +234 -0
  70. package/src/runtime-env.ts +6 -0
  71. package/src/utils.ts +68 -0
package/dist/furin.js ADDED
@@ -0,0 +1,937 @@
1
+ // @bun
2
+ var __defProp = Object.defineProperty;
3
+ var __returnValue = (v) => v;
4
+ function __exportSetter(name, newValue) {
5
+ this[name] = __returnValue.bind(null, newValue);
6
+ }
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, {
10
+ get: all[name],
11
+ enumerable: true,
12
+ configurable: true,
13
+ set: __exportSetter.bind(all, name)
14
+ });
15
+ };
16
+ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
17
+
18
+ // src/render/shell.ts
19
+ function extractTitle(meta) {
20
+ if (!meta) {
21
+ return;
22
+ }
23
+ for (const entry of meta) {
24
+ if ("title" in entry) {
25
+ return entry.title;
26
+ }
27
+ }
28
+ return;
29
+ }
30
+ function isMetaTag(entry) {
31
+ return !(("title" in entry) || ("charSet" in entry) || ("script:ld+json" in entry) || ("tagName" in entry));
32
+ }
33
+ function escapeHtml(str) {
34
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
35
+ }
36
+ function safeJson(value) {
37
+ return JSON.stringify(value).replace(/</g, "\\u003c");
38
+ }
39
+ function renderAttrs(obj) {
40
+ return Object.entries(obj).filter(([, v]) => v !== undefined).map(([k, v]) => `${k}="${escapeHtml(String(v))}"`).join(" ");
41
+ }
42
+ function buildMetaParts(meta) {
43
+ const parts = [];
44
+ const title = extractTitle(meta);
45
+ if (title) {
46
+ parts.push(`<title>${escapeHtml(title)}</title>`);
47
+ }
48
+ for (const m of meta) {
49
+ if (isMetaTag(m)) {
50
+ parts.push(`<meta ${renderAttrs(m)} />`);
51
+ }
52
+ if ("script:ld+json" in m) {
53
+ parts.push(`<script type="application/ld+json">${safeJson(m["script:ld+json"])}</script>`);
54
+ }
55
+ }
56
+ return parts;
57
+ }
58
+ function buildLinkParts(links) {
59
+ return links.map((link) => `<link ${renderAttrs(link)} />`);
60
+ }
61
+ function buildScriptParts(scripts) {
62
+ return scripts.map((script) => {
63
+ const { children, ...rest } = script;
64
+ const attrs = renderAttrs(rest);
65
+ if (children) {
66
+ return `<script ${attrs}>${children}</script>`;
67
+ }
68
+ return `<script ${attrs}></script>`;
69
+ });
70
+ }
71
+ function buildStyleParts(styles) {
72
+ return styles.map((style) => {
73
+ const typeAttr = style.type ? ` type="${escapeHtml(style.type)}"` : "";
74
+ return `<style${typeAttr}>${style.children}</style>`;
75
+ });
76
+ }
77
+ function buildHeadInjection(headData) {
78
+ const parts = [];
79
+ if (headData?.meta) {
80
+ parts.push(...buildMetaParts(headData.meta));
81
+ }
82
+ if (headData?.links) {
83
+ parts.push(...buildLinkParts(headData.links));
84
+ }
85
+ if (headData?.scripts) {
86
+ parts.push(...buildScriptParts(headData.scripts));
87
+ }
88
+ if (headData?.styles) {
89
+ parts.push(...buildStyleParts(headData.styles));
90
+ }
91
+ return parts.length > 0 ? `
92
+ ${parts.join(`
93
+ `)}
94
+ ` : "";
95
+ }
96
+ function generateIndexHtml() {
97
+ return `<!DOCTYPE html>
98
+ <html lang="en">
99
+ <head>
100
+ <meta charset="UTF-8">
101
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
102
+ <!--ssr-head-->
103
+ </head>
104
+ <body>
105
+ <div id="root"><!--ssr-outlet--></div>
106
+ <script type="module" src="./_hydrate.tsx"></script>
107
+ </body>
108
+ </html>
109
+ `;
110
+ }
111
+
112
+ // src/build/route-types.ts
113
+ import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync } from "fs";
114
+ import { join as join2 } from "path";
115
+ function patternToTypeString(pattern) {
116
+ const t = pattern.replace(/:[^/]+/g, "${string}").replace(/\*/g, "${string}");
117
+ return t.includes("${") ? `\`${t}\`` : `"${t}"`;
118
+ }
119
+ function schemaToTypeString(schema) {
120
+ if (!schema || typeof schema !== "object") {
121
+ return "unknown";
122
+ }
123
+ const s = schema;
124
+ if (s.anyOf && Array.isArray(s.anyOf)) {
125
+ const parts = s.anyOf.map(schemaToTypeString).filter((t) => t !== "null");
126
+ return parts.join(" | ") || "unknown";
127
+ }
128
+ switch (s.type) {
129
+ case "string":
130
+ return "string";
131
+ case "number":
132
+ case "integer":
133
+ return "number";
134
+ case "boolean":
135
+ return "boolean";
136
+ case "null":
137
+ return "null";
138
+ case "object": {
139
+ if (!s.properties || typeof s.properties !== "object") {
140
+ return "Record<string, unknown>";
141
+ }
142
+ const required = new Set(Array.isArray(s.required) ? s.required : []);
143
+ const props = Object.entries(s.properties).map(([k, v]) => `${k}${required.has(k) ? "" : "?"}: ${schemaToTypeString(v)}`).join("; ");
144
+ return `{ ${props} }`;
145
+ }
146
+ default:
147
+ return "unknown";
148
+ }
149
+ }
150
+ function writeRouteTypes(routes, outDir) {
151
+ const entries = routes.map((r) => {
152
+ const typeKey = patternToTypeString(r.pattern);
153
+ const isDynamic = typeKey.startsWith("`");
154
+ const querySchema = r.routeChain?.find((rt) => rt.query)?.query;
155
+ const searchType = querySchema ? schemaToTypeString(querySchema) : "never";
156
+ return isDynamic ? ` [key: ${typeKey}]: { search?: ${searchType} }` : ` ${typeKey}: { search?: ${searchType} }`;
157
+ });
158
+ const content = `// Auto-generated by Furin. Do not edit manually.
159
+ // Add ".furin/routes.d.ts" to your tsconfig.json "include" array to enable typed navigation.
160
+ import "@teyik0/furin/link";
161
+
162
+ declare module "@teyik0/furin/link" {
163
+ interface RouteManifest {
164
+ ${entries.join(`;
165
+ `)};
166
+ }
167
+ }
168
+ `;
169
+ const typesPath = join2(outDir, "routes.d.ts");
170
+ const existing = existsSync2(typesPath) ? readFileSync2(typesPath, "utf8") : "";
171
+ if (content !== existing) {
172
+ writeFileSync(typesPath, content);
173
+ }
174
+ }
175
+ var init_route_types = () => {};
176
+
177
+ // src/build/hydrate.ts
178
+ var exports_hydrate = {};
179
+ __export(exports_hydrate, {
180
+ writeDevFiles: () => writeDevFiles,
181
+ generateHydrateEntry: () => generateHydrateEntry
182
+ });
183
+ import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
184
+ import { join as join3 } from "path";
185
+ function generateHydrateEntry(routes, rootLayout) {
186
+ const routeEntries = [];
187
+ for (const route of routes) {
188
+ const resolvedPage = route.path.replace(/\\/g, "/");
189
+ const regexPattern = route.pattern.replace(/:[^/]+/g, "([^/]+)").replace(/\*/g, "(.*)");
190
+ routeEntries.push(` { pattern: "${route.pattern}", regex: new RegExp("^${regexPattern}$"), load: () => import("${resolvedPage}") }`);
191
+ }
192
+ return `import { hydrateRoot, createRoot } from "react-dom/client";
193
+ import { createElement } from "react";
194
+ import { RouterProvider } from "@teyik0/furin/link";
195
+ import { route as root } from "${rootLayout.replace(/\\/g, "/")}";
196
+
197
+ const routes = [
198
+ ${routeEntries.join(`,
199
+ `)}
200
+ ];
201
+
202
+ const pathname = window.location.pathname;
203
+ const _match = routes.find((r) => r.regex.test(pathname));
204
+
205
+ // Eagerly load only the current page module for initial hydration.
206
+ // All other pages are loaded on demand when the user navigates to them.
207
+ if (_match) {
208
+ const _mod = await _match.load();
209
+ const match = { ..._match, component: _mod.default.component, pageRoute: _mod.default._route };
210
+
211
+ const dataEl = document.getElementById("__FURIN_DATA__");
212
+ const loaderData = dataEl ? JSON.parse(dataEl.textContent || "{}") : {};
213
+ const rootEl = document.getElementById("root") as HTMLElement;
214
+
215
+ const app = createElement(RouterProvider, {
216
+ routes,
217
+ root,
218
+ initialMatch: match,
219
+ initialData: loaderData,
220
+ } as any);
221
+
222
+ if (import.meta.hot) {
223
+ // Retain React root across hot reloads so Fast Refresh applies in-place.
224
+ const hotRoot = (import.meta.hot.data.root ??= rootEl.innerHTML.trim()
225
+ ? hydrateRoot(rootEl, app)
226
+ : createRoot(rootEl));
227
+ hotRoot.render(app);
228
+ } else if (rootEl.innerHTML.trim()) {
229
+ hydrateRoot(rootEl, app);
230
+ } else {
231
+ createRoot(rootEl).render(app);
232
+ }
233
+ } else {
234
+ console.error("[furin] No matching route for", pathname);
235
+ }
236
+ `;
237
+ }
238
+ function writeDevFiles(routes, { outDir, rootLayout }) {
239
+ if (!existsSync3(outDir)) {
240
+ mkdirSync(outDir, { recursive: true });
241
+ }
242
+ const hydrateCode = generateHydrateEntry(routes, rootLayout);
243
+ const hydratePath = join3(outDir, "_hydrate.tsx");
244
+ const existingHydrate = existsSync3(hydratePath) ? readFileSync3(hydratePath, "utf8") : "";
245
+ if (hydrateCode !== existingHydrate) {
246
+ writeFileSync2(hydratePath, hydrateCode);
247
+ }
248
+ const indexHtml = generateIndexHtml();
249
+ const indexPath = join3(outDir, "index.html");
250
+ const existingIndex = existsSync3(indexPath) ? readFileSync3(indexPath, "utf8") : "";
251
+ if (indexHtml !== existingIndex) {
252
+ writeFileSync2(indexPath, indexHtml);
253
+ }
254
+ writeRouteTypes(routes, outDir);
255
+ console.log("[furin] Dev files written (.furin/_hydrate.tsx + .furin/index.html + .furin/routes.d.ts)");
256
+ }
257
+ var init_hydrate = __esm(() => {
258
+ init_route_types();
259
+ });
260
+
261
+ // src/furin.ts
262
+ import { existsSync as existsSync4 } from "fs";
263
+ import { basename, dirname, join as join4, resolve } from "path";
264
+ import { fileURLToPath } from "url";
265
+ import { staticPlugin } from "@elysiajs/static";
266
+ import { Elysia as Elysia2 } from "elysia";
267
+
268
+ // src/internal.ts
269
+ var _compileCtx = null;
270
+ function getCompileContext() {
271
+ return _compileCtx;
272
+ }
273
+
274
+ // src/render/index.ts
275
+ import { renderToReadableStream } from "react-dom/server";
276
+
277
+ // src/render/assemble.ts
278
+ function resolvePath(pattern, params) {
279
+ let path = pattern;
280
+ for (const [key, val] of Object.entries(params ?? {})) {
281
+ path = path.replace(key === "*" ? "*" : `:${key}`, val);
282
+ }
283
+ return path;
284
+ }
285
+ async function streamToString(stream) {
286
+ const reader = stream.getReader();
287
+ const decoder = new TextDecoder;
288
+ let html = "";
289
+ for (;; ) {
290
+ const { done, value } = await reader.read();
291
+ if (done) {
292
+ break;
293
+ }
294
+ html += decoder.decode(value, { stream: true });
295
+ }
296
+ html += decoder.decode();
297
+ return html;
298
+ }
299
+ function splitTemplate(template) {
300
+ const [headPre, afterHead = ""] = template.split("<!--ssr-head-->");
301
+ const [bodyPre, bodyPost = ""] = afterHead.split("<!--ssr-outlet-->");
302
+ return { headPre, bodyPre, bodyPost };
303
+ }
304
+ function assembleHTML(template, headData, reactHtml, data) {
305
+ const { headPre, bodyPre, bodyPost } = splitTemplate(template);
306
+ const dataScript = data ? `<script id="__FURIN_DATA__" type="application/json">${safeJson(data)}</script>` : "";
307
+ return headPre + headData + bodyPre + reactHtml + dataScript + bodyPost;
308
+ }
309
+
310
+ // src/render/cache.ts
311
+ var isrCache = new Map;
312
+ var ssgCache = new Map;
313
+
314
+ // src/render/element.tsx
315
+ import { jsxDEV } from "react/jsx-dev-runtime";
316
+ function buildElement(route, data, rootLayout) {
317
+ const Component = route.page.component;
318
+ let element = /* @__PURE__ */ jsxDEV(Component, {
319
+ ...data
320
+ }, undefined, false, undefined, this);
321
+ for (let i = route.routeChain.length - 1;i >= 1; i--) {
322
+ const routeEntry = route.routeChain[i];
323
+ if (routeEntry?.layout) {
324
+ const Layout = routeEntry.layout;
325
+ element = /* @__PURE__ */ jsxDEV(Layout, {
326
+ ...data,
327
+ children: element
328
+ }, undefined, false, undefined, this);
329
+ }
330
+ }
331
+ if (rootLayout.layout) {
332
+ const RootLayoutComponent = rootLayout.layout;
333
+ element = /* @__PURE__ */ jsxDEV(RootLayoutComponent, {
334
+ ...data,
335
+ children: element
336
+ }, undefined, false, undefined, this);
337
+ }
338
+ return element;
339
+ }
340
+
341
+ // src/render/loaders.ts
342
+ async function runLoaders(route, ctx, rootLayout) {
343
+ try {
344
+ const loaderMap = new Map;
345
+ const deps = (routeRef) => loaderMap.get(routeRef) ?? Promise.resolve({});
346
+ const rootData = rootLayout.loader ? await rootLayout.loader({ ...ctx }, deps) ?? {} : {};
347
+ const ctxWithRoot = { ...ctx, ...rootData };
348
+ for (let i = 1;i < route.routeChain.length; i++) {
349
+ const ancestor = route.routeChain[i];
350
+ if (ancestor?.loader) {
351
+ loaderMap.set(ancestor, Promise.resolve(ancestor.loader(ctxWithRoot, deps)).then((r) => r ?? {}));
352
+ }
353
+ }
354
+ const pagePromise = route.page?.loader ? Promise.resolve(route.page.loader(ctxWithRoot, deps)).then((r) => r ?? {}) : Promise.resolve({});
355
+ const results = await Promise.all([...loaderMap.values(), pagePromise]);
356
+ const data = Object.assign({}, rootData, ...results);
357
+ const headers = {};
358
+ Object.assign(headers, ctx.set.headers);
359
+ return { type: "data", data, headers };
360
+ } catch (err) {
361
+ if (err instanceof Response) {
362
+ return { type: "redirect", response: err };
363
+ }
364
+ throw err;
365
+ }
366
+ }
367
+ // src/render/template.ts
368
+ import { readFileSync } from "fs";
369
+ var devTemplatePromises = new Map;
370
+ var _prodTemplatePath = null;
371
+ var _prodTemplateContent = null;
372
+ function getDevTemplate(origin) {
373
+ const cached = devTemplatePromises.get(origin);
374
+ if (cached) {
375
+ return cached;
376
+ }
377
+ const promise = fetch(`${origin}/_bun_hmr_entry`).then((r) => {
378
+ if (!r.ok) {
379
+ throw new Error(`/_bun_hmr_entry returned ${r.status}`);
380
+ }
381
+ return r.text();
382
+ }).catch((err) => {
383
+ devTemplatePromises.delete(origin);
384
+ throw err;
385
+ });
386
+ devTemplatePromises.set(origin, promise);
387
+ return promise;
388
+ }
389
+ function setProductionTemplatePath(path) {
390
+ _prodTemplatePath = path;
391
+ _prodTemplateContent = null;
392
+ }
393
+ function setProductionTemplateContent(content) {
394
+ _prodTemplatePath = null;
395
+ _prodTemplateContent = content;
396
+ }
397
+ function getProductionTemplate() {
398
+ if (_prodTemplateContent !== null) {
399
+ return _prodTemplateContent;
400
+ }
401
+ if (!_prodTemplatePath) {
402
+ return null;
403
+ }
404
+ try {
405
+ _prodTemplateContent = readFileSync(_prodTemplatePath, "utf8");
406
+ return _prodTemplateContent;
407
+ } catch {
408
+ return null;
409
+ }
410
+ }
411
+ // src/runtime-env.ts
412
+ var IS_DEV = true;
413
+
414
+ // src/render/index.ts
415
+ function catchRedirect(err) {
416
+ if (err instanceof Response) {
417
+ return err;
418
+ }
419
+ throw err;
420
+ }
421
+ async function renderForPath(route, params, root, origin) {
422
+ const resolvedPath = resolvePath(route.pattern, params);
423
+ return await renderToHTML(route, {
424
+ params,
425
+ query: {},
426
+ request: new Request(`${origin}${resolvedPath}`),
427
+ headers: {},
428
+ cookie: {},
429
+ redirect: (url, status = 302) => new Response(null, { status, headers: { Location: url } }),
430
+ set: { headers: {} },
431
+ path: resolvedPath
432
+ }, root);
433
+ }
434
+ async function renderToHTML(route, ctx, root) {
435
+ const loaderResult = await runLoaders(route, ctx, root.route);
436
+ if (loaderResult.type === "redirect") {
437
+ throw loaderResult.response;
438
+ }
439
+ const { data, headers } = loaderResult;
440
+ const componentProps = {
441
+ ...data,
442
+ params: ctx.params,
443
+ query: ctx.query,
444
+ path: ctx.path
445
+ };
446
+ const headData = buildHeadInjection(route.page?.head?.(componentProps));
447
+ const element = buildElement(route, componentProps, root.route);
448
+ const stream = await renderToReadableStream(element);
449
+ await stream.allReady;
450
+ const reactHtml = await streamToString(stream);
451
+ const template = IS_DEV ? await getDevTemplate(new URL(ctx.request.url).origin) : getProductionTemplate() ?? generateIndexHtml();
452
+ return {
453
+ html: assembleHTML(template, headData, reactHtml, data),
454
+ headers
455
+ };
456
+ }
457
+ async function prerenderSSG(route, params, root, origin = "http://localhost:3000") {
458
+ const resolvedPath = resolvePath(route.pattern, params);
459
+ const cached = ssgCache.get(resolvedPath);
460
+ if (cached && !IS_DEV) {
461
+ return cached;
462
+ }
463
+ const result = await renderForPath(route, params, root, origin);
464
+ if (!IS_DEV) {
465
+ ssgCache.set(resolvedPath, result.html);
466
+ }
467
+ return result.html;
468
+ }
469
+ async function renderSSR(route, ctx, root) {
470
+ try {
471
+ const loaderResult = await runLoaders(route, ctx, root.route);
472
+ if (loaderResult.type === "redirect") {
473
+ return loaderResult.response;
474
+ }
475
+ const { data, headers } = loaderResult;
476
+ const componentProps = { ...data, params: ctx.params, query: ctx.query, path: ctx.path };
477
+ const headData = buildHeadInjection(route.page.head?.(componentProps));
478
+ const template = IS_DEV ? await getDevTemplate(new URL(ctx.request.url).origin) : getProductionTemplate() ?? generateIndexHtml();
479
+ const { headPre, bodyPre, bodyPost } = splitTemplate(template);
480
+ const dataScript = `<script id="__FURIN_DATA__" type="application/json">${safeJson(data)}</script>`;
481
+ const element = buildElement(route, componentProps, root.route);
482
+ const reactStream = await renderToReadableStream(element);
483
+ const { readable, writable } = new TransformStream;
484
+ const writer = writable.getWriter();
485
+ const enc = new TextEncoder;
486
+ (async () => {
487
+ await writer.write(enc.encode(headPre + headData + bodyPre));
488
+ const reader = reactStream.getReader();
489
+ for (;; ) {
490
+ const { done, value } = await reader.read();
491
+ if (done) {
492
+ break;
493
+ }
494
+ await writer.write(value);
495
+ }
496
+ await writer.write(enc.encode(dataScript + bodyPost));
497
+ await writer.close();
498
+ })().catch((err) => writer.abort(err));
499
+ return new Response(readable, {
500
+ headers: {
501
+ "Content-Type": "text/html; charset=utf-8",
502
+ "Cache-Control": "no-cache, no-store, must-revalidate",
503
+ ...headers
504
+ }
505
+ });
506
+ } catch (err) {
507
+ return catchRedirect(err);
508
+ }
509
+ }
510
+ async function handleISR(route, ctx, root) {
511
+ const revalidate = route.page._route.revalidate ?? 60;
512
+ const params = ctx.params ?? {};
513
+ const cacheKey = resolvePath(route.pattern, params);
514
+ const cached = isrCache.get(cacheKey);
515
+ if (cached && !IS_DEV) {
516
+ const age = Date.now() - cached.generatedAt;
517
+ const isFresh = age < revalidate * 1000;
518
+ if (!isFresh) {
519
+ revalidateInBackground(route, params, cacheKey, revalidate, root, ctx);
520
+ }
521
+ ctx.set.headers["content-type"] = "text/html; charset=utf-8";
522
+ ctx.set.headers["cache-control"] = isFresh ? `public, s-maxage=${revalidate}, stale-while-revalidate=${revalidate}` : "public, s-maxage=0, must-revalidate";
523
+ return cached.html;
524
+ }
525
+ try {
526
+ const result = await renderToHTML(route, ctx, root);
527
+ if (!IS_DEV) {
528
+ isrCache.set(cacheKey, { html: result.html, generatedAt: Date.now(), revalidate });
529
+ }
530
+ ctx.set.headers["content-type"] = "text/html; charset=utf-8";
531
+ ctx.set.headers["cache-control"] = `public, s-maxage=${revalidate}, stale-while-revalidate=${revalidate}`;
532
+ return result.html;
533
+ } catch (err) {
534
+ return catchRedirect(err);
535
+ }
536
+ }
537
+ var SSG_WARM_CONCURRENCY = 4;
538
+ async function warmSSGCache(routes, root, origin) {
539
+ const targets = routes.filter((r) => r.mode === "ssg" && r.page.staticParams);
540
+ const tasks = [];
541
+ for (const route of targets) {
542
+ let paramSets;
543
+ try {
544
+ paramSets = await route.page.staticParams?.() ?? [];
545
+ } catch (err) {
546
+ console.error(`[furin] SSG warm-up failed for ${route.pattern}:`, err);
547
+ continue;
548
+ }
549
+ for (const params of paramSets) {
550
+ tasks.push(async () => {
551
+ try {
552
+ await prerenderSSG(route, params, root, origin);
553
+ } catch (err) {
554
+ console.error(`[furin] SSG prerender failed for ${route.pattern}:`, err);
555
+ }
556
+ });
557
+ }
558
+ }
559
+ if (tasks.length === 0) {
560
+ return;
561
+ }
562
+ const queue = [...tasks];
563
+ const workers = Array.from({ length: Math.min(SSG_WARM_CONCURRENCY, tasks.length) }, async () => {
564
+ while (queue.length > 0) {
565
+ await queue.shift()?.();
566
+ }
567
+ });
568
+ await Promise.all(workers);
569
+ }
570
+ function revalidateInBackground(route, params, cacheKey, revalidate, root, originalCtx) {
571
+ renderForPath(route, params, root, new URL(originalCtx.request.url).origin).then((result) => {
572
+ isrCache.set(cacheKey, {
573
+ html: result.html,
574
+ generatedAt: Date.now(),
575
+ revalidate
576
+ });
577
+ }).catch((err) => {
578
+ console.error("[furin] ISR background revalidation failed:", err);
579
+ });
580
+ }
581
+
582
+ // src/router.ts
583
+ import { existsSync } from "fs";
584
+ import { readdir } from "fs/promises";
585
+ import { join, parse } from "path";
586
+ import { Elysia } from "elysia";
587
+
588
+ // src/utils.ts
589
+ function isFurinPage(value) {
590
+ return typeof value === "object" && value !== null && "__type" in value && value.__type === "FURIN_PAGE";
591
+ }
592
+ function isFurinRoute(value) {
593
+ return typeof value === "object" && value !== null && "__type" in value && value.__type === "FURIN_ROUTE";
594
+ }
595
+ function collectRouteChainFromRoute(route) {
596
+ const chain = [];
597
+ let current = route;
598
+ while (current) {
599
+ chain.unshift(current);
600
+ current = current.parent;
601
+ }
602
+ return chain;
603
+ }
604
+ function hasCycle(route) {
605
+ const visited = new Set;
606
+ let current = route;
607
+ while (current) {
608
+ if (visited.has(current)) {
609
+ return true;
610
+ }
611
+ visited.add(current);
612
+ current = current.parent;
613
+ }
614
+ return false;
615
+ }
616
+ function validateRouteChain(chain, root, pagePath) {
617
+ const hasRoot = chain.some((r) => r === root);
618
+ if (!hasRoot) {
619
+ const location = pagePath ? `in ${pagePath}` : "";
620
+ throw new Error(`[furin] Page ${location} must inherit from root route. ` + 'Add: import { route } from "./root"; and use route.page() or set parent: route');
621
+ }
622
+ for (const route of chain) {
623
+ if (hasCycle(route)) {
624
+ throw new Error("[furin] Cycle detected in route chain. A route cannot be its own ancestor.");
625
+ }
626
+ }
627
+ }
628
+
629
+ // src/router.ts
630
+ function createRoutePlugin(route, root) {
631
+ const { pattern, mode, routeChain } = route;
632
+ const plugins = [];
633
+ const allParams = routeChain.find((r) => r.params)?.params;
634
+ const allQuery = routeChain.find((r) => r.query)?.query;
635
+ if (allParams || allQuery) {
636
+ plugins.push(new Elysia().guard({
637
+ params: allParams,
638
+ query: allQuery
639
+ }));
640
+ }
641
+ plugins.push(new Elysia().get(pattern, async (ctx) => {
642
+ switch (mode) {
643
+ case "ssg": {
644
+ ctx.set.headers["content-type"] = "text/html; charset=utf-8";
645
+ ctx.set.headers["cache-control"] = "public, max-age=0, must-revalidate";
646
+ const origin = new URL(ctx.request.url).origin;
647
+ return await prerenderSSG(route, ctx.params, root, origin);
648
+ }
649
+ case "isr":
650
+ return handleISR(route, ctx, root);
651
+ default:
652
+ return renderSSR(route, ctx, root);
653
+ }
654
+ }));
655
+ return plugins.reduce((app, plugin) => app.use(plugin), new Elysia);
656
+ }
657
+ async function scanRootLayout(pagesDir) {
658
+ const rootPath = `${pagesDir}/root.tsx`;
659
+ const ctx = getCompileContext();
660
+ if (!(existsSync(rootPath) || ctx?.modules[rootPath])) {
661
+ throw new Error("[furin] root.tsx: not found.");
662
+ }
663
+ const mod = ctx?.modules[rootPath] ?? await import(rootPath);
664
+ const rootExport = mod.route ?? mod.default;
665
+ if (!(rootExport && isFurinRoute(rootExport))) {
666
+ throw new Error("[furin] root.tsx: createRoute() export not found.");
667
+ }
668
+ if (!rootExport.layout) {
669
+ throw new Error("[furin] root.tsx: createRoute() has no layout.");
670
+ }
671
+ return { path: rootPath, route: rootExport };
672
+ }
673
+ async function collectPageFilePaths(dir) {
674
+ const files = [];
675
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
676
+ const absolutePath = join(dir, entry.name);
677
+ if (entry.isDirectory()) {
678
+ files.push(...await collectPageFilePaths(absolutePath));
679
+ continue;
680
+ }
681
+ if (entry.isFile()) {
682
+ files.push(absolutePath);
683
+ }
684
+ }
685
+ return files;
686
+ }
687
+ async function scanPageFiles(pagesDir, root) {
688
+ const routes = [];
689
+ for (const absolutePath of await collectPageFilePaths(pagesDir)) {
690
+ if (![".tsx", ".ts", ".jsx", ".js"].some((ext) => absolutePath.endsWith(ext))) {
691
+ continue;
692
+ }
693
+ const relativePath = absolutePath.replace(`${pagesDir}/`, "");
694
+ const fileName = parse(relativePath).name;
695
+ if (fileName.startsWith("_") || fileName === "root") {
696
+ continue;
697
+ }
698
+ const ctx = getCompileContext();
699
+ const pageMod = ctx?.modules[absolutePath] ?? await import(absolutePath);
700
+ const page = pageMod.default;
701
+ if (!isFurinPage(page)) {
702
+ throw new Error(`[furin] ${relativePath}: no valid createRoute().page() export found`);
703
+ }
704
+ const routeChain = collectRouteChainFromRoute(page._route);
705
+ validateRouteChain(routeChain, root.route, relativePath);
706
+ routes.push({
707
+ pattern: filePathToPattern(relativePath),
708
+ page,
709
+ path: absolutePath,
710
+ routeChain,
711
+ mode: resolveMode(page, routeChain)
712
+ });
713
+ }
714
+ return routes;
715
+ }
716
+ async function scanPages(pagesDir) {
717
+ const ctx = getCompileContext();
718
+ if (ctx) {
719
+ return loadProdRoutes(ctx);
720
+ }
721
+ const root = await scanRootLayout(pagesDir);
722
+ const routes = await scanPageFiles(pagesDir, root);
723
+ return { root, routes };
724
+ }
725
+ function loadProdRoutes(ctx) {
726
+ const rootMod = ctx.modules[ctx.rootPath];
727
+ const rootExport = rootMod.route ?? rootMod.default;
728
+ if (!(rootExport && isFurinRoute(rootExport) && rootExport.layout)) {
729
+ throw new Error("[furin] root.tsx: createRoute() with layout not found in CompileContext.");
730
+ }
731
+ const root = { path: ctx.rootPath, route: rootExport };
732
+ const routes = [];
733
+ for (const { pattern, path, mode } of ctx.routes) {
734
+ const pageMod = ctx.modules[path];
735
+ const page = pageMod.default;
736
+ if (!isFurinPage(page)) {
737
+ throw new Error(`[furin] ${path}: invalid page module in CompileContext.`);
738
+ }
739
+ const routeChain = collectRouteChainFromRoute(page._route);
740
+ validateRouteChain(routeChain, root.route, path);
741
+ routes.push({ pattern, page, path, routeChain, mode });
742
+ }
743
+ return { root, routes };
744
+ }
745
+ function resolveMode(page, routeChain) {
746
+ const routeConfig = page._route;
747
+ if (routeConfig.mode) {
748
+ return routeConfig.mode;
749
+ }
750
+ const hasLoader = routeChain.some((r) => r.loader) || !!page.loader;
751
+ if (!hasLoader) {
752
+ return "ssg";
753
+ }
754
+ if (routeConfig.revalidate && routeConfig.revalidate > 0) {
755
+ return "isr";
756
+ }
757
+ return "ssr";
758
+ }
759
+ function filePathToPattern(path) {
760
+ const parts = path.replaceAll("\\", "/").split("/");
761
+ const segments = [];
762
+ for (const part of parts) {
763
+ const name = parse(part).name;
764
+ if (name === "index") {
765
+ continue;
766
+ }
767
+ if (name.startsWith("[") && name.endsWith("]")) {
768
+ const inner = name.slice(1, -1);
769
+ if (inner.startsWith("...")) {
770
+ segments.push("*");
771
+ continue;
772
+ }
773
+ segments.push(`:${inner}`);
774
+ continue;
775
+ }
776
+ segments.push(name);
777
+ }
778
+ return `/${segments.join("/")}`;
779
+ }
780
+
781
+ // src/furin.ts
782
+ function resolveClientDirFromArgv() {
783
+ return resolveClientDirFromEnv() ?? resolveClientDirFromModuleUrl() ?? resolveClientDirFromProcessArgs() ?? resolveFallbackClientDir();
784
+ }
785
+ function resolveClientDirFromEnv() {
786
+ const envClientDir = process.env.FURIN_CLIENT_DIR;
787
+ if (!envClientDir) {
788
+ return null;
789
+ }
790
+ return envClientDir.startsWith("/") ? envClientDir : resolve(process.cwd(), envClientDir);
791
+ }
792
+ function resolveClientDirFromModuleUrl() {
793
+ try {
794
+ const moduleUrl = new URL(import.meta.url);
795
+ if (moduleUrl.protocol !== "file:") {
796
+ return null;
797
+ }
798
+ const modulePath = fileURLToPath(moduleUrl);
799
+ if (modulePath.includes("/$bunfs/")) {
800
+ return null;
801
+ }
802
+ const moduleClientDir = join4(dirname(modulePath), "client");
803
+ if (existsSync4(join4(moduleClientDir, "index.html"))) {
804
+ return moduleClientDir;
805
+ }
806
+ } catch {}
807
+ return null;
808
+ }
809
+ function resolveClientDirFromProcessArgs() {
810
+ const candidates = [
811
+ process.argv[1],
812
+ process.argv[0],
813
+ process.argv0,
814
+ process.execPath
815
+ ].filter((value) => typeof value === "string" && value.length > 0);
816
+ for (const candidate of candidates) {
817
+ const resolved = resolveClientDirFromCandidate(candidate);
818
+ if (resolved) {
819
+ return resolved;
820
+ }
821
+ }
822
+ return null;
823
+ }
824
+ function resolveClientDirFromCandidate(candidate) {
825
+ const name = basename(candidate);
826
+ if (name === "bun" || name === "node") {
827
+ return null;
828
+ }
829
+ if (candidate.includes("/$bunfs/") || candidate.startsWith("bunfs:")) {
830
+ return null;
831
+ }
832
+ const absolute = candidate.startsWith("/") ? candidate : resolve(process.cwd(), candidate);
833
+ if (existsSync4(absolute)) {
834
+ return join4(dirname(absolute), "client");
835
+ }
836
+ if (!candidate.includes("/")) {
837
+ return resolveClientDirFromPath(candidate);
838
+ }
839
+ return null;
840
+ }
841
+ function resolveClientDirFromPath(candidate) {
842
+ const pathEntries = process.env.PATH?.split(":") ?? [];
843
+ for (const dir of pathEntries) {
844
+ const fullPath = join4(dir, candidate);
845
+ if (existsSync4(fullPath)) {
846
+ return join4(dirname(fullPath), "client");
847
+ }
848
+ }
849
+ return null;
850
+ }
851
+ function resolveFallbackClientDir() {
852
+ const defaultClientDir = resolve(process.cwd(), ".furin/build/bun/client");
853
+ if (existsSync4(join4(defaultClientDir, "index.html"))) {
854
+ return defaultClientDir;
855
+ }
856
+ return join4(process.cwd(), "client");
857
+ }
858
+ async function setupProdTemplate(embedded, clientDir) {
859
+ if (embedded) {
860
+ if (!embedded.template) {
861
+ throw new Error("[furin] Embedded app is missing its HTML template (index.html).");
862
+ }
863
+ const html = await Bun.file(embedded.template).text();
864
+ setProductionTemplateContent(html);
865
+ return;
866
+ }
867
+ const templatePath = join4(clientDir, "index.html");
868
+ if (!existsSync4(templatePath)) {
869
+ throw new Error("[furin] No pre-built assets found. Run `bun run build` first.");
870
+ }
871
+ setProductionTemplatePath(templatePath);
872
+ }
873
+ function buildEmbedInstance(instanceName, resolvedPagesDir, embedded) {
874
+ const { assets } = embedded;
875
+ return new Elysia2({ name: instanceName, seed: resolvedPagesDir }).get("/_client/*", ({ params }) => {
876
+ const filePath = assets[`/_client/${params["*"]}`];
877
+ return filePath ? new Response(Bun.file(filePath)) : new Response("Not Found", { status: 404 });
878
+ }).get("/public/*", ({ params }) => {
879
+ const filePath = assets[`/public/${params["*"]}`];
880
+ return filePath ? new Response(Bun.file(filePath)) : new Response("Not Found", { status: 404 });
881
+ });
882
+ }
883
+ async function buildDiskInstance(instanceName, resolvedPagesDir, clientDir, publicDir) {
884
+ let instance = new Elysia2({ name: instanceName, seed: resolvedPagesDir });
885
+ if (existsSync4(publicDir)) {
886
+ instance = instance.use(await staticPlugin({ assets: publicDir, prefix: "/public" }));
887
+ }
888
+ instance = instance.use(await staticPlugin({ assets: clientDir, prefix: "/_client" }));
889
+ return instance;
890
+ }
891
+ async function furin({ pagesDir }) {
892
+ const cwd = process.cwd();
893
+ const ctx = getCompileContext();
894
+ const resolvedPagesDir = ctx?.rootPath ? dirname(ctx.rootPath) : resolve(cwd, pagesDir ?? "src/pages");
895
+ const instanceName = `furin-${resolvedPagesDir.replaceAll("\\", "/")}`;
896
+ if (IS_DEV) {
897
+ const furinDir = resolve(cwd, ".furin");
898
+ const { root: root2, routes: routes2 } = await scanPages(resolvedPagesDir);
899
+ console.info(`[furin] Configuration: ${routes2.length} page(s) \u2014 ${IS_DEV ? "dev (Bun HMR)" : "production"}`);
900
+ for (const route of routes2) {
901
+ const hasLayout = route.routeChain.some((r) => r.layout);
902
+ console.info(` ${route.mode.toUpperCase().padEnd(4)} ${route.pattern}${hasLayout ? " + layout" : ""}`);
903
+ }
904
+ const { writeDevFiles: writeDevFiles2 } = await Promise.resolve().then(() => (init_hydrate(), exports_hydrate));
905
+ writeDevFiles2(routes2, { outDir: furinDir, rootLayout: root2.path });
906
+ let instance2 = new Elysia2({ name: instanceName, seed: resolvedPagesDir }).use(await staticPlugin({ assets: furinDir, prefix: "/_bun_hmr_entry" })).use(await staticPlugin());
907
+ for (const route of routes2) {
908
+ instance2 = instance2.use(createRoutePlugin(route, root2));
909
+ }
910
+ return instance2;
911
+ }
912
+ if (!ctx) {
913
+ throw new Error("[furin] No pre-built assets found. Run `bun run build` first.");
914
+ }
915
+ const { root, routes } = loadProdRoutes(ctx);
916
+ const embedded = ctx?.embedded;
917
+ const clientDir = embedded ? "" : resolveClientDirFromArgv();
918
+ const publicDir = embedded ? "" : join4(dirname(clientDir), "public");
919
+ await setupProdTemplate(embedded, clientDir);
920
+ let instance = embedded ? buildEmbedInstance(instanceName, resolvedPagesDir, embedded) : await buildDiskInstance(instanceName, resolvedPagesDir, clientDir, publicDir);
921
+ for (const route of routes) {
922
+ instance = instance.use(createRoutePlugin(route, root));
923
+ }
924
+ const ssgTargets = routes.filter((r) => r.mode === "ssg" && r.page?.staticParams);
925
+ if (ssgTargets.length > 0) {
926
+ instance = instance.onStart(async ({ server }) => {
927
+ const origin = server?.url?.origin ?? "http://localhost:3000";
928
+ console.log(`[furin] Warming SSG cache for ${ssgTargets.length} route(s)\u2026`);
929
+ await warmSSGCache(ssgTargets, root, origin);
930
+ console.log("[furin] SSG warm-up complete.");
931
+ });
932
+ }
933
+ return instance;
934
+ }
935
+ export {
936
+ furin
937
+ };