Skip to content
Open
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
24 changes: 24 additions & 0 deletions packages/cli/src/commands/cloudrun.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { isMissingCloudRunAdapterError, missingCloudRunAdapterMessage } from "./cloudrun.js";

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");
});
});
76 changes: 59 additions & 17 deletions packages/cli/src/commands/cloudrun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -89,6 +88,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: {
Expand Down Expand Up @@ -205,6 +233,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);
Expand Down Expand Up @@ -267,13 +314,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) {
Expand All @@ -291,15 +331,16 @@ 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.");
process.exit(1);
}
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}`);
Expand Down Expand Up @@ -467,7 +508,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,
Expand Down Expand Up @@ -510,7 +551,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"],
Expand Down Expand Up @@ -565,7 +606,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));
Expand Down Expand Up @@ -641,7 +682,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.
Expand Down Expand Up @@ -727,8 +768,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>)
: {};
Expand Down
3 changes: 1 addition & 2 deletions packages/cli/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,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",
Expand All @@ -83,8 +84,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" };
Expand Down
4 changes: 3 additions & 1 deletion packages/gcp-cloud-run/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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");
6 changes: 6 additions & 0 deletions packages/gcp-cloud-run/package-subpaths.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
}
}
9 changes: 9 additions & 0 deletions packages/gcp-cloud-run/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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/"
Expand Down
1 change: 1 addition & 0 deletions packages/gcp-cloud-run/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,4 @@ export {
type RenderCost,
} from "./sdk/costAccounting.js";
export { InvalidConfigError, validateDistributedRenderConfig } from "./sdk/validateConfig.js";
export { getTerraformModuleDir } from "./terraform.js";
12 changes: 12 additions & 0 deletions packages/gcp-cloud-run/src/terraform.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
14 changes: 14 additions & 0 deletions packages/gcp-cloud-run/src/terraform.ts
Original file line number Diff line number Diff line change
@@ -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");
}
6 changes: 5 additions & 1 deletion scripts/verify-packed-manifests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -407,13 +407,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`,
);
Expand Down
Loading