mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
@@ -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'
|
||||
|
||||
@@ -58,6 +58,7 @@ tmp/
|
||||
.tmp/
|
||||
|
||||
# Generated files
|
||||
packages/core/src/generated/
|
||||
packages/producer/src/services/fontData.generated.ts
|
||||
|
||||
# Test artifacts
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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,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,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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@hyperframes/studio",
|
||||
"version": "0.4.15",
|
||||
"version": "0.4.16",
|
||||
"description": "",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Reference in New Issue
Block a user