@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 +1 -1
- package/src/runtime.ts +11 -0
- package/src/ssr-client-bundler.test.ts +113 -0
- package/src/ssr-client-bundler.ts +269 -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,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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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).
|
|
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
|
-
//
|
|
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.
|
|
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",
|
|
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
|
|
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
|