@pylonsync/functions 0.3.269 → 0.3.271

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.269",
3
+ "version": "0.3.271",
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,190 @@ 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
+ // The props the active page was rendered with (auth, serverData, params, …).
274
+ // The boundary reuses them so a boundary module AND its layout chain — e.g. an
275
+ // auth-guarding dashboard layout that reads props.auth — render with the SAME
276
+ // context the page had. The server renders boundaries with the page props too;
277
+ // a minimal hand-built props object would make such a layout see no auth and
278
+ // (for instance) redirect to /login instead of showing the not-found page.
279
+ let currentPageProps = {};
280
+
281
+ // Walk up the active route's component path to the nearest boundary module
282
+ // (<dir>/not-found or <dir>/error) present in the manifest. Manifest route
283
+ // keys are cwd-relative component paths with "/" separators (e.g.
284
+ // "web/app/dashboard/not-found"), so no platform normalization is needed.
285
+ // MUST stay in lockstep with the exported nearestBoundaryComponent() — that
286
+ // function is the tested spec for this exact walk.
287
+ async function resolveBoundaryComponent(component, fileName) {
288
+ const manifest = await loadManifest();
289
+ if (!manifest || !manifest.routes || !component) return null;
290
+ let dir = String(component);
291
+ dir = dir.includes("/") ? dir.slice(0, dir.lastIndexOf("/")) : "";
292
+ while (dir) {
293
+ const key = dir + "/" + fileName;
294
+ if (manifest.routes[key]) return key;
295
+ const slash = dir.lastIndexOf("/");
296
+ dir = slash >= 0 ? dir.slice(0, slash) : "";
297
+ }
298
+ return null;
299
+ }
300
+
301
+ // Last-resort body when the app ships no not-found.tsx / error.tsx anywhere.
302
+ // Renders a full <html> document so it can replace the document root cleanly
303
+ // (the normal path renders the app's boundary wrapped in the root layout,
304
+ // which also owns <html>).
305
+ function DefaultBoundary(props) {
306
+ const isNF = props.kind === "not-found";
307
+ return createElement(
308
+ "html",
309
+ { lang: "en" },
310
+ createElement(
311
+ "head",
312
+ null,
313
+ createElement("meta", { charSet: "utf-8" }),
314
+ createElement("title", null, isNF ? "404 — Not found" : "Something went wrong"),
315
+ ),
316
+ createElement(
317
+ "body",
318
+ {
319
+ style: {
320
+ margin: 0,
321
+ minHeight: "100vh",
322
+ display: "flex",
323
+ alignItems: "center",
324
+ justifyContent: "center",
325
+ fontFamily: "system-ui, -apple-system, sans-serif",
326
+ },
327
+ },
328
+ createElement(
329
+ "div",
330
+ { style: { textAlign: "center", padding: "2rem" } },
331
+ createElement(
332
+ "h1",
333
+ { style: { fontSize: "1.375rem", margin: "0 0 0.5rem" } },
334
+ isNF ? "404 — Not found" : "Something went wrong",
335
+ ),
336
+ createElement(
337
+ "p",
338
+ { style: { color: "#666", margin: "0 0 1.25rem" } },
339
+ isNF
340
+ ? "This page doesn't exist or was moved."
341
+ : "An unexpected error occurred.",
342
+ ),
343
+ createElement(
344
+ "a",
345
+ { href: "/", style: { color: "#0969da", textDecoration: "none" } },
346
+ "\\u2190 Go home",
347
+ ),
348
+ ),
349
+ ),
350
+ );
351
+ }
352
+
353
+ // Lazily resolves + renders the boundary component in its own layout chain.
354
+ // Renders nothing for the brief moment before the (usually already-cached)
355
+ // entry loads, then the styled boundary — far better than a permanent blank.
356
+ function BoundaryView(props) {
357
+ const { component, kind, error, reset } = props;
358
+ const [resolved, setResolved] = useState(null);
359
+ useEffect(() => {
360
+ let cancelled = false;
361
+ const fileName = kind === "not-found" ? "not-found" : "error";
362
+ (async () => {
363
+ const comp = await resolveBoundaryComponent(component, fileName);
364
+ if (cancelled) return;
365
+ if (!comp) {
366
+ setResolved({ missing: true });
367
+ return;
368
+ }
369
+ try {
370
+ const entry = await loadRouteEntry(comp);
371
+ if (!cancelled) setResolved({ entry });
372
+ } catch {
373
+ if (!cancelled) setResolved({ missing: true });
374
+ }
375
+ })();
376
+ return () => {
377
+ cancelled = true;
378
+ };
379
+ }, [component, kind]);
380
+ if (!resolved) return null;
381
+ if (resolved.missing) return createElement(DefaultBoundary, { kind });
382
+ // Reuse the page's props (auth, serverData, params, url, …) so the boundary's
383
+ // layout chain renders with the SAME context the page had — an auth-guarding
384
+ // layout that reads props.auth must not see undefined and redirect away. Add
385
+ // reset (+ the SAFE error projection for error.tsx — message + digest only,
386
+ // never a raw Error/stack, matching the server boundary's #270 posture).
387
+ const bProps = { ...currentPageProps, reset };
388
+ if (kind === "error" && error) {
389
+ bProps.error = {
390
+ message: String((error && error.message) || error),
391
+ digest: error && error.digest,
392
+ };
393
+ }
394
+ return buildTree(resolved.entry.Page, resolved.entry.Layouts, bProps);
395
+ }
396
+
397
+ class PylonBoundary extends Component {
398
+ constructor(p) {
399
+ super(p);
400
+ this.state = { err: null };
401
+ }
402
+ static getDerivedStateFromError(err) {
403
+ return { err };
404
+ }
405
+ componentDidUpdate(prev) {
406
+ // A navigation bumps navEpoch — clear a prior error so the freshly
407
+ // rendered children (the new page) show instead of the stale boundary.
408
+ if (prev.navEpoch !== this.props.navEpoch && this.state.err) {
409
+ this.setState({ err: null });
410
+ }
411
+ }
412
+ componentDidCatch(err) {
413
+ // notFound() is expected control flow, not a crash — keep it quiet.
414
+ // Genuine errors stay loud.
415
+ if (!(err && err.digest === "PYLON_NOT_FOUND")) {
416
+ console.error("[pylon ssr] render error caught by boundary:", err);
417
+ }
418
+ }
419
+ render() {
420
+ const err = this.state.err;
421
+ if (!err) return this.props.children;
422
+ const kind = err.digest === "PYLON_NOT_FOUND" ? "not-found" : "error";
423
+ const self = this;
424
+ return createElement(BoundaryView, {
425
+ component: this.props.component,
426
+ kind,
427
+ error: err,
428
+ reset() {
429
+ self.setState({ err: null });
430
+ navigate(location.pathname + location.search, { replace: true });
431
+ },
432
+ });
433
+ }
434
+ }
435
+
436
+ // Wrap a page tree in the root boundary. navEpoch (a prop, not a key) lets the
437
+ // boundary clear its error on navigation without remounting the layouts.
438
+ function withBoundary(tree, component) {
439
+ return createElement(PylonBoundary, { navEpoch, component }, tree);
440
+ }
441
+
225
442
  // Deterministic stringify — MUST match stableStringify in ssr-runtime.ts so
226
443
  // a serverData call's cache key is identical on server and client.
227
444
  function stableStringify(v) {
@@ -420,7 +637,11 @@ export function hydrate(component, Page, Layouts) {
420
637
  return;
421
638
  }
422
639
  setNavParams(data);
423
- const tree = buildTree(Page, Layouts, withClientProps(data));
640
+ currentPageProps = withClientProps(data);
641
+ const tree = withBoundary(
642
+ buildTree(Page, Layouts, currentPageProps),
643
+ data.component,
644
+ );
424
645
  activeRoot = hydrateRoot(document, tree);
425
646
  installNavHandlers();
426
647
  return;
@@ -497,7 +718,12 @@ async function navigate(href, opts) {
497
718
  document.title = doc.title || document.title;
498
719
  syncHeadMeta(doc);
499
720
  setNavParams(data);
500
- const tree = buildTree(route.Page, route.Layouts, withClientProps(data));
721
+ navEpoch++;
722
+ currentPageProps = withClientProps(data);
723
+ const tree = withBoundary(
724
+ buildTree(route.Page, route.Layouts, currentPageProps),
725
+ data.component,
726
+ );
501
727
  activeRoot.render(tree);
502
728
  const target = url.pathname + url.search;
503
729
  if (opts && opts.replace) {
@@ -759,30 +985,20 @@ async function buildTailwind(
759
985
  );
760
986
  }
761
987
 
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).
988
+ // Compile to a temp file first, then name the asset by a hash of the
989
+ // COMPILED OUTPUT NOT the source. Hashing `globals.css` was the wrong
990
+ // input: adding a Tailwind class in any component regenerates the CSS but
991
+ // leaves globals.css untouched, so the `styles-<hash>.css` filename never
992
+ // changed and browsers / CDNs kept serving the STALE stylesheet (missing the
993
+ // new classes dropdowns rendered unstyled, etc.). The compiled output, by
994
+ // contrast, changes iff a scanned class changes: its hash busts the cache
995
+ // exactly when it must, and stays identical (cache stays warm) otherwise.
772
996
  //
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.
997
+ // Spawn the CLI. Bun is already running; reuse it as the interpreter so the
998
+ // user doesn't need node on PATH.
999
+ const tmpPath = path.join(outdir, ".styles.build.css");
784
1000
  const proc = (Bun as any).spawn({
785
- cmd: [process.execPath, cliPath, "-i", globalsPath, "-o", outPath, "--minify"],
1001
+ cmd: [process.execPath, cliPath, "-i", globalsPath, "-o", tmpPath, "--minify"],
786
1002
  cwd,
787
1003
  stdout: "pipe",
788
1004
  stderr: "pipe",
@@ -792,6 +1008,34 @@ async function buildTailwind(
792
1008
  const err = await new Response(proc.stderr).text();
793
1009
  throw new Error(`tailwindcss build failed (exit ${exitCode}): ${err}`);
794
1010
  }
1011
+
1012
+ const out = fs.readFileSync(tmpPath, "utf8");
1013
+ let hash = 0;
1014
+ for (let i = 0; i < out.length; i++) {
1015
+ hash = (hash * 31 + out.charCodeAt(i)) >>> 0;
1016
+ }
1017
+ // Pad the base36 hash to 8 chars: the runtime's `is_hashed_name`
1018
+ // (frontend.rs) only sends `Cache-Control: immutable` for hashes ≥8 chars
1019
+ // (Bun's JS chunk convention). A 32-bit base36 hash is ≤7 chars, so without
1020
+ // the pad the content-hashed CSS would be served `no-cache` — browsers + any
1021
+ // CDN would refetch it on every page load (and, behind Cloudflare, wake an
1022
+ // autostopped origin).
1023
+ const stylesName = `styles-${hash.toString(36).padStart(8, "0")}.css`;
1024
+ const outPath = path.join(outdir, stylesName);
1025
+
1026
+ // Drop previous styles-*.css so the outdir doesn't accumulate stale builds
1027
+ // (the filename now changes on every class change), then move the freshly
1028
+ // compiled file into place.
1029
+ try {
1030
+ for (const f of fs.readdirSync(outdir) as string[]) {
1031
+ if (f.startsWith("styles-") && f.endsWith(".css")) {
1032
+ fs.rmSync(path.join(outdir, f), { force: true });
1033
+ }
1034
+ }
1035
+ } catch {
1036
+ /* outdir may not be listable on the first build — ignore */
1037
+ }
1038
+ fs.renameSync(tmpPath, outPath);
795
1039
  return stylesName;
796
1040
  }
797
1041
 
@@ -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