@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
package/src/client.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/*
|
|
2
|
+
biome-ignore-all lint/complexity/noBannedTypes: The fundamental problem is that
|
|
3
|
+
`Record<string, unknown>` requires an index signature, and any type without one
|
|
4
|
+
(like `{}`, `object`, or a named interface) won't satisfy it. But `{}` is the only type that:
|
|
5
|
+
1. Satisfies `Record<string, unknown>` as a generic default (TS special-cases `{}`)
|
|
6
|
+
2. Doesn't have an index signature (so unknown prop access errors)
|
|
7
|
+
3. Is transparent in intersections(`{} & T = T`)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Cookie, StatusMap } from "elysia";
|
|
11
|
+
import type { AnySchema, HTTPHeaders, UnwrapSchema } from "elysia/types";
|
|
12
|
+
|
|
13
|
+
declare const UNSET: unique symbol;
|
|
14
|
+
type Unset = typeof UNSET;
|
|
15
|
+
|
|
16
|
+
type ResolvedSchema<T> = [T] extends [Unset]
|
|
17
|
+
? Unset
|
|
18
|
+
: T extends AnySchema
|
|
19
|
+
? UnwrapSchema<T>
|
|
20
|
+
: Unset;
|
|
21
|
+
|
|
22
|
+
type MergeSchema<TParent, TOwn> = [TParent] extends [Unset]
|
|
23
|
+
? TOwn
|
|
24
|
+
: [TOwn] extends [Unset]
|
|
25
|
+
? TParent
|
|
26
|
+
: TParent & TOwn;
|
|
27
|
+
|
|
28
|
+
type NormalizeUnset<T> = [T] extends [Unset] ? {} : T;
|
|
29
|
+
|
|
30
|
+
export interface RouteContext<TParams = {}, TQuery = {}> {
|
|
31
|
+
cookie: Record<string, Cookie<unknown>>;
|
|
32
|
+
headers: Record<string, string | undefined>;
|
|
33
|
+
params: NormalizeUnset<TParams>;
|
|
34
|
+
path: string;
|
|
35
|
+
query: NormalizeUnset<TQuery>;
|
|
36
|
+
redirect: (url: string, status?: 301 | 302 | 303 | 307 | 308) => Response;
|
|
37
|
+
request: Request;
|
|
38
|
+
set: {
|
|
39
|
+
headers: HTTPHeaders;
|
|
40
|
+
status?: number | keyof StatusMap;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ComponentProps<TParams = {}, TQuery = {}> {
|
|
45
|
+
params: NormalizeUnset<TParams>;
|
|
46
|
+
path: string;
|
|
47
|
+
query: NormalizeUnset<TQuery>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type ResolveParent<T> =
|
|
51
|
+
T extends RouteRef<infer D, infer P, infer Q>
|
|
52
|
+
? { data: D; params: P; query: Q }
|
|
53
|
+
: { data: {}; params: Unset; query: Unset };
|
|
54
|
+
|
|
55
|
+
interface Resolved<TParentRef, TLoaderData, TParamsSchema = Unset, TQuerySchema = Unset> {
|
|
56
|
+
data: ResolveParent<TParentRef>["data"] & TLoaderData;
|
|
57
|
+
params: MergeSchema<ResolveParent<TParentRef>["params"], ResolvedSchema<TParamsSchema>>;
|
|
58
|
+
query: MergeSchema<ResolveParent<TParentRef>["query"], ResolvedSchema<TQuerySchema>>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type MetaDescriptor =
|
|
62
|
+
| { charSet: "utf-8" }
|
|
63
|
+
| { title: string }
|
|
64
|
+
| { name: string; content: string }
|
|
65
|
+
| { property: string; content: string }
|
|
66
|
+
| { httpEquiv: string; content: string }
|
|
67
|
+
| { "script:ld+json": object }
|
|
68
|
+
| { tagName: "meta" | "link"; [name: string]: string | undefined };
|
|
69
|
+
|
|
70
|
+
export interface HeadOptions {
|
|
71
|
+
links?: Array<{ rel: string; href: string; [key: string]: string }>;
|
|
72
|
+
meta?: MetaDescriptor[];
|
|
73
|
+
scripts?: Array<{
|
|
74
|
+
src?: string;
|
|
75
|
+
type?: string;
|
|
76
|
+
children?: string;
|
|
77
|
+
[key: string]: string | undefined;
|
|
78
|
+
}>;
|
|
79
|
+
styles?: Array<{ type?: string; children: string }>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type LoaderDeps = (route: { __type: string }) => Promise<Record<string, unknown>>;
|
|
83
|
+
|
|
84
|
+
export interface PageConfig<
|
|
85
|
+
TParentData extends Record<string, unknown>,
|
|
86
|
+
TParams,
|
|
87
|
+
TQuery,
|
|
88
|
+
TPageLoaderData extends Record<string, unknown> = {},
|
|
89
|
+
> {
|
|
90
|
+
component: React.FC<TParentData & TPageLoaderData & ComponentProps<TParams, TQuery>>;
|
|
91
|
+
head?: (ctx: ComponentProps<TParams, TQuery> & TParentData & TPageLoaderData) => HeadOptions;
|
|
92
|
+
loader?: (
|
|
93
|
+
ctx: RouteContext<TParams, TQuery> & TParentData,
|
|
94
|
+
deps: TypedDeps
|
|
95
|
+
) => Promise<TPageLoaderData> | TPageLoaderData;
|
|
96
|
+
staticParams?: () => Promise<NormalizeUnset<TParams>[]> | NormalizeUnset<TParams>[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface RuntimeRoute {
|
|
100
|
+
__type: "FURIN_ROUTE";
|
|
101
|
+
layout?: React.FC<Record<string, unknown> & { children: React.ReactNode }>;
|
|
102
|
+
loader?(
|
|
103
|
+
ctx: Record<string, unknown>,
|
|
104
|
+
deps: LoaderDeps
|
|
105
|
+
): Promise<Record<string, unknown>> | Record<string, unknown>;
|
|
106
|
+
mode?: "ssr" | "ssg" | "isr";
|
|
107
|
+
params?: unknown;
|
|
108
|
+
parent?: RuntimeRoute;
|
|
109
|
+
query?: unknown;
|
|
110
|
+
revalidate?: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface RuntimePage {
|
|
114
|
+
__type: "FURIN_PAGE";
|
|
115
|
+
_route: RuntimeRoute;
|
|
116
|
+
component: React.FC<Record<string, unknown>>;
|
|
117
|
+
head?(ctx: Record<string, unknown>): HeadOptions;
|
|
118
|
+
loader?(
|
|
119
|
+
ctx: Record<string, unknown>,
|
|
120
|
+
deps: LoaderDeps
|
|
121
|
+
): Promise<Record<string, unknown>> | Record<string, unknown>;
|
|
122
|
+
staticParams?(): Promise<Record<string, string>[]> | Record<string, string>[];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface RouteRef<
|
|
126
|
+
TData extends Record<string, unknown> = Record<string, unknown>,
|
|
127
|
+
TParams = unknown,
|
|
128
|
+
TQuery = unknown,
|
|
129
|
+
> {
|
|
130
|
+
readonly __brand: "FURIN_ROUTE_REF";
|
|
131
|
+
readonly __phantom: { data: TData; params: TParams; query: TQuery };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
interface PageResult<
|
|
135
|
+
TData extends Record<string, unknown>,
|
|
136
|
+
TParams,
|
|
137
|
+
TQuery,
|
|
138
|
+
TPageLoaderData extends Record<string, unknown>,
|
|
139
|
+
> {
|
|
140
|
+
__type: "FURIN_PAGE";
|
|
141
|
+
_route: Route<TData, TParams, TQuery>;
|
|
142
|
+
component: React.FC<TData & TPageLoaderData & ComponentProps<TParams, TQuery>>;
|
|
143
|
+
head?: (ctx: ComponentProps<TParams, TQuery> & TData & TPageLoaderData) => HeadOptions;
|
|
144
|
+
loader?: (
|
|
145
|
+
ctx: RouteContext<TParams, TQuery> & TData,
|
|
146
|
+
deps: TypedDeps
|
|
147
|
+
) => Promise<TPageLoaderData> | TPageLoaderData;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface Route<TParentData extends Record<string, unknown>, TParams, TQuery> {
|
|
151
|
+
__type: "FURIN_ROUTE";
|
|
152
|
+
layout?: React.FC<TParentData & { children: React.ReactNode } & ComponentProps<TParams, TQuery>>;
|
|
153
|
+
loader?(
|
|
154
|
+
ctx: RouteContext<TParams, TQuery> & TParentData,
|
|
155
|
+
deps: TypedDeps
|
|
156
|
+
): Promise<TParentData> | TParentData;
|
|
157
|
+
mode?: "ssr" | "ssg" | "isr";
|
|
158
|
+
|
|
159
|
+
page<TPageLoaderData extends Record<string, unknown> = {}>(
|
|
160
|
+
config: PageConfig<TParentData, TParams, TQuery, TPageLoaderData>
|
|
161
|
+
): PageResult<TParentData, TParams, TQuery, TPageLoaderData>;
|
|
162
|
+
|
|
163
|
+
params?: unknown;
|
|
164
|
+
parent?: RuntimeRoute;
|
|
165
|
+
query?: unknown;
|
|
166
|
+
|
|
167
|
+
/** Branded ref for type inference when used as a parent. */
|
|
168
|
+
ref: RouteRef<TParentData, TParams, TQuery>;
|
|
169
|
+
revalidate?: number;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// User-facing typed deps: infers the loader data type from the route ref.
|
|
173
|
+
type TypedDeps = <TData extends Record<string, unknown>>(
|
|
174
|
+
// biome-ignore lint/suspicious/noExplicitAny: any needed for flexible route type inference
|
|
175
|
+
route: Route<TData, any, any>
|
|
176
|
+
) => Promise<TData>;
|
|
177
|
+
|
|
178
|
+
export function createRoute<
|
|
179
|
+
TParentRef extends RouteRef | undefined = undefined,
|
|
180
|
+
TParamsSchema extends AnySchema | Unset = Unset,
|
|
181
|
+
TQuerySchema extends AnySchema | Unset = Unset,
|
|
182
|
+
TLoaderData extends Record<string, unknown> = {},
|
|
183
|
+
>(config?: {
|
|
184
|
+
parent?: { ref: TParentRef } & { __type: "FURIN_ROUTE" };
|
|
185
|
+
mode?: "ssr" | "ssg" | "isr";
|
|
186
|
+
revalidate?: number;
|
|
187
|
+
params?: TParamsSchema;
|
|
188
|
+
query?: TQuerySchema;
|
|
189
|
+
loader?: (
|
|
190
|
+
ctx: RouteContext<
|
|
191
|
+
Resolved<TParentRef, TLoaderData, TParamsSchema, TQuerySchema>["params"],
|
|
192
|
+
Resolved<TParentRef, TLoaderData, TParamsSchema, TQuerySchema>["query"]
|
|
193
|
+
> &
|
|
194
|
+
ResolveParent<TParentRef>["data"],
|
|
195
|
+
deps: TypedDeps
|
|
196
|
+
) => Promise<TLoaderData> | TLoaderData;
|
|
197
|
+
layout?: React.FC<
|
|
198
|
+
Resolved<TParentRef, TLoaderData, TParamsSchema, TQuerySchema>["data"] & {
|
|
199
|
+
children: React.ReactNode;
|
|
200
|
+
} & ComponentProps<
|
|
201
|
+
Resolved<TParentRef, TLoaderData, TParamsSchema, TQuerySchema>["params"],
|
|
202
|
+
Resolved<TParentRef, TLoaderData, TParamsSchema, TQuerySchema>["query"]
|
|
203
|
+
>
|
|
204
|
+
>;
|
|
205
|
+
}): Route<
|
|
206
|
+
Resolved<TParentRef, TLoaderData, TParamsSchema, TQuerySchema>["data"],
|
|
207
|
+
Resolved<TParentRef, TLoaderData, TParamsSchema, TQuerySchema>["params"],
|
|
208
|
+
Resolved<TParentRef, TLoaderData, TParamsSchema, TQuerySchema>["query"]
|
|
209
|
+
> {
|
|
210
|
+
type R = Resolved<TParentRef, TLoaderData, TParamsSchema, TQuerySchema>;
|
|
211
|
+
|
|
212
|
+
const route = {
|
|
213
|
+
...config,
|
|
214
|
+
__type: "FURIN_ROUTE" as const,
|
|
215
|
+
ref: {} as RouteRef<R["data"], R["params"], R["query"]>,
|
|
216
|
+
|
|
217
|
+
page<TPageLoaderData extends Record<string, unknown> = {}>(
|
|
218
|
+
pageConfig: PageConfig<R["data"], R["params"], R["query"], TPageLoaderData>
|
|
219
|
+
) {
|
|
220
|
+
return {
|
|
221
|
+
...pageConfig,
|
|
222
|
+
__type: "FURIN_PAGE" as const,
|
|
223
|
+
_route: route,
|
|
224
|
+
};
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
return route as Route<R["data"], R["params"], R["query"]>;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export type InferProps<T> = T extends {
|
|
231
|
+
__type: "ELYRA_PAGE";
|
|
232
|
+
component: React.FC<infer P>;
|
|
233
|
+
}
|
|
234
|
+
? P
|
|
235
|
+
: T extends Route<infer D, infer P, infer Q>
|
|
236
|
+
? D & { children: React.ReactNode } & ComponentProps<P, Q>
|
|
237
|
+
: never;
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type { BunPlugin } from "bun";
|
|
2
|
+
|
|
3
|
+
import { t } from "elysia";
|
|
4
|
+
|
|
5
|
+
export const BUILD_TARGETS = ["bun", "node", "vercel", "cloudflare"] as const;
|
|
6
|
+
|
|
7
|
+
export type BuildTarget = (typeof BUILD_TARGETS)[number];
|
|
8
|
+
|
|
9
|
+
const buildTargetSchema = t.Union(BUILD_TARGETS.map((v) => t.Literal(v)));
|
|
10
|
+
const compileTargetSchema = t.Union([t.Literal("server"), t.Literal("embed")]);
|
|
11
|
+
|
|
12
|
+
export const configSchema = t.Object({
|
|
13
|
+
rootDir: t.Optional(t.String()),
|
|
14
|
+
pagesDir: t.Optional(t.String()),
|
|
15
|
+
serverEntry: t.Optional(t.String()),
|
|
16
|
+
targets: t.Optional(t.Array(buildTargetSchema)),
|
|
17
|
+
bun: t.Optional(
|
|
18
|
+
t.Object({
|
|
19
|
+
compile: t.Optional(compileTargetSchema),
|
|
20
|
+
})
|
|
21
|
+
),
|
|
22
|
+
// plugins omitted : TypeBox can't validate Bun.BunPlugin[] (functions)
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export type FurinConfig = (typeof configSchema)["static"] & {
|
|
26
|
+
plugins?: Bun.BunPlugin[];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function defineConfig(config: FurinConfig): FurinConfig {
|
|
30
|
+
return config;
|
|
31
|
+
}
|
package/src/furin.ts
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { staticPlugin } from "@elysiajs/static";
|
|
5
|
+
import { Elysia } from "elysia";
|
|
6
|
+
import type { EmbeddedAppData } from "./internal.ts";
|
|
7
|
+
import { getCompileContext } from "./internal.ts";
|
|
8
|
+
import { warmSSGCache } from "./render/index.ts";
|
|
9
|
+
import { setProductionTemplateContent, setProductionTemplatePath } from "./render/template.ts";
|
|
10
|
+
import { createRoutePlugin, loadProdRoutes, scanPages } from "./router.ts";
|
|
11
|
+
import { IS_DEV } from "./runtime-env.ts";
|
|
12
|
+
|
|
13
|
+
function resolveClientDirFromArgv(): string {
|
|
14
|
+
return (
|
|
15
|
+
resolveClientDirFromEnv() ??
|
|
16
|
+
resolveClientDirFromModuleUrl() ??
|
|
17
|
+
resolveClientDirFromProcessArgs() ??
|
|
18
|
+
resolveFallbackClientDir()
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function resolveClientDirFromEnv(): string | null {
|
|
23
|
+
const envClientDir = process.env.FURIN_CLIENT_DIR;
|
|
24
|
+
if (!envClientDir) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
return envClientDir.startsWith("/") ? envClientDir : resolve(process.cwd(), envClientDir);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function resolveClientDirFromModuleUrl(): string | null {
|
|
31
|
+
try {
|
|
32
|
+
const moduleUrl = new URL(import.meta.url);
|
|
33
|
+
if (moduleUrl.protocol !== "file:") {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const modulePath = fileURLToPath(moduleUrl);
|
|
37
|
+
if (modulePath.includes("/$bunfs/")) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const moduleClientDir = join(dirname(modulePath), "client");
|
|
41
|
+
if (existsSync(join(moduleClientDir, "index.html"))) {
|
|
42
|
+
return moduleClientDir;
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// ignore, fallback to argv-based resolution
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function resolveClientDirFromProcessArgs(): string | null {
|
|
51
|
+
const candidates = [
|
|
52
|
+
process.argv[1],
|
|
53
|
+
process.argv[0],
|
|
54
|
+
(process as { argv0?: string }).argv0,
|
|
55
|
+
process.execPath,
|
|
56
|
+
].filter((value): value is string => typeof value === "string" && value.length > 0);
|
|
57
|
+
|
|
58
|
+
for (const candidate of candidates) {
|
|
59
|
+
const resolved = resolveClientDirFromCandidate(candidate);
|
|
60
|
+
if (resolved) {
|
|
61
|
+
return resolved;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function resolveClientDirFromCandidate(candidate: string): string | null {
|
|
69
|
+
const name = basename(candidate);
|
|
70
|
+
if (name === "bun" || name === "node") {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
if (candidate.includes("/$bunfs/") || candidate.startsWith("bunfs:")) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const absolute = candidate.startsWith("/") ? candidate : resolve(process.cwd(), candidate);
|
|
78
|
+
if (existsSync(absolute)) {
|
|
79
|
+
return join(dirname(absolute), "client");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!candidate.includes("/")) {
|
|
83
|
+
return resolveClientDirFromPath(candidate);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function resolveClientDirFromPath(candidate: string): string | null {
|
|
90
|
+
const pathEntries = process.env.PATH?.split(":") ?? [];
|
|
91
|
+
for (const dir of pathEntries) {
|
|
92
|
+
const fullPath = join(dir, candidate);
|
|
93
|
+
if (existsSync(fullPath)) {
|
|
94
|
+
return join(dirname(fullPath), "client");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function resolveFallbackClientDir(): string {
|
|
101
|
+
const defaultClientDir = resolve(process.cwd(), ".furin/build/bun/client");
|
|
102
|
+
if (existsSync(join(defaultClientDir, "index.html"))) {
|
|
103
|
+
return defaultClientDir;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return join(process.cwd(), "client");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function setupProdTemplate(
|
|
110
|
+
embedded: EmbeddedAppData | undefined,
|
|
111
|
+
clientDir: string
|
|
112
|
+
): Promise<void> {
|
|
113
|
+
if (embedded) {
|
|
114
|
+
if (!embedded.template) {
|
|
115
|
+
throw new Error("[furin] Embedded app is missing its HTML template (index.html).");
|
|
116
|
+
}
|
|
117
|
+
const html = await Bun.file(embedded.template).text();
|
|
118
|
+
setProductionTemplateContent(html);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const templatePath = join(clientDir, "index.html");
|
|
123
|
+
if (!existsSync(templatePath)) {
|
|
124
|
+
throw new Error("[furin] No pre-built assets found. Run `bun run build` first.");
|
|
125
|
+
}
|
|
126
|
+
setProductionTemplatePath(templatePath);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function buildEmbedInstance(
|
|
130
|
+
instanceName: string,
|
|
131
|
+
resolvedPagesDir: string,
|
|
132
|
+
embedded: EmbeddedAppData
|
|
133
|
+
): Elysia {
|
|
134
|
+
const { assets } = embedded;
|
|
135
|
+
// Explicit wildcard route — lifecycle hooks don't fire for unmatched routes.
|
|
136
|
+
return new Elysia({ name: instanceName, seed: resolvedPagesDir })
|
|
137
|
+
.get("/_client/*", ({ params }) => {
|
|
138
|
+
const filePath = assets[`/_client/${params["*"]}`];
|
|
139
|
+
return filePath
|
|
140
|
+
? new Response(Bun.file(filePath))
|
|
141
|
+
: new Response("Not Found", { status: 404 });
|
|
142
|
+
})
|
|
143
|
+
.get("/public/*", ({ params }) => {
|
|
144
|
+
const filePath = assets[`/public/${params["*"]}`];
|
|
145
|
+
return filePath
|
|
146
|
+
? new Response(Bun.file(filePath))
|
|
147
|
+
: new Response("Not Found", { status: 404 });
|
|
148
|
+
}) as unknown as Elysia;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function buildDiskInstance(
|
|
152
|
+
instanceName: string,
|
|
153
|
+
resolvedPagesDir: string,
|
|
154
|
+
clientDir: string,
|
|
155
|
+
publicDir: string
|
|
156
|
+
): Promise<Elysia> {
|
|
157
|
+
let instance = new Elysia({ name: instanceName, seed: resolvedPagesDir });
|
|
158
|
+
|
|
159
|
+
if (existsSync(publicDir)) {
|
|
160
|
+
instance = instance.use(await staticPlugin({ assets: publicDir, prefix: "/public" }));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
instance = instance.use(await staticPlugin({ assets: clientDir, prefix: "/_client" }));
|
|
164
|
+
return instance;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Main Furin plugin.
|
|
169
|
+
*
|
|
170
|
+
* Returns a standalone Elysia instance (async function) so that routes are
|
|
171
|
+
* properly registered in Elysia's router for SPA navigation to work.
|
|
172
|
+
*
|
|
173
|
+
* ## Usage
|
|
174
|
+
*
|
|
175
|
+
* ```ts
|
|
176
|
+
* new Elysia()
|
|
177
|
+
* .use(await furin({ ... }))
|
|
178
|
+
* .listen(3000)
|
|
179
|
+
* ```
|
|
180
|
+
*/
|
|
181
|
+
export async function furin({ pagesDir }: { pagesDir?: string }) {
|
|
182
|
+
const cwd = process.cwd();
|
|
183
|
+
const ctx = getCompileContext();
|
|
184
|
+
const resolvedPagesDir = ctx?.rootPath
|
|
185
|
+
? dirname(ctx.rootPath)
|
|
186
|
+
: resolve(cwd, pagesDir ?? "src/pages");
|
|
187
|
+
|
|
188
|
+
// Unique name per pagesDir to avoid Elysia's name-based plugin dedup.
|
|
189
|
+
const instanceName = `furin-${resolvedPagesDir.replaceAll("\\", "/")}`;
|
|
190
|
+
|
|
191
|
+
// ── Dev: Bun native HMR ────────────────────────────────────────────────
|
|
192
|
+
if (IS_DEV) {
|
|
193
|
+
const furinDir = resolve(cwd, ".furin");
|
|
194
|
+
const { root, routes } = await scanPages(resolvedPagesDir);
|
|
195
|
+
console.info(
|
|
196
|
+
`[furin] Configuration: ${routes.length} page(s) — ${IS_DEV ? "dev (Bun HMR)" : "production"}`
|
|
197
|
+
);
|
|
198
|
+
for (const route of routes) {
|
|
199
|
+
const hasLayout = route.routeChain.some((r) => r.layout);
|
|
200
|
+
console.info(
|
|
201
|
+
` ${route.mode.toUpperCase().padEnd(4)} ${route.pattern}${hasLayout ? " + layout" : ""}`
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
// Lazy import — build pipeline has native deps not available in compiled binaries
|
|
205
|
+
const { writeDevFiles } = await import("./build/hydrate.ts");
|
|
206
|
+
writeDevFiles(routes, { outDir: furinDir, rootLayout: root.path });
|
|
207
|
+
|
|
208
|
+
let instance = new Elysia({ name: instanceName, seed: resolvedPagesDir })
|
|
209
|
+
.use(await staticPlugin({ assets: furinDir, prefix: "/_bun_hmr_entry" }))
|
|
210
|
+
.use(await staticPlugin());
|
|
211
|
+
|
|
212
|
+
for (const route of routes) {
|
|
213
|
+
instance = instance.use(createRoutePlugin(route, root));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return instance;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── Production ──────────────────────────────────────────────────────────
|
|
220
|
+
if (!ctx) {
|
|
221
|
+
throw new Error("[furin] No pre-built assets found. Run `bun run build` first.");
|
|
222
|
+
}
|
|
223
|
+
const { root, routes } = loadProdRoutes(ctx);
|
|
224
|
+
|
|
225
|
+
const embedded = ctx?.embedded;
|
|
226
|
+
const clientDir = embedded ? "" : resolveClientDirFromArgv();
|
|
227
|
+
const publicDir = embedded ? "" : join(dirname(clientDir), "public");
|
|
228
|
+
|
|
229
|
+
await setupProdTemplate(embedded, clientDir);
|
|
230
|
+
|
|
231
|
+
let instance = embedded
|
|
232
|
+
? buildEmbedInstance(instanceName, resolvedPagesDir, embedded)
|
|
233
|
+
: await buildDiskInstance(instanceName, resolvedPagesDir, clientDir, publicDir);
|
|
234
|
+
|
|
235
|
+
for (const route of routes) {
|
|
236
|
+
instance = instance.use(createRoutePlugin(route, root));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Pre-render SSG routes with staticParams before the first request arrives.
|
|
240
|
+
const ssgTargets = routes.filter((r) => r.mode === "ssg" && r.page?.staticParams);
|
|
241
|
+
if (ssgTargets.length > 0) {
|
|
242
|
+
instance = instance.onStart(async ({ server }) => {
|
|
243
|
+
const origin = server?.url?.origin ?? "http://localhost:3000";
|
|
244
|
+
console.log(`[furin] Warming SSG cache for ${ssgTargets.length} route(s)…`);
|
|
245
|
+
await warmSSGCache(ssgTargets, root, origin);
|
|
246
|
+
console.log("[furin] SSG warm-up complete.");
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return instance;
|
|
251
|
+
}
|
package/src/internal.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// ── Compile-time context for compiled binaries ──────────────────────────────
|
|
2
|
+
// The generated compile entry calls `__setCompileContext()` before importing
|
|
3
|
+
// server.ts. At runtime, `router.ts` and `furin.ts` use `getCompileContext()`
|
|
4
|
+
// to resolve modules and assets from the binary instead of the filesystem.
|
|
5
|
+
|
|
6
|
+
export interface EmbeddedAppData {
|
|
7
|
+
assets: Record<string, string>;
|
|
8
|
+
template: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface CompileContextRoute {
|
|
12
|
+
mode: "ssr" | "ssg" | "isr";
|
|
13
|
+
path: string;
|
|
14
|
+
pattern: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CompileContext {
|
|
18
|
+
embedded?: EmbeddedAppData;
|
|
19
|
+
modules: Record<string, unknown>;
|
|
20
|
+
rootPath: string;
|
|
21
|
+
routes: CompileContextRoute[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let _compileCtx: CompileContext | null = null;
|
|
25
|
+
|
|
26
|
+
export function __setCompileContext(ctx: CompileContext): void {
|
|
27
|
+
_compileCtx = ctx;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function getCompileContext(): CompileContext | null {
|
|
31
|
+
return _compileCtx;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function __resetCompileContext(): void {
|
|
35
|
+
_compileCtx = null;
|
|
36
|
+
}
|