fix(core,cli,ci): harden runtime resolution + inline constant + smoke test (#458)

Guard buildHyperframesRuntimeScript() against missing entry.ts so it
returns null instead of crashing with esbuild stderr output. Add
getHyperframeRuntimeScript() that returns the pre-built IIFE as a
baked-in string constant — no esbuild, no file I/O, no import.meta.url.

Consolidate CLI runtime source resolution into a single module with
a clear priority chain: esbuild from source (dev) → inlined constant
(production) → pre-built artifact file (fallback).

Add CI smoke test that npm-packs the CLI, installs globally, runs
hyperframes preview, and asserts no stderr errors + runtime endpoint
returns JS.

Bump version to 0.4.16.
This commit is contained in:
Miguel Ángel
2026-04-23 21:44:26 +02:00
committed by GitHub
parent 34db66ef0a
commit 072814e65c
17 changed files with 158 additions and 27 deletions
+73
View File
@@ -36,6 +36,7 @@ jobs:
- "bun.lock"
- "tsconfig*.json"
- "Dockerfile*"
- ".github/workflows/**"
build:
name: Build
@@ -119,6 +120,7 @@ jobs:
with:
node-version: 22
- run: bun install --frozen-lockfile
- run: bun run --cwd packages/core build:hyperframes-runtime
- run: bun run --filter '!@hyperframes/producer' test
test-runtime-contract:
@@ -138,6 +140,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'
+1
View File
@@ -58,6 +58,7 @@ tmp/
.tmp/
# Generated files
packages/core/src/generated/
packages/producer/src/services/fontData.generated.ts
# Test artifacts
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/cli",
"version": "0.4.15",
"version": "0.4.16",
"description": "HyperFrames CLI — create, preview, and render HTML video compositions",
"repository": {
"type": "git",
+21 -16
View File
@@ -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<string | null> {
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<string | null> {
if (source) return source;
}
} catch {
// esbuild failed — fall through to artifact
// esbuild failed — fall through to inlined / artifact
}
return null;
}
// ── Strategy 2-4: pre-built IIFE artifact ──────────────────────────────────
// ── Strategy 2: inlined constant from core build ──────────────────────────
async function getInlinedRuntime(): Promise<string | null> {
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 3: pre-built IIFE artifact ───────────────────────────────────
function readPrebuiltArtifact(): string | null {
return readFromDir(__dirname) ?? readFromCoreDistDir() ?? readFromNodeModules();
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/core",
"version": "0.4.15",
"version": "0.4.16",
"description": "",
"repository": {
"type": "git",
@@ -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",
@@ -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,
@@ -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");
@@ -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",
+1
View File
@@ -139,6 +139,7 @@ export {
HYPERFRAME_CONTROL_ACTIONS,
type HyperframeControlAction,
} from "./inline-scripts/runtimeContract";
export { getHyperframeRuntimeScript } from "./generated/runtime-inline";
export {
buildHyperframesRuntimeScript,
type HyperframesRuntimeBuildOptions,
@@ -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();
}
@@ -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,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/engine",
"version": "0.4.15",
"version": "0.4.16",
"description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
"repository": {
"type": "git",
@@ -33,6 +33,10 @@ async function main() {
}
const runtimeSource = buildHyperframesRuntimeScript({ minify: false });
assert(
runtimeSource !== null,
"buildHyperframesRuntimeScript returned null — entry.ts not found",
);
const html = `<!DOCTYPE html>
<html><head>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/player",
"version": "0.4.15",
"version": "0.4.16",
"description": "Embeddable web component for HyperFrames compositions",
"repository": {
"type": "git",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/producer",
"version": "0.4.15",
"version": "0.4.16",
"description": "HTML-to-video rendering engine using Chrome's BeginFrame API",
"repository": {
"type": "git",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/shader-transitions",
"version": "0.4.15",
"version": "0.4.16",
"description": "WebGL shader transitions for HyperFrames compositions",
"repository": {
"type": "git",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/studio",
"version": "0.4.15",
"version": "0.4.16",
"description": "",
"repository": {
"type": "git",