mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-08-31 02:41:44 +00:00
fix(cloudrun): publish adapter contract
This commit is contained in:
@@ -8,7 +8,11 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildRenderConfig } from "./cloudrun.js";
|
||||
import {
|
||||
buildRenderConfig,
|
||||
isMissingCloudRunAdapterError,
|
||||
missingCloudRunAdapterMessage,
|
||||
} from "./cloudrun.js";
|
||||
|
||||
describe("cloudrun wire config — aspect-agnostic threading", () => {
|
||||
const baseArgs: Record<string, unknown> = {
|
||||
@@ -64,3 +68,25 @@ describe("cloudrun wire config — aspect-agnostic threading", () => {
|
||||
).toThrow(/\[cloudrun render\]/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cloud Run adapter preflight", () => {
|
||||
it("identifies only the missing adapter, not a missing transitive dependency", () => {
|
||||
const adapterError = Object.assign(
|
||||
new Error("Cannot find package '@hyperframes/gcp-cloud-run' imported from cli.js"),
|
||||
{ code: "ERR_MODULE_NOT_FOUND" },
|
||||
);
|
||||
const transitiveError = Object.assign(new Error("Cannot find package 'google-auth-library'"), {
|
||||
code: "ERR_MODULE_NOT_FOUND",
|
||||
});
|
||||
|
||||
expect(isMissingCloudRunAdapterError(adapterError)).toBe(true);
|
||||
expect(isMissingCloudRunAdapterError(transitiveError)).toBe(false);
|
||||
});
|
||||
|
||||
it("provides global and project-local install recovery commands", () => {
|
||||
const message = missingCloudRunAdapterMessage("deploy");
|
||||
expect(message).toContain("hyperframes cloudrun deploy");
|
||||
expect(message).toContain("npm install -g @hyperframes/gcp-cloud-run");
|
||||
expect(message).toContain("npm install @hyperframes/gcp-cloud-run");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,10 +13,9 @@
|
||||
*/
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { join, resolve } from "node:path";
|
||||
import { defineCommand } from "citty";
|
||||
import { type CanvasResolution } from "@hyperframes/core";
|
||||
import { parseOutputResolutionFlag } from "../utils/parseOutputResolution.js";
|
||||
@@ -86,6 +85,35 @@ interface StackState {
|
||||
workflowId: string;
|
||||
}
|
||||
|
||||
type CloudRunAdapter = typeof import("@hyperframes/gcp-cloud-run/sdk") &
|
||||
typeof import("@hyperframes/gcp-cloud-run/terraform");
|
||||
let cloudRunAdapterPromise: Promise<CloudRunAdapter> | undefined;
|
||||
|
||||
function loadCloudRunAdapter(): Promise<CloudRunAdapter> {
|
||||
cloudRunAdapterPromise ??= Promise.all([
|
||||
import("@hyperframes/gcp-cloud-run/sdk"),
|
||||
import("@hyperframes/gcp-cloud-run/terraform"),
|
||||
]).then(([sdk, terraform]) => ({ ...sdk, ...terraform }));
|
||||
return cloudRunAdapterPromise;
|
||||
}
|
||||
|
||||
export function isMissingCloudRunAdapterError(error: unknown): boolean {
|
||||
return (
|
||||
(error as NodeJS.ErrnoException)?.code === "ERR_MODULE_NOT_FOUND" &&
|
||||
normalizeErrorMessage(error).includes("@hyperframes/gcp-cloud-run")
|
||||
);
|
||||
}
|
||||
|
||||
export function missingCloudRunAdapterMessage(subcommand: string): string {
|
||||
return (
|
||||
`${c.error("@hyperframes/gcp-cloud-run is not installed.")} The ${c.accent(`hyperframes cloudrun ${subcommand}`)} command needs it at runtime.\n` +
|
||||
`Install it alongside the CLI:\n` +
|
||||
` ${c.accent("npm install -g @hyperframes/gcp-cloud-run")}\n` +
|
||||
`Or, for an opt-in project setup:\n` +
|
||||
` ${c.accent("npm install @hyperframes/gcp-cloud-run")}`
|
||||
);
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
meta: { name: "cloudrun", description: "Deploy and drive renders on Google Cloud Run" },
|
||||
args: {
|
||||
@@ -202,6 +230,25 @@ export default defineCommand({
|
||||
console.log(HELP);
|
||||
return;
|
||||
}
|
||||
const verbsNeedingAdapter = new Set([
|
||||
"deploy",
|
||||
"sites",
|
||||
"render",
|
||||
"render-batch",
|
||||
"progress",
|
||||
"destroy",
|
||||
]);
|
||||
if (verbsNeedingAdapter.has(subcommand)) {
|
||||
try {
|
||||
await loadCloudRunAdapter();
|
||||
} catch (error) {
|
||||
if (isMissingCloudRunAdapterError(error)) {
|
||||
console.error(missingCloudRunAdapterMessage(subcommand));
|
||||
process.exit(1);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
switch (subcommand) {
|
||||
case "deploy":
|
||||
return runDeploy(args);
|
||||
@@ -264,13 +311,6 @@ function stripUndefined<T extends Record<string, unknown>>(o: T): Partial<T> {
|
||||
return Object.fromEntries(Object.entries(o).filter(([, v]) => v != null)) as Partial<T>;
|
||||
}
|
||||
|
||||
/** Resolve the Terraform module dir shipped with @hyperframes/gcp-cloud-run. */
|
||||
function terraformDir(): string {
|
||||
const require = createRequire(import.meta.url);
|
||||
const pkgJson = require.resolve("@hyperframes/gcp-cloud-run/package.json");
|
||||
return join(dirname(pkgJson), "terraform");
|
||||
}
|
||||
|
||||
function run(cmd: string, cmdArgs: string[], opts: { cwd?: string } = {}): void {
|
||||
const res = spawnSync(cmd, cmdArgs, { stdio: "inherit", cwd: opts.cwd });
|
||||
if (res.status !== 0) {
|
||||
@@ -288,7 +328,7 @@ function capture(cmd: string, cmdArgs: string[], opts: { cwd?: string } = {}): s
|
||||
// ── deploy ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function runDeploy(args: Record<string, unknown>): void {
|
||||
async function runDeploy(args: Record<string, unknown>): Promise<void> {
|
||||
const project = args.project as string | undefined;
|
||||
if (!project) {
|
||||
console.error("[cloudrun deploy] --project <gcp-project-id> is required.");
|
||||
@@ -296,7 +336,8 @@ function runDeploy(args: Record<string, unknown>): void {
|
||||
}
|
||||
const region = (args.region as string | undefined) ?? "us-central1";
|
||||
const repo = (args.repo as string | undefined) ?? "hyperframes";
|
||||
const tfDir = terraformDir();
|
||||
const { getTerraformModuleDir } = await loadCloudRunAdapter();
|
||||
const tfDir = getTerraformModuleDir();
|
||||
const repoRoot = findRepoRoot(tfDir);
|
||||
|
||||
console.log(`→ Enabling required APIs on ${project}`);
|
||||
@@ -464,7 +505,7 @@ async function runSites(args: Record<string, unknown>): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
const state = readState(args);
|
||||
const { deploySite } = await import("@hyperframes/gcp-cloud-run/sdk");
|
||||
const { deploySite } = await loadCloudRunAdapter();
|
||||
const handle = await deploySite({
|
||||
projectDir: resolve(projectDir),
|
||||
bucketName: state.bucketName,
|
||||
@@ -507,7 +548,7 @@ async function runRender(args: Record<string, unknown>): Promise<void> {
|
||||
const variables = resolveAndValidateVariables(args, resolve(projectDir));
|
||||
const config = buildRenderConfig(args, fps, width, height, variables);
|
||||
|
||||
const { renderToCloudRun, getRenderProgress } = await import("@hyperframes/gcp-cloud-run/sdk");
|
||||
const { renderToCloudRun, getRenderProgress } = await loadCloudRunAdapter();
|
||||
const handle = await renderToCloudRun({
|
||||
projectDir: resolve(projectDir),
|
||||
config: config as Parameters<typeof renderToCloudRun>[0]["config"],
|
||||
@@ -562,7 +603,7 @@ async function runProgress(args: Record<string, unknown>): Promise<void> {
|
||||
console.error("[cloudrun progress] usage: hyperframes cloudrun progress <executionName>");
|
||||
process.exit(1);
|
||||
}
|
||||
const { getRenderProgress } = await import("@hyperframes/gcp-cloud-run/sdk");
|
||||
const { getRenderProgress } = await loadCloudRunAdapter();
|
||||
const progress = await getRenderProgress({ executionName });
|
||||
if (args.json) {
|
||||
console.log(JSON.stringify(progress, null, 2));
|
||||
@@ -638,7 +679,7 @@ async function runRenderBatch(args: Record<string, unknown>): Promise<void> {
|
||||
const state = readState(args);
|
||||
const maxConcurrent =
|
||||
parsePositiveInt(args["max-concurrent"], "--max-concurrent") ?? DEFAULT_BATCH_MAX_CONCURRENT;
|
||||
const { deploySite, renderToCloudRun } = await import("@hyperframes/gcp-cloud-run/sdk");
|
||||
const { deploySite, renderToCloudRun } = await loadCloudRunAdapter();
|
||||
|
||||
// Upload the project once; every entry reuses the same content-addressed
|
||||
// site handle so the tar+upload cost is paid a single time.
|
||||
@@ -724,8 +765,9 @@ function parseBatchFile(path: string): BatchEntry[] {
|
||||
// ── destroy ──────────────────────────────────────────────────────────────
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function runDestroy(args: Record<string, unknown>): void {
|
||||
const tfDir = terraformDir();
|
||||
async function runDestroy(args: Record<string, unknown>): Promise<void> {
|
||||
const { getTerraformModuleDir } = await loadCloudRunAdapter();
|
||||
const tfDir = getTerraformModuleDir();
|
||||
const state = existsSync(statePath())
|
||||
? (JSON.parse(readFileSync(statePath(), "utf8")) as Partial<StackState>)
|
||||
: {};
|
||||
|
||||
@@ -60,6 +60,7 @@ var __dirname = __hf_dirname(__filename);`,
|
||||
// from the `dependencies`/workspace entry, not the bundled CLI.
|
||||
"@hyperframes/gcp-cloud-run",
|
||||
"@hyperframes/gcp-cloud-run/sdk",
|
||||
"@hyperframes/gcp-cloud-run/terraform",
|
||||
],
|
||||
noExternal: [
|
||||
"@hyperframes/core",
|
||||
@@ -84,8 +85,6 @@ var __dirname = __hf_dirname(__filename);`,
|
||||
// Exact subpaths are generated from the same contracts as package
|
||||
// exports, avoiding esbuild's root-alias prefix substitution trap.
|
||||
...sourceAliases(resolve(__dirname, "../producer"), [".", "./distributed"]),
|
||||
...sourceAliases(resolve(__dirname, "../aws-lambda"), ["./sdk"]),
|
||||
...sourceAliases(resolve(__dirname, "../gcp-cloud-run"), ["./sdk"]),
|
||||
...sourceAliases(resolve(__dirname, "../engine"), [".", "./shader-transitions"]),
|
||||
};
|
||||
options.loader = { ...options.loader, ".browser.js": "text" };
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
* ./server (the Cloud Run runtime entry — what the Dockerfile runs)
|
||||
* ./sdk (client-side helpers — GCS + Workflows clients only, no
|
||||
* chromium/puppeteer)
|
||||
* ./terraform (stable resolver for the packaged Terraform module)
|
||||
*
|
||||
* All production deps are kept external so consumers (and the container
|
||||
* image) resolve them via their own node_modules.
|
||||
@@ -46,6 +47,7 @@ await Promise.all([
|
||||
build({ ...sharedOpts, entryPoints: ["src/index.ts"], outfile: "dist/index.js" }),
|
||||
build({ ...sharedOpts, entryPoints: ["src/server.ts"], outfile: "dist/server.js" }),
|
||||
build({ ...sharedOpts, entryPoints: ["src/sdk/index.ts"], outfile: "dist/sdk/index.js" }),
|
||||
build({ ...sharedOpts, entryPoints: ["src/terraform.ts"], outfile: "dist/terraform.js" }),
|
||||
]);
|
||||
|
||||
// esbuild doesn't emit .d.ts. tsc does, with a build-only tsconfig that
|
||||
@@ -55,4 +57,4 @@ await Promise.all([
|
||||
// violate rootDir).
|
||||
execSync("tsc -p tsconfig.build.json --emitDeclarationOnly", { stdio: "inherit" });
|
||||
|
||||
console.log("[Build] Complete: dist/{index,server,sdk/index}.js + .d.ts");
|
||||
console.log("[Build] Complete: dist/{index,server,sdk/index,terraform}.js + .d.ts");
|
||||
|
||||
@@ -18,6 +18,12 @@
|
||||
"runtime": "./dist/sdk/index.js",
|
||||
"types": "./dist/sdk/index.d.ts",
|
||||
"environments": ["bun", "node"]
|
||||
},
|
||||
"./terraform": {
|
||||
"source": "./src/terraform.ts",
|
||||
"runtime": "./dist/terraform.js",
|
||||
"types": "./dist/terraform.d.ts",
|
||||
"environments": ["bun", "node"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,11 @@
|
||||
"bun": "./src/sdk/index.ts",
|
||||
"import": "./src/sdk/index.ts",
|
||||
"types": "./src/sdk/index.ts"
|
||||
},
|
||||
"./terraform": {
|
||||
"bun": "./src/terraform.ts",
|
||||
"import": "./src/terraform.ts",
|
||||
"types": "./src/terraform.ts"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
@@ -47,6 +52,10 @@
|
||||
"./sdk": {
|
||||
"import": "./dist/sdk/index.js",
|
||||
"types": "./dist/sdk/index.d.ts"
|
||||
},
|
||||
"./terraform": {
|
||||
"import": "./dist/terraform.js",
|
||||
"types": "./dist/terraform.d.ts"
|
||||
}
|
||||
},
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
|
||||
@@ -62,3 +62,4 @@ export {
|
||||
type RenderCost,
|
||||
} from "./sdk/costAccounting.js";
|
||||
export { InvalidConfigError, validateDistributedRenderConfig } from "./sdk/validateConfig.js";
|
||||
export { getTerraformModuleDir } from "./terraform.js";
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getTerraformModuleDir } from "./terraform.js";
|
||||
|
||||
describe("getTerraformModuleDir", () => {
|
||||
it("resolves the adapter-owned Terraform assets through public code", () => {
|
||||
const directory = getTerraformModuleDir();
|
||||
expect(existsSync(join(directory, "main.tf"))).toBe(true);
|
||||
expect(existsSync(join(directory, "workflow.yaml"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
/**
|
||||
* Absolute path to the Terraform module shipped with this adapter.
|
||||
*
|
||||
* This module is built as `dist/terraform.js`, preserving the same one-level
|
||||
* relationship to the package-owned `terraform/` directory as the source
|
||||
* file. Consumers should use this API instead of resolving package.json,
|
||||
* which is not a stable public subpath.
|
||||
*/
|
||||
export function getTerraformModuleDir(): string {
|
||||
return resolve(dirname(fileURLToPath(import.meta.url)), "..", "terraform");
|
||||
}
|
||||
@@ -405,13 +405,17 @@ function writeConsumerFixture(packDir, packedWorkspaces) {
|
||||
.map(({ specifier }) => specifier);
|
||||
writeFileSync(
|
||||
join(fixtureDir, "consumer-smoke.mjs"),
|
||||
`const specifiers = ${JSON.stringify(specifiers, null, 2)};\n` +
|
||||
`import { existsSync } from "node:fs";\n` +
|
||||
`import { join } from "node:path";\n` +
|
||||
`const specifiers = ${JSON.stringify(specifiers, null, 2)};\n` +
|
||||
`const nodeSpecifiers = ${JSON.stringify(nodeSpecifiers, null, 2)};\n` +
|
||||
`for (const specifier of specifiers) import.meta.resolve(specifier);\n` +
|
||||
`for (const specifier of nodeSpecifiers) {\n` +
|
||||
` const options = specifier.endsWith(".json") ? { with: { type: "json" } } : undefined;\n` +
|
||||
` await import(specifier, options);\n` +
|
||||
`}\n` +
|
||||
`const terraform = await import("@hyperframes/gcp-cloud-run/terraform");\n` +
|
||||
`if (!existsSync(join(terraform.getTerraformModuleDir(), "main.tf"))) throw new Error("packed Terraform module missing");\n` +
|
||||
`console.log(\`Resolved \${specifiers.length} packed exports and executed \${nodeSpecifiers.length} Node exports.\`);\n` +
|
||||
`process.exit(0);\n`,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user