@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,372 @@
1
+ import MagicString from "magic-string";
2
+ import { parseSync } from "oxc-parser";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Bun.Transpiler singleton — strips TypeScript + JSX before AST work.
6
+ // Force classic JSX transform (React.createElement) regardless of the
7
+ // project tsconfig's "jsx": "react-jsx" setting — the Bun.build() step
8
+ // that consumes this output handles the automatic runtime itself.
9
+ // ---------------------------------------------------------------------------
10
+ const bunTranspiler = new Bun.Transpiler({
11
+ loader: "tsx",
12
+ tsconfig: {
13
+ compilerOptions: {
14
+ jsx: "react",
15
+ jsxFactory: "React.createElement",
16
+ jsxFragmentFactory: "React.Fragment",
17
+ },
18
+ },
19
+ });
20
+
21
+ // loader: data fetching (runs on server only)
22
+ // query / params: Elysia TypeBox schemas — validated server-side, not used in browser
23
+ const SERVER_ONLY_PROPERTIES = new Set(["loader", "query", "params", "staticParams"]);
24
+
25
+ interface TransformResult {
26
+ code: string;
27
+ map: ReturnType<MagicString["generateMap"]> | null;
28
+ removedServerCode: boolean;
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // ESTree node types (minimal subset needed for our walk)
33
+ // ---------------------------------------------------------------------------
34
+ interface AstNode {
35
+ body?: AstNode[];
36
+ end: number;
37
+ start: number;
38
+ type: string;
39
+ [key: string]: unknown;
40
+ }
41
+
42
+ interface Property extends AstNode {
43
+ // Identifier key: { loader: fn } → key.name === "loader"
44
+ // Literal key: { "loader": fn } → key.value === "loader"
45
+ key: AstNode & { name?: string; value?: unknown };
46
+ type: "Property";
47
+ }
48
+
49
+ interface SpreadElement extends AstNode {
50
+ argument: AstNode;
51
+ type: "SpreadElement";
52
+ }
53
+
54
+ interface ObjectExpression extends AstNode {
55
+ // properties may include SpreadElement nodes, e.g. { ...spread, loader: fn }
56
+ properties: Array<Property | SpreadElement>;
57
+ type: "ObjectExpression";
58
+ }
59
+
60
+ interface CallExpression extends AstNode {
61
+ arguments: AstNode[];
62
+ callee: AstNode & { name?: string; property?: AstNode & { name?: string } };
63
+ type: "CallExpression";
64
+ }
65
+
66
+ interface ImportDeclaration extends AstNode {
67
+ specifiers: Array<AstNode & { local: AstNode & { name: string } }>;
68
+ type: "ImportDeclaration";
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // AST walking
73
+ // ---------------------------------------------------------------------------
74
+ function walk(node: unknown, visitor: (n: AstNode) => void): void {
75
+ if (!node || typeof node !== "object") {
76
+ return;
77
+ }
78
+ if (Array.isArray(node)) {
79
+ for (const child of node) {
80
+ walk(child, visitor);
81
+ }
82
+ return;
83
+ }
84
+ const n = node as AstNode;
85
+ if (typeof n.type === "string") {
86
+ visitor(n);
87
+ }
88
+ for (const key of Object.keys(n)) {
89
+ if (key === "type" || key === "start" || key === "end") {
90
+ continue;
91
+ }
92
+ walk(n[key], visitor);
93
+ }
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Check if a CallExpression is createRoute() or page() / route.page()
98
+ // ---------------------------------------------------------------------------
99
+ function isCreateRouteCall(node: CallExpression): boolean {
100
+ return node.callee.type === "Identifier" && node.callee.name === "createRoute";
101
+ }
102
+
103
+ function isRoutePageCall(node: CallExpression): boolean {
104
+ const { callee } = node;
105
+ if (callee.type === "Identifier" && callee.name === "page") {
106
+ return true;
107
+ }
108
+ if (callee.type === "MemberExpression" && callee.property?.name === "page") {
109
+ return true;
110
+ }
111
+ return false;
112
+ }
113
+
114
+ function isTargetCall(node: CallExpression): boolean {
115
+ return isCreateRouteCall(node) || isRoutePageCall(node);
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Remove server-only properties from an ObjectExpression using MagicString.
120
+ // Returns true if any property was removed.
121
+ // ---------------------------------------------------------------------------
122
+ function removeServerProperties(s: MagicString, source: string, obj: ObjectExpression): boolean {
123
+ const toRemove = obj.properties.filter((p): p is Property => {
124
+ // Skip spread elements — they have no key.
125
+ if (p.type !== "Property") {
126
+ return false;
127
+ }
128
+ const { key } = p;
129
+ // Static identifier key: { loader: fn }
130
+ if (p.computed) {
131
+ return false;
132
+ }
133
+ if (key.type === "Identifier" && typeof key.name === "string") {
134
+ return SERVER_ONLY_PROPERTIES.has(key.name);
135
+ }
136
+ // Quoted string key: { "loader": fn }
137
+ if (key.type === "Literal" && typeof key.value === "string") {
138
+ return SERVER_ONLY_PROPERTIES.has(key.value);
139
+ }
140
+ return false;
141
+ });
142
+ if (toRemove.length === 0) {
143
+ return false;
144
+ }
145
+
146
+ for (const prop of toRemove) {
147
+ // Find the range to remove including the trailing comma + whitespace.
148
+ let removeEnd = prop.end;
149
+ // Skip comma and whitespace after the property
150
+ while (
151
+ removeEnd < source.length &&
152
+ (source[removeEnd] === "," ||
153
+ source[removeEnd] === " " ||
154
+ source[removeEnd] === "\n" ||
155
+ source[removeEnd] === "\r" ||
156
+ source[removeEnd] === "\t")
157
+ ) {
158
+ if (source[removeEnd] === ",") {
159
+ removeEnd++;
160
+ break;
161
+ }
162
+ removeEnd++;
163
+ }
164
+
165
+ // Also remove leading whitespace (indentation before the property)
166
+ let removeStart = prop.start;
167
+ while (
168
+ removeStart > 0 &&
169
+ (source[removeStart - 1] === " " || source[removeStart - 1] === "\t")
170
+ ) {
171
+ removeStart--;
172
+ }
173
+ // If there's a newline before the leading whitespace, consume it too
174
+ if (removeStart > 0 && source[removeStart - 1] === "\n") {
175
+ removeStart--;
176
+ if (removeStart > 0 && source[removeStart - 1] === "\r") {
177
+ removeStart--;
178
+ }
179
+ }
180
+
181
+ s.remove(removeStart, removeEnd);
182
+ }
183
+
184
+ return true;
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Collect all Identifier names referenced in the AST (excluding imports).
189
+ // Skips identifiers that appear as static (non-computed) property keys or
190
+ // as static member-expression properties (both require computed=false),
191
+ // because those positions are not identifier *references* and including them
192
+ // would prevent DCE of same-named imports.
193
+ // Computed keys like `{ [someVar]: v }` are left in — they ARE references.
194
+ // ---------------------------------------------------------------------------
195
+ function collectReferencedNames(program: AstNode): Set<string> {
196
+ const refs = new Set<string>();
197
+ // Nodes that occupy a non-reference Identifier position.
198
+ const excluded = new Set<unknown>();
199
+
200
+ for (const stmt of program.body ?? []) {
201
+ if (stmt.type === "ImportDeclaration") {
202
+ continue;
203
+ }
204
+ // Pass 1 — mark non-reference identifier positions.
205
+ // Only exclude *static* keys (computed=false); computed keys like
206
+ // `{ [someVar]: v }` are genuine identifier references.
207
+ walk(stmt, (node) => {
208
+ if (node.type === "Property" && !node.computed) {
209
+ excluded.add(node.key);
210
+ }
211
+ if (node.type === "MemberExpression" && !node.computed) {
212
+ excluded.add(node.property);
213
+ }
214
+ });
215
+ }
216
+
217
+ for (const stmt of program.body ?? []) {
218
+ if (stmt.type === "ImportDeclaration") {
219
+ continue;
220
+ }
221
+ // Pass 2 — collect genuine identifier references.
222
+ walk(stmt, (node) => {
223
+ if (excluded.has(node)) {
224
+ return;
225
+ }
226
+ if (
227
+ node.type === "Identifier" &&
228
+ typeof (node as AstNode & { name: string }).name === "string"
229
+ ) {
230
+ refs.add((node as AstNode & { name: string }).name);
231
+ }
232
+ });
233
+ }
234
+
235
+ return refs;
236
+ }
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // Import pruning helpers
240
+ // ---------------------------------------------------------------------------
241
+ function removeEntireImport(s: MagicString, code: string, decl: ImportDeclaration): void {
242
+ let removeEnd = decl.end;
243
+ while (removeEnd < code.length && (code[removeEnd] === "\n" || code[removeEnd] === "\r")) {
244
+ removeEnd++;
245
+ }
246
+ s.remove(decl.start, removeEnd);
247
+ }
248
+
249
+ function removeUnusedSpecifiers(
250
+ s: MagicString,
251
+ code: string,
252
+ decl: ImportDeclaration,
253
+ refs: Set<string>
254
+ ): void {
255
+ const removedSpecs = decl.specifiers.filter((spec) => !refs.has(spec.local.name));
256
+ for (const spec of removedSpecs) {
257
+ let removeStart = spec.start;
258
+ let removeEnd = spec.end;
259
+ while (removeEnd < code.length && (code[removeEnd] === "," || code[removeEnd] === " ")) {
260
+ removeEnd++;
261
+ }
262
+ if (!code.slice(spec.end, removeEnd).includes(",")) {
263
+ while (removeStart > 0 && (code[removeStart - 1] === " " || code[removeStart - 1] === ",")) {
264
+ removeStart--;
265
+ }
266
+ }
267
+ s.remove(removeStart, removeEnd);
268
+ }
269
+ }
270
+
271
+ // ---------------------------------------------------------------------------
272
+ // Dead code elimination: remove import specifiers that are no longer
273
+ // referenced after server property removal.
274
+ //
275
+ // A fresh MagicString is created from s.toString() so that the AST offsets
276
+ // produced by re-parsing the *current* output agree with the string positions
277
+ // operated on by MagicString.remove() — the original MagicString always uses
278
+ // original-source positions, which diverge from output positions whenever
279
+ // earlier passes have removed content.
280
+ // ---------------------------------------------------------------------------
281
+ export function deadCodeElimination(s: MagicString): MagicString {
282
+ const code = s.toString();
283
+ const { program, errors } = parseSync("dce.js", code);
284
+ if (errors.length > 0) {
285
+ console.error("[furin] DCE: failed to parse transformed output:", errors[0]?.message);
286
+ return s;
287
+ }
288
+
289
+ const fresh = new MagicString(code);
290
+ const programNode = program as unknown as AstNode;
291
+ const refs = collectReferencedNames(programNode);
292
+
293
+ const body = programNode.body ?? [];
294
+ for (let i = body.length - 1; i >= 0; i--) {
295
+ const stmt = body[i];
296
+ if (!stmt) {
297
+ continue;
298
+ }
299
+ if (stmt.type !== "ImportDeclaration") {
300
+ continue;
301
+ }
302
+ const decl = stmt as unknown as ImportDeclaration;
303
+ if (!decl.specifiers || decl.specifiers.length === 0) {
304
+ continue;
305
+ }
306
+
307
+ const usedCount = decl.specifiers.filter((spec) => refs.has(spec.local.name)).length;
308
+
309
+ if (usedCount === 0) {
310
+ removeEntireImport(fresh, code, decl);
311
+ } else if (usedCount < decl.specifiers.length) {
312
+ removeUnusedSpecifiers(fresh, code, decl, refs);
313
+ }
314
+ }
315
+
316
+ return fresh;
317
+ }
318
+
319
+ // ---------------------------------------------------------------------------
320
+ // Remove server-only properties from createRoute() / page() / route.page()
321
+ // calls found anywhere in the AST.
322
+ // ---------------------------------------------------------------------------
323
+ function removeServerExports(s: MagicString, source: string, program: AstNode): boolean {
324
+ let removedServerCode = false;
325
+
326
+ walk(program, (node) => {
327
+ if (node.type !== "CallExpression") {
328
+ return;
329
+ }
330
+ const call = node as unknown as CallExpression;
331
+ if (!isTargetCall(call)) {
332
+ return;
333
+ }
334
+ const arg = call.arguments[0];
335
+ if (!arg || arg.type !== "ObjectExpression") {
336
+ return;
337
+ }
338
+ if (removeServerProperties(s, source, arg as unknown as ObjectExpression)) {
339
+ removedServerCode = true;
340
+ }
341
+ });
342
+
343
+ return removedServerCode;
344
+ }
345
+
346
+ export function transformForClient(code: string, filename: string): TransformResult {
347
+ // Pass 1 — Bun.Transpiler: strip TypeScript + JSX → plain JS.
348
+ const plainJs = bunTranspiler.transformSync(code);
349
+
350
+ // Pass 2 — oxc-parser: parse plain JS to ESTree AST with span offsets.
351
+ const { program, errors } = parseSync(filename, plainJs);
352
+ if (errors.length > 0) {
353
+ throw new Error(`Failed to parse ${filename}: ${errors[0]?.message}`);
354
+ }
355
+
356
+ // Pass 3 — MagicString: surgically remove server-only properties.
357
+ let s = new MagicString(plainJs);
358
+ const removedServerCode = removeServerExports(s, plainJs, program as unknown as AstNode);
359
+
360
+ // Pass 4 — DCE: prune imports that are no longer referenced.
361
+ // deadCodeElimination returns a fresh MagicString keyed on the current
362
+ // output so its internal AST offsets remain consistent.
363
+ if (removedServerCode) {
364
+ s = deadCodeElimination(s);
365
+ }
366
+
367
+ return {
368
+ code: s.toString(),
369
+ map: s.generateMap({ source: filename, includeContent: true }),
370
+ removedServerCode,
371
+ };
372
+ }
@@ -0,0 +1,57 @@
1
+ import type { RouteContext } from "../client";
2
+ import type { buildHeadInjection } from "./shell";
3
+ import { safeJson } from "./shell";
4
+
5
+ export type LoaderContext = RouteContext<Record<string, string>, Record<string, string>>;
6
+
7
+ export function resolvePath(pattern: string, params: Record<string, string>): string {
8
+ let path = pattern;
9
+ for (const [key, val] of Object.entries(params ?? {})) {
10
+ path = path.replace(key === "*" ? "*" : `:${key}`, val);
11
+ }
12
+ return path;
13
+ }
14
+
15
+ export async function streamToString(stream: ReadableStream): Promise<string> {
16
+ const reader = stream.getReader();
17
+ const decoder = new TextDecoder();
18
+ let html = "";
19
+
20
+ for (;;) {
21
+ const { done, value } = await reader.read();
22
+ if (done) {
23
+ break;
24
+ }
25
+ html += decoder.decode(value, { stream: true });
26
+ }
27
+
28
+ html += decoder.decode();
29
+ return html;
30
+ }
31
+
32
+ interface SplitTemplate {
33
+ bodyPost: string;
34
+ bodyPre: string;
35
+ headPre: string;
36
+ }
37
+
38
+ export function splitTemplate(template: string): SplitTemplate {
39
+ const [headPre, afterHead = ""] = template.split("<!--ssr-head-->");
40
+ const [bodyPre, bodyPost = ""] = afterHead.split("<!--ssr-outlet-->");
41
+ return { headPre, bodyPre, bodyPost } as SplitTemplate;
42
+ }
43
+
44
+ export function assembleHTML(
45
+ template: string,
46
+ headData: ReturnType<typeof buildHeadInjection>,
47
+ reactHtml: string,
48
+ data: Record<string, unknown> | undefined
49
+ ): string {
50
+ const { headPre, bodyPre, bodyPost } = splitTemplate(template);
51
+
52
+ const dataScript = data
53
+ ? `<script id="__FURIN_DATA__" type="application/json">${safeJson(data)}</script>`
54
+ : "";
55
+
56
+ return headPre + headData + bodyPre + reactHtml + dataScript + bodyPost;
57
+ }
@@ -0,0 +1,9 @@
1
+ export interface ISRCacheEntry {
2
+ generatedAt: number;
3
+ html: string;
4
+ revalidate: number;
5
+ }
6
+
7
+ export const isrCache = new Map<string, ISRCacheEntry>();
8
+
9
+ export const ssgCache = new Map<string, string>();
@@ -0,0 +1,28 @@
1
+ import type { ReactNode } from "react";
2
+ import type { RuntimeRoute } from "../client";
3
+ import type { ResolvedRoute } from "../router";
4
+
5
+ export function buildElement(
6
+ route: ResolvedRoute,
7
+ data: Record<string, unknown>,
8
+ rootLayout: RuntimeRoute
9
+ ): ReactNode {
10
+ const Component = route.page.component;
11
+ let element: ReactNode = <Component {...data} />;
12
+
13
+ for (let i = route.routeChain.length - 1; i >= 1; i--) {
14
+ const routeEntry = route.routeChain[i];
15
+
16
+ if (routeEntry?.layout) {
17
+ const Layout = routeEntry.layout;
18
+ element = <Layout {...data}>{element}</Layout>;
19
+ }
20
+ }
21
+
22
+ if (rootLayout.layout) {
23
+ const RootLayoutComponent = rootLayout.layout;
24
+ element = <RootLayoutComponent {...data}>{element}</RootLayoutComponent>;
25
+ }
26
+
27
+ return element;
28
+ }