@pylonsync/functions 0.3.268 → 0.3.270

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.3.268",
3
+ "version": "0.3.270",
4
4
  "description": "TypeScript function runtime for pylon — defines server-side queries, mutations, and actions.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/runtime.ts CHANGED
@@ -35,6 +35,17 @@ import { validateArgs } from "./validators";
35
35
  import { readdirSync } from "fs";
36
36
  import { join, basename } from "path";
37
37
 
38
+ // Bun runtime globals this process uses. The runtime executes under Bun, but
39
+ // consuming apps type-check this source under node/DOM where the `Bun` global
40
+ // is absent — so declare the surface we touch (mirrors the ambient in
41
+ // ssr-client-bundler.ts). Keeps `tsc` clean in a scaffolded app.
42
+ declare const Bun: {
43
+ write(destination: unknown, input: string): Promise<number>;
44
+ stdout: unknown;
45
+ stderr: unknown;
46
+ stdin: { stream(): ReadableStream<Uint8Array> };
47
+ };
48
+
38
49
  // ---------------------------------------------------------------------------
39
50
  // Protocol types
40
51
  // ---------------------------------------------------------------------------
@@ -23,6 +23,7 @@ import * as os from "node:os";
23
23
 
24
24
  import {
25
25
  buildClientBundle,
26
+ nearestBoundaryComponent,
26
27
  type PylonBundleManifest,
27
28
  } from "./ssr-client-bundler";
28
29
 
@@ -234,3 +235,115 @@ describe("ssr-client-bundler (Phase 1.5e)", () => {
234
235
  expect(unique.size).toBe(1);
235
236
  });
236
237
  });
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // Client error/not-found boundary. Regression coverage for the blank-page bug:
241
+ // a notFound() (or any error) thrown DURING a client render — the normal case
242
+ // when an async lookup 404s after hydration — used to propagate uncaught and
243
+ // blank the whole document because the client runtime wrapped pages in NO
244
+ // error boundary. nearestBoundaryComponent() is the resolution the runtime
245
+ // inlines; the build test guards the boundary wiring + not-found.tsx entry.
246
+ // ---------------------------------------------------------------------------
247
+
248
+ describe("nearestBoundaryComponent (client boundary resolution)", () => {
249
+ const routes = new Set([
250
+ "web/app/not-found",
251
+ "web/app/dashboard/not-found",
252
+ "web/app/dashboard/error",
253
+ "web/app/dashboard/orgs/[slug]/page",
254
+ "web/app/page",
255
+ ]);
256
+
257
+ test("returns the NEAREST ancestor boundary, not a farther one", () => {
258
+ // [slug]/page has no own boundary → nearest not-found is dashboard's,
259
+ // NOT the app-root one.
260
+ expect(
261
+ nearestBoundaryComponent(
262
+ "web/app/dashboard/orgs/[slug]/page",
263
+ "not-found",
264
+ routes,
265
+ ),
266
+ ).toBe("web/app/dashboard/not-found");
267
+ });
268
+
269
+ test("falls back to a farther ancestor when no nearer one exists", () => {
270
+ // A page outside /dashboard only has the app-root not-found.
271
+ expect(
272
+ nearestBoundaryComponent("web/app/page", "not-found", routes),
273
+ ).toBe("web/app/not-found");
274
+ // settings/page is under /dashboard but above no closer boundary → dashboard.
275
+ expect(
276
+ nearestBoundaryComponent(
277
+ "web/app/dashboard/settings/page",
278
+ "not-found",
279
+ routes,
280
+ ),
281
+ ).toBe("web/app/dashboard/not-found");
282
+ });
283
+
284
+ test("resolves error.tsx independently of not-found.tsx", () => {
285
+ expect(
286
+ nearestBoundaryComponent(
287
+ "web/app/dashboard/orgs/[slug]/page",
288
+ "error",
289
+ routes,
290
+ ),
291
+ ).toBe("web/app/dashboard/error");
292
+ // No error.tsx outside /dashboard → null (runtime renders its default).
293
+ expect(nearestBoundaryComponent("web/app/page", "error", routes)).toBeNull();
294
+ });
295
+
296
+ test("returns null when the app ships no boundary at all", () => {
297
+ expect(
298
+ nearestBoundaryComponent("web/app/page", "not-found", new Set(["web/app/page"])),
299
+ ).toBeNull();
300
+ });
301
+
302
+ test("does not match a boundary in a sibling/unrelated subtree", () => {
303
+ const r = new Set(["web/app/admin/not-found", "web/app/dashboard/page"]);
304
+ // dashboard/page must NOT pick up admin's not-found.
305
+ expect(
306
+ nearestBoundaryComponent("web/app/dashboard/page", "not-found", r),
307
+ ).toBeNull();
308
+ });
309
+ });
310
+
311
+ const NOT_FOUND_BODY = `
312
+ import React from "react";
313
+ export default function NotFound() {
314
+ return <div data-boundary="not-found">Not found</div>;
315
+ }
316
+ `;
317
+
318
+ describe("client boundary is wired into the build", () => {
319
+ test("not-found.tsx gets its own manifest entry AND the runtime wraps pages in the error boundary", async () => {
320
+ tempDir = makeFixture(
321
+ {
322
+ "page.tsx": PAGE_BODY("Home"),
323
+ "dashboard/page.tsx": PAGE_BODY("Dash"),
324
+ "dashboard/not-found.tsx": NOT_FOUND_BODY,
325
+ },
326
+ { "layout.tsx": LAYOUT_BODY },
327
+ );
328
+ originalCwd = process.cwd();
329
+ process.chdir(tempDir);
330
+
331
+ const { manifestPath, outdir } = await buildClientBundle();
332
+ const manifest = JSON.parse(
333
+ fs.readFileSync(manifestPath, "utf8"),
334
+ ) as PylonBundleManifest;
335
+
336
+ // The not-found boundary must be a first-class route entry so the client
337
+ // can resolve + dynamically load it on a client-thrown notFound().
338
+ expect(manifest.routes["app/dashboard/not-found"]).toBeTruthy();
339
+
340
+ // The shared runtime chunk must contain the boundary wiring — without it,
341
+ // a client-thrown notFound() blanks the page (the bug this fixes).
342
+ const sharedChunks = fs
343
+ .readdirSync(path.join(outdir, "chunks"))
344
+ .map((n) => fs.readFileSync(path.join(outdir, "chunks", n), "utf8"));
345
+ const allShared = sharedChunks.join("\n");
346
+ expect(allShared).toContain("PYLON_NOT_FOUND");
347
+ expect(allShared).toContain("getDerivedStateFromError");
348
+ });
349
+ });
@@ -177,6 +177,39 @@ function discoverRoutes(
177
177
  }));
178
178
  }
179
179
 
180
+ /**
181
+ * Resolve the nearest boundary module (`<dir>/not-found` or `<dir>/error`)
182
+ * for a route by walking its component path up to the app root, returning the
183
+ * first manifest route key that exists. Nearest ancestor wins — the same model
184
+ * the server's `findBoundary` uses, but driven off the client build manifest's
185
+ * route keys so the client runtime needs no extra server round-trip.
186
+ *
187
+ * Exported as the CANONICAL spec for the client error boundary: the runtime in
188
+ * `CLIENT_RUNTIME_SOURCE` inlines this exact walk (it can't import across the
189
+ * bundle), and `ssr-client-bundler.test.ts` exercises this function so a
190
+ * regression in the algorithm is caught here.
191
+ *
192
+ * `component` is a cwd-relative path with "/" separators and no extension
193
+ * (e.g. "web/app/dashboard/orgs/[slug]/page"); `routeKeys` is
194
+ * `Object.keys(manifest.routes)`.
195
+ */
196
+ export function nearestBoundaryComponent(
197
+ component: string,
198
+ fileName: "not-found" | "error",
199
+ routeKeys: Iterable<string>,
200
+ ): string | null {
201
+ const keys = routeKeys instanceof Set ? routeKeys : new Set(routeKeys);
202
+ let dir = String(component);
203
+ dir = dir.includes("/") ? dir.slice(0, dir.lastIndexOf("/")) : "";
204
+ while (dir) {
205
+ const key = `${dir}/${fileName}`;
206
+ if (keys.has(key)) return key;
207
+ const slash = dir.lastIndexOf("/");
208
+ dir = slash >= 0 ? dir.slice(0, slash) : "";
209
+ }
210
+ return null;
211
+ }
212
+
180
213
  /**
181
214
  * The shared hydration dispatcher + router. ONE module, imported
182
215
  * by every per-route entry. Bun's splitter sees N entries reach
@@ -204,7 +237,7 @@ function discoverRoutes(
204
237
  const CLIENT_RUNTIME_SOURCE = `// Generated by Pylon SSR (Phase 2 client runtime).
205
238
  // DO NOT EDIT — overwritten on every pylon dev / build.
206
239
 
207
- import { createElement } from "react";
240
+ import { Component, createElement, useEffect, useState } from "react";
208
241
  import { hydrateRoot } from "react-dom/client";
209
242
 
210
243
  const routeCache = Object.create(null);
@@ -222,6 +255,185 @@ function buildTree(Page, Layouts, props) {
222
255
  return tree;
223
256
  }
224
257
 
258
+ // ---------------------------------------------------------------------------
259
+ // Client error / not-found boundary. notFound() (from @pylonsync/react) and
260
+ // any other error thrown DURING a client render — the normal case when an
261
+ // async lookup 404s after hydration — would otherwise propagate uncaught and
262
+ // blank the whole document. React error boundaries must be class components;
263
+ // this one catches the throw, resolves the nearest not-found.tsx / error.tsx
264
+ // for the active route from the build manifest (the same nearest-ancestor
265
+ // model the server's findBoundary uses), and renders it in its own layout
266
+ // chain. It sits at the root and is transparent until something throws, so
267
+ // hydration still matches the server HTML exactly. It resets on navigation
268
+ // via a changing navEpoch prop (NOT a key — a key change would remount every
269
+ // layout on each nav and lose their state).
270
+ // ---------------------------------------------------------------------------
271
+
272
+ let navEpoch = 0;
273
+
274
+ // Walk up the active route's component path to the nearest boundary module
275
+ // (<dir>/not-found or <dir>/error) present in the manifest. Manifest route
276
+ // keys are cwd-relative component paths with "/" separators (e.g.
277
+ // "web/app/dashboard/not-found"), so no platform normalization is needed.
278
+ // MUST stay in lockstep with the exported nearestBoundaryComponent() — that
279
+ // function is the tested spec for this exact walk.
280
+ async function resolveBoundaryComponent(component, fileName) {
281
+ const manifest = await loadManifest();
282
+ if (!manifest || !manifest.routes || !component) return null;
283
+ let dir = String(component);
284
+ dir = dir.includes("/") ? dir.slice(0, dir.lastIndexOf("/")) : "";
285
+ while (dir) {
286
+ const key = dir + "/" + fileName;
287
+ if (manifest.routes[key]) return key;
288
+ const slash = dir.lastIndexOf("/");
289
+ dir = slash >= 0 ? dir.slice(0, slash) : "";
290
+ }
291
+ return null;
292
+ }
293
+
294
+ // Last-resort body when the app ships no not-found.tsx / error.tsx anywhere.
295
+ // Renders a full <html> document so it can replace the document root cleanly
296
+ // (the normal path renders the app's boundary wrapped in the root layout,
297
+ // which also owns <html>).
298
+ function DefaultBoundary(props) {
299
+ const isNF = props.kind === "not-found";
300
+ return createElement(
301
+ "html",
302
+ { lang: "en" },
303
+ createElement(
304
+ "head",
305
+ null,
306
+ createElement("meta", { charSet: "utf-8" }),
307
+ createElement("title", null, isNF ? "404 — Not found" : "Something went wrong"),
308
+ ),
309
+ createElement(
310
+ "body",
311
+ {
312
+ style: {
313
+ margin: 0,
314
+ minHeight: "100vh",
315
+ display: "flex",
316
+ alignItems: "center",
317
+ justifyContent: "center",
318
+ fontFamily: "system-ui, -apple-system, sans-serif",
319
+ },
320
+ },
321
+ createElement(
322
+ "div",
323
+ { style: { textAlign: "center", padding: "2rem" } },
324
+ createElement(
325
+ "h1",
326
+ { style: { fontSize: "1.375rem", margin: "0 0 0.5rem" } },
327
+ isNF ? "404 — Not found" : "Something went wrong",
328
+ ),
329
+ createElement(
330
+ "p",
331
+ { style: { color: "#666", margin: "0 0 1.25rem" } },
332
+ isNF
333
+ ? "This page doesn't exist or was moved."
334
+ : "An unexpected error occurred.",
335
+ ),
336
+ createElement(
337
+ "a",
338
+ { href: "/", style: { color: "#0969da", textDecoration: "none" } },
339
+ "\\u2190 Go home",
340
+ ),
341
+ ),
342
+ ),
343
+ );
344
+ }
345
+
346
+ // Lazily resolves + renders the boundary component in its own layout chain.
347
+ // Renders nothing for the brief moment before the (usually already-cached)
348
+ // entry loads, then the styled boundary — far better than a permanent blank.
349
+ function BoundaryView(props) {
350
+ const { component, kind, error, reset } = props;
351
+ const [resolved, setResolved] = useState(null);
352
+ useEffect(() => {
353
+ let cancelled = false;
354
+ const fileName = kind === "not-found" ? "not-found" : "error";
355
+ (async () => {
356
+ const comp = await resolveBoundaryComponent(component, fileName);
357
+ if (cancelled) return;
358
+ if (!comp) {
359
+ setResolved({ missing: true });
360
+ return;
361
+ }
362
+ try {
363
+ const entry = await loadRouteEntry(comp);
364
+ if (!cancelled) setResolved({ entry });
365
+ } catch {
366
+ if (!cancelled) setResolved({ missing: true });
367
+ }
368
+ })();
369
+ return () => {
370
+ cancelled = true;
371
+ };
372
+ }, [component, kind]);
373
+ if (!resolved) return null;
374
+ if (resolved.missing) return createElement(DefaultBoundary, { kind });
375
+ const bProps = {
376
+ params: currentParams,
377
+ searchParams: Object.fromEntries(new URLSearchParams(location.search)),
378
+ url: location.pathname + location.search,
379
+ reset,
380
+ };
381
+ // error.tsx receives the SAFE error projection (message + digest) — never a
382
+ // raw Error/stack, matching the server boundary's #270 posture.
383
+ if (kind === "error" && error) {
384
+ bProps.error = {
385
+ message: String((error && error.message) || error),
386
+ digest: error && error.digest,
387
+ };
388
+ }
389
+ return buildTree(resolved.entry.Page, resolved.entry.Layouts, bProps);
390
+ }
391
+
392
+ class PylonBoundary extends Component {
393
+ constructor(p) {
394
+ super(p);
395
+ this.state = { err: null };
396
+ }
397
+ static getDerivedStateFromError(err) {
398
+ return { err };
399
+ }
400
+ componentDidUpdate(prev) {
401
+ // A navigation bumps navEpoch — clear a prior error so the freshly
402
+ // rendered children (the new page) show instead of the stale boundary.
403
+ if (prev.navEpoch !== this.props.navEpoch && this.state.err) {
404
+ this.setState({ err: null });
405
+ }
406
+ }
407
+ componentDidCatch(err) {
408
+ // notFound() is expected control flow, not a crash — keep it quiet.
409
+ // Genuine errors stay loud.
410
+ if (!(err && err.digest === "PYLON_NOT_FOUND")) {
411
+ console.error("[pylon ssr] render error caught by boundary:", err);
412
+ }
413
+ }
414
+ render() {
415
+ const err = this.state.err;
416
+ if (!err) return this.props.children;
417
+ const kind = err.digest === "PYLON_NOT_FOUND" ? "not-found" : "error";
418
+ const self = this;
419
+ return createElement(BoundaryView, {
420
+ component: this.props.component,
421
+ kind,
422
+ error: err,
423
+ reset() {
424
+ self.setState({ err: null });
425
+ navigate(location.pathname + location.search, { replace: true });
426
+ },
427
+ });
428
+ }
429
+ }
430
+
431
+ // Wrap a page tree in the root boundary. navEpoch (a prop, not a key) lets the
432
+ // boundary clear its error on navigation without remounting the layouts.
433
+ function withBoundary(tree, component) {
434
+ return createElement(PylonBoundary, { navEpoch, component }, tree);
435
+ }
436
+
225
437
  // Deterministic stringify — MUST match stableStringify in ssr-runtime.ts so
226
438
  // a serverData call's cache key is identical on server and client.
227
439
  function stableStringify(v) {
@@ -420,7 +632,10 @@ export function hydrate(component, Page, Layouts) {
420
632
  return;
421
633
  }
422
634
  setNavParams(data);
423
- const tree = buildTree(Page, Layouts, withClientProps(data));
635
+ const tree = withBoundary(
636
+ buildTree(Page, Layouts, withClientProps(data)),
637
+ data.component,
638
+ );
424
639
  activeRoot = hydrateRoot(document, tree);
425
640
  installNavHandlers();
426
641
  return;
@@ -497,7 +712,11 @@ async function navigate(href, opts) {
497
712
  document.title = doc.title || document.title;
498
713
  syncHeadMeta(doc);
499
714
  setNavParams(data);
500
- const tree = buildTree(route.Page, route.Layouts, withClientProps(data));
715
+ navEpoch++;
716
+ const tree = withBoundary(
717
+ buildTree(route.Page, route.Layouts, withClientProps(data)),
718
+ data.component,
719
+ );
501
720
  activeRoot.render(tree);
502
721
  const target = url.pathname + url.search;
503
722
  if (opts && opts.replace) {
@@ -759,30 +978,20 @@ async function buildTailwind(
759
978
  );
760
979
  }
761
980
 
762
- // Hash the source content so we can name the output uniquely +
763
- // serve it with long-cache immutable headers.
764
- const src = fs.readFileSync(globalsPath, "utf8");
765
- let hash = 0;
766
- for (let i = 0; i < src.length; i++) {
767
- hash = (hash * 31 + src.charCodeAt(i)) >>> 0;
768
- }
769
- // Mix in the discovered routes so adding/removing pages changes
770
- // the hash (Tailwind v4 auto-discovers `@source` paths; we still
771
- // want the cache to bust on layout changes).
981
+ // Compile to a temp file first, then name the asset by a hash of the
982
+ // COMPILED OUTPUT NOT the source. Hashing `globals.css` was the wrong
983
+ // input: adding a Tailwind class in any component regenerates the CSS but
984
+ // leaves globals.css untouched, so the `styles-<hash>.css` filename never
985
+ // changed and browsers / CDNs kept serving the STALE stylesheet (missing the
986
+ // new classes dropdowns rendered unstyled, etc.). The compiled output, by
987
+ // contrast, changes iff a scanned class changes: its hash busts the cache
988
+ // exactly when it must, and stays identical (cache stays warm) otherwise.
772
989
  //
773
- // Pad the base36 hash to 8 chars: the runtime's `is_hashed_name`
774
- // (frontend.rs) only sends `Cache-Control: immutable` for hashes ≥8
775
- // chars (Bun's JS chunk convention). A 32-bit base36 hash is ≤7 chars,
776
- // so WITHOUT the pad the content-hashed CSS was served `no-cache` —
777
- // browsers + any CDN refetched it on every page load (defeats the cache
778
- // and, behind Cloudflare, needlessly wakes an autostopped origin).
779
- const stylesName = `styles-${hash.toString(36).padStart(8, "0")}.css`;
780
- const outPath = path.join(outdir, stylesName);
781
-
782
- // Spawn the CLI. Bun is already running; reuse it as the
783
- // interpreter so the user doesn't need node on PATH.
990
+ // Spawn the CLI. Bun is already running; reuse it as the interpreter so the
991
+ // user doesn't need node on PATH.
992
+ const tmpPath = path.join(outdir, ".styles.build.css");
784
993
  const proc = (Bun as any).spawn({
785
- cmd: [process.execPath, cliPath, "-i", globalsPath, "-o", outPath, "--minify"],
994
+ cmd: [process.execPath, cliPath, "-i", globalsPath, "-o", tmpPath, "--minify"],
786
995
  cwd,
787
996
  stdout: "pipe",
788
997
  stderr: "pipe",
@@ -792,6 +1001,34 @@ async function buildTailwind(
792
1001
  const err = await new Response(proc.stderr).text();
793
1002
  throw new Error(`tailwindcss build failed (exit ${exitCode}): ${err}`);
794
1003
  }
1004
+
1005
+ const out = fs.readFileSync(tmpPath, "utf8");
1006
+ let hash = 0;
1007
+ for (let i = 0; i < out.length; i++) {
1008
+ hash = (hash * 31 + out.charCodeAt(i)) >>> 0;
1009
+ }
1010
+ // Pad the base36 hash to 8 chars: the runtime's `is_hashed_name`
1011
+ // (frontend.rs) only sends `Cache-Control: immutable` for hashes ≥8 chars
1012
+ // (Bun's JS chunk convention). A 32-bit base36 hash is ≤7 chars, so without
1013
+ // the pad the content-hashed CSS would be served `no-cache` — browsers + any
1014
+ // CDN would refetch it on every page load (and, behind Cloudflare, wake an
1015
+ // autostopped origin).
1016
+ const stylesName = `styles-${hash.toString(36).padStart(8, "0")}.css`;
1017
+ const outPath = path.join(outdir, stylesName);
1018
+
1019
+ // Drop previous styles-*.css so the outdir doesn't accumulate stale builds
1020
+ // (the filename now changes on every class change), then move the freshly
1021
+ // compiled file into place.
1022
+ try {
1023
+ for (const f of fs.readdirSync(outdir) as string[]) {
1024
+ if (f.startsWith("styles-") && f.endsWith(".css")) {
1025
+ fs.rmSync(path.join(outdir, f), { force: true });
1026
+ }
1027
+ }
1028
+ } catch {
1029
+ /* outdir may not be listable on the first build — ignore */
1030
+ }
1031
+ fs.renameSync(tmpPath, outPath);
795
1032
  return stylesName;
796
1033
  }
797
1034
 
@@ -7,6 +7,12 @@
7
7
  // the dispatch arm) so projects without SSR routes pay nothing — no
8
8
  // react-dom dependency requirement, no startup cost.
9
9
 
10
+ // Bun runtime global. This module runs under Bun but is type-checked by
11
+ // consuming apps under node/DOM (no `Bun` global) — declare the surface used.
12
+ declare const Bun: {
13
+ resolveSync?(specifier: string, from: string): string;
14
+ };
15
+
10
16
  /**
11
17
  * Is the runtime in dev mode? MUST match the Rust host's `is_dev_mode()`
12
18
  * (crates/runtime/src/frontend.rs): `PYLON_DEV_MODE` is on ONLY for the exact