Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ jobs:
- "bun.lock"
- "tsconfig*.json"
- "Dockerfile*"
- ".github/workflows/**"

build:
name: Build
Expand Down Expand Up @@ -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'
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ tmp/
.tmp/

# Generated files
packages/core/src/generated/
packages/producer/src/services/fontData.generated.ts

# Test artifacts
Expand Down
37 changes: 21 additions & 16 deletions packages/cli/src/server/runtimeSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ──────────────────────────
Expand All @@ -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: 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 2-4: pre-built IIFE artifact ──────────────────────────────────
// ── Strategy 3: pre-built IIFE artifact ──────────────────────────────────

function readPrebuiltArtifact(): string | null {
return readFromDir(__dirname) ?? readFromCoreDistDir() ?? readFromNodeModules();
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
34 changes: 33 additions & 1 deletion packages/core/scripts/build-hyperframes-runtime-artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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",
Expand All @@ -55,6 +86,7 @@ console.log(
iifePath,
esmPath,
manifestPath,
inlineModulePath,
sourceBytes: Buffer.byteLength(runtimeSource, "utf8"),
esmBytes: Buffer.byteLength(esmSource, "utf8"),
sha256: runtimeSha256,
Expand Down
4 changes: 4 additions & 0 deletions packages/core/scripts/test-hyperframe-runtime-behavior.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
1 change: 1 addition & 0 deletions packages/core/scripts/test-hyperframe-runtime-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ export {
HYPERFRAME_CONTROL_ACTIONS,
type HyperframeControlAction,
} from "./inline-scripts/runtimeContract";
export { getHyperframeRuntimeScript } from "./generated/runtime-inline";
export {
buildHyperframesRuntimeScript,
type HyperframesRuntimeBuildOptions,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/inline-scripts/hyperframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
12 changes: 11 additions & 1 deletion packages/core/src/inline-scripts/hyperframesRuntime.engine.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { buildSync } from "esbuild";
import { existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";

Expand All @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions packages/engine/scripts/test-fitTextFontSize-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Loading