// Shared scaffolding for the lightweight composition servers used by `play` and
// `present`: locating the built runtime/player/slideshow bundles, serving
// composition asset files, and binding to a free port.
import { createReadStream, existsSync, statSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { Readable } from "node:stream";
import { fileURLToPath } from "node:url";
import { getMimeType } from "@hyperframes/studio-server";
import { injectTagsAtHeadStart } from "@hyperframes/core/compiler/html-document";
/**
* `window.__HF_MEDIA_CODEC_MAP__` injection + proxy pre-warm for HTML served
* by `play` and the static project server. Re-exported from the
* single shared implementation in
* `packages/studio-server/src/helpers/mediaProxyPreview.ts` (also used by the
* studio preview route) so injection behavior cannot drift between surfaces.
*/
export { injectMediaCodecMapIntoHtml as injectMediaCodecMap } from "@hyperframes/studio-server/media-proxy-preview";
/** Minimal surface of a listening server (satisfied by @hono/node-server's ServerType). */
interface PortBindable {
listen(port: number): unknown;
once(event: "listening" | "error", listener: (err?: NodeJS.ErrnoException) => void): unknown;
removeListener(
event: "listening" | "error",
listener: (err?: NodeJS.ErrnoException) => void,
): unknown;
}
function helperDir(): string {
// fileURLToPath (not URL.pathname) so the Windows "/D:/..." leading-slash form
// doesn't break the bundle-path resolution below.
return dirname(fileURLToPath(import.meta.url));
}
export function resolveRuntimePath(): string | null {
const d = helperDir();
const candidates = [
resolve(d, "hyperframe-runtime.js"),
resolve(d, "..", "hyperframe-runtime.js"),
// Monorepo dev: src/
/ → src/ → cli/ → packages/ then into core/dist/
resolve(d, "..", "..", "..", "core", "dist", "hyperframe.runtime.iife.js"),
];
return candidates.find((p) => existsSync(p)) ?? null;
}
export function resolvePlayerPath(): string | null {
const d = helperDir();
const candidates = [
resolve(d, "..", "..", "..", "player", "dist", "hyperframes-player.global.js"),
resolve(d, "hyperframes-player.global.js"),
resolve(d, "..", "hyperframes-player.global.js"),
];
return candidates.find((p) => existsSync(p)) ?? null;
}
export function resolveSlideshowPath(): string | null {
const d = helperDir();
const candidates = [
resolve(d, "..", "..", "..", "player", "dist", "slideshow", "hyperframes-slideshow.global.js"),
resolve(d, "hyperframes-slideshow.global.js"),
resolve(d, "..", "hyperframes-slideshow.global.js"),
];
return candidates.find((p) => existsSync(p)) ?? null;
}
/**
* Inject the runtime `);
}
export function assetContentType(filePath: string): string {
return getMimeType(filePath);
}
/**
* Hono-native Range/206 response for a file on disk, mirroring the inline
* Range logic in `packages/studio-server/src/routes/preview.ts`'s static
* asset route. `staticProjectServer.ts`'s raw-`node:http` counterpart is
* `serveFileWithRange`; this version converts Node's bounded file stream to a
* Fetch API stream for Hono without allocating the whole media/proxy file.
*/
export function buildRangeResponse(
filePath: string,
contentType: string,
rangeHeader: string | undefined,
): Response {
const size = statSync(filePath).size;
const last = size - 1;
const match = rangeHeader ? /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim()) : null;
const body = (start: number, end: number): ReadableStream | null =>
size === 0
? null
: (Readable.toWeb(createReadStream(filePath, { start, end })) as ReadableStream);
if (!match) {
return new Response(body(0, last), {
status: 200,
headers: {
"Content-Type": contentType,
"Accept-Ranges": "bytes",
"Content-Length": String(size),
},
});
}
const hasStart = match[1] !== "";
const start = hasStart ? Number(match[1]) : Math.max(0, size - Number(match[2]));
const end = !hasStart ? last : match[2] !== "" ? Math.min(Number(match[2]), last) : last;
if (start > end || start > last) {
return new Response(null, {
status: 416,
headers: { "Content-Range": `bytes */${size}`, "Accept-Ranges": "bytes" },
});
}
return new Response(body(start, end), {
status: 206,
headers: {
"Content-Type": contentType,
"Accept-Ranges": "bytes",
"Content-Range": `bytes ${start}-${end}/${size}`,
"Content-Length": String(end - start + 1),
},
});
}
/**
* Bind `server` to the first free port at or after `startPort` (scanning up to
* 10 ports). Returns the bound port. Rejects if all candidates are in use or on
* a non-EADDRINUSE error.
*/
export async function listenOnFreePort(server: PortBindable, startPort: number): Promise {
for (let attempt = 0; attempt < 10; attempt++) {
const port = startPort + attempt;
try {
await new Promise((res, rej) => {
const onErr = (err?: NodeJS.ErrnoException) => {
server.removeListener("listening", onOk);
rej(err ?? new Error("server error"));
};
const onOk = () => {
server.removeListener("error", onErr);
res();
};
server.once("error", onErr);
server.once("listening", onOk);
server.listen(port);
});
return port;
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === "EADDRINUSE") continue;
throw err;
}
}
throw new Error(`No free port found in [${startPort}, ${startPort + 9}]`);
}