@hyperframes/gcp-cloud-run 0.6.79

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Cloud Run request handler for HyperFrames distributed rendering.
3
+ *
4
+ * One container image, three roles. Cloud Workflows POSTs a JSON body with
5
+ * an `Action` field; the handler unwraps any `Payload`/`Input` envelope,
6
+ * primes the runtime (Chrome path), and forwards to the matching OSS
7
+ * primitive from `@hyperframes/producer/distributed`.
8
+ *
9
+ * Everything heavy — capture, encode, audio mix — happens inside the OSS
10
+ * primitives. The handler is thin glue: parse body → GCS download → call
11
+ * primitive → GCS upload → return small JSON result.
12
+ *
13
+ * `dispatch()` is the testable core (inject `storage` + `primitives`); the
14
+ * Hono app at the bottom is the HTTP shell the Dockerfile runs. The shape
15
+ * deliberately tracks `@hyperframes/aws-lambda`'s `handler.ts` so the two
16
+ * adapters stay easy to diff.
17
+ */
18
+ import { Storage } from "@google-cloud/storage";
19
+ import { Hono } from "hono";
20
+ import { assemble, plan, renderChunk } from "@hyperframes/producer/distributed";
21
+ import type { AssembleEvent, CloudRunEvent, CloudRunResult, PlanEvent, RenderChunkEvent } from "./events.js";
22
+ /**
23
+ * Optional injection points used by the handler's unit tests. Production
24
+ * callers leave these unset; the real OSS primitives are used. Tests inject
25
+ * `storage` and `primitives` directly rather than mutating module state.
26
+ */
27
+ export interface HandlerDeps {
28
+ storage?: Storage;
29
+ primitives?: {
30
+ plan: typeof plan;
31
+ renderChunk: typeof renderChunk;
32
+ assemble: typeof assemble;
33
+ };
34
+ /** Override the per-request workdir root (defaults to the OS tmpdir). */
35
+ tmpRoot?: string;
36
+ /** Skip Chrome resolution (used by dispatch tests that mock renderChunk). */
37
+ skipChromeResolution?: boolean;
38
+ }
39
+ /**
40
+ * Dispatch a single render request. Cloud Workflows (or a direct caller)
41
+ * sometimes wraps the body in `{ Payload: ... }` or `{ Input: ... }`; unwrap
42
+ * until we hit a discriminated event.
43
+ */
44
+ export declare function dispatch(event: CloudRunEvent, deps?: HandlerDeps): Promise<CloudRunResult>;
45
+ export declare function unwrapEvent(event: CloudRunEvent): PlanEvent | RenderChunkEvent | AssembleEvent;
46
+ /**
47
+ * Build the Hono app. A single `POST /` endpoint dispatches on the body's
48
+ * `Action` field — the workflow points every step (plan, each renderChunk,
49
+ * assemble) at the same URL and varies only the body. `GET /healthz` backs
50
+ * the Cloud Run startup/liveness probe.
51
+ *
52
+ * `deps` is threaded through so tests can drive the real HTTP surface with
53
+ * an injected Storage double + mocked primitives.
54
+ */
55
+ export declare function createApp(deps?: HandlerDeps): Hono;
56
+ /** Start the HTTP server. Cloud Run injects `PORT` (default 8080). */
57
+ export declare function startServer(): void;
58
+ //# sourceMappingURL=server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAOH,OAAO,EAAE,OAAO,EAAE,MAAM,uBAAuB,CAAC;AAChD,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAC5B,OAAO,EACL,QAAQ,EAIR,IAAI,EAEJ,WAAW,EACZ,MAAM,mCAAmC,CAAC;AAE3C,OAAO,KAAK,EACV,aAAa,EAGb,aAAa,EACb,cAAc,EACd,SAAS,EAET,gBAAgB,EAEjB,MAAM,aAAa,CAAC;AAsBrB;;;;GAIG;AACH,MAAM,WAAW,WAAW;IAC1B,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,UAAU,CAAC,EAAE;QACX,IAAI,EAAE,OAAO,IAAI,CAAC;QAClB,WAAW,EAAE,OAAO,WAAW,CAAC;QAChC,QAAQ,EAAE,OAAO,QAAQ,CAAC;KAC3B,CAAC;IACF,yEAAyE;IACzE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,6EAA6E;IAC7E,oBAAoB,CAAC,EAAE,OAAO,CAAC;CAChC;AAED;;;;GAIG;AAEH,wBAAsB,QAAQ,CAAC,KAAK,EAAE,aAAa,EAAE,IAAI,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,cAAc,CAAC,CAgChG;AAOD,wBAAgB,WAAW,CAAC,KAAK,EAAE,aAAa,GAAG,SAAS,GAAG,gBAAgB,GAAG,aAAa,CAsB9F;AA2bD;;;;;;;;GAQG;AACH,wBAAgB,SAAS,CAAC,IAAI,CAAC,EAAE,WAAW,GAAG,IAAI,CA2BlD;AAED,sEAAsE;AACtE,wBAAgB,WAAW,IAAI,IAAI,CAMlC"}
package/dist/server.js ADDED
@@ -0,0 +1,517 @@
1
+ // src/server.ts
2
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, mkdtempSync, readFileSync, rmSync as rmSync2, statSync as statSync2 } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { basename, extname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { serve } from "@hono/node-server";
7
+ import { Storage } from "@google-cloud/storage";
8
+ import { Hono } from "hono";
9
+ import {
10
+ assemble,
11
+ plan,
12
+ renderChunk
13
+ } from "@hyperframes/producer/distributed";
14
+
15
+ // src/chromium.ts
16
+ import { existsSync } from "node:fs";
17
+ var ChromeBinaryUnavailableError = class extends Error {
18
+ // Read indirectly via the error envelope / Error.prototype.toString.
19
+ // fallow-ignore-next-line unused-class-member
20
+ name = "ChromeBinaryUnavailableError";
21
+ resolvedPath;
22
+ constructor(resolvedPath, hint) {
23
+ super(`[chromium] Chrome binary unavailable: ${hint}`);
24
+ this.resolvedPath = resolvedPath;
25
+ }
26
+ };
27
+ var FALLBACK_CHROME_PATHS = [
28
+ "/opt/chrome/chrome-headless-shell",
29
+ "/usr/bin/chrome-headless-shell",
30
+ "/usr/bin/google-chrome",
31
+ "/usr/bin/google-chrome-stable",
32
+ "/usr/bin/chromium",
33
+ "/usr/bin/chromium-browser"
34
+ ];
35
+ function resolveChromeExecutablePath() {
36
+ const fromEngineOverride = process.env.PRODUCER_HEADLESS_SHELL_PATH?.trim();
37
+ if (fromEngineOverride) {
38
+ if (!existsSync(fromEngineOverride)) {
39
+ throw new ChromeBinaryUnavailableError(
40
+ fromEngineOverride,
41
+ `PRODUCER_HEADLESS_SHELL_PATH=${JSON.stringify(fromEngineOverride)} does not exist on disk.`
42
+ );
43
+ }
44
+ return fromEngineOverride;
45
+ }
46
+ const fromImage = process.env.HYPERFRAMES_CHROME_PATH?.trim();
47
+ if (fromImage) {
48
+ if (!existsSync(fromImage)) {
49
+ throw new ChromeBinaryUnavailableError(
50
+ fromImage,
51
+ `HYPERFRAMES_CHROME_PATH=${JSON.stringify(fromImage)} does not exist on disk.`
52
+ );
53
+ }
54
+ return fromImage;
55
+ }
56
+ for (const candidate of FALLBACK_CHROME_PATHS) {
57
+ if (existsSync(candidate)) return candidate;
58
+ }
59
+ throw new ChromeBinaryUnavailableError(
60
+ null,
61
+ `no Chrome binary found. Set HYPERFRAMES_CHROME_PATH (the Dockerfile does this) or PRODUCER_HEADLESS_SHELL_PATH to the absolute path of a chrome-headless-shell binary. Searched: ${FALLBACK_CHROME_PATHS.join(", ")}.`
62
+ );
63
+ }
64
+
65
+ // src/formatExtension.ts
66
+ var FORMAT_EXTENSIONS = {
67
+ mp4: ".mp4",
68
+ mov: ".mov",
69
+ webm: ".webm",
70
+ "png-sequence": ""
71
+ };
72
+ function formatExtension(format) {
73
+ return FORMAT_EXTENSIONS[format];
74
+ }
75
+
76
+ // src/gcsTransport.ts
77
+ import { createWriteStream, existsSync as existsSync2, mkdirSync, rmSync, statSync } from "node:fs";
78
+ import { dirname } from "node:path";
79
+ import { pipeline } from "node:stream/promises";
80
+ import * as tar from "tar";
81
+ function parseGcsUri(uri) {
82
+ if (!uri.startsWith("gs://")) {
83
+ throw new Error(`[gcsTransport] expected gs:// URI, got: ${JSON.stringify(uri)}`);
84
+ }
85
+ const rest = uri.slice("gs://".length);
86
+ const slash = rest.indexOf("/");
87
+ if (slash === -1) {
88
+ throw new Error(`[gcsTransport] missing key in gs URI: ${JSON.stringify(uri)}`);
89
+ }
90
+ const bucket = rest.slice(0, slash);
91
+ const key = rest.slice(slash + 1);
92
+ if (!bucket || !key) {
93
+ throw new Error(`[gcsTransport] empty bucket or key in gs URI: ${JSON.stringify(uri)}`);
94
+ }
95
+ return { bucket, key };
96
+ }
97
+ async function downloadGcsObjectToFile(storage, uri, destPath) {
98
+ const { bucket, key } = parseGcsUri(uri);
99
+ mkdirSync(dirname(destPath), { recursive: true });
100
+ const file = storage.bucket(bucket).file(key);
101
+ await pipeline(file.createReadStream(), createWriteStream(destPath));
102
+ }
103
+ async function uploadFileToGcs(storage, localPath, uri, contentType) {
104
+ if (!existsSync2(localPath)) {
105
+ throw new Error(`[gcsTransport] upload source missing: ${localPath}`);
106
+ }
107
+ const { bucket, key } = parseGcsUri(uri);
108
+ await storage.bucket(bucket).upload(localPath, {
109
+ destination: key,
110
+ // `resumable: false` (simple upload) is faster for the small-to-medium
111
+ // objects this adapter moves and avoids the extra round-trip a resumable
112
+ // session start costs; GCS recommends resumable only past ~8 MB but our
113
+ // chunks are reliably above that, so let the client pick by default.
114
+ contentType
115
+ });
116
+ }
117
+ async function tarDirectory(sourceDir, destTarball) {
118
+ if (!existsSync2(sourceDir) || !statSync(sourceDir).isDirectory()) {
119
+ throw new Error(`[gcsTransport] tar source must be an existing directory: ${sourceDir}`);
120
+ }
121
+ mkdirSync(dirname(destTarball), { recursive: true });
122
+ await tar.create({ gzip: true, file: destTarball, cwd: sourceDir }, ["."]);
123
+ }
124
+ async function untarDirectory(tarballPath, destDir) {
125
+ if (!existsSync2(tarballPath)) {
126
+ throw new Error(`[gcsTransport] tarball missing: ${tarballPath}`);
127
+ }
128
+ if (existsSync2(destDir)) {
129
+ rmSync(destDir, { recursive: true, force: true });
130
+ }
131
+ mkdirSync(destDir, { recursive: true });
132
+ await tar.extract({ file: tarballPath, cwd: destDir });
133
+ }
134
+
135
+ // src/server.ts
136
+ var cachedStorage = null;
137
+ function getStorage() {
138
+ if (cachedStorage) return cachedStorage;
139
+ cachedStorage = new Storage();
140
+ return cachedStorage;
141
+ }
142
+ async function dispatch(event, deps) {
143
+ const unwrapped = unwrapEvent(event);
144
+ validateEventGcsUris(unwrapped);
145
+ logEvent({ event: "handler_start", action: unwrapped.Action, input: summarizeEvent(unwrapped) });
146
+ try {
147
+ switch (unwrapped.Action) {
148
+ case "plan":
149
+ return await handlePlan(unwrapped, deps);
150
+ case "renderChunk":
151
+ return await handleRenderChunk(unwrapped, deps);
152
+ case "assemble":
153
+ return await handleAssemble(unwrapped, deps);
154
+ default: {
155
+ const _exhaustive = unwrapped;
156
+ throw new Error(
157
+ `[handler] unknown Action: ${JSON.stringify(
158
+ _exhaustive.Action
159
+ )}. Expected one of "plan", "renderChunk", "assemble".`
160
+ );
161
+ }
162
+ }
163
+ } catch (err) {
164
+ logEvent({
165
+ event: "handler_error",
166
+ action: unwrapped.Action,
167
+ message: err instanceof Error ? err.message : String(err),
168
+ name: err instanceof Error ? err.name : void 0
169
+ });
170
+ throw err;
171
+ }
172
+ }
173
+ var MAX_ENVELOPE_DEPTH = 4;
174
+ function unwrapEvent(event) {
175
+ let cursor = event;
176
+ for (let i = 0; i < MAX_ENVELOPE_DEPTH; i++) {
177
+ if (cursor && typeof cursor === "object") {
178
+ const obj = cursor;
179
+ if (typeof obj.Action === "string" && isCloudRunAction(obj.Action)) {
180
+ return cursor;
181
+ }
182
+ if ("Payload" in obj) {
183
+ cursor = obj.Payload;
184
+ continue;
185
+ }
186
+ if ("Input" in obj) {
187
+ cursor = obj.Input;
188
+ continue;
189
+ }
190
+ }
191
+ break;
192
+ }
193
+ throw new Error(
194
+ `[handler] body has no recognised Action; unwrapped ${MAX_ENVELOPE_DEPTH} levels of Payload/Input without finding one.`
195
+ );
196
+ }
197
+ function isCloudRunAction(value) {
198
+ return value === "plan" || value === "renderChunk" || value === "assemble";
199
+ }
200
+ function logEvent(payload) {
201
+ console.log(JSON.stringify(payload));
202
+ }
203
+ function summarizeEvent(event) {
204
+ switch (event.Action) {
205
+ case "plan":
206
+ return {
207
+ projectGcsUri: event.ProjectGcsUri,
208
+ planOutputGcsPrefix: event.PlanOutputGcsPrefix,
209
+ format: event.Config.format,
210
+ fps: event.Config.fps
211
+ };
212
+ case "renderChunk":
213
+ return {
214
+ planGcsUri: event.PlanGcsUri,
215
+ chunkIndex: event.ChunkIndex,
216
+ format: event.Format
217
+ };
218
+ case "assemble":
219
+ return {
220
+ planGcsUri: event.PlanGcsUri,
221
+ chunkCount: event.ChunkGcsUris.length,
222
+ hasAudio: event.AudioGcsUri !== null,
223
+ outputGcsUri: event.OutputGcsUri,
224
+ format: event.Format
225
+ };
226
+ }
227
+ }
228
+ function primeChrome(deps) {
229
+ if (deps?.skipChromeResolution) return;
230
+ if (process.env.PRODUCER_HEADLESS_SHELL_PATH) return;
231
+ process.env.PRODUCER_HEADLESS_SHELL_PATH = resolveChromeExecutablePath();
232
+ }
233
+ async function handlePlan(event, deps) {
234
+ const started = Date.now();
235
+ const storage = deps?.storage ?? getStorage();
236
+ const primitive = deps?.primitives?.plan ?? plan;
237
+ primeChrome(deps);
238
+ const work = mkdtempSync(join(deps?.tmpRoot ?? tmpdir(), "hf-cr-plan-"));
239
+ const projectArchive = join(work, "project.tar.gz");
240
+ const projectDir = join(work, "project");
241
+ const planDir = join(work, "plan");
242
+ try {
243
+ await downloadGcsObjectToFile(storage, event.ProjectGcsUri, projectArchive);
244
+ await untarDirectory(projectArchive, projectDir);
245
+ const config = {
246
+ ...event.Config
247
+ };
248
+ const result = await primitive(projectDir, config, planDir);
249
+ const planTar = join(work, "plan.tar.gz");
250
+ await tarDirectory(planDir, planTar);
251
+ const planTarUri = `${trimTrailingSlash(event.PlanOutputGcsPrefix)}/plan.tar.gz`;
252
+ const audioPath = join(planDir, "audio.aac");
253
+ const hasAudio = existsSync3(audioPath) && statSync2(audioPath).size > 0;
254
+ await uploadFileToGcs(storage, planTar, planTarUri, "application/gzip");
255
+ return {
256
+ Action: "plan",
257
+ PlanGcsUri: planTarUri,
258
+ PlanHash: result.planHash,
259
+ ChunkCount: result.chunkCount,
260
+ TotalFrames: result.totalFrames,
261
+ Fps: result.fps,
262
+ Width: result.width,
263
+ Height: result.height,
264
+ Format: result.format,
265
+ HasAudio: hasAudio,
266
+ AudioGcsUri: null,
267
+ FfmpegVersion: result.ffmpegVersion,
268
+ ProducerVersion: result.producerVersion,
269
+ DurationMs: Date.now() - started
270
+ };
271
+ } finally {
272
+ cleanupDir(work);
273
+ }
274
+ }
275
+ async function handleRenderChunk(event, deps) {
276
+ const started = Date.now();
277
+ const storage = deps?.storage ?? getStorage();
278
+ const primitive = deps?.primitives?.renderChunk ?? renderChunk;
279
+ primeChrome(deps);
280
+ const work = mkdtempSync(join(deps?.tmpRoot ?? tmpdir(), "hf-cr-chunk-"));
281
+ const planTar = join(work, "plan.tar.gz");
282
+ const planDir = join(work, "plan");
283
+ try {
284
+ await downloadGcsObjectToFile(storage, event.PlanGcsUri, planTar);
285
+ await untarDirectory(planTar, planDir);
286
+ verifyPlanHash(planDir, event.PlanHash);
287
+ const chunkOutputBase = join(
288
+ work,
289
+ event.Format === "png-sequence" ? `chunk-${pad(event.ChunkIndex)}` : `chunk-${pad(event.ChunkIndex)}${formatExtension(event.Format)}`
290
+ );
291
+ const result = await primitive(planDir, event.ChunkIndex, chunkOutputBase);
292
+ const chunkUri = await uploadChunkOutput(
293
+ storage,
294
+ result,
295
+ event.ChunkOutputGcsPrefix,
296
+ event.ChunkIndex
297
+ );
298
+ return {
299
+ Action: "renderChunk",
300
+ ChunkGcsUri: chunkUri,
301
+ ChunkIndex: event.ChunkIndex,
302
+ Sha256: result.sha256,
303
+ FramesEncoded: result.framesEncoded,
304
+ DurationMs: Date.now() - started
305
+ };
306
+ } finally {
307
+ cleanupDir(work);
308
+ }
309
+ }
310
+ async function uploadChunkOutput(storage, result, prefix, chunkIndex) {
311
+ const trimmed = trimTrailingSlash(prefix);
312
+ if (result.outputKind === "file") {
313
+ const ext = extname(result.outputPath);
314
+ const uri2 = `${trimmed}/chunks/${pad(chunkIndex)}${ext}`;
315
+ await uploadFileToGcs(storage, result.outputPath, uri2);
316
+ return uri2;
317
+ }
318
+ const tarball = `${result.outputPath}.tar.gz`;
319
+ await tarDirectory(result.outputPath, tarball);
320
+ const uri = `${trimmed}/chunks/${pad(chunkIndex)}.tar.gz`;
321
+ await uploadFileToGcs(storage, tarball, uri, "application/gzip");
322
+ return uri;
323
+ }
324
+ async function handleAssemble(event, deps) {
325
+ const started = Date.now();
326
+ const storage = deps?.storage ?? getStorage();
327
+ const primitive = deps?.primitives?.assemble ?? assemble;
328
+ const work = mkdtempSync(join(deps?.tmpRoot ?? tmpdir(), "hf-cr-assemble-"));
329
+ const planTar = join(work, "plan.tar.gz");
330
+ const planDir = join(work, "plan");
331
+ try {
332
+ await downloadGcsObjectToFile(storage, event.PlanGcsUri, planTar);
333
+ await untarDirectory(planTar, planDir);
334
+ const chunkPaths = await downloadChunkObjects(storage, event.ChunkGcsUris, work, event.Format);
335
+ let audioPath = null;
336
+ const planAudio = join(planDir, "audio.aac");
337
+ if (existsSync3(planAudio) && statSync2(planAudio).size > 0) {
338
+ audioPath = planAudio;
339
+ } else if (event.AudioGcsUri) {
340
+ audioPath = planAudio;
341
+ await downloadGcsObjectToFile(storage, event.AudioGcsUri, audioPath);
342
+ }
343
+ const finalOutput = event.Format === "png-sequence" ? join(work, "output-frames") : join(work, `output${formatExtension(event.Format)}`);
344
+ const result = await primitive(planDir, chunkPaths, audioPath, finalOutput, {
345
+ cfr: event.Cfr === true
346
+ });
347
+ if (event.Format === "png-sequence") {
348
+ const tarball = `${finalOutput}.tar.gz`;
349
+ await tarDirectory(finalOutput, tarball);
350
+ await uploadFileToGcs(storage, tarball, event.OutputGcsUri, "application/gzip");
351
+ } else {
352
+ await uploadFileToGcs(storage, finalOutput, event.OutputGcsUri);
353
+ }
354
+ return {
355
+ Action: "assemble",
356
+ OutputGcsUri: event.OutputGcsUri,
357
+ FramesEncoded: result.framesEncoded,
358
+ FileSize: result.fileSize,
359
+ DurationMs: Date.now() - started
360
+ };
361
+ } finally {
362
+ cleanupDir(work);
363
+ }
364
+ }
365
+ async function downloadChunkObjects(storage, uris, workDir, format) {
366
+ const chunksDir = join(workDir, "chunks");
367
+ mkdirSync2(chunksDir, { recursive: true });
368
+ const local = new Array(uris.length);
369
+ await Promise.all(
370
+ uris.map(async (uri, i) => {
371
+ if (!uri) {
372
+ throw new Error(`[handler] chunk URI at index ${i} is empty`);
373
+ }
374
+ const { key } = parseGcsUri(uri);
375
+ const localPath = join(chunksDir, basename(key));
376
+ await downloadGcsObjectToFile(storage, uri, localPath);
377
+ if (format === "png-sequence") {
378
+ const dirPath = join(chunksDir, `frames-${pad(i)}`);
379
+ await untarDirectory(localPath, dirPath);
380
+ local[i] = dirPath;
381
+ } else {
382
+ local[i] = localPath;
383
+ }
384
+ })
385
+ );
386
+ return local;
387
+ }
388
+ function getEventGcsUris(event) {
389
+ switch (event.Action) {
390
+ case "plan":
391
+ return [event.ProjectGcsUri, event.PlanOutputGcsPrefix];
392
+ case "renderChunk":
393
+ return [event.PlanGcsUri, event.ChunkOutputGcsPrefix];
394
+ case "assemble":
395
+ return [
396
+ event.PlanGcsUri,
397
+ ...event.ChunkGcsUris,
398
+ event.OutputGcsUri,
399
+ event.AudioGcsUri
400
+ ].filter((u) => u != null);
401
+ }
402
+ }
403
+ var warnedAllowlistDisabled = false;
404
+ function validateEventGcsUris(event) {
405
+ const allowedBucket = process.env.HYPERFRAMES_RENDER_BUCKET?.trim();
406
+ if (allowedBucket === "*") return;
407
+ if (!allowedBucket) {
408
+ if (!warnedAllowlistDisabled) {
409
+ warnedAllowlistDisabled = true;
410
+ logEvent({
411
+ event: "bucket_allowlist_disabled",
412
+ level: "WARNING",
413
+ message: 'HYPERFRAMES_RENDER_BUCKET is unset \u2014 the GCS bucket-allowlist guard is DISABLED. Set it to the render bucket name to enforce, or to "*" to opt out intentionally.'
414
+ });
415
+ }
416
+ return;
417
+ }
418
+ for (const uri of getEventGcsUris(event)) {
419
+ const { bucket } = parseGcsUri(uri);
420
+ if (bucket !== allowedBucket) {
421
+ const err = new Error(
422
+ `[handler] GCS_URI_NOT_ALLOWED: URI ${JSON.stringify(uri)} targets bucket "${bucket}" but only "${allowedBucket}" is permitted`
423
+ );
424
+ err.name = "GCS_URI_NOT_ALLOWED";
425
+ throw err;
426
+ }
427
+ }
428
+ }
429
+ function pad(n) {
430
+ return n.toString().padStart(4, "0");
431
+ }
432
+ function trimTrailingSlash(prefix) {
433
+ return prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
434
+ }
435
+ function cleanupDir(dir) {
436
+ try {
437
+ rmSync2(dir, { recursive: true, force: true });
438
+ } catch {
439
+ }
440
+ }
441
+ function verifyPlanHash(planDir, expected) {
442
+ const planJsonPath = join(planDir, "plan.json");
443
+ let parsed;
444
+ try {
445
+ parsed = JSON.parse(readFileSync(planJsonPath, "utf-8"));
446
+ } catch (err) {
447
+ const msg = err instanceof Error ? err.message : String(err);
448
+ const error = new Error(`PLAN_HASH_MISMATCH: failed to read ${planJsonPath}: ${msg}`);
449
+ error.name = "PLAN_HASH_MISMATCH";
450
+ throw error;
451
+ }
452
+ const actual = parsed.planHash;
453
+ if (typeof actual !== "string" || actual !== expected) {
454
+ const error = new Error(
455
+ `PLAN_HASH_MISMATCH: event PlanHash=${expected} did not match plan.json planHash=${String(actual)}`
456
+ );
457
+ error.name = "PLAN_HASH_MISMATCH";
458
+ throw error;
459
+ }
460
+ }
461
+ var NON_RETRYABLE_ERROR_NAMES = /* @__PURE__ */ new Set([
462
+ // Handler-boundary guards.
463
+ "GCS_URI_NOT_ALLOWED",
464
+ "PLAN_HASH_MISMATCH",
465
+ // Producer error class names (`.name`) + their string code aliases — the
466
+ // class sets `.name` to the class name but wraps a `code`; cover both so a
467
+ // raw-code throw is caught too. Mirrors the AWS state machine's
468
+ // non-retryable list.
469
+ "FormatNotSupportedInDistributedError",
470
+ "PlanTooLargeError",
471
+ "RenderChunkValidationError",
472
+ "FFMPEG_VERSION_MISMATCH",
473
+ "FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
474
+ "PLAN_TOO_LARGE",
475
+ "BROWSER_GPU_NOT_SOFTWARE",
476
+ "FONT_FETCH_FAILED",
477
+ "ChromeBinaryUnavailableError"
478
+ ]);
479
+ function createApp(deps) {
480
+ const app = new Hono();
481
+ app.get("/healthz", (c) => c.json({ status: "ok" }));
482
+ app.post("/", async (c) => {
483
+ let body;
484
+ try {
485
+ body = await c.req.json();
486
+ } catch {
487
+ return c.json({ error: "BAD_REQUEST", message: "request body must be JSON" }, 400);
488
+ }
489
+ try {
490
+ const result = await dispatch(body, deps);
491
+ return c.json(result, 200);
492
+ } catch (err) {
493
+ const name = err instanceof Error ? err.name : void 0;
494
+ const message = err instanceof Error ? err.message : String(err);
495
+ const status = name && NON_RETRYABLE_ERROR_NAMES.has(name) ? 400 : 500;
496
+ return c.json({ error: name ?? "RenderError", message }, status);
497
+ }
498
+ });
499
+ return app;
500
+ }
501
+ function startServer() {
502
+ const port = Number(process.env.PORT ?? 8080);
503
+ const app = createApp();
504
+ serve({ fetch: app.fetch, port }, (info) => {
505
+ logEvent({ event: "server_listening", port: info.port });
506
+ });
507
+ }
508
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
509
+ startServer();
510
+ }
511
+ export {
512
+ createApp,
513
+ dispatch,
514
+ startServer,
515
+ unwrapEvent
516
+ };
517
+ //# sourceMappingURL=server.js.map