diff --git a/package.json b/package.json index 5e171cb955..5dbaf112a8 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "type": "module", "scripts": { "dev": "bun run studio", - "build": "bun run --filter '@hyperframes/{parsers,lint,studio-server}' build && bun run --filter @hyperframes/core build && bun run --filter '@hyperframes/{core,engine,producer,player,studio,shader-transitions,aws-lambda,gcp-cloud-run,sdk}' build && bun run --filter @hyperframes/cli build && bun run --filter @hyperframes/sdk-playground build", + "build": "bun run --filter '@hyperframes/{parsers,lint,studio-server}' build && bun run --filter @hyperframes/core build && bun run --filter '@hyperframes/{engine,producer,player,studio,shader-transitions,aws-lambda,gcp-cloud-run,sdk}' build && bun run --filter @hyperframes/cli build && bun run --filter @hyperframes/sdk-playground build", "build:producer": "bun run --filter @hyperframes/producer build", "studio": "bun run --filter @hyperframes/studio dev", "build:hyperframes-runtime": "bun run --filter @hyperframes/core build:hyperframes-runtime", @@ -48,7 +48,7 @@ "player:perf": "bun run --filter @hyperframes/player perf", "format:check": "oxfmt --check .", "knip": "knip", - "test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs && vitest run scripts/catalog/", + "test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts && vitest run scripts/catalog/", "typecheck:scripts": "tsc --noEmit -p scripts/tsconfig.json", "test:skills": "node --test 'skills/**/*.test.mjs'", "generate:previews": "tsx scripts/generate-template-previews.ts", diff --git a/packages/core/scripts/build-hyperframes-runtime-artifact.ts b/packages/core/scripts/build-hyperframes-runtime-artifact.ts index 3042e17706..3543992dcf 100644 --- a/packages/core/scripts/build-hyperframes-runtime-artifact.ts +++ b/packages/core/scripts/build-hyperframes-runtime-artifact.ts @@ -1,5 +1,4 @@ import { createHash } from "node:crypto"; -import { mkdirSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { buildSync } from "esbuild"; @@ -8,6 +7,7 @@ import { HYPERFRAME_RUNTIME_CONTRACT, loadHyperframeRuntimeSource, } from "../src/inline-scripts/hyperframe"; +import { writeGeneratedFile } from "./writeGeneratedFile.js"; const thisDir = dirname(fileURLToPath(import.meta.url)); const distDir = resolve(thisDir, "../dist"); @@ -46,20 +46,17 @@ const manifest = { contract: HYPERFRAME_RUNTIME_CONTRACT, }; -mkdirSync(distDir, { recursive: true }); -writeFileSync(iifePath, runtimeSource, "utf8"); -writeFileSync(esmPath, esmSource, "utf8"); -writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); +writeGeneratedFile(iifePath, runtimeSource); +writeGeneratedFile(esmPath, esmSource); +writeGeneratedFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); // ── Generate src/generated/runtime-inline.ts ────────────────────────────── // This file is compiled by tsc into dist/ and provides the production-safe // getHyperframeRuntimeScript() that returns the IIFE as a string constant — // no esbuild, no file I/O, no import.meta.url arithmetic. -const generatedDir = resolve(thisDir, "../src/generated"); -mkdirSync(generatedDir, { recursive: true }); -const inlineModulePath = resolve(generatedDir, "runtime-inline.ts"); +const inlineModulePath = resolve(thisDir, "../src/generated/runtime-inline.ts"); const escapedSource = JSON.stringify(runtimeSourceRaw); -writeFileSync( +writeGeneratedFile( inlineModulePath, [ "// AUTO-GENERATED by scripts/build-hyperframes-runtime-artifact.ts — do not edit", @@ -75,7 +72,6 @@ writeFileSync( "}", "", ].join("\n"), - "utf8", ); console.log( diff --git a/packages/core/scripts/buildInjectedArtifact.ts b/packages/core/scripts/buildInjectedArtifact.ts index b7faa7d0f7..de3e97e4b3 100644 --- a/packages/core/scripts/buildInjectedArtifact.ts +++ b/packages/core/scripts/buildInjectedArtifact.ts @@ -8,11 +8,10 @@ * producing a subtly different artifact with nothing to say so. */ -import { execFileSync } from "node:child_process"; -import { mkdirSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { buildSync } from "esbuild"; +import { formatGeneratedSource, writeGeneratedFile } from "./writeGeneratedFile.js"; export interface InjectedArtifact { /** The build script's own `import.meta.url`, so paths resolve beside it. */ @@ -52,27 +51,18 @@ export function buildInjectedArtifact(spec: InjectedArtifact): void { const iife = result.outputFiles[0]?.text ?? ""; if (!iife) throw new Error(`esbuild produced no output for ${spec.entry.split("/").pop()}`); - mkdirSync(generatedDir, { recursive: true }); - writeFileSync( - outPath, - [ - `// AUTO-GENERATED by scripts/${scriptName} - do not edit`, - `const ${spec.constName}: string = ${JSON.stringify(iife)};`, - "", - `/** Returns the pre-built ${spec.what} as a string constant. */`, - `export function ${spec.fnName}(): string {`, - ` return ${spec.constName};`, - "}", - "", - ].join("\n"), - "utf8", - ); + const source = [ + `// AUTO-GENERATED by scripts/${scriptName} - do not edit`, + `const ${spec.constName}: string = ${JSON.stringify(iife)};`, + "", + `/** Returns the pre-built ${spec.what} as a string constant. */`, + `export function ${spec.fnName}(): string {`, + ` return ${spec.constName};`, + "}", + "", + ].join("\n"); - try { - execFileSync("bun", ["x", "oxfmt", outPath], { stdio: "ignore" }); - } catch { - // Formatting is best effort when the generator runs in a minimal environment. - } + writeGeneratedFile(outPath, formatGeneratedSource(source, outPath)); console.log(JSON.stringify({ event: spec.event, outPath, bytes: iife.length })); } diff --git a/packages/core/scripts/writeGeneratedFile.test.ts b/packages/core/scripts/writeGeneratedFile.test.ts new file mode 100644 index 0000000000..8acc53d248 --- /dev/null +++ b/packages/core/scripts/writeGeneratedFile.test.ts @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import { + closeSync, + fstatSync, + mkdtempSync, + openSync, + readdirSync, + readFileSync, + rmSync, + statSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Identity is read through a descriptor, not a path, so the snapshot cannot be mistaken +// for a check that the following write is then trusted to still hold (the pattern +// CodeQL's file-system-race query looks for). The write under test is the point. +const snapshot = (path: string) => { + const fd = openSync(path, "r"); + try { + return fstatSync(fd); + } finally { + closeSync(fd); + } +}; +import { after, describe, it } from "node:test"; +import { writeGeneratedFile } from "./writeGeneratedFile.js"; + +const workDir = mkdtempSync(join(tmpdir(), "write-generated-")); +after(() => rmSync(workDir, { recursive: true, force: true })); + +const target = (name: string) => join(workDir, `${name}.ts`); + +describe("writeGeneratedFile", () => { + it("publishes the whole file and leaves no temp behind", () => { + const out = target("published"); + const contents = `export const BIG = ${JSON.stringify("x".repeat(50_000))};\n`; + + assert.equal(writeGeneratedFile(out, contents), true); + + assert.equal(readFileSync(out, "utf8"), contents); + assert.deepEqual( + readdirSync(workDir).filter((f) => f.endsWith(".tmp")), + [], + ); + }); + + it("leaves the target untouched when the content is byte-identical", () => { + const out = target("unchanged"); + const contents = "export const A = 1;\n"; + writeGeneratedFile(out, contents); + // Backdated so a republish cannot coincidentally land on the same mtime. Read the + // stamp back rather than trusting the one just set: filesystems round it. + const backdated = new Date(Date.now() - 60_000); + utimesSync(out, backdated, backdated); + const before = snapshot(out).mtimeMs; + + assert.equal(writeGeneratedFile(out, contents), false); + + assert.equal(statSync(out).mtimeMs, before); + }); + + it("replaces the target rather than rewriting it in place", () => { + const out = target("replaced"); + writeFileSync(out, "export const A = 1;\n", "utf8"); + const before = snapshot(out).ino; + + assert.equal(writeGeneratedFile(out, "export const A = 2;\n"), true); + + assert.equal(readFileSync(out, "utf8"), "export const A = 2;\n"); + // A new inode is the observable proof the target was swapped in whole. Rewriting + // in place keeps the inode and exposes a truncated file to a concurrent reader. + assert.notEqual(statSync(out).ino, before); + }); + + it("creates the directory when it does not exist yet", () => { + const out = join(workDir, "nested", "deeper", "created.ts"); + + assert.equal(writeGeneratedFile(out, "export const A = 1;\n"), true); + + assert.equal(readFileSync(out, "utf8"), "export const A = 1;\n"); + }); +}); diff --git a/packages/core/scripts/writeGeneratedFile.ts b/packages/core/scripts/writeGeneratedFile.ts new file mode 100644 index 0000000000..b9a49511fb --- /dev/null +++ b/packages/core/scripts/writeGeneratedFile.ts @@ -0,0 +1,64 @@ +/** + * Publish a file under `src/generated` so a concurrent reader never observes it + * half-written. + * + * The root build runs several package builds at once, and more than one of them + * regenerates files here while a sibling's `tsc` is reading them. A plain + * `writeFileSync` leaves the target truncated and then growing for the duration of + * the write, which surfaces in whichever build read it mid-flight as + * `TS1002: Unterminated string literal` — a failure that never reproduces locally, + * because locally nothing is reading at that instant. + * + * Writing to a temp file in the same directory and renaming over the target makes + * publication one atomic step: a reader sees either the whole previous file or the + * whole new one, never a prefix. Same directory matters — `rename` is only atomic + * within a filesystem. + * + * Byte-identical content is not republished, so a no-op rebuild leaves the mtime + * alone and nothing downstream that keys off mtime is needlessly invalidated. + * + * Returns whether the target was replaced. + * + * Lives inside `packages/core` rather than the repo-root `scripts/` because every + * container image that builds a package copies `packages/…` wholesale but + * cherry-picks root scripts one file at a time. A helper the core build imports + * has to travel with the package, or the image build fails on a missing module. + */ + +import { execFileSync } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; + +export function writeGeneratedFile(outPath: string, contents: string): boolean { + if (existsSync(outPath) && readFileSync(outPath, "utf8") === contents) return false; + + mkdirSync(dirname(outPath), { recursive: true }); + // Not a `.ts` suffix: the temp file sits inside the package's `src` glob, and a + // concurrent `tsc` would otherwise try to compile it. + const tempPath = `${outPath}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`; + try { + writeFileSync(tempPath, contents, "utf8"); + renameSync(tempPath, outPath); + } finally { + rmSync(tempPath, { force: true }); + } + return true; +} + +/** + * Runs the generated source through oxfmt's stdin so it is published already + * formatted. Formatting the file after publishing it would rewrite it in place and + * reopen the very window the atomic rename closes. Best effort: an environment + * without oxfmt still gets a valid, if unformatted, artifact. + */ +export function formatGeneratedSource(source: string, outPath: string): string { + try { + return execFileSync("bun", ["x", "oxfmt", `--stdin-filepath=${outPath}`], { + input: source, + encoding: "utf8", + }); + } catch { + return source; + } +} diff --git a/packages/engine/src/utils/ffprobe.test.ts b/packages/engine/src/utils/ffprobe.test.ts index 2030dc1054..20186127dc 100644 --- a/packages/engine/src/utils/ffprobe.test.ts +++ b/packages/engine/src/utils/ffprobe.test.ts @@ -251,11 +251,10 @@ describe("probeMediaProfile", () => { }); it("bounds the process-scoped probe cache", async () => { - const fixtureDir = mkdtempSync(resolve(tmpdir(), "hf-media-probe-lru-")); - const fixturePaths = Array.from({ length: 129 }, (_, index) => - resolve(fixtureDir, `asset-${index}`), - ); - for (const fixturePath of fixturePaths) writeFileSync(fixturePath, "probe identity"); + // Cache identity is derived from stat, so synthesise it instead of writing + // 129 real files: the eviction rule is in-memory, and on a contended + // Windows runner the file churn alone pushed this past the test timeout. + const paths = Array.from({ length: 129 }, (_, index) => `/probe-lru/asset-${index}`); const outcome = { kind: "exit" as const, code: 0, @@ -267,13 +266,31 @@ describe("probeMediaProfile", () => { const { spawn, calls } = createSpawnSpy([outcome]); vi.resetModules(); vi.doMock("child_process", () => ({ spawn })); - const { probeMediaProfile } = await import("./ffprobe.js"); + vi.doMock("fs", async () => { + const actual = await vi.importActual("fs"); + const statSync = (filePath: string) => ({ + dev: 1n, + ino: BigInt(paths.indexOf(filePath)), + size: 1n, + mtimeNs: 0n, + ctimeNs: 0n, + }); + return { ...actual, statSync }; + }); try { - for (const fixturePath of fixturePaths) await probeMediaProfile(fixturePath); - await probeMediaProfile(fixturePaths[0]!); + const { probeMediaProfile } = await import("./ffprobe.js"); + const [first, second, ...rest] = paths; + for (const filePath of paths.slice(0, 128)) await probeMediaProfile(filePath); + // A hit refreshes the entry, so the 129th insert evicts `second`, not `first`. + await probeMediaProfile(first!); + await probeMediaProfile(rest.at(-1)!); + expect(calls).toHaveLength(129); + await probeMediaProfile(first!); + expect(calls).toHaveLength(129); + await probeMediaProfile(second!); expect(calls).toHaveLength(130); } finally { - rmSync(fixtureDir, { recursive: true, force: true }); + vi.doUnmock("fs"); } }); diff --git a/packages/lint/src/utils.test.ts b/packages/lint/src/utils.test.ts index a024e19bad..41663ff9d8 100644 --- a/packages/lint/src/utils.test.ts +++ b/packages/lint/src/utils.test.ts @@ -84,21 +84,34 @@ describe("stripJsStringLiterals", () => { }); describe("stripJsStringLiterals scaling", () => { - it("stays linear in slash-dense input", () => { - const time = (n: number) => { + // Guards the quadratic backtracking this scanner was rewritten to avoid: + // deciding regex-versus-division by re-reading the accumulated output on + // every candidate slash. + // + // Measured in CPU time, not wall time: `process.cpuUsage` counts only work + // this process did, so time spent descheduled on a shared runner does not + // count. Only the ratio is asserted; an absolute millisecond bound is a + // claim about the hardware, and it is how this test failed on unrelated + // pull requests. Inputs stay well under 100k characters: above that the + // output string's growth adds its own cost and 8x input measures ~20x even + // for the linear scan. Inside that range 8x input measures ~8x; a quadratic + // scan measures 60x or more. + it("stays linear in slash-dense input", { timeout: 30_000 }, () => { + const cpuMs = (n: number) => { const src = "a=b/c;".repeat(n); + stripJsStringLiterals(src); // warm up before the first sample let best = Infinity; - for (let run = 0; run < 3; run += 1) { - const started = performance.now(); + for (let run = 0; run < 5; run += 1) { + const started = process.cpuUsage(); stripJsStringLiterals(src); - best = Math.min(best, performance.now() - started); + const spent = process.cpuUsage(started); + best = Math.min(best, (spent.user + spent.system) / 1000); } return best; }; - const small = Math.max(time(20_000), 0.5); - const large = time(160_000); + const small = cpuMs(10_000); + const large = cpuMs(80_000); expect(large / small).toBeLessThan(24); - expect(large).toBeLessThan(2_000); }); }); diff --git a/packages/producer/scripts/build-hf-early-stub.ts b/packages/producer/scripts/build-hf-early-stub.ts index 46f932cf9a..e675b763a5 100644 --- a/packages/producer/scripts/build-hf-early-stub.ts +++ b/packages/producer/scripts/build-hf-early-stub.ts @@ -10,18 +10,19 @@ * runtime. */ -import { mkdirSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { buildSync } from "esbuild"; -import { execSync } from "node:child_process"; +import { + formatGeneratedSource, + writeGeneratedFile, +} from "../../core/scripts/writeGeneratedFile.js"; const thisDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(thisDir, ".."); const stubEntry = resolve(repoRoot, "stubs/hf-early-stub.ts"); -const generatedDir = resolve(repoRoot, "src/generated"); -const outPath = resolve(generatedDir, "hf-early-stub-inline.ts"); +const outPath = resolve(repoRoot, "src/generated/hf-early-stub-inline.ts"); // ── Compile the stub to a self-contained IIFE ───────────────────────────────── const result = buildSync({ @@ -41,36 +42,24 @@ if (!iife) { throw new Error("esbuild produced no output for hf-early-stub.ts"); } -// ── Write the generated module ──────────────────────────────────────────────── -mkdirSync(generatedDir, { recursive: true }); - +// ── Publish the generated module ────────────────────────────────────────────── const escaped = JSON.stringify(iife); -writeFileSync( - outPath, - [ - "// AUTO-GENERATED by scripts/build-hf-early-stub.ts — do not edit", - `const HF_EARLY_STUB_IIFE: string = ${escaped};`, - "", - "/**", - " * Returns the pre-built HyperFrames early stub IIFE as a string constant.", - " * Inject into before any other scripts so the GSAP batching", - " * interceptor is in place when user composition scripts run.", - " */", - "export function getHfEarlyStub(): string {", - " return HF_EARLY_STUB_IIFE;", - "}", - "", - ].join("\n"), - "utf8", -); +const source = [ + "// AUTO-GENERATED by scripts/build-hf-early-stub.ts — do not edit", + `const HF_EARLY_STUB_IIFE: string = ${escaped};`, + "", + "/**", + " * Returns the pre-built HyperFrames early stub IIFE as a string constant.", + " * Inject into before any other scripts so the GSAP batching", + " * interceptor is in place when user composition scripts run.", + " */", + "export function getHfEarlyStub(): string {", + " return HF_EARLY_STUB_IIFE;", + "}", + "", +].join("\n"); -// Format the generated file so `oxfmt --check` passes in CI. -// Errors are intentionally swallowed — oxfmt unavailable in some envs. -try { - execSync(`bunx oxfmt ${outPath}`, { stdio: "ignore" }); -} catch { - // not fatal -} +writeGeneratedFile(outPath, formatGeneratedSource(source, outPath)); console.log( JSON.stringify({