@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
@@ -0,0 +1,119 @@
1
+ import { existsSync, rmSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { buildClient } from "../build/client.ts";
4
+ import { generateCompileEntry } from "../build/compile-entry.ts";
5
+ import { generateServerRoutesEntry } from "../build/server-routes-entry.ts";
6
+ import {
7
+ buildTargetManifest,
8
+ copyDirRecursive,
9
+ ensureDir,
10
+ toPosixPath,
11
+ writeTargetManifest,
12
+ } from "../build/shared.ts";
13
+ import type { BuildAppOptions, TargetBuildManifest } from "../build/types.ts";
14
+ import type { BuildTarget } from "../config.ts";
15
+ import type { ResolvedRoute } from "../router.ts";
16
+
17
+ export async function buildBunTarget(
18
+ routes: ResolvedRoute[],
19
+ rootDir: string,
20
+ buildRoot: string,
21
+ rootPath: string,
22
+ serverEntry: string | null,
23
+ options: BuildAppOptions
24
+ ): Promise<TargetBuildManifest> {
25
+ if (options.compile && !serverEntry) {
26
+ throw new Error(
27
+ `[furin] \`compile: "${options.compile}"\` requires a server entry point. ` +
28
+ "Create src/server.ts or set `serverEntry` in your furin.config.ts."
29
+ );
30
+ }
31
+
32
+ const target = "bun" satisfies BuildTarget;
33
+ const targetManifest = buildTargetManifest(rootDir, buildRoot, target, serverEntry);
34
+ const targetDir = resolve(rootDir, targetManifest.targetDir);
35
+
36
+ rmSync(targetDir, { force: true, recursive: true });
37
+ ensureDir(targetDir);
38
+
39
+ await buildClient(routes, {
40
+ outDir: targetDir,
41
+ rootLayout: rootPath,
42
+ plugins: options.plugins,
43
+ });
44
+
45
+ const routeManifest = routes.map((r) => ({ pattern: r.pattern, path: r.path, mode: r.mode }));
46
+ const publicDir = existsSync(join(rootDir, "public")) ? join(rootDir, "public") : undefined;
47
+ const targetPublicDir = publicDir ? join(targetDir, "public") : undefined;
48
+
49
+ if (publicDir && targetPublicDir && options.compile !== "embed") {
50
+ copyDirRecursive(publicDir, targetPublicDir);
51
+ }
52
+
53
+ if (options.compile && serverEntry) {
54
+ const clientDir = join(targetDir, "client");
55
+ const outfile = join(targetDir, "server");
56
+
57
+ const entryPath = generateCompileEntry({
58
+ rootPath,
59
+ routes: routeManifest,
60
+ serverEntry,
61
+ outDir: targetDir,
62
+ embed: options.compile === "embed" ? { clientDir } : undefined,
63
+ publicDir,
64
+ });
65
+
66
+ await Bun.build({
67
+ entrypoints: [entryPath],
68
+ compile: { outfile },
69
+ minify: true,
70
+ sourcemap: "linked",
71
+ plugins: options.plugins,
72
+ });
73
+ console.log(`[furin] Server binary: ${outfile}`);
74
+
75
+ targetManifest.serverPath = toPosixPath(join(targetManifest.targetDir, "server"));
76
+
77
+ // Embed mode: assets are in the binary — clean up client dir too.
78
+ if (options.compile === "embed") {
79
+ rmSync(clientDir, { force: true, recursive: true });
80
+ }
81
+ } else if (serverEntry) {
82
+ // Disk mode: generate server.ts then bundle it into self-contained server.js
83
+ const entryPath = generateServerRoutesEntry({
84
+ rootPath,
85
+ routes: routeManifest,
86
+ serverEntry,
87
+ outDir: targetDir,
88
+ });
89
+
90
+ await Bun.build({
91
+ entrypoints: [entryPath],
92
+ outdir: targetDir,
93
+ target: "bun",
94
+ minify: true,
95
+ sourcemap: "linked",
96
+ plugins: options.plugins,
97
+ });
98
+ console.log(
99
+ `[furin] Server bundle: ${toPosixPath(join(targetManifest.targetDir, "server.js"))}`
100
+ );
101
+
102
+ targetManifest.serverPath = toPosixPath(join(targetManifest.targetDir, "server.js"));
103
+ }
104
+
105
+ // Clean up build intermediates — no longer needed once the bundle/binary is built.
106
+ for (const file of [
107
+ "_compile-entry.ts",
108
+ "_compile-entry.js.map",
109
+ "server.ts", // disk mode intermediate
110
+ "_hydrate.tsx",
111
+ "index.html",
112
+ ]) {
113
+ rmSync(join(targetDir, file), { force: true });
114
+ }
115
+
116
+ writeTargetManifest(targetDir, targetManifest);
117
+
118
+ return targetManifest;
119
+ }
@@ -0,0 +1,110 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { transformForClient } from "../plugin/transform-client";
4
+ import { generateIndexHtml } from "../render/shell";
5
+ import type { ResolvedRoute } from "../router";
6
+ import { generateHydrateEntry } from "./hydrate";
7
+ import { CLIENT_MODULE_PATH, LINK_MODULE_PATH } from "./shared";
8
+ import type { BunBuildAliasConfig, BuildClientOptions } from "./types";
9
+
10
+ const TS_FILE_FILTER = /\.(tsx|ts)$/;
11
+ const REACT_IMPORT_RE = /import\s+React\b/;
12
+
13
+ /**
14
+ * Builds the production client bundle via Bun.build() using the generated
15
+ * index.html as the HTML entrypoint. Bun produces:
16
+ * <outDir>/client/index.html — processed template with hashed chunk paths
17
+ * <outDir>/client/chunk-*.js — code-split bundles
18
+ * <outDir>/client/styles.css — CSS (if imported)
19
+ *
20
+ * The output index.html is NOT served to browsers directly. The server reads
21
+ * it as an SSR template, injects the pre-rendered React HTML into
22
+ * <!--ssr-outlet-->, and sends the complete page.
23
+ */
24
+ export async function buildClient(
25
+ routes: ResolvedRoute[],
26
+ { outDir, rootLayout, plugins }: BuildClientOptions
27
+ ): Promise<void> {
28
+ const clientDir = join(outDir, "client");
29
+
30
+ if (!existsSync(outDir)) {
31
+ mkdirSync(outDir, { recursive: true });
32
+ }
33
+ if (!existsSync(clientDir)) {
34
+ mkdirSync(clientDir, { recursive: true });
35
+ }
36
+
37
+ const hydrateCode = generateHydrateEntry(routes, rootLayout);
38
+ const hydratePath = join(outDir, "_hydrate.tsx");
39
+ writeFileSync(hydratePath, hydrateCode);
40
+
41
+ const indexHtml = generateIndexHtml();
42
+ const indexPath = join(outDir, "index.html");
43
+ writeFileSync(indexPath, indexHtml);
44
+
45
+ console.log("[furin] Building production client bundle…");
46
+
47
+ const transformPlugin: Bun.BunPlugin = {
48
+ name: "furin-transform-client",
49
+ setup(build) {
50
+ build.onLoad({ filter: TS_FILE_FILTER }, async (args) => {
51
+ const { path } = args;
52
+ if (path.includes("node_modules")) {
53
+ return undefined;
54
+ }
55
+
56
+ const code = await Bun.file(path).text();
57
+ try {
58
+ const result = transformForClient(code, path);
59
+ let transformed = result.code;
60
+
61
+ if (transformed.includes("React.createElement") && !REACT_IMPORT_RE.test(transformed)) {
62
+ transformed = `import React from "react";\n${transformed}`;
63
+ }
64
+
65
+ transformed = transformed
66
+ .replaceAll(`"@teyik0/furin/client"`, JSON.stringify(CLIENT_MODULE_PATH))
67
+ .replaceAll(`'furin/client'`, JSON.stringify(CLIENT_MODULE_PATH))
68
+ .replaceAll(`"@teyik0/furin/link"`, JSON.stringify(LINK_MODULE_PATH))
69
+ .replaceAll(`'furin/link'`, JSON.stringify(LINK_MODULE_PATH));
70
+
71
+ return {
72
+ contents: transformed,
73
+ loader: path.endsWith(".tsx") ? "tsx" : "ts",
74
+ };
75
+ } catch (error) {
76
+ console.error(`[furin] Transform error for ${path}:`, error);
77
+ return undefined;
78
+ }
79
+ });
80
+ },
81
+ };
82
+
83
+ const clientBuildConfig: BunBuildAliasConfig = {
84
+ entrypoints: [indexPath],
85
+ outdir: clientDir,
86
+ target: "browser",
87
+ format: "esm",
88
+ splitting: true,
89
+ minify: true,
90
+ sourcemap: "linked",
91
+ // Absolute public path so SSR template asset URLs resolve on any route
92
+ publicPath: "/_client/",
93
+ // User plugins run before the internal transform so they pre-process files first
94
+ plugins: plugins ? [...plugins, transformPlugin] : [transformPlugin],
95
+ alias: {
96
+ "@teyik0/furin/client": CLIENT_MODULE_PATH,
97
+ "@teyik0/furin/link": LINK_MODULE_PATH,
98
+ },
99
+ define: {
100
+ "process.env.NODE_ENV": JSON.stringify("production"),
101
+ },
102
+ };
103
+
104
+ const result = await Bun.build(clientBuildConfig);
105
+ for (const output of result.outputs) {
106
+ console.log(`[furin] ${output.path} (${(output.size / 1024).toFixed(1)} KB)`);
107
+ }
108
+
109
+ console.log("[furin] Production client build complete");
110
+ }
@@ -0,0 +1,99 @@
1
+ import { existsSync, writeFileSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ import { buildEntrySource } from "./entry-template";
4
+ import { collectFilesRecursive, ensureDir, toPosixPath } from "./shared";
5
+
6
+ export interface CompileEntryOptions {
7
+ outDir: string;
8
+ rootPath: string;
9
+ routes: Array<{ mode: "ssr" | "ssg" | "isr"; path: string; pattern: string }>;
10
+ serverEntry: string;
11
+ embed?: { clientDir: string };
12
+ publicDir?: string;
13
+ }
14
+
15
+ /**
16
+ * Generates a single `_compile-entry.ts` that:
17
+ * 1. Statically imports every page module so Bun bundles them into the binary
18
+ * 2. Optionally embeds client assets via `with { type: "file" }` (embed mode)
19
+ * 3. Sets production mode and registers everything in a single CompileContext
20
+ * 4. Dynamically imports server.ts to boot the app
21
+ */
22
+ export function generateCompileEntry(options: CompileEntryOptions): string {
23
+ const { outDir, rootPath, routes, serverEntry, embed, publicDir } = options;
24
+ ensureDir(outDir);
25
+
26
+ // Build embedded asset imports if embed mode
27
+ const assetImports: string[] = [];
28
+ let embeddedBlock: string[] = [];
29
+ if (embed) {
30
+ if (!existsSync(embed.clientDir)) {
31
+ throw new Error(`[furin] Client directory not found: ${embed.clientDir}. Run the client build first.`);
32
+ }
33
+ const clientFiles = collectFilesRecursive(embed.clientDir);
34
+ const assetEntries: string[] = [];
35
+ let templateVarName: string | null = null;
36
+
37
+ let assetIndex = 0;
38
+ for (const file of clientFiles) {
39
+ const varName = `_asset${assetIndex++}`;
40
+ const relPath = toPosixPath(relative(outDir, file));
41
+ const importPath = relPath.startsWith(".") ? relPath : `./${relPath}`;
42
+
43
+ assetImports.push(
44
+ `import ${varName} from ${JSON.stringify(importPath)} with { type: "file" };`
45
+ );
46
+
47
+ const clientRelativePath = toPosixPath(relative(embed.clientDir, file));
48
+ if (clientRelativePath === "index.html") {
49
+ templateVarName = varName;
50
+ } else {
51
+ assetEntries.push(` ${JSON.stringify(`/_client/${clientRelativePath}`)}: ${varName},`);
52
+ }
53
+ }
54
+
55
+ if (templateVarName === null) {
56
+ throw new Error(
57
+ `[furin] Embed mode requires a client index.html at ${join(embed.clientDir, "index.html")}. Run the client build first.`
58
+ );
59
+ }
60
+
61
+ if (publicDir && existsSync(publicDir)) {
62
+ const publicFiles = collectFilesRecursive(publicDir);
63
+ for (const file of publicFiles) {
64
+ const varName = `_asset${assetIndex++}`;
65
+ const relPath = toPosixPath(relative(outDir, file));
66
+ const importPath = relPath.startsWith(".") ? relPath : `./${relPath}`;
67
+
68
+ assetImports.push(
69
+ `import ${varName} from ${JSON.stringify(importPath)} with { type: "file" };`
70
+ );
71
+
72
+ const publicRelativePath = toPosixPath(relative(publicDir, file));
73
+ assetEntries.push(` ${JSON.stringify(`/public/${publicRelativePath}`)}: ${varName},`);
74
+ }
75
+ }
76
+
77
+ embeddedBlock = [
78
+ " embedded: {",
79
+ ` template: ${templateVarName},`,
80
+ " assets: {",
81
+ ...assetEntries,
82
+ " },",
83
+ " },",
84
+ ];
85
+ }
86
+
87
+ const source = buildEntrySource({
88
+ headerComment: "// Auto-generated by furin compile — do not edit",
89
+ rootPath,
90
+ routes,
91
+ serverEntry,
92
+ extraImports: assetImports,
93
+ extraContext: embeddedBlock,
94
+ });
95
+
96
+ const entryPath = join(outDir, "_compile-entry.ts");
97
+ writeFileSync(entryPath, source);
98
+ return entryPath;
99
+ }
@@ -0,0 +1,62 @@
1
+ import { resolve } from "node:path";
2
+
3
+ const INTERNAL_MODULE_PATH = resolve(import.meta.dir, "../internal.ts").replace(/\\/g, "/");
4
+ const RUNTIME_ENV_MODULE_PATH = resolve(import.meta.dir, "../runtime-env.ts").replace(/\\/g, "/");
5
+
6
+ export interface EntryTemplateOptions {
7
+ headerComment: string;
8
+ rootPath: string;
9
+ routes: Array<{ mode: "ssr" | "ssg" | "isr"; path: string; pattern: string }>;
10
+ serverEntry: string;
11
+ extraImports?: string[];
12
+ extraContext?: string[];
13
+ }
14
+
15
+ export function buildEntrySource(options: EntryTemplateOptions): string {
16
+ const { headerComment, rootPath, routes, serverEntry, extraImports = [], extraContext = [] } =
17
+ options;
18
+
19
+ const allModulePaths = [rootPath, ...routes.map((r) => r.path)];
20
+ const moduleImports: string[] = [];
21
+ const moduleEntries: string[] = [];
22
+
23
+ for (let i = 0; i < allModulePaths.length; i++) {
24
+ const absPath = (allModulePaths[i] as string).replace(/\\/g, "/");
25
+ const varName = `_mod${i}`;
26
+ moduleImports.push(`import * as ${varName} from ${JSON.stringify(absPath)};`);
27
+ moduleEntries.push(` ${JSON.stringify(absPath)}: ${varName},`);
28
+ }
29
+
30
+ const routeEntries = routes.map(
31
+ (r) =>
32
+ ` { pattern: ${JSON.stringify(r.pattern)}, path: ${JSON.stringify(r.path.replace(/\\/g, "/"))}, mode: ${JSON.stringify(r.mode)} },`
33
+ );
34
+
35
+ const lines = [
36
+ headerComment,
37
+ `import { __setCompileContext } from ${JSON.stringify(INTERNAL_MODULE_PATH)};`,
38
+ `import { __setDevMode } from ${JSON.stringify(RUNTIME_ENV_MODULE_PATH)};`,
39
+ ...moduleImports,
40
+ ...(extraImports.length > 0 ? ["", ...extraImports] : []),
41
+ "",
42
+ "// Force production mode — Bun may inline process.env.NODE_ENV at bundle time.",
43
+ "__setDevMode(false);",
44
+ 'process.env.NODE_ENV = "production";',
45
+ "",
46
+ "__setCompileContext({",
47
+ ` rootPath: ${JSON.stringify(rootPath.replace(/\\/g, "/"))},`,
48
+ " modules: {",
49
+ ...moduleEntries,
50
+ " },",
51
+ " routes: [",
52
+ ...routeEntries,
53
+ " ],",
54
+ ...extraContext,
55
+ "});",
56
+ "",
57
+ `await import(${JSON.stringify(serverEntry.replace(/\\/g, "/"))});`,
58
+ "",
59
+ ];
60
+
61
+ return lines.join("\n");
62
+ }
@@ -0,0 +1,106 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { generateIndexHtml } from "../render/shell";
4
+ import type { ResolvedRoute } from "../router";
5
+ import { writeRouteTypes } from "./route-types";
6
+ import type { BuildClientOptions } from "./types";
7
+
8
+ /**
9
+ * Generates the client hydration entry.
10
+ *
11
+ * Renders into <div id="root"> (the SSR outlet element) and retains the React
12
+ * root across hot reloads via import.meta.hot.data.root so React Fast Refresh
13
+ * applies in-place instead of remounting.
14
+ *
15
+ * @param routes - Resolved routes to include in the hydration manifest.
16
+ * @param rootLayout - Absolute path to the root layout module.
17
+ */
18
+ export function generateHydrateEntry(routes: ResolvedRoute[], rootLayout: string): string {
19
+ const routeEntries: string[] = [];
20
+
21
+ for (const route of routes) {
22
+ const resolvedPage = route.path.replace(/\\/g, "/");
23
+ const regexPattern = route.pattern.replace(/:[^/]+/g, "([^/]+)").replace(/\*/g, "(.*)");
24
+
25
+ routeEntries.push(
26
+ ` { pattern: "${route.pattern}", regex: new RegExp("^${regexPattern}$"), load: () => import("${resolvedPage}") }`
27
+ );
28
+ }
29
+
30
+ return `import { hydrateRoot, createRoot } from "react-dom/client";
31
+ import { createElement } from "react";
32
+ import { RouterProvider } from "@teyik0/furin/link";
33
+ import { route as root } from "${rootLayout.replace(/\\/g, "/")}";
34
+
35
+ const routes = [
36
+ ${routeEntries.join(",\n")}
37
+ ];
38
+
39
+ const pathname = window.location.pathname;
40
+ const _match = routes.find((r) => r.regex.test(pathname));
41
+
42
+ // Eagerly load only the current page module for initial hydration.
43
+ // All other pages are loaded on demand when the user navigates to them.
44
+ if (_match) {
45
+ const _mod = await _match.load();
46
+ const match = { ..._match, component: _mod.default.component, pageRoute: _mod.default._route };
47
+
48
+ const dataEl = document.getElementById("__FURIN_DATA__");
49
+ const loaderData = dataEl ? JSON.parse(dataEl.textContent || "{}") : {};
50
+ const rootEl = document.getElementById("root") as HTMLElement;
51
+
52
+ const app = createElement(RouterProvider, {
53
+ routes,
54
+ root,
55
+ initialMatch: match,
56
+ initialData: loaderData,
57
+ } as any);
58
+
59
+ if (import.meta.hot) {
60
+ // Retain React root across hot reloads so Fast Refresh applies in-place.
61
+ const hotRoot = (import.meta.hot.data.root ??= rootEl.innerHTML.trim()
62
+ ? hydrateRoot(rootEl, app)
63
+ : createRoot(rootEl));
64
+ hotRoot.render(app);
65
+ } else if (rootEl.innerHTML.trim()) {
66
+ hydrateRoot(rootEl, app);
67
+ } else {
68
+ createRoot(rootEl).render(app);
69
+ }
70
+ } else {
71
+ console.error("[furin] No matching route for", pathname);
72
+ }
73
+ `;
74
+ }
75
+
76
+ /**
77
+ * Writes _hydrate.tsx + index.html to outDir for dev (Bun HMR) mode.
78
+ *
79
+ * Only rewrites a file when its content has actually changed so Bun's --hot
80
+ * watcher does not trigger a spurious reload on every server restart.
81
+ */
82
+ export function writeDevFiles(routes: ResolvedRoute[], {outDir, rootLayout}: BuildClientOptions): void {
83
+ if (!existsSync(outDir)) {
84
+ mkdirSync(outDir, { recursive: true });
85
+ }
86
+
87
+ const hydrateCode = generateHydrateEntry(routes, rootLayout);
88
+ const hydratePath = join(outDir, "_hydrate.tsx");
89
+ const existingHydrate = existsSync(hydratePath) ? readFileSync(hydratePath, "utf8") : "";
90
+ if (hydrateCode !== existingHydrate) {
91
+ writeFileSync(hydratePath, hydrateCode);
92
+ }
93
+
94
+ const indexHtml = generateIndexHtml();
95
+ const indexPath = join(outDir, "index.html");
96
+ const existingIndex = existsSync(indexPath) ? readFileSync(indexPath, "utf8") : "";
97
+ if (indexHtml !== existingIndex) {
98
+ writeFileSync(indexPath, indexHtml);
99
+ }
100
+
101
+ writeRouteTypes(routes, outDir);
102
+
103
+ console.log(
104
+ "[furin] Dev files written (.furin/_hydrate.tsx + .furin/index.html + .furin/routes.d.ts)"
105
+ );
106
+ }
@@ -0,0 +1,120 @@
1
+ import { existsSync, writeFileSync } from "node:fs";
2
+ import { join, relative, resolve } from "node:path";
3
+ import { buildBunTarget } from "../adapter/bun";
4
+ import { BUILD_TARGETS, type BuildTarget } from "../config";
5
+ import { scanPages } from "../router";
6
+ import { scanFurinInstances } from "./scan-server";
7
+ import {
8
+ ensureDir,
9
+ toBuildRouteManifestEntry,
10
+ toPosixPath,
11
+ } from "./shared";
12
+ import type {
13
+ BuildAppOptions,
14
+ BuildAppResult,
15
+ BuildManifest,
16
+ } from "./types";
17
+
18
+ export type {
19
+ BuildAppOptions,
20
+ BuildAppResult,
21
+ BuildClientOptions,
22
+ BuildManifest,
23
+ BuildRouteManifestEntry,
24
+ TargetBuildManifest,
25
+ } from "./types";
26
+ export { buildClient } from "./client";
27
+ export { writeDevFiles } from "./hydrate";
28
+ export { patternToTypeString, schemaToTypeString, writeRouteTypes } from "./route-types";
29
+
30
+ const IMPLEMENTED_TARGETS = ["bun"] as const satisfies BuildTarget[];
31
+ export const BUILD_OUTPUT_DIR = ".furin/build";
32
+
33
+ function resolvePagesDirFromServer(serverEntry: string | null, rootDir: string): string | null {
34
+ if (!serverEntry) return null;
35
+ const detected = scanFurinInstances(serverEntry);
36
+ if (detected.length === 0) return null;
37
+ // Use the first detected pagesDir relative to rootDir
38
+ return resolve(rootDir, detected[0] as string);
39
+ }
40
+
41
+ export async function buildApp(options: BuildAppOptions): Promise<BuildAppResult> {
42
+ const rootDir = resolve(options.rootDir ?? process.cwd());
43
+ const buildRoot = join(rootDir, BUILD_OUTPUT_DIR);
44
+ const serverEntry = (() => {
45
+ if (options.serverEntry) {
46
+ const resolved = resolve(rootDir, options.serverEntry);
47
+ if (existsSync(resolved)) return resolved;
48
+ }
49
+ const serverEntry = resolve(rootDir, "src/server.ts");
50
+ if (!existsSync(serverEntry)) {
51
+ throw new Error("[furin] Entrypoint server.ts not found");
52
+ }
53
+ return serverEntry
54
+ })();
55
+
56
+ // Priority: explicit config > auto-detected from server entry > default
57
+ const rawPagesDir =
58
+ options.pagesDir ?? resolvePagesDirFromServer(serverEntry, rootDir) ?? "src/pages";
59
+ const pagesDir = resolve(rootDir, rawPagesDir);
60
+
61
+ const requestedTargets =
62
+ options.target === "all"
63
+ ? [...IMPLEMENTED_TARGETS]
64
+ : [options.target].map((target) => {
65
+ if (!(BUILD_TARGETS as readonly string[]).includes(target)) {
66
+ throw new Error(`[furin] Unsupported build target "${target}"`);
67
+ }
68
+ return target as BuildTarget;
69
+ });
70
+
71
+ const { root, routes } = await scanPages(pagesDir);
72
+ if (!root) {
73
+ throw new Error(
74
+ "[furin] No root layout found. Create a root.tsx in your pages directory with a layout component."
75
+ );
76
+ }
77
+
78
+ ensureDir(buildRoot);
79
+
80
+ const manifest: BuildManifest = {
81
+ version: 1,
82
+ generatedAt: new Date().toISOString(),
83
+ rootDir: toPosixPath(rootDir),
84
+ pagesDir: toPosixPath(relative(rootDir, pagesDir)),
85
+ rootPath: toPosixPath(relative(rootDir, root.path)),
86
+ serverEntry: serverEntry ? toPosixPath(relative(rootDir, serverEntry)) : null,
87
+ routes: routes.map((route) => toBuildRouteManifestEntry(route, rootDir)),
88
+ targets: {},
89
+ };
90
+
91
+ for (const target of requestedTargets) {
92
+ switch (target) {
93
+ case "bun":
94
+ manifest.targets.bun = await buildBunTarget(
95
+ routes,
96
+ rootDir,
97
+ buildRoot,
98
+ root.path,
99
+ serverEntry,
100
+ options
101
+ );
102
+ break;
103
+ case "node":
104
+ case "vercel":
105
+ case "cloudflare":
106
+ throw new Error(
107
+ `[furin] \`--target ${target}\` is planned but not implemented yet in this branch.`
108
+ );
109
+ default:
110
+ throw new Error(`[furin] Unsupported build target "${target}"`);
111
+ }
112
+ }
113
+
114
+ writeFileSync(join(buildRoot, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`)
115
+
116
+ return {
117
+ manifest,
118
+ targets: manifest.targets,
119
+ };
120
+ }