@teyik0/furin 0.1.0-alpha.4 → 0.1.0-alpha.6

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.
@@ -1,18 +1,25 @@
1
1
  import type { ResolvedRoute } from "../router";
2
2
  import type { BuildClientOptions } from "./types";
3
+ export interface BuildClientResult {
4
+ /** Public path of the JS entry chunk, e.g. `/_client/chunk-abc.js` */
5
+ entryChunk: string;
6
+ /** Public paths of all CSS chunks, e.g. `["/_client/chunk-abc.css"]` */
7
+ cssChunks: string[];
8
+ }
3
9
  /**
4
10
  * Builds the production client bundle via Bun.build() using _hydrate.tsx as
5
11
  * the JS entrypoint (NOT an HTML entrypoint). Bun produces:
6
12
  * <outDir>/client/chunk-*.js — code-split bundles
7
13
  * <outDir>/client/chunk-*.css — extracted CSS (if imported)
8
14
  *
9
- * After the build, index.html is written manually using the entry chunk path
10
- * from result.outputs. Using an HTML entrypoint with code-splitting causes a
11
- * Bun bug where the output index.html references a leaf chunk instead of the
12
- * actual entry chunk, preventing React from mounting in production.
15
+ * Returns the chunk paths so the caller can compute a `buildId` and write
16
+ * `index.html` with the correct meta tag. Using an HTML entrypoint with
17
+ * code-splitting causes a Bun bug where the output index.html references a
18
+ * leaf chunk instead of the actual entry chunk, preventing React from mounting
19
+ * in production.
13
20
  *
14
21
  * The output index.html is NOT served to browsers directly. The server reads
15
22
  * it as an SSR template, injects the pre-rendered React HTML into
16
23
  * <!--ssr-outlet-->, and sends the complete page.
17
24
  */
18
- export declare function buildClient(routes: ResolvedRoute[], { outDir, rootLayout, plugins }: BuildClientOptions): Promise<void>;
25
+ export declare function buildClient(routes: ResolvedRoute[], { outDir, rootLayout, plugins }: BuildClientOptions): Promise<BuildClientResult>;
@@ -1,4 +1,5 @@
1
1
  export interface CompileEntryOptions {
2
+ buildId?: string;
2
3
  outDir: string;
3
4
  rootPath: string;
4
5
  routes: Array<{
@@ -1,4 +1,5 @@
1
1
  export interface EntryTemplateOptions {
2
+ buildId?: string;
2
3
  headerComment: string;
3
4
  rootPath: string;
4
5
  routes: Array<{
@@ -1,10 +1,10 @@
1
1
  // @bun
2
2
  // src/build/index.ts
3
- import { existsSync as existsSync8, writeFileSync as writeFileSync6 } from "fs";
3
+ import { existsSync as existsSync8, writeFileSync as writeFileSync7 } from "fs";
4
4
  import { join as join9, relative as relative3, resolve as resolve4 } from "path";
5
5
 
6
6
  // src/adapter/bun.ts
7
- import { existsSync as existsSync6, rmSync as rmSync2 } from "fs";
7
+ import { existsSync as existsSync6, rmSync as rmSync2, writeFileSync as writeFileSync6 } from "fs";
8
8
  import { join as join7, resolve as resolve3 } from "path";
9
9
 
10
10
  // src/build/client.ts
@@ -1368,13 +1368,22 @@ function transformForClient(code, filename) {
1368
1368
  };
1369
1369
  }
1370
1370
 
1371
+ // src/build/hydrate.ts
1372
+ import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
1373
+ import { join as join2 } from "path";
1374
+
1371
1375
  // src/render/shell.ts
1376
+ function escapeHtml(str) {
1377
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
1378
+ }
1379
+ var THEME_INIT_SCRIPT = `<script>try{var __t=localStorage.getItem("furin-theme");` + `document.documentElement.classList.add(__t==="light"?"light":"dark")}` + `catch(e){document.documentElement.classList.add("dark")}</script>`;
1372
1380
  function generateIndexHtml() {
1373
1381
  return `<!DOCTYPE html>
1374
1382
  <html lang="en">
1375
1383
  <head>
1376
1384
  <meta charset="UTF-8">
1377
1385
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1386
+ ${THEME_INIT_SCRIPT}
1378
1387
  <!--ssr-head-->
1379
1388
  </head>
1380
1389
  <body>
@@ -1384,16 +1393,19 @@ function generateIndexHtml() {
1384
1393
  </html>
1385
1394
  `;
1386
1395
  }
1387
- function generateProdIndexHtml(entryChunk, cssChunks) {
1396
+ function generateProdIndexHtml(entryChunk, cssChunks, buildId) {
1388
1397
  const cssLinks = cssChunks.map((c) => ` <link rel="stylesheet" crossorigin href="${c}">`).join(`
1389
1398
  `);
1390
- const scriptTag = entryChunk ? `<script type="module" crossorigin src="${entryChunk}"></script>` : "";
1399
+ const scriptTag = `<script type="module" crossorigin src="${entryChunk}"></script>`;
1400
+ const buildIdMeta = buildId ? ` <meta name="furin-build-id" content="${escapeHtml(buildId)}">
1401
+ ` : "";
1391
1402
  return `<!DOCTYPE html>
1392
1403
  <html lang="en">
1393
1404
  <head>
1394
1405
  <meta charset="UTF-8">
1395
1406
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1396
- ${cssLinks ? `${cssLinks}
1407
+ ${THEME_INIT_SCRIPT}
1408
+ ${buildIdMeta}${cssLinks ? `${cssLinks}
1397
1409
  ` : ""} <!--ssr-head-->
1398
1410
  </head>
1399
1411
  <body>
@@ -1404,10 +1416,6 @@ ${cssLinks ? `${cssLinks}
1404
1416
  `;
1405
1417
  }
1406
1418
 
1407
- // src/build/hydrate.ts
1408
- import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
1409
- import { join as join2 } from "path";
1410
-
1411
1419
  // src/build/route-types.ts
1412
1420
  import { existsSync, readFileSync, writeFileSync } from "fs";
1413
1421
  import { join } from "path";
@@ -1607,6 +1615,7 @@ function toBuildRouteManifestEntry(route, rootDir) {
1607
1615
  function buildTargetManifest(rootDir, buildRoot, target, serverEntry) {
1608
1616
  const targetDir = join3(buildRoot, target);
1609
1617
  return {
1618
+ buildId: "",
1610
1619
  generatedAt: new Date().toISOString(),
1611
1620
  targetDir: toPosixPath(relative(rootDir, targetDir)),
1612
1621
  clientDir: toPosixPath(relative(rootDir, join3(targetDir, "client"))),
@@ -1683,10 +1692,13 @@ ${transformed}`;
1683
1692
  }
1684
1693
  const entryOutput = result.outputs.find((o) => o.kind === "entry-point");
1685
1694
  const cssOutputs = result.outputs.filter((o) => o.path.endsWith(".css") && !o.path.endsWith(".css.map"));
1686
- const entryChunk = entryOutput ? `/_client/${basename(entryOutput.path)}` : undefined;
1695
+ if (!entryOutput) {
1696
+ throw new Error("[furin] client build did not emit entry chunk");
1697
+ }
1698
+ const entryChunk = `/_client/${basename(entryOutput.path)}`;
1687
1699
  const cssChunks = cssOutputs.map((o) => `/_client/${basename(o.path)}`);
1688
- writeFileSync3(join4(clientDir, "index.html"), generateProdIndexHtml(entryChunk, cssChunks));
1689
1700
  console.log("[furin] Production client build complete");
1701
+ return { entryChunk, cssChunks };
1690
1702
  }
1691
1703
 
1692
1704
  // src/build/compile-entry.ts
@@ -1698,7 +1710,14 @@ import { resolve as resolve2 } from "path";
1698
1710
  var INTERNAL_MODULE_PATH = resolve2(import.meta.dir, "../internal.ts").replace(/\\/g, "/");
1699
1711
  var RUNTIME_ENV_MODULE_PATH = resolve2(import.meta.dir, "../runtime-env.ts").replace(/\\/g, "/");
1700
1712
  function buildEntrySource(options) {
1701
- const { headerComment, rootPath, routes, serverEntry, extraImports = [], extraContext = [] } = options;
1713
+ const { buildId, headerComment, rootPath, routes, serverEntry } = options;
1714
+ let { extraImports, extraContext } = options;
1715
+ if (extraImports === undefined) {
1716
+ extraImports = [];
1717
+ }
1718
+ if (extraContext === undefined) {
1719
+ extraContext = [];
1720
+ }
1702
1721
  const allModulePaths = [rootPath, ...routes.map((r) => r.path)];
1703
1722
  const moduleImports = [];
1704
1723
  const moduleEntries = [];
@@ -1721,6 +1740,7 @@ function buildEntrySource(options) {
1721
1740
  'process.env.NODE_ENV = "production";',
1722
1741
  "",
1723
1742
  "__setCompileContext({",
1743
+ ` buildId: ${JSON.stringify(buildId ?? "")},`,
1724
1744
  ` rootPath: ${JSON.stringify(rootPath.replace(/\\/g, "/"))},`,
1725
1745
  " modules: {",
1726
1746
  ...moduleEntries,
@@ -1740,7 +1760,7 @@ function buildEntrySource(options) {
1740
1760
 
1741
1761
  // src/build/compile-entry.ts
1742
1762
  function generateCompileEntry(options) {
1743
- const { outDir, rootPath, routes, serverEntry, embed, publicDir } = options;
1763
+ const { buildId, outDir, rootPath, routes, serverEntry, embed, publicDir } = options;
1744
1764
  ensureDir(outDir);
1745
1765
  const assetImports = [];
1746
1766
  let embeddedBlock = [];
@@ -1788,6 +1808,7 @@ function generateCompileEntry(options) {
1788
1808
  ];
1789
1809
  }
1790
1810
  const source = buildEntrySource({
1811
+ buildId,
1791
1812
  headerComment: "// Auto-generated by furin compile \u2014 do not edit",
1792
1813
  rootPath,
1793
1814
  routes,
@@ -1804,9 +1825,10 @@ function generateCompileEntry(options) {
1804
1825
  import { writeFileSync as writeFileSync5 } from "fs";
1805
1826
  import { join as join6 } from "path";
1806
1827
  function generateServerRoutesEntry(options) {
1807
- const { outDir, rootPath, routes, serverEntry } = options;
1828
+ const { buildId, outDir, rootPath, routes, serverEntry } = options;
1808
1829
  ensureDir(outDir);
1809
1830
  const source = buildEntrySource({
1831
+ buildId,
1810
1832
  headerComment: "// Auto-generated by furin build \u2014 do not edit",
1811
1833
  rootPath,
1812
1834
  routes,
@@ -1818,6 +1840,33 @@ function generateServerRoutesEntry(options) {
1818
1840
  }
1819
1841
 
1820
1842
  // src/adapter/bun.ts
1843
+ var BUILD_ID_INPUT_PATHS = [
1844
+ resolve3(import.meta.dir, "../build/compile-entry.ts"),
1845
+ resolve3(import.meta.dir, "../build/entry-template.ts"),
1846
+ resolve3(import.meta.dir, "../build/server-routes-entry.ts"),
1847
+ resolve3(import.meta.dir, "../render/index.ts"),
1848
+ resolve3(import.meta.dir, "../render/shell.ts"),
1849
+ resolve3(import.meta.dir, "../router.ts")
1850
+ ];
1851
+ async function createBuildFingerprint(entryChunk, cssChunks, routes, rootPath, serverEntry) {
1852
+ const fingerprintPaths = new Set([rootPath, ...routes.map((route) => route.path)]);
1853
+ if (serverEntry) {
1854
+ fingerprintPaths.add(serverEntry);
1855
+ }
1856
+ for (const path of BUILD_ID_INPUT_PATHS) {
1857
+ if (!existsSync6(path)) {
1858
+ console.warn(`[furin] Warning: build fingerprint input "${toPosixPath(path)}" is missing \u2014 ` + "the generated build ID may not reflect all framework changes.");
1859
+ }
1860
+ fingerprintPaths.add(path);
1861
+ }
1862
+ const fileParts = await Promise.all([...fingerprintPaths].sort().map(async (path) => {
1863
+ const content = existsSync6(path) ? await Bun.file(path).text() : "";
1864
+ return `${toPosixPath(path)}:${content}`;
1865
+ }));
1866
+ const routeParts = routes.map((route) => JSON.stringify({ mode: route.mode, path: toPosixPath(route.path), pattern: route.pattern })).sort();
1867
+ return [entryChunk, ...[...cssChunks].sort(), ...routeParts, ...fileParts].join(`
1868
+ `);
1869
+ }
1821
1870
  async function buildBunTarget(routes, rootDir, buildRoot, rootPath, serverEntry, options) {
1822
1871
  if (options.compile && !serverEntry) {
1823
1872
  throw new Error(`[furin] \`compile: "${options.compile}"\` requires a server entry point. ` + "Create src/server.ts or set `serverEntry` in your furin.config.ts.");
@@ -1827,11 +1876,16 @@ async function buildBunTarget(routes, rootDir, buildRoot, rootPath, serverEntry,
1827
1876
  const targetDir = resolve3(rootDir, targetManifest.targetDir);
1828
1877
  rmSync2(targetDir, { force: true, recursive: true });
1829
1878
  ensureDir(targetDir);
1830
- await buildClient(routes, {
1879
+ const { entryChunk, cssChunks } = await buildClient(routes, {
1831
1880
  outDir: targetDir,
1832
1881
  rootLayout: rootPath,
1833
1882
  plugins: options.plugins
1834
1883
  });
1884
+ const buildFingerprint = await createBuildFingerprint(entryChunk, cssChunks, routes, rootPath, serverEntry);
1885
+ const buildId = Bun.hash(buildFingerprint).toString(16).slice(0, 12);
1886
+ targetManifest.buildId = buildId;
1887
+ const clientDir = join7(targetDir, "client");
1888
+ writeFileSync6(join7(clientDir, "index.html"), generateProdIndexHtml(entryChunk, cssChunks, buildId));
1835
1889
  const routeManifest = routes.map((r) => ({ pattern: r.pattern, path: r.path, mode: r.mode }));
1836
1890
  const publicDir = existsSync6(join7(rootDir, "public")) ? join7(rootDir, "public") : undefined;
1837
1891
  const targetPublicDir = publicDir ? join7(targetDir, "public") : undefined;
@@ -1839,14 +1893,15 @@ async function buildBunTarget(routes, rootDir, buildRoot, rootPath, serverEntry,
1839
1893
  copyDirRecursive(publicDir, targetPublicDir);
1840
1894
  }
1841
1895
  if (options.compile && serverEntry) {
1842
- const clientDir = join7(targetDir, "client");
1896
+ const clientDir2 = join7(targetDir, "client");
1843
1897
  const outfile = join7(targetDir, "server");
1844
1898
  const entryPath = generateCompileEntry({
1899
+ buildId,
1845
1900
  rootPath,
1846
1901
  routes: routeManifest,
1847
1902
  serverEntry,
1848
1903
  outDir: targetDir,
1849
- embed: options.compile === "embed" ? { clientDir } : undefined,
1904
+ embed: options.compile === "embed" ? { clientDir: clientDir2 } : undefined,
1850
1905
  publicDir
1851
1906
  });
1852
1907
  await Bun.build({
@@ -1859,12 +1914,13 @@ async function buildBunTarget(routes, rootDir, buildRoot, rootPath, serverEntry,
1859
1914
  console.log(`[furin] Server binary: ${outfile}`);
1860
1915
  targetManifest.serverPath = toPosixPath(join7(targetManifest.targetDir, "server"));
1861
1916
  if (options.compile === "embed") {
1862
- rmSync2(clientDir, { force: true, recursive: true });
1917
+ rmSync2(clientDir2, { force: true, recursive: true });
1863
1918
  targetManifest.clientDir = null;
1864
1919
  targetManifest.templatePath = null;
1865
1920
  }
1866
1921
  } else if (serverEntry) {
1867
1922
  const entryPath = generateServerRoutesEntry({
1923
+ buildId,
1868
1924
  rootPath,
1869
1925
  routes: routeManifest,
1870
1926
  serverEntry,
@@ -1885,8 +1941,7 @@ async function buildBunTarget(routes, rootDir, buildRoot, rootPath, serverEntry,
1885
1941
  "_compile-entry.ts",
1886
1942
  "_compile-entry.js.map",
1887
1943
  "server.ts",
1888
- "_hydrate.tsx",
1889
- "index.html"
1944
+ "_hydrate.tsx"
1890
1945
  ]) {
1891
1946
  rmSync2(join7(targetDir, file), { force: true });
1892
1947
  }
@@ -1924,14 +1979,20 @@ function getCompileContext() {
1924
1979
  import { renderToReadableStream } from "react-dom/server";
1925
1980
 
1926
1981
  // src/render/cache.ts
1982
+ import { AsyncLocalStorage } from "async_hooks";
1927
1983
  var isrCache = new Map;
1928
1984
  var ssgCache = new Map;
1985
+ var _requestInvalidationScope = new AsyncLocalStorage;
1986
+ var _globalPendingInvalidations = new Set;
1929
1987
 
1930
1988
  // src/render/element.tsx
1931
1989
  import { jsxDEV } from "react/jsx-dev-runtime";
1932
1990
  // src/runtime-env.ts
1933
1991
  var IS_DEV = true;
1934
1992
 
1993
+ // src/render/index.ts
1994
+ var pendingRevalidations = new Set;
1995
+
1935
1996
  // src/utils.ts
1936
1997
  function isFurinPage(value) {
1937
1998
  return typeof value === "object" && value !== null && "__type" in value && value.__type === "FURIN_PAGE";
@@ -2007,13 +2068,7 @@ async function scanPageFiles(pagesDir, root) {
2007
2068
  continue;
2008
2069
  }
2009
2070
  if (IS_DEV) {
2010
- routes.push({
2011
- pattern: filePathToPattern(relativePath),
2012
- path: absolutePath,
2013
- mode: "ssr",
2014
- page: undefined,
2015
- routeChain: []
2016
- });
2071
+ routes.push(await buildDevRoute(absolutePath, relativePath, root));
2017
2072
  continue;
2018
2073
  }
2019
2074
  const ctx = getCompileContext();
@@ -2034,6 +2089,30 @@ async function scanPageFiles(pagesDir, root) {
2034
2089
  }
2035
2090
  return routes;
2036
2091
  }
2092
+ async function buildDevRoute(absolutePath, relativePath, root) {
2093
+ let page;
2094
+ let routeChain = [];
2095
+ try {
2096
+ const pageMod = await import(`${absolutePath}?furin-server&t=${Date.now()}`);
2097
+ if (isFurinPage(pageMod.default)) {
2098
+ page = pageMod.default;
2099
+ routeChain = collectRouteChainFromRoute(page._route);
2100
+ validateRouteChain(routeChain, root.route, relativePath);
2101
+ }
2102
+ } catch {}
2103
+ const devStubPage = {
2104
+ __type: "FURIN_PAGE",
2105
+ _route: { __type: "FURIN_ROUTE" },
2106
+ component: () => null
2107
+ };
2108
+ return {
2109
+ pattern: filePathToPattern(relativePath),
2110
+ path: absolutePath,
2111
+ mode: page ? resolveMode(page, routeChain) : "ssr",
2112
+ page: page ?? devStubPage,
2113
+ routeChain
2114
+ };
2115
+ }
2037
2116
  async function collectPageFilePaths(dir) {
2038
2117
  const files = [];
2039
2118
  for (const entry of await readdir(dir, { withFileTypes: true })) {
@@ -2103,8 +2182,8 @@ function walkNode(node, out) {
2103
2182
  if (node.type === "CallExpression") {
2104
2183
  const callee = node.callee;
2105
2184
  const args = node.arguments;
2106
- const isElyraCall = callee?.type === "Identifier" && callee.name === "furin";
2107
- if (isElyraCall && Array.isArray(args) && args.length > 0) {
2185
+ const isFurinCall = callee?.type === "Identifier" && callee.name === "furin";
2186
+ if (isFurinCall && Array.isArray(args) && args.length > 0) {
2108
2187
  const firstArg = args[0];
2109
2188
  if (firstArg?.type === "ObjectExpression") {
2110
2189
  const pagesDir = extractStringProperty(firstArg, "pagesDir");
@@ -2184,9 +2263,6 @@ async function buildApp(options) {
2184
2263
  return target;
2185
2264
  });
2186
2265
  const { root, routes } = await scanPages(pagesDir);
2187
- if (!root) {
2188
- throw new Error("[furin] No root layout found. Create a root.tsx in your pages directory with a layout component.");
2189
- }
2190
2266
  ensureDir(buildRoot);
2191
2267
  const manifest = {
2192
2268
  version: 1,
@@ -2211,7 +2287,7 @@ async function buildApp(options) {
2211
2287
  throw new Error(`[furin] Unsupported build target "${target}"`);
2212
2288
  }
2213
2289
  }
2214
- writeFileSync6(join9(buildRoot, "manifest.json"), `${JSON.stringify(manifest, null, 2)}
2290
+ writeFileSync7(join9(buildRoot, "manifest.json"), `${JSON.stringify(manifest, null, 2)}
2215
2291
  `);
2216
2292
  return {
2217
2293
  manifest,
@@ -1,4 +1,5 @@
1
1
  export interface ServerRoutesEntryOptions {
2
+ buildId?: string;
2
3
  outDir: string;
3
4
  rootPath: string;
4
5
  routes: Array<{
@@ -15,6 +15,7 @@ export interface BuildRouteManifestEntry {
15
15
  revalidate: number | null;
16
16
  }
17
17
  export interface TargetBuildManifest {
18
+ buildId: string;
18
19
  clientDir: string | null;
19
20
  generatedAt: string;
20
21
  serverEntry: string | null;