@timber-js/app 0.2.0-alpha.183 → 0.2.0-alpha.184

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/index.js CHANGED
@@ -20,7 +20,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
20
20
  import { styleText } from "node:util";
21
21
  import { constants, createGzip, gzipSync } from "node:zlib";
22
22
  import { createHash, randomUUID } from "node:crypto";
23
- import { copyFile, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
23
+ import { copyFile, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
24
24
  //#region src/plugins/content.ts
25
25
  var CONFIG_FILE_NAMES$1 = [
26
26
  "content-collections.ts",
@@ -3491,7 +3491,7 @@ function filterBySubsets(faces, subsets) {
3491
3491
  * Generate a content hash for font data.
3492
3492
  * Returns the first 8 hex chars of the SHA-256 hash.
3493
3493
  */
3494
- function contentHash(data) {
3494
+ function contentHash$1(data) {
3495
3495
  return createHash("sha256").update(data).digest("hex").slice(0, 8);
3496
3496
  }
3497
3497
  /**
@@ -3502,7 +3502,7 @@ function contentHash(data) {
3502
3502
  */
3503
3503
  function hashedFontFilename(face, data) {
3504
3504
  const slug = face.family.toLowerCase().replace(/\s+/g, "-");
3505
- const hash = contentHash(data);
3505
+ const hash = contentHash$1(data);
3506
3506
  return `${slug}-${face.subset}-${face.weight}-${face.style}-${hash}.woff2`;
3507
3507
  }
3508
3508
  /**
@@ -4733,7 +4733,7 @@ var FontPipeline = class {
4733
4733
  const absolutePath = normalize(resolve(src.path));
4734
4734
  if (!existsSync(absolutePath)) continue;
4735
4735
  const data = readFileSync(absolutePath);
4736
- src.hashedBasename = hashedFontBasename(src.path, contentHash(data));
4736
+ src.hashedBasename = hashedFontBasename(src.path, contentHash$1(data));
4737
4737
  }
4738
4738
  this._entries.set(entry.id, entry);
4739
4739
  }
@@ -6578,6 +6578,30 @@ async function stripJsFromRscAssetsManifests(buildDir) {
6578
6578
  }
6579
6579
  }
6580
6580
  //#endregion
6581
+ //#region src/server/flight-scripts.ts
6582
+ /**
6583
+ * Shared Flight data script generation.
6584
+ *
6585
+ * Both html-injectors.ts (Web Streams / dev) and node-stream-transforms.ts
6586
+ * (Node streams / production) use this module to generate inline `<script>`
6587
+ * tags for RSC Flight data injection.
6588
+ *
6589
+ * The init script goes in `<head>` as part of the HTML shell (guaranteed
6590
+ * to execute before any streaming chunk scripts). Push scripts are emitted
6591
+ * as RSC chunks arrive during streaming.
6592
+ *
6593
+ * See design/02-rendering-pipeline.md, LOCAL-415
6594
+ */
6595
+ /**
6596
+ * Escape a JSON string for safe embedding inside an HTML `<script>` tag.
6597
+ *
6598
+ * Prevents XSS via `<\/script>` injection and handles Unicode line/paragraph
6599
+ * separators that are valid JSON but invalid in JS string literals (pre-ES2019).
6600
+ */
6601
+ function htmlEscapeJsonString(str) {
6602
+ return str.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
6603
+ }
6604
+ //#endregion
6581
6605
  //#region src/plugins/static-build.ts
6582
6606
  /**
6583
6607
  * Patterns that indicate dynamic per-request API usage.
@@ -6684,6 +6708,10 @@ async function runStaticGeneration(ctx) {
6684
6708
  const generate = mod.__timber_generateStaticSite;
6685
6709
  if (typeof generate !== "function") throw new Error("[timber] Static generation: built RSC entry does not export __timber_generateStaticSite — rebuild from a clean build directory");
6686
6710
  const staticDir = join(ctx.buildDir, "static-pages");
6711
+ await rm(join(staticDir, "_rsc"), {
6712
+ recursive: true,
6713
+ force: true
6714
+ }).catch(() => {});
6687
6715
  await mkdir(staticDir, { recursive: true });
6688
6716
  const sitemapConfig = ctx.config.sitemap;
6689
6717
  const autoSitemapEnabled = Boolean(sitemapConfig?.enabled && sitemapConfig?.baseUrl);
@@ -6709,6 +6737,14 @@ async function runStaticGeneration(ctx) {
6709
6737
  throw new Error(`[timber] Static generation failed:\n${details}`);
6710
6738
  }
6711
6739
  if (Object.keys(contentTypes).length > 0) ctx.staticContentTypes = contentTypes;
6740
+ if (!ctx.clientJavascript.disabled) {
6741
+ const rscManifest = await hashRscPayloads(staticDir);
6742
+ if (Object.keys(rscManifest).length > 0) {
6743
+ await injectRscManifestIntoHtml(staticDir, rscManifest);
6744
+ ctx.rscManifest = rscManifest;
6745
+ console.log(`[timber] Static generation: hashed ${Object.keys(rscManifest).length} RSC flight payloads`);
6746
+ }
6747
+ }
6712
6748
  if (summary.redirects.length > 0) {
6713
6749
  const generated = generateRedirectsFile(summary.redirects);
6714
6750
  const redirectsPath = join(staticDir, "_redirects");
@@ -6724,6 +6760,79 @@ async function runStaticGeneration(ctx) {
6724
6760
  return summary;
6725
6761
  }
6726
6762
  /**
6763
+ * Compute a short content hash for immutable caching.
6764
+ * Uses 8 chars of base64url-encoded SHA-256, matching Vite's asset hash style.
6765
+ */
6766
+ function contentHash(data) {
6767
+ return createHash("sha256").update(data).digest("base64url").slice(0, 8);
6768
+ }
6769
+ /**
6770
+ * Walk a directory recursively and return all file paths.
6771
+ */
6772
+ async function walkDir(dir) {
6773
+ const results = [];
6774
+ let entries;
6775
+ try {
6776
+ entries = await readdir(dir, { withFileTypes: true });
6777
+ } catch {
6778
+ return results;
6779
+ }
6780
+ for (const entry of entries) {
6781
+ const full = join(dir, entry.name);
6782
+ if (entry.isDirectory()) results.push(...await walkDir(full));
6783
+ else results.push(full);
6784
+ }
6785
+ return results;
6786
+ }
6787
+ /**
6788
+ * Hash all RSC flight payloads and params sidecars in `_rsc/`, rename
6789
+ * them with content hashes, and inject a manifest into each HTML file.
6790
+ *
6791
+ * Before: `_rsc/index.rsc`, `_rsc/about.rsc`
6792
+ * After: `_rsc/index-B7YxEKdN.rsc`, `_rsc/about-C8ZzFLfO.rsc`
6793
+ *
6794
+ * The manifest maps the original `_rsc/` URL to the hashed filename:
6795
+ * `{"/_rsc/index.rsc":"/_rsc/index-B7YxEKdN.rsc", ...}`
6796
+ *
6797
+ * Injected into HTML as:
6798
+ * `<script>window.__TIMBER_RSC_MANIFEST__={...}<\/script>`
6799
+ *
6800
+ * @internal Exported for testing.
6801
+ */
6802
+ async function hashRscPayloads(staticDir) {
6803
+ const files = await walkDir(join(staticDir, "_rsc"));
6804
+ const manifest = {};
6805
+ for (const filePath of files) {
6806
+ if (!filePath.endsWith(".rsc") && !filePath.endsWith(".params.json")) continue;
6807
+ const hash = contentHash(await readFile(filePath));
6808
+ const rel = relative(staticDir, filePath).replace(/\\/g, "/");
6809
+ const ext = filePath.endsWith(".params.json") ? ".params.json" : ".rsc";
6810
+ const hashedPath = `${filePath.slice(0, -ext.length)}-${hash}${ext}`;
6811
+ await rename(filePath, hashedPath);
6812
+ const hashedRel = relative(staticDir, hashedPath).replace(/\\/g, "/");
6813
+ manifest[`/${rel}`] = `/${hashedRel}`;
6814
+ }
6815
+ return manifest;
6816
+ }
6817
+ /**
6818
+ * Inject the RSC manifest into all HTML files as a `<script>` tag
6819
+ * before `</head>`. This lets the client resolve hashed RSC URLs
6820
+ * without an extra network request.
6821
+ *
6822
+ * @internal Exported for testing.
6823
+ */
6824
+ async function injectRscManifestIntoHtml(staticDir, manifest) {
6825
+ const htmlFiles = (await walkDir(staticDir)).filter((f) => f.endsWith(".html"));
6826
+ const scriptTag = `<script>window.__TIMBER_RSC_MANIFEST__=${htmlEscapeJsonString(JSON.stringify(manifest))}<\/script>`;
6827
+ for (const htmlFile of htmlFiles) {
6828
+ let html = await readFile(htmlFile, "utf-8");
6829
+ const headClose = html.indexOf("</head>");
6830
+ if (headClose !== -1) html = html.slice(0, headClose) + scriptTag + html.slice(headClose);
6831
+ else html = scriptTag + html;
6832
+ await writeFile(htmlFile, html);
6833
+ }
6834
+ }
6835
+ /**
6727
6836
  * Read a user-authored `_redirects` file from the project's `public/`
6728
6837
  * directory. Returns `null` if no file exists.
6729
6838
  *