diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 98e770466..b41efb1e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,7 @@ jobs: - "bun.lock" - "tsconfig*.json" - "Dockerfile*" + - ".github/workflows/**" build: name: Build @@ -138,6 +139,77 @@ jobs: - run: bun install --frozen-lockfile - run: bun run --filter @hyperframes/core test:hyperframe-runtime-ci + smoke-global-install: + name: "Smoke: global install" + needs: [changes, build] + if: needs.changes.outputs.code == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + lfs: true + - uses: oven-sh/setup-bun@v2 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: bun install --frozen-lockfile + - run: bun run build + + # Pack the CLI as a tarball (simulates what `npm publish` produces) + - name: Pack CLI tarball + run: cd packages/cli && npm pack + + # Install globally using --prefix to avoid sudo + - name: Install globally via npm + run: npm install -g --prefix /tmp/hf-smoke ./packages/cli/hyperframes-cli-*.tgz + + # Scaffold a blank project + - name: Init blank project + run: | + export PATH="/tmp/hf-smoke/bin:$PATH" + mkdir /tmp/hf-project && cd /tmp/hf-project + hyperframes init test-project --example blank + + # Start preview, probe the runtime endpoint, assert no esbuild errors + - name: Smoke-test preview server + run: | + export PATH="/tmp/hf-smoke/bin:$PATH" + cd /tmp/hf-project/test-project + + # Start the preview server in the background; capture stderr + CI=true hyperframes preview --port 3099 2>/tmp/hf-stderr.log & + SERVER_PID=$! + + # Wait for the server to be ready (up to 15 s) + for i in $(seq 1 30); do + if curl -sf http://localhost:3099/ >/dev/null 2>&1; then + break + fi + sleep 0.5 + done + + # Probe the runtime JS endpoint + BODY=$(curl -sf http://localhost:3099/api/runtime.js | head -c 200 || true) + if [ -z "$BODY" ]; then + echo "FAIL: /api/runtime.js returned empty response" + kill $SERVER_PID 2>/dev/null || true + cat /tmp/hf-stderr.log + exit 1 + fi + + kill $SERVER_PID 2>/dev/null || true + wait $SERVER_PID 2>/dev/null || true + + # Assert stderr does not contain esbuild / runtime load errors + if grep -qE '✘ \[ERROR\]|Failed to load runtime' /tmp/hf-stderr.log; then + echo "FAIL: preview emitted runtime errors:" + cat /tmp/hf-stderr.log + exit 1 + fi + + echo "PASS: global install smoke test succeeded" + semantic-pr-title: name: Semantic PR title if: github.event_name == 'pull_request' diff --git a/.gitignore b/.gitignore index 5f127cdb1..b23d83d47 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,7 @@ tmp/ .tmp/ # Generated files +packages/core/src/generated/ packages/producer/src/services/fontData.generated.ts # Test artifacts diff --git a/packages/cli/src/server/runtimeSource.ts b/packages/cli/src/server/runtimeSource.ts index 7bc74ed17..258dbd997 100644 --- a/packages/cli/src/server/runtimeSource.ts +++ b/packages/cli/src/server/runtimeSource.ts @@ -6,23 +6,14 @@ const ARTIFACT_NAMES = ["hyperframe-runtime.js", "hyperframe.runtime.iife.js"]; /** * Resolve the runtime JS source for the studio preview server. * - * Two contexts exist: + * Three resolution strategies, in priority order: * - * Dev (monorepo workspace) — `entry.ts` exists next to `@hyperframes/core` - * source. We build from source via esbuild so edits to the runtime are - * reflected without a manual `bun run build`. - * - * Installed (npm global / npx) — only `dist/` ships. We read the pre-built - * IIFE artifact that `build:runtime` copies alongside `cli.js`. - * - * The priority chain: - * 1. esbuild from source (dev only — gated on entry.ts existence) - * 2. pre-built artifact (alongside cli.js in dist/) - * 3. core/dist artifact (dev fallback if build:runtime already ran) - * 4. node_modules walk (nested install edge cases) + * 1. esbuild from source (dev only — gated on entry.ts existence) + * 2. Inlined constant (production — baked into @hyperframes/core at build time) + * 3. Pre-built artifact (fallback — reads IIFE file from dist/) */ export async function loadRuntimeSource(): Promise { - return (await buildFromSource()) ?? readPrebuiltArtifact(); + return (await buildFromSource()) ?? (await getInlinedRuntime()) ?? readPrebuiltArtifact(); } // ── Strategy 1: live build from source (dev only) ────────────────────────── @@ -38,12 +29,26 @@ async function buildFromSource(): Promise { if (source) return source; } } catch { - // esbuild failed — fall through to artifact + // esbuild failed — fall through to inlined / artifact + } + return null; +} + +// ── Strategy 2: inlined constant from core build ────────────────────────── + +async function getInlinedRuntime(): Promise { + try { + const mod = await import("@hyperframes/core"); + if (typeof mod.getHyperframeRuntimeScript === "function") { + return mod.getHyperframeRuntimeScript() ?? null; + } + } catch { + // Not available — fall through to artifact } return null; } -// ── Strategy 2-4: pre-built IIFE artifact ────────────────────────────────── +// ── Strategy 3: pre-built IIFE artifact ─────────────────────────────────── function readPrebuiltArtifact(): string | null { return readFromDir(__dirname) ?? readFromCoreDistDir() ?? readFromNodeModules(); diff --git a/packages/core/package.json b/packages/core/package.json index 7ef0bed3a..4d408afc2 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -80,7 +80,7 @@ "types": "./dist/index.d.ts" }, "scripts": { - "build": "tsc && bun run build:hyperframes-runtime", + "build": "bun run build:hyperframes-runtime && tsc", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", diff --git a/packages/core/scripts/build-hyperframes-runtime-artifact.ts b/packages/core/scripts/build-hyperframes-runtime-artifact.ts index bf556cfc1..3042e1770 100644 --- a/packages/core/scripts/build-hyperframes-runtime-artifact.ts +++ b/packages/core/scripts/build-hyperframes-runtime-artifact.ts @@ -15,7 +15,11 @@ const iifePath = resolve(distDir, HYPERFRAME_RUNTIME_ARTIFACTS.iife); const esmPath = resolve(distDir, HYPERFRAME_RUNTIME_ARTIFACTS.esm); const manifestPath = resolve(distDir, HYPERFRAME_RUNTIME_ARTIFACTS.manifest); -const runtimeSource = `${loadHyperframeRuntimeSource()}\n`; +const runtimeSourceRaw = loadHyperframeRuntimeSource(); +if (runtimeSourceRaw === null) { + throw new Error("Cannot build runtime artifact: entry.ts not found at expected path"); +} +const runtimeSource = `${runtimeSourceRaw}\n`; const runtimeSha256 = createHash("sha256").update(runtimeSource, "utf8").digest("hex"); const buildId = process.env.HYPERFRAME_RUNTIME_BUILD_ID?.trim() || "dev"; const runtimeEntryPath = resolve(thisDir, "../src/runtime/entry.ts"); @@ -47,6 +51,33 @@ writeFileSync(iifePath, runtimeSource, "utf8"); writeFileSync(esmPath, esmSource, "utf8"); writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); +// ── 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 escapedSource = JSON.stringify(runtimeSourceRaw); +writeFileSync( + inlineModulePath, + [ + "// AUTO-GENERATED by scripts/build-hyperframes-runtime-artifact.ts — do not edit", + `const RUNTIME_IIFE: string = ${escapedSource};`, + "", + "/**", + " * Returns the pre-built hyperframe runtime IIFE as a string constant.", + " * This is the production-safe path: no esbuild, no file I/O,", + " * no import.meta.url arithmetic.", + " */", + "export function getHyperframeRuntimeScript(): string {", + " return RUNTIME_IIFE;", + "}", + "", + ].join("\n"), + "utf8", +); + console.log( JSON.stringify({ event: "hyperframe_runtime_artifacts_generated", @@ -55,6 +86,7 @@ console.log( iifePath, esmPath, manifestPath, + inlineModulePath, sourceBytes: Buffer.byteLength(runtimeSource, "utf8"), esmBytes: Buffer.byteLength(esmSource, "utf8"), sha256: runtimeSha256, diff --git a/packages/core/scripts/test-hyperframe-runtime-behavior.ts b/packages/core/scripts/test-hyperframe-runtime-behavior.ts index 0ac5325f9..fa02f0c81 100644 --- a/packages/core/scripts/test-hyperframe-runtime-behavior.ts +++ b/packages/core/scripts/test-hyperframe-runtime-behavior.ts @@ -7,11 +7,15 @@ function assert(condition: unknown, message: string): void { } const baseline = buildHyperframesRuntimeScript(); +assert(baseline !== null, "buildHyperframesRuntimeScript() returned null — entry.ts not found"); const parityEnabled = buildHyperframesRuntimeScript({ defaultParityMode: true }); +assert(parityEnabled !== null, "Parity-enabled build returned null"); const parityDisabled = buildHyperframesRuntimeScript({ defaultParityMode: false }); +assert(parityDisabled !== null, "Parity-disabled build returned null"); const withSourceUrl = buildHyperframesRuntimeScript({ sourceUrl: "hyperframe.runtime.iife.js", }); +assert(withSourceUrl !== null, "Build with sourceUrl returned null"); assert(baseline.includes("window.__player"), "Baseline runtime should include player contract"); assert(parityEnabled.length > 0, "Parity-enabled build should produce non-empty runtime source"); diff --git a/packages/core/scripts/test-hyperframe-runtime-contract.ts b/packages/core/scripts/test-hyperframe-runtime-contract.ts index e0c1cfb16..699dafb37 100644 --- a/packages/core/scripts/test-hyperframe-runtime-contract.ts +++ b/packages/core/scripts/test-hyperframe-runtime-contract.ts @@ -10,6 +10,7 @@ function assert(condition: unknown, message: string): void { } const runtimeSource = loadHyperframeRuntimeSource(); +assert(runtimeSource !== null, "loadHyperframeRuntimeSource() returned null — entry.ts not found"); const requiredSnippets = [ "window.__player", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 490cafc4e..eef7d3563 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -139,6 +139,7 @@ export { HYPERFRAME_CONTROL_ACTIONS, type HyperframeControlAction, } from "./inline-scripts/runtimeContract"; +export { getHyperframeRuntimeScript } from "./generated/runtime-inline"; export { buildHyperframesRuntimeScript, type HyperframesRuntimeBuildOptions, diff --git a/packages/core/src/inline-scripts/hyperframe.ts b/packages/core/src/inline-scripts/hyperframe.ts index 698783339..9f2c2c311 100644 --- a/packages/core/src/inline-scripts/hyperframe.ts +++ b/packages/core/src/inline-scripts/hyperframe.ts @@ -17,6 +17,6 @@ export const HYPERFRAME_RUNTIME_CONTRACT: HyperframeRuntimeContract = { messageSources: HYPERFRAME_BRIDGE_SOURCES, }; -export function loadHyperframeRuntimeSource(): string { +export function loadHyperframeRuntimeSource(): string | null { return buildHyperframesRuntimeScript(); } diff --git a/packages/core/src/inline-scripts/hyperframesRuntime.engine.ts b/packages/core/src/inline-scripts/hyperframesRuntime.engine.ts index 1d1e94cfc..a4335f547 100644 --- a/packages/core/src/inline-scripts/hyperframesRuntime.engine.ts +++ b/packages/core/src/inline-scripts/hyperframesRuntime.engine.ts @@ -1,4 +1,5 @@ import { buildSync } from "esbuild"; +import { existsSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -17,10 +18,19 @@ function applyDefaultParityMode(script: string, enabled: boolean): string { ); } +/** + * Build the runtime IIFE from source via esbuild. + * + * Returns `null` when `entry.ts` does not exist at the resolved path — + * this happens in bundled / published contexts where only `dist/` ships. + * Callers must fall back to the pre-built artifact or the inlined constant. + */ export function buildHyperframesRuntimeScript( options: HyperframesRuntimeBuildOptions = {}, -): string { +): string | null { const entryPath = resolve(dirname(fileURLToPath(import.meta.url)), "../runtime/entry.ts"); + if (!existsSync(entryPath)) return null; + const result = buildSync({ entryPoints: [entryPath], bundle: true, diff --git a/packages/engine/scripts/test-fitTextFontSize-browser.ts b/packages/engine/scripts/test-fitTextFontSize-browser.ts index 0211ffcc0..c14935464 100644 --- a/packages/engine/scripts/test-fitTextFontSize-browser.ts +++ b/packages/engine/scripts/test-fitTextFontSize-browser.ts @@ -33,6 +33,10 @@ async function main() { } const runtimeSource = buildHyperframesRuntimeScript({ minify: false }); + assert( + runtimeSource !== null, + "buildHyperframesRuntimeScript returned null — entry.ts not found", + ); const html = `