Skip to content

Commit 072814e

Browse files
fix(core,cli,ci): harden runtime resolution + inline constant + smoke test (heygen-com#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.
1 parent 34db66e commit 072814e

17 files changed

Lines changed: 158 additions & 27 deletions

.github/workflows/ci.yml

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ jobs:
3636
- "bun.lock"
3737
- "tsconfig*.json"
3838
- "Dockerfile*"
39+
- ".github/workflows/**"
3940
4041
build:
4142
name: Build
@@ -119,6 +120,7 @@ jobs:
119120
with:
120121
node-version: 22
121122
- run: bun install --frozen-lockfile
123+
- run: bun run --cwd packages/core build:hyperframes-runtime
122124
- run: bun run --filter '!@hyperframes/producer' test
123125

124126
test-runtime-contract:
@@ -138,6 +140,77 @@ jobs:
138140
- run: bun install --frozen-lockfile
139141
- run: bun run --filter @hyperframes/core test:hyperframe-runtime-ci
140142

143+
smoke-global-install:
144+
name: "Smoke: global install"
145+
needs: [changes, build]
146+
if: needs.changes.outputs.code == 'true'
147+
runs-on: ubuntu-latest
148+
timeout-minutes: 10
149+
steps:
150+
- uses: actions/checkout@v4
151+
with:
152+
lfs: true
153+
- uses: oven-sh/setup-bun@v2
154+
- uses: actions/setup-node@v4
155+
with:
156+
node-version: 22
157+
- run: bun install --frozen-lockfile
158+
- run: bun run build
159+
160+
# Pack the CLI as a tarball (simulates what `npm publish` produces)
161+
- name: Pack CLI tarball
162+
run: cd packages/cli && npm pack
163+
164+
# Install globally using --prefix to avoid sudo
165+
- name: Install globally via npm
166+
run: npm install -g --prefix /tmp/hf-smoke ./packages/cli/hyperframes-cli-*.tgz
167+
168+
# Scaffold a blank project
169+
- name: Init blank project
170+
run: |
171+
export PATH="/tmp/hf-smoke/bin:$PATH"
172+
mkdir /tmp/hf-project && cd /tmp/hf-project
173+
hyperframes init test-project --example blank
174+
175+
# Start preview, probe the runtime endpoint, assert no esbuild errors
176+
- name: Smoke-test preview server
177+
run: |
178+
export PATH="/tmp/hf-smoke/bin:$PATH"
179+
cd /tmp/hf-project/test-project
180+
181+
# Start the preview server in the background; capture stderr
182+
CI=true hyperframes preview --port 3099 2>/tmp/hf-stderr.log &
183+
SERVER_PID=$!
184+
185+
# Wait for the server to be ready (up to 15 s)
186+
for i in $(seq 1 30); do
187+
if curl -sf http://localhost:3099/ >/dev/null 2>&1; then
188+
break
189+
fi
190+
sleep 0.5
191+
done
192+
193+
# Probe the runtime JS endpoint
194+
BODY=$(curl -sf http://localhost:3099/api/runtime.js | head -c 200 || true)
195+
if [ -z "$BODY" ]; then
196+
echo "FAIL: /api/runtime.js returned empty response"
197+
kill $SERVER_PID 2>/dev/null || true
198+
cat /tmp/hf-stderr.log
199+
exit 1
200+
fi
201+
202+
kill $SERVER_PID 2>/dev/null || true
203+
wait $SERVER_PID 2>/dev/null || true
204+
205+
# Assert stderr does not contain esbuild / runtime load errors
206+
if grep -qE '✘ \[ERROR\]|Failed to load runtime' /tmp/hf-stderr.log; then
207+
echo "FAIL: preview emitted runtime errors:"
208+
cat /tmp/hf-stderr.log
209+
exit 1
210+
fi
211+
212+
echo "PASS: global install smoke test succeeded"
213+
141214
semantic-pr-title:
142215
name: Semantic PR title
143216
if: github.event_name == 'pull_request'

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ tmp/
5858
.tmp/
5959

6060
# Generated files
61+
packages/core/src/generated/
6162
packages/producer/src/services/fontData.generated.ts
6263

6364
# Test artifacts

packages/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@hyperframes/cli",
3-
"version": "0.4.15",
3+
"version": "0.4.16",
44
"description": "HyperFrames CLI — create, preview, and render HTML video compositions",
55
"repository": {
66
"type": "git",

packages/cli/src/server/runtimeSource.ts

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,14 @@ const ARTIFACT_NAMES = ["hyperframe-runtime.js", "hyperframe.runtime.iife.js"];
66
/**
77
* Resolve the runtime JS source for the studio preview server.
88
*
9-
* Two contexts exist:
9+
* Three resolution strategies, in priority order:
1010
*
11-
* Dev (monorepo workspace) — `entry.ts` exists next to `@hyperframes/core`
12-
* source. We build from source via esbuild so edits to the runtime are
13-
* reflected without a manual `bun run build`.
14-
*
15-
* Installed (npm global / npx) — only `dist/` ships. We read the pre-built
16-
* IIFE artifact that `build:runtime` copies alongside `cli.js`.
17-
*
18-
* The priority chain:
19-
* 1. esbuild from source (dev only — gated on entry.ts existence)
20-
* 2. pre-built artifact (alongside cli.js in dist/)
21-
* 3. core/dist artifact (dev fallback if build:runtime already ran)
22-
* 4. node_modules walk (nested install edge cases)
11+
* 1. esbuild from source (dev only — gated on entry.ts existence)
12+
* 2. Inlined constant (production — baked into @hyperframes/core at build time)
13+
* 3. Pre-built artifact (fallback — reads IIFE file from dist/)
2314
*/
2415
export async function loadRuntimeSource(): Promise<string | null> {
25-
return (await buildFromSource()) ?? readPrebuiltArtifact();
16+
return (await buildFromSource()) ?? (await getInlinedRuntime()) ?? readPrebuiltArtifact();
2617
}
2718

2819
// ── Strategy 1: live build from source (dev only) ──────────────────────────
@@ -38,12 +29,26 @@ async function buildFromSource(): Promise<string | null> {
3829
if (source) return source;
3930
}
4031
} catch {
41-
// esbuild failed — fall through to artifact
32+
// esbuild failed — fall through to inlined / artifact
33+
}
34+
return null;
35+
}
36+
37+
// ── Strategy 2: inlined constant from core build ──────────────────────────
38+
39+
async function getInlinedRuntime(): Promise<string | null> {
40+
try {
41+
const mod = await import("@hyperframes/core");
42+
if (typeof mod.getHyperframeRuntimeScript === "function") {
43+
return mod.getHyperframeRuntimeScript() ?? null;
44+
}
45+
} catch {
46+
// Not available — fall through to artifact
4247
}
4348
return null;
4449
}
4550

46-
// ── Strategy 2-4: pre-built IIFE artifact ──────────────────────────────────
51+
// ── Strategy 3: pre-built IIFE artifact ──────────────────────────────────
4752

4853
function readPrebuiltArtifact(): string | null {
4954
return readFromDir(__dirname) ?? readFromCoreDistDir() ?? readFromNodeModules();

packages/core/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@hyperframes/core",
3-
"version": "0.4.15",
3+
"version": "0.4.16",
44
"description": "",
55
"repository": {
66
"type": "git",
@@ -80,7 +80,7 @@
8080
"types": "./dist/index.d.ts"
8181
},
8282
"scripts": {
83-
"build": "tsc && bun run build:hyperframes-runtime",
83+
"build": "bun run build:hyperframes-runtime && tsc",
8484
"test": "vitest run",
8585
"test:watch": "vitest",
8686
"test:coverage": "vitest run --coverage",

packages/core/scripts/build-hyperframes-runtime-artifact.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ const iifePath = resolve(distDir, HYPERFRAME_RUNTIME_ARTIFACTS.iife);
1515
const esmPath = resolve(distDir, HYPERFRAME_RUNTIME_ARTIFACTS.esm);
1616
const manifestPath = resolve(distDir, HYPERFRAME_RUNTIME_ARTIFACTS.manifest);
1717

18-
const runtimeSource = `${loadHyperframeRuntimeSource()}\n`;
18+
const runtimeSourceRaw = loadHyperframeRuntimeSource();
19+
if (runtimeSourceRaw === null) {
20+
throw new Error("Cannot build runtime artifact: entry.ts not found at expected path");
21+
}
22+
const runtimeSource = `${runtimeSourceRaw}\n`;
1923
const runtimeSha256 = createHash("sha256").update(runtimeSource, "utf8").digest("hex");
2024
const buildId = process.env.HYPERFRAME_RUNTIME_BUILD_ID?.trim() || "dev";
2125
const runtimeEntryPath = resolve(thisDir, "../src/runtime/entry.ts");
@@ -47,6 +51,33 @@ writeFileSync(iifePath, runtimeSource, "utf8");
4751
writeFileSync(esmPath, esmSource, "utf8");
4852
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
4953

54+
// ── Generate src/generated/runtime-inline.ts ──────────────────────────────
55+
// This file is compiled by tsc into dist/ and provides the production-safe
56+
// getHyperframeRuntimeScript() that returns the IIFE as a string constant —
57+
// no esbuild, no file I/O, no import.meta.url arithmetic.
58+
const generatedDir = resolve(thisDir, "../src/generated");
59+
mkdirSync(generatedDir, { recursive: true });
60+
const inlineModulePath = resolve(generatedDir, "runtime-inline.ts");
61+
const escapedSource = JSON.stringify(runtimeSourceRaw);
62+
writeFileSync(
63+
inlineModulePath,
64+
[
65+
"// AUTO-GENERATED by scripts/build-hyperframes-runtime-artifact.ts — do not edit",
66+
`const RUNTIME_IIFE: string = ${escapedSource};`,
67+
"",
68+
"/**",
69+
" * Returns the pre-built hyperframe runtime IIFE as a string constant.",
70+
" * This is the production-safe path: no esbuild, no file I/O,",
71+
" * no import.meta.url arithmetic.",
72+
" */",
73+
"export function getHyperframeRuntimeScript(): string {",
74+
" return RUNTIME_IIFE;",
75+
"}",
76+
"",
77+
].join("\n"),
78+
"utf8",
79+
);
80+
5081
console.log(
5182
JSON.stringify({
5283
event: "hyperframe_runtime_artifacts_generated",
@@ -55,6 +86,7 @@ console.log(
5586
iifePath,
5687
esmPath,
5788
manifestPath,
89+
inlineModulePath,
5890
sourceBytes: Buffer.byteLength(runtimeSource, "utf8"),
5991
esmBytes: Buffer.byteLength(esmSource, "utf8"),
6092
sha256: runtimeSha256,

packages/core/scripts/test-hyperframe-runtime-behavior.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,15 @@ function assert(condition: unknown, message: string): void {
77
}
88

99
const baseline = buildHyperframesRuntimeScript();
10+
assert(baseline !== null, "buildHyperframesRuntimeScript() returned null — entry.ts not found");
1011
const parityEnabled = buildHyperframesRuntimeScript({ defaultParityMode: true });
12+
assert(parityEnabled !== null, "Parity-enabled build returned null");
1113
const parityDisabled = buildHyperframesRuntimeScript({ defaultParityMode: false });
14+
assert(parityDisabled !== null, "Parity-disabled build returned null");
1215
const withSourceUrl = buildHyperframesRuntimeScript({
1316
sourceUrl: "hyperframe.runtime.iife.js",
1417
});
18+
assert(withSourceUrl !== null, "Build with sourceUrl returned null");
1519

1620
assert(baseline.includes("window.__player"), "Baseline runtime should include player contract");
1721
assert(parityEnabled.length > 0, "Parity-enabled build should produce non-empty runtime source");

packages/core/scripts/test-hyperframe-runtime-contract.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ function assert(condition: unknown, message: string): void {
1010
}
1111

1212
const runtimeSource = loadHyperframeRuntimeSource();
13+
assert(runtimeSource !== null, "loadHyperframeRuntimeSource() returned null — entry.ts not found");
1314

1415
const requiredSnippets = [
1516
"window.__player",

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ export {
139139
HYPERFRAME_CONTROL_ACTIONS,
140140
type HyperframeControlAction,
141141
} from "./inline-scripts/runtimeContract";
142+
export { getHyperframeRuntimeScript } from "./generated/runtime-inline";
142143
export {
143144
buildHyperframesRuntimeScript,
144145
type HyperframesRuntimeBuildOptions,

packages/core/src/inline-scripts/hyperframe.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,6 @@ export const HYPERFRAME_RUNTIME_CONTRACT: HyperframeRuntimeContract = {
1717
messageSources: HYPERFRAME_BRIDGE_SOURCES,
1818
};
1919

20-
export function loadHyperframeRuntimeSource(): string {
20+
export function loadHyperframeRuntimeSource(): string | null {
2121
return buildHyperframesRuntimeScript();
2222
}

0 commit comments

Comments
 (0)