@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,88 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { ResolvedRoute } from "../router";
|
|
4
|
+
|
|
5
|
+
/** @internal Exported for unit testing only. */
|
|
6
|
+
export function patternToTypeString(pattern: string): string {
|
|
7
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: intentional — generates TS template literal syntax
|
|
8
|
+
const t = pattern.replace(/:[^/]+/g, "${string}").replace(/\*/g, "${string}");
|
|
9
|
+
return t.includes("${") ? `\`${t}\`` : `"${t}"`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Converts a runtime TypeBox/JSON Schema object to a TypeScript type string.
|
|
14
|
+
* Handles the common cases found in Elysia query schemas (string, number, boolean,
|
|
15
|
+
* optional fields, nullable via anyOf).
|
|
16
|
+
*
|
|
17
|
+
* @internal Exported for unit testing only.
|
|
18
|
+
*/
|
|
19
|
+
export function schemaToTypeString(schema: unknown): string {
|
|
20
|
+
if (!schema || typeof schema !== "object") {
|
|
21
|
+
return "unknown";
|
|
22
|
+
}
|
|
23
|
+
const s = schema as Record<string, unknown>;
|
|
24
|
+
if (s.anyOf && Array.isArray(s.anyOf)) {
|
|
25
|
+
const parts = (s.anyOf as unknown[]).map(schemaToTypeString).filter((t) => t !== "null");
|
|
26
|
+
return parts.join(" | ") || "unknown";
|
|
27
|
+
}
|
|
28
|
+
switch (s.type) {
|
|
29
|
+
case "string":
|
|
30
|
+
return "string";
|
|
31
|
+
case "number":
|
|
32
|
+
case "integer":
|
|
33
|
+
return "number";
|
|
34
|
+
case "boolean":
|
|
35
|
+
return "boolean";
|
|
36
|
+
case "null":
|
|
37
|
+
return "null";
|
|
38
|
+
case "object": {
|
|
39
|
+
if (!s.properties || typeof s.properties !== "object") {
|
|
40
|
+
return "Record<string, unknown>";
|
|
41
|
+
}
|
|
42
|
+
const required = new Set<string>(Array.isArray(s.required) ? (s.required as string[]) : []);
|
|
43
|
+
const props = Object.entries(s.properties as Record<string, unknown>)
|
|
44
|
+
.map(([k, v]) => `${k}${required.has(k) ? "" : "?"}: ${schemaToTypeString(v)}`)
|
|
45
|
+
.join("; ");
|
|
46
|
+
return `{ ${props} }`;
|
|
47
|
+
}
|
|
48
|
+
default:
|
|
49
|
+
return "unknown";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Generates .furin/routes.d.ts — augments RouteManifest in furin/link
|
|
55
|
+
* so that <Link to="..."> has type-safe autocompletion and <Link search={...}>
|
|
56
|
+
* is typed per-route from the route's query schema.
|
|
57
|
+
*
|
|
58
|
+
* Users must add ".furin/routes.d.ts" to their tsconfig.json "include" array once.
|
|
59
|
+
*/
|
|
60
|
+
/** @internal Exported for unit testing only. */
|
|
61
|
+
export function writeRouteTypes(routes: ResolvedRoute[], outDir: string): void {
|
|
62
|
+
const entries = routes.map((r) => {
|
|
63
|
+
const typeKey = patternToTypeString(r.pattern);
|
|
64
|
+
const isDynamic = typeKey.startsWith("`");
|
|
65
|
+
const querySchema = r.routeChain?.find((rt) => rt.query)?.query;
|
|
66
|
+
const searchType = querySchema ? schemaToTypeString(querySchema) : "never";
|
|
67
|
+
return isDynamic
|
|
68
|
+
? ` [key: ${typeKey}]: { search?: ${searchType} }`
|
|
69
|
+
: ` ${typeKey}: { search?: ${searchType} }`;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const content = `// Auto-generated by Furin. Do not edit manually.
|
|
73
|
+
// Add ".furin/routes.d.ts" to your tsconfig.json "include" array to enable typed navigation.
|
|
74
|
+
import "@teyik0/furin/link";
|
|
75
|
+
|
|
76
|
+
declare module "@teyik0/furin/link" {
|
|
77
|
+
interface RouteManifest {
|
|
78
|
+
${entries.join(";\n")};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
`;
|
|
82
|
+
|
|
83
|
+
const typesPath = join(outDir, "routes.d.ts");
|
|
84
|
+
const existing = existsSync(typesPath) ? readFileSync(typesPath, "utf8") : "";
|
|
85
|
+
if (content !== existing) {
|
|
86
|
+
writeFileSync(typesPath, content);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { parseSync } from "oxc-parser";
|
|
3
|
+
|
|
4
|
+
// Minimal AST node shapes — just what we need
|
|
5
|
+
interface AstNode {
|
|
6
|
+
type: string;
|
|
7
|
+
[key: string]: unknown;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Statically scans a server entry file and returns all `pagesDir` string
|
|
12
|
+
* literal values found inside `furin({ pagesDir: "..." })` call expressions.
|
|
13
|
+
*
|
|
14
|
+
* Dynamic paths (template literals, variables) are silently ignored.
|
|
15
|
+
* Returns an empty array when nothing is detected.
|
|
16
|
+
*/
|
|
17
|
+
export function scanFurinInstances(serverEntryPath: string): string[] {
|
|
18
|
+
const code = readFileSync(serverEntryPath, "utf8");
|
|
19
|
+
const { program, errors } = parseSync(serverEntryPath, code);
|
|
20
|
+
if (errors.length > 0) {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const results: string[] = [];
|
|
25
|
+
walkNode(program as unknown as AstNode, results);
|
|
26
|
+
return results;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function walkNode(node: AstNode, out: string[]): void {
|
|
30
|
+
if (!node || typeof node !== "object") return;
|
|
31
|
+
|
|
32
|
+
if (node.type === "CallExpression") {
|
|
33
|
+
const callee = node.callee as AstNode | undefined;
|
|
34
|
+
const args = node.arguments as AstNode[] | undefined;
|
|
35
|
+
|
|
36
|
+
const isElyraCall =
|
|
37
|
+
callee?.type === "Identifier" && (callee as { name?: string }).name === "furin";
|
|
38
|
+
|
|
39
|
+
if (isElyraCall && Array.isArray(args) && args.length > 0) {
|
|
40
|
+
const firstArg = args[0] as AstNode;
|
|
41
|
+
if (firstArg?.type === "ObjectExpression") {
|
|
42
|
+
const pagesDir = extractStringProperty(firstArg, "pagesDir");
|
|
43
|
+
if (pagesDir !== null) {
|
|
44
|
+
out.push(pagesDir);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Recurse into all child node values
|
|
51
|
+
for (const key of Object.keys(node)) {
|
|
52
|
+
if (key === "type" || key === "start" || key === "end") continue;
|
|
53
|
+
const child = node[key];
|
|
54
|
+
if (Array.isArray(child)) {
|
|
55
|
+
for (const item of child) {
|
|
56
|
+
if (item && typeof item === "object") {
|
|
57
|
+
walkNode(item as AstNode, out);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} else if (child && typeof child === "object") {
|
|
61
|
+
walkNode(child as AstNode, out);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function extractStringProperty(obj: AstNode, propName: string): string | null {
|
|
67
|
+
const properties = obj.properties as AstNode[] | undefined;
|
|
68
|
+
if (!Array.isArray(properties)) return null;
|
|
69
|
+
|
|
70
|
+
for (const prop of properties) {
|
|
71
|
+
if (prop.type !== "Property") continue;
|
|
72
|
+
const key = prop.key as AstNode & { name?: string; value?: unknown };
|
|
73
|
+
const value = prop.value as AstNode & { value?: unknown };
|
|
74
|
+
|
|
75
|
+
const keyMatches =
|
|
76
|
+
(key.type === "Identifier" && key.name === propName) ||
|
|
77
|
+
(key.type === "Literal" && key.value === propName);
|
|
78
|
+
|
|
79
|
+
if (!keyMatches) continue;
|
|
80
|
+
|
|
81
|
+
// Only accept string literals — ignore template literals, identifiers, etc.
|
|
82
|
+
if (value?.type === "Literal" && typeof value.value === "string") {
|
|
83
|
+
return value.value;
|
|
84
|
+
}
|
|
85
|
+
return null; // dynamic path — silently skip
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { buildEntrySource } from "./entry-template";
|
|
4
|
+
import { ensureDir } from "./shared";
|
|
5
|
+
|
|
6
|
+
export interface ServerRoutesEntryOptions {
|
|
7
|
+
outDir: string;
|
|
8
|
+
rootPath: string;
|
|
9
|
+
routes: Array<{ mode: "ssr" | "ssg" | "isr"; path: string; pattern: string }>;
|
|
10
|
+
serverEntry: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Generates `server.ts` — an intermediate entry used to produce `server.js`.
|
|
15
|
+
*
|
|
16
|
+
* Equivalent to `_compile-entry.ts` but for disk (non-binary) production builds:
|
|
17
|
+
* 1. Statically imports every page module so Bun bundles them into server.js
|
|
18
|
+
* 2. Sets production mode and registers everything in a single CompileContext
|
|
19
|
+
* 3. Imports server.ts to boot the app
|
|
20
|
+
*
|
|
21
|
+
* This file is intermediate: `adapter/bun.ts` runs `Bun.build()` on it to produce
|
|
22
|
+
* the self-contained `server.js` bundle, then deletes this file.
|
|
23
|
+
*/
|
|
24
|
+
export function generateServerRoutesEntry(options: ServerRoutesEntryOptions): string {
|
|
25
|
+
const { outDir, rootPath, routes, serverEntry } = options;
|
|
26
|
+
ensureDir(outDir);
|
|
27
|
+
const source = buildEntrySource({
|
|
28
|
+
headerComment: "// Auto-generated by furin build — do not edit",
|
|
29
|
+
rootPath,
|
|
30
|
+
routes,
|
|
31
|
+
serverEntry,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Named "server.ts" so Bun.build() outputs "server.js"
|
|
35
|
+
const entryPath = join(outDir, "server.ts");
|
|
36
|
+
writeFileSync(entryPath, source);
|
|
37
|
+
return entryPath;
|
|
38
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, relative, resolve } from "node:path";
|
|
3
|
+
import type { BuildTarget } from "../config";
|
|
4
|
+
import type { ResolvedRoute } from "../router";
|
|
5
|
+
import type { BuildRouteManifestEntry, TargetBuildManifest } from "./types";
|
|
6
|
+
|
|
7
|
+
export const CLIENT_MODULE_PATH = resolve(import.meta.dir, "../client.ts").replace(/\\/g, "/");
|
|
8
|
+
export const LINK_MODULE_PATH = resolve(import.meta.dir, "../link.tsx").replace(/\\/g, "/");
|
|
9
|
+
|
|
10
|
+
export function ensureDir(path: string): void {
|
|
11
|
+
if (!existsSync(path)) {
|
|
12
|
+
mkdirSync(path, { recursive: true });
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function toPosixPath(path: string): string {
|
|
17
|
+
return path.replace(/\\/g, "/");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function collectFilesRecursive(dir: string): string[] {
|
|
21
|
+
const files: string[] = [];
|
|
22
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
23
|
+
|
|
24
|
+
for (const entry of entries) {
|
|
25
|
+
const absolutePath = join(dir, entry.name);
|
|
26
|
+
if (entry.isDirectory()) {
|
|
27
|
+
files.push(...collectFilesRecursive(absolutePath));
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (entry.isFile()) {
|
|
31
|
+
files.push(absolutePath);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return files.sort();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function copyDirRecursive(sourceDir: string, targetDir: string): void {
|
|
39
|
+
rmSync(targetDir, { force: true, recursive: true });
|
|
40
|
+
cpSync(sourceDir, targetDir, { recursive: true });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function toBuildRouteManifestEntry(
|
|
44
|
+
route: ResolvedRoute,
|
|
45
|
+
rootDir: string
|
|
46
|
+
): BuildRouteManifestEntry {
|
|
47
|
+
return {
|
|
48
|
+
pattern: route.pattern,
|
|
49
|
+
mode: route.mode,
|
|
50
|
+
pagePath: toPosixPath(relative(rootDir, route.path)),
|
|
51
|
+
hasLayout: route.routeChain.some((entry) => !!entry.layout),
|
|
52
|
+
hasStaticParams: !!route.page?.staticParams,
|
|
53
|
+
revalidate: route.page?._route.revalidate ?? null,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function buildTargetManifest(
|
|
58
|
+
rootDir: string,
|
|
59
|
+
buildRoot: string,
|
|
60
|
+
target: BuildTarget,
|
|
61
|
+
serverEntry: string | null
|
|
62
|
+
): TargetBuildManifest {
|
|
63
|
+
const targetDir = join(buildRoot, target);
|
|
64
|
+
const manifestPath = join(targetDir, "manifest.json");
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
generatedAt: new Date().toISOString(),
|
|
68
|
+
targetDir: toPosixPath(relative(rootDir, targetDir)),
|
|
69
|
+
clientDir: toPosixPath(relative(rootDir, join(targetDir, "client"))),
|
|
70
|
+
templatePath: toPosixPath(relative(rootDir, join(targetDir, "client", "index.html"))),
|
|
71
|
+
manifestPath: toPosixPath(relative(rootDir, manifestPath)),
|
|
72
|
+
serverPath: null,
|
|
73
|
+
serverEntry: serverEntry ? toPosixPath(relative(rootDir, serverEntry)) : null,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function writeTargetManifest(targetDir: string, targetManifest: TargetBuildManifest): void {
|
|
78
|
+
const manifestPath = join(targetDir, "manifest.json");
|
|
79
|
+
writeFileSync(manifestPath, `${JSON.stringify(targetManifest, null, 2)}\n`);
|
|
80
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { BuildTarget } from "../config";
|
|
2
|
+
import type { ResolvedRoute } from "../router";
|
|
3
|
+
|
|
4
|
+
export interface BuildClientOptions {
|
|
5
|
+
outDir: string;
|
|
6
|
+
pagesDir?: string;
|
|
7
|
+
plugins?: Bun.BunPlugin[];
|
|
8
|
+
rootLayout: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface BuildRouteManifestEntry {
|
|
12
|
+
hasLayout: boolean;
|
|
13
|
+
hasStaticParams: boolean;
|
|
14
|
+
mode: ResolvedRoute["mode"];
|
|
15
|
+
pagePath: string;
|
|
16
|
+
pattern: string;
|
|
17
|
+
revalidate: number | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface TargetBuildManifest {
|
|
21
|
+
clientDir: string;
|
|
22
|
+
generatedAt: string;
|
|
23
|
+
manifestPath: string;
|
|
24
|
+
serverEntry: string | null;
|
|
25
|
+
serverPath: string | null;
|
|
26
|
+
targetDir: string;
|
|
27
|
+
templatePath: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface BuildManifest {
|
|
31
|
+
generatedAt: string;
|
|
32
|
+
pagesDir: string;
|
|
33
|
+
rootDir: string;
|
|
34
|
+
rootPath: string;
|
|
35
|
+
routes: BuildRouteManifestEntry[];
|
|
36
|
+
serverEntry: string | null;
|
|
37
|
+
targets: Partial<Record<BuildTarget, TargetBuildManifest>>;
|
|
38
|
+
version: 1;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface BuildAppOptions {
|
|
42
|
+
compile?: "server" | "embed";
|
|
43
|
+
pagesDir?: string;
|
|
44
|
+
plugins?: Bun.BunPlugin[];
|
|
45
|
+
rootDir?: string;
|
|
46
|
+
serverEntry?: string;
|
|
47
|
+
target: BuildTarget | "all";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface BuildAppResult {
|
|
51
|
+
manifest: BuildManifest;
|
|
52
|
+
targets: Partial<Record<BuildTarget, TargetBuildManifest>>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type BunBuildAliasConfig = Bun.BuildConfig & {
|
|
56
|
+
alias?: Record<string, string>;
|
|
57
|
+
outfile?: string;
|
|
58
|
+
packages?: "bundle" | "external";
|
|
59
|
+
write?: boolean;
|
|
60
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { TypeCompiler } from "elysia/type-system";
|
|
5
|
+
import { configSchema, type FurinConfig } from "../config.ts";
|
|
6
|
+
|
|
7
|
+
const compiledConfigSchema = TypeCompiler.Compile(configSchema);
|
|
8
|
+
|
|
9
|
+
const DEFAULT_CONFIG_FILENAMES = [
|
|
10
|
+
"furin.config.ts",
|
|
11
|
+
"furin.config.js",
|
|
12
|
+
"furin.config.mjs",
|
|
13
|
+
] as const;
|
|
14
|
+
|
|
15
|
+
interface ResolvedCliConfig extends FurinConfig {
|
|
16
|
+
configPath: string | null;
|
|
17
|
+
pagesDir: string;
|
|
18
|
+
plugins?: Bun.BunPlugin[];
|
|
19
|
+
rootDir: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function loadCliConfig(
|
|
23
|
+
cwd: string,
|
|
24
|
+
explicitConfigPath?: string
|
|
25
|
+
): Promise<ResolvedCliConfig> {
|
|
26
|
+
const rootDir = resolve(cwd);
|
|
27
|
+
const configPath = explicitConfigPath
|
|
28
|
+
? resolve(rootDir, explicitConfigPath)
|
|
29
|
+
: DEFAULT_CONFIG_FILENAMES.map((filename) => resolve(rootDir, filename)).find((path) =>
|
|
30
|
+
existsSync(path)
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
if (!configPath) {
|
|
34
|
+
return {
|
|
35
|
+
configPath: null,
|
|
36
|
+
rootDir,
|
|
37
|
+
pagesDir: resolve(rootDir, "src/pages"),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const imported = await import(pathToFileURL(configPath).href);
|
|
42
|
+
const rawConfig: FurinConfig = imported.default ?? imported;
|
|
43
|
+
|
|
44
|
+
// Extract plugins before TypeBox validation: functions cannot be JSON-schema validated
|
|
45
|
+
const { plugins, ...configToValidate } = rawConfig;
|
|
46
|
+
|
|
47
|
+
if (plugins !== undefined && !Array.isArray(plugins)) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`[furin] Invalid config at ${configPath}: "plugins" must be an array of BunPlugin objects`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!compiledConfigSchema.Check(configToValidate)) {
|
|
54
|
+
const [firstError] = compiledConfigSchema.Errors(configToValidate);
|
|
55
|
+
throw new Error(
|
|
56
|
+
`[furin] Invalid config at ${configPath}: ${firstError?.message ?? "unknown error"} (path: ${firstError?.path ?? "/"})`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const resolvedRootDir = resolve(rootDir, configToValidate.rootDir ?? ".");
|
|
61
|
+
return {
|
|
62
|
+
...configToValidate,
|
|
63
|
+
plugins,
|
|
64
|
+
configPath,
|
|
65
|
+
rootDir: resolvedRootDir,
|
|
66
|
+
pagesDir: resolve(resolvedRootDir, configToValidate.pagesDir ?? "src/pages"),
|
|
67
|
+
};
|
|
68
|
+
}
|
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { buildApp } from "../build/index.ts";
|
|
5
|
+
import { BUILD_TARGETS, type BuildTarget } from "../config.ts";
|
|
6
|
+
import { loadCliConfig } from "./config.ts";
|
|
7
|
+
|
|
8
|
+
const argv = process.argv.slice(2);
|
|
9
|
+
const command = argv[0];
|
|
10
|
+
|
|
11
|
+
function log(msg: string): void {
|
|
12
|
+
console.log(`\x1b[32m◆\x1b[0m ${msg}`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function bail(msg: string): never {
|
|
16
|
+
console.error(`\x1b[31m✗\x1b[0m ${msg}`);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function resolveCompileMode(
|
|
21
|
+
flag: string | boolean | undefined,
|
|
22
|
+
configCompile: "server" | "embed" | undefined
|
|
23
|
+
): "server" | "embed" | undefined {
|
|
24
|
+
if (flag === "embed") {
|
|
25
|
+
return "embed";
|
|
26
|
+
}
|
|
27
|
+
if (flag === true || flag === "server") {
|
|
28
|
+
return "server";
|
|
29
|
+
}
|
|
30
|
+
if (flag !== undefined && flag !== false) {
|
|
31
|
+
bail(`Invalid compile mode "${flag}". Valid: --compile server or --compile embed`);
|
|
32
|
+
}
|
|
33
|
+
return configCompile;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (command === "build") {
|
|
37
|
+
const { values: rawValues } = parseArgs({
|
|
38
|
+
args: argv.slice(1),
|
|
39
|
+
options: {
|
|
40
|
+
target: { type: "string" },
|
|
41
|
+
pagesDir: { type: "string" },
|
|
42
|
+
config: { type: "string" },
|
|
43
|
+
},
|
|
44
|
+
strict: false,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const values = rawValues as {
|
|
48
|
+
target?: string;
|
|
49
|
+
pagesDir?: string;
|
|
50
|
+
config?: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// --compile has an optional value: absent → undefined, present alone → true, present with "embed" → "embed"
|
|
54
|
+
const buildArgv = argv.slice(1);
|
|
55
|
+
const compileIdx = buildArgv.indexOf("--compile");
|
|
56
|
+
let compileFlag: string | boolean | undefined;
|
|
57
|
+
if (compileIdx < 0) {
|
|
58
|
+
compileFlag = undefined;
|
|
59
|
+
} else {
|
|
60
|
+
const next = buildArgv[compileIdx + 1];
|
|
61
|
+
compileFlag = next && !next.startsWith("-") ? next : true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const target = values.target ?? "bun";
|
|
65
|
+
|
|
66
|
+
if (target !== "all" && !(BUILD_TARGETS as readonly string[]).includes(target)) {
|
|
67
|
+
bail(`Unsupported build target "${target}". Valid: ${BUILD_TARGETS.join(", ")}, all`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const config = await loadCliConfig(process.cwd(), values.config);
|
|
71
|
+
|
|
72
|
+
const resolvedServerEntry = resolve(config.rootDir, config.serverEntry ?? "src/server.ts");
|
|
73
|
+
if (!existsSync(resolvedServerEntry)) {
|
|
74
|
+
const expected = config.serverEntry ?? "src/server.ts";
|
|
75
|
+
throw new Error(`[furin] Entrypoint ${expected} not found`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
log(`Building Furin for ${target}…`);
|
|
79
|
+
|
|
80
|
+
const result = await buildApp({
|
|
81
|
+
target: target as BuildTarget | "all",
|
|
82
|
+
compile: resolveCompileMode(compileFlag, config.bun?.compile),
|
|
83
|
+
rootDir: config.rootDir,
|
|
84
|
+
pagesDir: values.pagesDir ?? config.pagesDir,
|
|
85
|
+
serverEntry: resolvedServerEntry,
|
|
86
|
+
plugins: config.plugins,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const built = Object.keys(result.targets).join(", ") || "none";
|
|
90
|
+
log(`Done: ${built} → .furin/build`);
|
|
91
|
+
} else if (!command || command === "help") {
|
|
92
|
+
console.log(
|
|
93
|
+
`Furin CLI
|
|
94
|
+
|
|
95
|
+
USAGE furin build [options]
|
|
96
|
+
|
|
97
|
+
OPTIONS
|
|
98
|
+
--target ${BUILD_TARGETS.join(" | ")} | all (default: bun)
|
|
99
|
+
--pagesDir Pages directory
|
|
100
|
+
--config Config file path
|
|
101
|
+
--compile server | embed Compile to binary: "server" keeps client on disk, "embed" is self-contained
|
|
102
|
+
`
|
|
103
|
+
);
|
|
104
|
+
} else {
|
|
105
|
+
bail(`Unknown command "${command}". Run "furin help" for usage.`);
|
|
106
|
+
}
|