@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 +1 -1
- package/src/runtime.ts +11 -0
- package/src/ssr-client-bundler.test.ts +113 -0
- package/src/ssr-client-bundler.ts +262 -25
- package/src/ssr-runtime.ts +6 -0
package/package.json
CHANGED
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 =
|
|
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
|
-
|
|
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
|
-
//
|
|
763
|
-
//
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
//
|
|
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
|
-
//
|
|
774
|
-
//
|
|
775
|
-
|
|
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",
|
|
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
|
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -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
|