@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.
- package/dist/adapter/bun.d.ts +3 -0
- package/dist/build/client.d.ts +14 -0
- package/dist/build/compile-entry.d.ts +22 -0
- package/dist/build/entry-template.d.ts +13 -0
- package/dist/build/hydrate.d.ts +20 -0
- package/dist/build/index.d.ts +7 -0
- package/dist/build/index.js +2212 -0
- package/dist/build/route-types.d.ts +20 -0
- package/dist/build/scan-server.d.ts +8 -0
- package/dist/build/server-routes-entry.d.ts +22 -0
- package/dist/build/shared.d.ts +12 -0
- package/dist/build/types.d.ts +53 -0
- package/dist/cli/config.d.ts +9 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +2240 -0
- package/dist/client.d.ts +158 -0
- package/dist/client.js +20 -0
- package/dist/config.d.ts +16 -0
- package/dist/config.js +23 -0
- package/dist/furin.d.ts +45 -0
- package/dist/furin.js +937 -0
- package/dist/internal.d.ts +18 -0
- package/dist/link.d.ts +119 -0
- package/dist/link.js +281 -0
- package/dist/plugin/index.d.ts +20 -0
- package/dist/plugin/index.js +1408 -0
- package/dist/plugin/transform-client.d.ts +9 -0
- package/dist/render/assemble.d.ts +13 -0
- package/dist/render/cache.d.ts +7 -0
- package/dist/render/element.d.ts +4 -0
- package/dist/render/index.d.ts +26 -0
- package/dist/render/loaders.d.ts +12 -0
- package/dist/render/shell.d.ts +17 -0
- package/dist/render/template.d.ts +4 -0
- package/dist/router.d.ts +32 -0
- package/dist/router.js +575 -0
- package/dist/runtime-env.d.ts +3 -0
- package/dist/tsconfig.dts.tsbuildinfo +1 -0
- package/dist/utils.d.ts +6 -0
- package/package.json +74 -0
- package/src/adapter/README.md +13 -0
- package/src/adapter/bun.ts +119 -0
- package/src/build/client.ts +110 -0
- package/src/build/compile-entry.ts +99 -0
- package/src/build/entry-template.ts +62 -0
- package/src/build/hydrate.ts +106 -0
- package/src/build/index.ts +120 -0
- package/src/build/route-types.ts +88 -0
- package/src/build/scan-server.ts +88 -0
- package/src/build/server-routes-entry.ts +38 -0
- package/src/build/shared.ts +80 -0
- package/src/build/types.ts +60 -0
- package/src/cli/config.ts +68 -0
- package/src/cli/index.ts +106 -0
- package/src/client.ts +237 -0
- package/src/config.ts +31 -0
- package/src/furin.ts +251 -0
- package/src/internal.ts +36 -0
- package/src/link.tsx +480 -0
- package/src/plugin/index.ts +80 -0
- package/src/plugin/transform-client.ts +372 -0
- package/src/render/assemble.ts +57 -0
- package/src/render/cache.ts +9 -0
- package/src/render/element.tsx +28 -0
- package/src/render/index.ts +312 -0
- package/src/render/loaders.ts +67 -0
- package/src/render/shell.ts +128 -0
- package/src/render/template.ts +54 -0
- package/src/router.ts +234 -0
- package/src/runtime-env.ts +6 -0
- 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
|
+
}
|