@pylonsync/functions 0.3.294 → 0.3.295

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.
@@ -58,6 +58,14 @@ export interface BuildOutput {
58
58
  * from `getManifest` (in-process SSR path).
59
59
  */
60
60
  export declare function buildClientBundle(appDirRel?: string): Promise<BuildOutput>;
61
+ /**
62
+ * Compile `app/globals.css` through Tailwind v4 (`@tailwindcss/cli`)
63
+ * if both are present. Returns the relative output path (under
64
+ * outdir) when produced, else null. Skipped silently when the
65
+ * project hasn't opted in to Tailwind — we don't want every SSR
66
+ * project to need Tailwind installed.
67
+ */
68
+ export declare function buildTailwind(fs: any, path: any, cwd: string, outdir: string, appDirRel: string): Promise<string | null>;
61
69
  /**
62
70
  * Return the bundle manifest. If a fresh manifest exists on disk,
63
71
  * use it (caching parse output across requests). Otherwise build
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.3.294",
3
+ "version": "0.3.295",
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",
@@ -23,6 +23,7 @@ import * as os from "node:os";
23
23
 
24
24
  import {
25
25
  buildClientBundle,
26
+ buildTailwind,
26
27
  type PylonBundleManifest,
27
28
  } from "./ssr-client-bundler";
28
29
  import { nearestBoundaryComponent } from "./ssr-client-boundary";
@@ -379,3 +380,53 @@ describe("client boundary is wired into the build", () => {
379
380
  expect(allShared).toContain("getDerivedStateFromError");
380
381
  });
381
382
  });
383
+
384
+ describe("Tailwind compile is concurrency-safe", () => {
385
+ // Regression: `pylon dev` warms the SSR bundle in one runner process while the
386
+ // first requests drive their own rebuild in another. Both compiled Tailwind to
387
+ // a SHARED temp file (`.styles.build.css`); whichever renamed it first won, and
388
+ // the other's `renameSync` ENOENT'd → the compile threw → the route shipped
389
+ // with no `css` → the page rendered UNSTYLED. Reproduced ~25% of cold boots
390
+ // under concurrent load. The fix gives each compile a process-unique temp path
391
+ // and publishes (rename) before pruning others. Drive several compiles at once
392
+ // against one outdir and prove they all succeed.
393
+ test("concurrent compiles against one outdir all produce the stylesheet", async () => {
394
+ const root = makeFixture(
395
+ { "page.tsx": PAGE_BODY("Home") },
396
+ { "layout.tsx": LAYOUT_BODY },
397
+ );
398
+ tempDir = root; // registers it for afterEach cleanup
399
+ // Opt into Tailwind: a real globals.css the CLI can compile. `@tailwindcss/cli`
400
+ // + `tailwindcss` resolve through the fixture's node_modules symlink (it points
401
+ // at examples/ssr-hello/node_modules, which declares both).
402
+ fs.writeFileSync(
403
+ path.join(root, "app", "globals.css"),
404
+ '@import "tailwindcss";\n@source "../app/**/*.{tsx,ts,jsx,js}";\n',
405
+ "utf8",
406
+ );
407
+ originalCwd = process.cwd();
408
+ process.chdir(root);
409
+
410
+ const outdir = path.join(root, ".pylon", "client-build");
411
+ fs.mkdirSync(outdir, { recursive: true });
412
+
413
+ // Old code: at least one of these rejects with the rename ENOENT.
414
+ const results = await Promise.all(
415
+ Array.from({ length: 4 }, () =>
416
+ buildTailwind(fs, path, root, outdir, "app"),
417
+ ),
418
+ );
419
+
420
+ for (const name of results) {
421
+ expect(name).toMatch(/^styles-[a-z0-9]+\.css$/);
422
+ expect(fs.existsSync(path.join(outdir, name as string))).toBe(true);
423
+ }
424
+ // Deterministic output → identical hash → one published asset, no leftover
425
+ // temp files stranded in the outdir.
426
+ expect(new Set(results).size).toBe(1);
427
+ const stranded = (fs.readdirSync(outdir) as string[]).filter((f) =>
428
+ f.startsWith(".styles.build."),
429
+ );
430
+ expect(stranded).toEqual([]);
431
+ }, 20_000);
432
+ });
@@ -868,6 +868,13 @@ async function _doBuild(appDirRel: string): Promise<BuildOutput> {
868
868
  return _doBuildInner(fs, path, cwd, appDirRel);
869
869
  }
870
870
 
871
+ // Monotonic per-process counter for the Tailwind temp filename. `pylon dev`
872
+ // warms the SSR bundle in one runner process while incoming requests can drive
873
+ // their own rebuild in another, so two `buildTailwind` calls may run against the
874
+ // same outdir at once. Combined with the pid it gives every compile a unique
875
+ // temp path, so concurrent builds never rename each other's file away.
876
+ let _styleBuildSeq = 0;
877
+
871
878
  /**
872
879
  * Compile `app/globals.css` through Tailwind v4 (`@tailwindcss/cli`)
873
880
  * if both are present. Returns the relative output path (under
@@ -875,7 +882,10 @@ async function _doBuild(appDirRel: string): Promise<BuildOutput> {
875
882
  * project hasn't opted in to Tailwind — we don't want every SSR
876
883
  * project to need Tailwind installed.
877
884
  */
878
- async function buildTailwind(
885
+ // Exported for the concurrency regression test (ssr-client-bundler.test.ts):
886
+ // it drives several compiles at once against one outdir to prove they no longer
887
+ // race on a shared temp file.
888
+ export async function buildTailwind(
879
889
  fs: any,
880
890
  path: any,
881
891
  cwd: string,
@@ -915,7 +925,15 @@ async function buildTailwind(
915
925
  //
916
926
  // Spawn the CLI. Bun is already running; reuse it as the interpreter so the
917
927
  // user doesn't need node on PATH.
918
- const tmpPath = path.join(outdir, ".styles.build.css");
928
+ // PROCESS-UNIQUE temp file. A shared name (`.styles.build.css`) let a
929
+ // concurrent build rename the file out from under this one — its `renameSync`
930
+ // then ENOENT'd, the compile threw, the route shipped with no `css`, and the
931
+ // page rendered UNSTYLED. Worst under cold-boot traffic (a warm build racing
932
+ // the first requests), which is exactly when it must not happen.
933
+ const tmpPath = path.join(
934
+ outdir,
935
+ `.styles.build.${process.pid}.${_styleBuildSeq++}.css`,
936
+ );
919
937
  const proc = (Bun as any).spawn({
920
938
  cmd: [process.execPath, cliPath, "-i", globalsPath, "-o", tmpPath, "--minify"],
921
939
  cwd,
@@ -925,6 +943,11 @@ async function buildTailwind(
925
943
  const exitCode = await proc.exited;
926
944
  if (exitCode !== 0) {
927
945
  const err = await new Response(proc.stderr).text();
946
+ try {
947
+ fs.rmSync(tmpPath, { force: true });
948
+ } catch {
949
+ /* nothing to clean up */
950
+ }
928
951
  throw new Error(`tailwindcss build failed (exit ${exitCode}): ${err}`);
929
952
  }
930
953
 
@@ -942,19 +965,22 @@ async function buildTailwind(
942
965
  const stylesName = `styles-${hash.toString(36).padStart(8, "0")}.css`;
943
966
  const outPath = path.join(outdir, stylesName);
944
967
 
945
- // Drop previous styles-*.css so the outdir doesn't accumulate stale builds
946
- // (the filename now changes on every class change), then move the freshly
947
- // compiled file into place.
968
+ // Publish our freshly-compiled CSS FIRST (rename replaces atomically on
969
+ // POSIX, so the asset is never momentarily absent), THEN prune OTHER stale
970
+ // styles-*.css. The old order pruned before renaming, so a concurrent build
971
+ // could delete the file we were about to publish; pruning our own name could
972
+ // delete a concurrent winner's identical output. Excluding `stylesName` keeps
973
+ // both safe while still clearing builds from earlier class changes.
974
+ fs.renameSync(tmpPath, outPath);
948
975
  try {
949
976
  for (const f of fs.readdirSync(outdir) as string[]) {
950
- if (f.startsWith("styles-") && f.endsWith(".css")) {
977
+ if (f.startsWith("styles-") && f.endsWith(".css") && f !== stylesName) {
951
978
  fs.rmSync(path.join(outdir, f), { force: true });
952
979
  }
953
980
  }
954
981
  } catch {
955
982
  /* outdir may not be listable on the first build — ignore */
956
983
  }
957
- fs.renameSync(tmpPath, outPath);
958
984
  return stylesName;
959
985
  }
960
986