@hyperframes/aws-lambda 0.7.71 → 0.7.73

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.
package/dist/index.js CHANGED
@@ -1,11 +1,15 @@
1
1
  // src/handler.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, join } from "node:path";
2
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, mkdtempSync as mkdtempSync2, readFileSync, rmSync as rmSync3, statSync as statSync3 } from "node:fs";
3
+ import { tmpdir as tmpdir2 } from "node:os";
4
+ import { basename, join as join2 } from "node:path";
5
5
  import { S3Client } from "@aws-sdk/client-s3";
6
6
  import {
7
7
  assemble,
8
+ listPlanV2ArtifactsForTarget,
9
+ materializePlanV2Target,
8
10
  plan,
11
+ planV2WithPublisher,
12
+ readPlanV2Manifest,
9
13
  renderChunk
10
14
  } from "@hyperframes/producer/distributed";
11
15
 
@@ -95,9 +99,14 @@ import {
95
99
  rmSync,
96
100
  statSync
97
101
  } from "node:fs";
102
+ import { createHash } from "node:crypto";
98
103
  import { dirname } from "node:path";
99
104
  import { pipeline } from "node:stream/promises";
100
- import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
105
+ import {
106
+ GetObjectCommand,
107
+ HeadObjectCommand,
108
+ PutObjectCommand
109
+ } from "@aws-sdk/client-s3";
101
110
  import * as tar from "tar";
102
111
  function parseS3Uri(uri) {
103
112
  if (!uri.startsWith("s3://")) {
@@ -128,6 +137,19 @@ async function downloadS3ObjectToFile(client, uri, destPath) {
128
137
  mkdirSync(dirname(destPath), { recursive: true });
129
138
  await pipeline(body, createWriteStream(destPath));
130
139
  }
140
+ async function downloadS3ObjectToFileVerified(client, uri, destPath, expectedSha256) {
141
+ assertSha256(expectedSha256);
142
+ await downloadS3ObjectToFile(client, uri, destPath);
143
+ const actual = await sha256File(destPath);
144
+ if (actual !== expectedSha256) {
145
+ rmSync(destPath, { force: true });
146
+ const error = new Error(
147
+ `[s3Transport] PLAN_ARTIFACT_DIGEST_MISMATCH: ${uri} expected ${expectedSha256}, got ${actual}`
148
+ );
149
+ error.name = "PLAN_ARTIFACT_DIGEST_MISMATCH";
150
+ throw error;
151
+ }
152
+ }
131
153
  async function uploadFileToS3(client, localPath, uri, contentType) {
132
154
  if (!existsSync2(localPath)) {
133
155
  throw new Error(`[s3Transport] upload source missing: ${localPath}`);
@@ -144,6 +166,97 @@ async function uploadFileToS3(client, localPath, uri, contentType) {
144
166
  })
145
167
  );
146
168
  }
169
+ async function uploadContentAddressedFileToS3(client, localPath, uri, expectedSha256, contentType) {
170
+ assertSha256(expectedSha256);
171
+ if (!existsSync2(localPath)) {
172
+ throw new Error(`[s3Transport] upload source missing: ${localPath}`);
173
+ }
174
+ const actualSha256 = await sha256File(localPath);
175
+ if (actualSha256 !== expectedSha256) {
176
+ const error = new Error(
177
+ `[s3Transport] PLAN_ARTIFACT_DIGEST_MISMATCH: local artifact ${localPath} expected ${expectedSha256}, got ${actualSha256}`
178
+ );
179
+ error.name = "PLAN_ARTIFACT_DIGEST_MISMATCH";
180
+ throw error;
181
+ }
182
+ const { bucket, key } = parseS3Uri(uri);
183
+ const size = statSync(localPath).size;
184
+ const existing = await inspectContentAddressedObject(client, bucket, key, size, expectedSha256);
185
+ if (existing === "matching") return "reused";
186
+ if (existing === "conflict") throwImmutableObjectConflict(uri);
187
+ const body = createReadStream(localPath);
188
+ try {
189
+ await client.send(
190
+ new PutObjectCommand({
191
+ Bucket: bucket,
192
+ Key: key,
193
+ Body: body,
194
+ ContentType: contentType,
195
+ ContentLength: size,
196
+ Metadata: { sha256: expectedSha256 },
197
+ ChecksumSHA256: Buffer.from(expectedSha256, "hex").toString("base64"),
198
+ // HEAD followed by an unconditional PUT can overwrite a conflicting
199
+ // object published by a concurrent planner. Conditional create makes
200
+ // immutable CAS and fixed-key manifest publication race-safe.
201
+ IfNoneMatch: "*"
202
+ })
203
+ );
204
+ return "uploaded";
205
+ } catch (error) {
206
+ if (!isS3PreconditionFailed(error)) throw error;
207
+ const raced = await inspectContentAddressedObject(client, bucket, key, size, expectedSha256);
208
+ if (raced === "matching") return "reused";
209
+ if (raced === "conflict") throwImmutableObjectConflict(uri);
210
+ throw error;
211
+ } finally {
212
+ body.destroy();
213
+ }
214
+ }
215
+ async function sha256File(path) {
216
+ const hash = createHash("sha256");
217
+ for await (const chunk of createReadStream(path)) {
218
+ hash.update(chunk);
219
+ }
220
+ return hash.digest("hex");
221
+ }
222
+ function assertSha256(value) {
223
+ if (!/^[a-f0-9]{64}$/.test(value)) {
224
+ throw new Error(
225
+ `[s3Transport] expected lowercase SHA-256 digest, got ${JSON.stringify(value)}`
226
+ );
227
+ }
228
+ }
229
+ async function inspectContentAddressedObject(client, bucket, key, expectedSize, expectedSha256) {
230
+ try {
231
+ const existing = await client.send(
232
+ new HeadObjectCommand({ Bucket: bucket, Key: key, ChecksumMode: "ENABLED" })
233
+ );
234
+ return existing.ContentLength === expectedSize && existing.Metadata?.sha256 === expectedSha256 ? "matching" : "conflict";
235
+ } catch (error) {
236
+ if (isS3NotFound(error)) return "missing";
237
+ throw error;
238
+ }
239
+ }
240
+ function throwImmutableObjectConflict(uri) {
241
+ const error = new Error(
242
+ `[s3Transport] PLAN_ARTIFACT_DIGEST_MISMATCH: immutable object ${uri} already exists with different digest metadata or size`
243
+ );
244
+ error.name = "PLAN_ARTIFACT_DIGEST_MISMATCH";
245
+ throw error;
246
+ }
247
+ function isS3NotFound(error) {
248
+ if (!isRecord(error)) return false;
249
+ const metadata = isRecord(error.$metadata) ? error.$metadata : void 0;
250
+ return error.name === "NotFound" || error.name === "NoSuchKey" || metadata?.httpStatusCode === 404;
251
+ }
252
+ function isS3PreconditionFailed(error) {
253
+ if (!isRecord(error)) return false;
254
+ const metadata = isRecord(error.$metadata) ? error.$metadata : void 0;
255
+ return error.name === "PreconditionFailed" || metadata?.httpStatusCode === 412;
256
+ }
257
+ function isRecord(value) {
258
+ return value !== null && typeof value === "object" && !Array.isArray(value);
259
+ }
147
260
  async function tarDirectory(sourceDir, destTarball) {
148
261
  if (!existsSync2(sourceDir) || !statSync(sourceDir).isDirectory()) {
149
262
  throw new Error(`[s3Transport] tar source must be an existing directory: ${sourceDir}`);
@@ -162,6 +275,112 @@ async function untarDirectory(tarballPath, destDir) {
162
275
  await tar.extract({ file: tarballPath, cwd: destDir });
163
276
  }
164
277
 
278
+ // src/s3PlanV2Publisher.ts
279
+ import { createHash as createHash2 } from "node:crypto";
280
+ import { mkdirSync as mkdirSync2, mkdtempSync, rmSync as rmSync2, statSync as statSync2, writeFileSync } from "node:fs";
281
+ import { tmpdir } from "node:os";
282
+ import { join } from "node:path";
283
+ import {
284
+ PlanV2IntegrityError
285
+ } from "@hyperframes/producer/distributed";
286
+ function isRecord2(value) {
287
+ return value !== null && typeof value === "object" && !Array.isArray(value);
288
+ }
289
+ function assertSha2562(value, label) {
290
+ if (typeof value !== "string" || !/^[a-f0-9]{64}$/.test(value)) {
291
+ throw new PlanV2IntegrityError(`${label} must be a lowercase SHA-256 digest`);
292
+ }
293
+ return value;
294
+ }
295
+ function manifestDigests(manifestBytes) {
296
+ let value;
297
+ try {
298
+ value = JSON.parse(manifestBytes);
299
+ } catch {
300
+ throw new PlanV2IntegrityError("S3 publisher received invalid manifest JSON");
301
+ }
302
+ if (!isRecord2(value) || !Array.isArray(value.artifacts)) {
303
+ throw new PlanV2IntegrityError("S3 publisher manifest requires an artifacts array");
304
+ }
305
+ return new Set(
306
+ value.artifacts.map((artifact, index) => {
307
+ if (!isRecord2(artifact)) {
308
+ throw new PlanV2IntegrityError(`S3 publisher artifacts[${index}] must be an object`);
309
+ }
310
+ return assertSha2562(artifact.sha256, `S3 publisher artifacts[${index}].sha256`);
311
+ })
312
+ );
313
+ }
314
+ function trimTrailingSlash(value) {
315
+ let end = value.length;
316
+ while (end > 0 && value.charCodeAt(end - 1) === 47) end -= 1;
317
+ return value.slice(0, end);
318
+ }
319
+ var S3PlanV2ArtifactPublisher = class {
320
+ artifactPrefix;
321
+ manifestUri;
322
+ #s3;
323
+ #temporaryRoot;
324
+ #publishedDigests = /* @__PURE__ */ new Set();
325
+ #state = "open";
326
+ constructor(options) {
327
+ const outputPrefix = `${trimTrailingSlash(options.planOutputS3Prefix)}/v2`;
328
+ parseS3Uri(outputPrefix);
329
+ this.#s3 = options.s3;
330
+ this.artifactPrefix = `${outputPrefix}/artifacts/sha256`;
331
+ this.manifestUri = `${outputPrefix}/manifest.json`;
332
+ this.#temporaryRoot = options.temporaryRoot ?? tmpdir();
333
+ mkdirSync2(this.#temporaryRoot, { recursive: true });
334
+ }
335
+ async putBlob(blob) {
336
+ this.#assertOpen("publish a blob");
337
+ const digest = assertSha2562(blob.sha256, "S3 published blob sha256");
338
+ const sourceSize = statSync2(blob.sourcePath).size;
339
+ if (sourceSize !== blob.sizeBytes) {
340
+ throw new PlanV2IntegrityError(
341
+ `S3 published blob size changed for ${digest}: expected ${blob.sizeBytes}, got ${sourceSize}`
342
+ );
343
+ }
344
+ const uri = `${this.artifactPrefix}/${digest.slice(0, 2)}/${digest}`;
345
+ await uploadContentAddressedFileToS3(this.#s3, blob.sourcePath, uri, digest);
346
+ this.#publishedDigests.add(digest);
347
+ }
348
+ async commitManifest(manifestBytes) {
349
+ this.#assertOpen("commit a manifest");
350
+ for (const digest of manifestDigests(manifestBytes)) {
351
+ if (!this.#publishedDigests.has(digest)) {
352
+ throw new PlanV2IntegrityError(
353
+ `cannot commit S3 manifest before referenced blob is durable: ${digest}`
354
+ );
355
+ }
356
+ }
357
+ const manifestDigest = createHash2("sha256").update(manifestBytes, "utf8").digest("hex");
358
+ const stagingDir = mkdtempSync(join(this.#temporaryRoot, "hf-plan-v2-manifest-"));
359
+ const manifestPath = join(stagingDir, "manifest.json");
360
+ try {
361
+ writeFileSync(manifestPath, manifestBytes, "utf8");
362
+ await uploadContentAddressedFileToS3(
363
+ this.#s3,
364
+ manifestPath,
365
+ this.manifestUri,
366
+ manifestDigest,
367
+ "application/json"
368
+ );
369
+ this.#state = "committed";
370
+ } finally {
371
+ rmSync2(stagingDir, { recursive: true, force: true });
372
+ }
373
+ }
374
+ async abort() {
375
+ if (this.#state === "open") this.#state = "aborted";
376
+ }
377
+ #assertOpen(operation) {
378
+ if (this.#state !== "open") {
379
+ throw new PlanV2IntegrityError(`cannot ${operation} after publisher is ${this.#state}`);
380
+ }
381
+ }
382
+ };
383
+
165
384
  // src/handler.ts
166
385
  var cachedS3Client = null;
167
386
  function getS3Client() {
@@ -192,15 +411,24 @@ async function handler(event, deps) {
192
411
  }
193
412
  }
194
413
  } catch (err) {
414
+ normalizeTerminalErrorName(err);
195
415
  logEvent({
196
416
  event: "handler_error",
197
417
  action: unwrapped.Action,
418
+ input: summarizeEvent(unwrapped),
198
419
  message: err instanceof Error ? err.message : String(err),
199
420
  name: err instanceof Error ? err.name : void 0
200
421
  });
201
422
  throw err;
202
423
  }
203
424
  }
425
+ function normalizeTerminalErrorName(error) {
426
+ if (!error || typeof error !== "object") return;
427
+ const candidate = error;
428
+ if (candidate.code === "PLAN_PROTOCOL_UNSUPPORTED" || candidate.code === "PLAN_TOO_LARGE" || candidate.code === "PLAN_V2_INTEGRITY_UNRECOVERABLE") {
429
+ candidate.name = candidate.code;
430
+ }
431
+ }
204
432
  var MAX_ENVELOPE_DEPTH = 4;
205
433
  function unwrapEvent(event) {
206
434
  let cursor = event;
@@ -237,18 +465,21 @@ function summarizeEvent(event) {
237
465
  return {
238
466
  projectS3Uri: event.ProjectS3Uri,
239
467
  planOutputS3Prefix: event.PlanOutputS3Prefix,
468
+ planProtocol: event.PlanProtocol ?? "v1",
240
469
  format: event.Config.format,
241
470
  fps: event.Config.fps
242
471
  };
243
472
  case "renderChunk":
244
473
  return {
245
- planS3Uri: event.PlanS3Uri,
474
+ planProtocol: event.PlanProtocol ?? "v1",
475
+ ...event.PlanProtocol === "v2" ? { planV2ManifestS3Uri: event.PlanV2ManifestS3Uri } : { planS3Uri: event.PlanS3Uri },
246
476
  chunkIndex: event.ChunkIndex,
247
477
  format: event.Format
248
478
  };
249
479
  case "assemble":
250
480
  return {
251
- planS3Uri: event.PlanS3Uri,
481
+ planProtocol: event.PlanProtocol ?? "v1",
482
+ ...event.PlanProtocol === "v2" ? { planV2ManifestS3Uri: event.PlanV2ManifestS3Uri } : { planS3Uri: event.PlanS3Uri },
252
483
  chunkCount: event.ChunkS3Uris.length,
253
484
  hasAudio: event.AudioS3Uri !== null,
254
485
  outputS3Uri: event.OutputS3Uri,
@@ -261,12 +492,15 @@ function primeRuntimeEnv() {
261
492
  if (runtimeEnvPrimed) return;
262
493
  runtimeEnvPrimed = true;
263
494
  const taskRoot = process.env.LAMBDA_TASK_ROOT ?? "/var/task";
264
- const bin = join(taskRoot, "bin");
495
+ const bin = join2(taskRoot, "bin");
265
496
  if (existsSync3(bin)) {
266
497
  process.env.PATH = `${bin}:${process.env.PATH ?? ""}`;
267
498
  }
268
499
  }
269
500
  async function handlePlan(event, deps) {
501
+ if (event.PlanProtocol === "v2") {
502
+ return handlePlanV2(event, deps);
503
+ }
270
504
  const started = Date.now();
271
505
  const s3 = deps?.s3 ?? getS3Client();
272
506
  const primitive = deps?.primitives?.plan ?? plan;
@@ -274,10 +508,10 @@ async function handlePlan(event, deps) {
274
508
  const chromePath = await resolveChromeExecutablePath();
275
509
  process.env.PRODUCER_HEADLESS_SHELL_PATH = chromePath;
276
510
  }
277
- const work = mkdtempSync(join(deps?.tmpRoot ?? tmpdir(), "hf-lambda-plan-"));
278
- const projectArchive = join(work, "project.tar.gz");
279
- const projectDir = join(work, "project");
280
- const planDir = join(work, "plan");
511
+ const work = mkdtempSync2(join2(deps?.tmpRoot ?? tmpdir2(), "hf-lambda-plan-"));
512
+ const projectArchive = join2(work, "project.tar.gz");
513
+ const projectDir = join2(work, "project");
514
+ const planDir = join2(work, "plan");
281
515
  try {
282
516
  await downloadS3ObjectToFile(s3, event.ProjectS3Uri, projectArchive);
283
517
  await untarDirectory(projectArchive, projectDir);
@@ -285,12 +519,12 @@ async function handlePlan(event, deps) {
285
519
  ...event.Config
286
520
  };
287
521
  const result = await primitive(projectDir, config, planDir);
288
- const planTar = join(work, "plan.tar.gz");
522
+ const planTar = join2(work, "plan.tar.gz");
289
523
  await tarDirectory(planDir, planTar);
290
- const planTarUri = `${trimTrailingSlash(event.PlanOutputS3Prefix)}/plan.tar.gz`;
291
- const audioPath = join(planDir, "audio.aac");
292
- const hasAudio = existsSync3(audioPath) && statSync2(audioPath).size > 0;
293
- const audioUri = hasAudio ? `${trimTrailingSlash(event.PlanOutputS3Prefix)}/audio.aac` : null;
524
+ const planTarUri = `${trimTrailingSlash2(event.PlanOutputS3Prefix)}/plan.tar.gz`;
525
+ const audioPath = join2(planDir, "audio.aac");
526
+ const hasAudio = existsSync3(audioPath) && statSync3(audioPath).size > 0;
527
+ const audioUri = hasAudio ? `${trimTrailingSlash2(event.PlanOutputS3Prefix)}/audio.aac` : null;
294
528
  await Promise.all([
295
529
  uploadFileToS3(s3, planTar, planTarUri, "application/gzip"),
296
530
  hasAudio && audioUri ? uploadFileToS3(s3, audioPath, audioUri, "audio/aac") : null
@@ -315,7 +549,53 @@ async function handlePlan(event, deps) {
315
549
  cleanupDir(work);
316
550
  }
317
551
  }
552
+ async function handlePlanV2(event, deps) {
553
+ const started = Date.now();
554
+ const s3 = deps?.s3 ?? getS3Client();
555
+ const primitive = deps?.primitives?.planV2WithPublisher ?? planV2WithPublisher;
556
+ if (!deps?.skipChromeResolution && !process.env.PRODUCER_HEADLESS_SHELL_PATH) {
557
+ process.env.PRODUCER_HEADLESS_SHELL_PATH = await resolveChromeExecutablePath();
558
+ }
559
+ const work = mkdtempSync2(join2(deps?.tmpRoot ?? tmpdir2(), "hf-lambda-plan-v2-"));
560
+ const projectArchive = join2(work, "project.tar.gz");
561
+ const projectDir = join2(work, "project");
562
+ try {
563
+ await downloadS3ObjectToFile(s3, event.ProjectS3Uri, projectArchive);
564
+ await untarDirectory(projectArchive, projectDir);
565
+ const publisher = new S3PlanV2ArtifactPublisher({
566
+ s3,
567
+ planOutputS3Prefix: event.PlanOutputS3Prefix,
568
+ temporaryRoot: work
569
+ });
570
+ const manifest = await primitive(projectDir, { ...event.Config }, publisher, {
571
+ stagingParentDir: work
572
+ });
573
+ return {
574
+ Action: "plan",
575
+ PlanProtocol: "v2",
576
+ PlanV2ManifestS3Uri: publisher.manifestUri,
577
+ PlanV2ArtifactS3Prefix: publisher.artifactPrefix,
578
+ PlanHash: manifest.planHash,
579
+ ChunkCount: manifest.chunkCount,
580
+ TotalFrames: manifest.totalFrames,
581
+ Fps: manifest.fps,
582
+ Width: manifest.width,
583
+ Height: manifest.height,
584
+ Format: manifest.format,
585
+ HasAudio: manifest.artifacts.some((artifact) => artifact.path === "audio.aac"),
586
+ AudioS3Uri: null,
587
+ FfmpegVersion: manifest.ffmpegVersion,
588
+ ProducerVersion: manifest.producerVersion,
589
+ DurationMs: Date.now() - started
590
+ };
591
+ } finally {
592
+ cleanupDir(work);
593
+ }
594
+ }
318
595
  async function handleRenderChunk(event, deps) {
596
+ if (event.PlanProtocol === "v2") {
597
+ return handleRenderChunkV2(event, deps);
598
+ }
319
599
  const started = Date.now();
320
600
  const s3 = deps?.s3 ?? getS3Client();
321
601
  const primitive = deps?.primitives?.renderChunk ?? renderChunk;
@@ -323,14 +603,52 @@ async function handleRenderChunk(event, deps) {
323
603
  const chromePath = await resolveChromeExecutablePath();
324
604
  process.env.PRODUCER_HEADLESS_SHELL_PATH = chromePath;
325
605
  }
326
- const work = mkdtempSync(join(deps?.tmpRoot ?? tmpdir(), "hf-lambda-chunk-"));
327
- const planTar = join(work, "plan.tar.gz");
328
- const planDir = join(work, "plan");
606
+ const work = mkdtempSync2(join2(deps?.tmpRoot ?? tmpdir2(), "hf-lambda-chunk-"));
607
+ const planTar = join2(work, "plan.tar.gz");
608
+ const planDir = join2(work, "plan");
329
609
  try {
330
610
  await downloadS3ObjectToFile(s3, event.PlanS3Uri, planTar);
331
611
  await untarDirectory(planTar, planDir);
332
612
  verifyPlanHash(planDir, event.PlanHash);
333
- const chunkOutputBase = join(
613
+ const chunkOutputBase = join2(
614
+ work,
615
+ event.Format === "png-sequence" ? `chunk-${pad(event.ChunkIndex)}` : `chunk-${pad(event.ChunkIndex)}${formatExtension(event.Format)}`
616
+ );
617
+ const result = await primitive(planDir, event.ChunkIndex, chunkOutputBase);
618
+ const chunkUri = await uploadChunkOutput(
619
+ s3,
620
+ result,
621
+ event.ChunkOutputS3Prefix,
622
+ event.ChunkIndex
623
+ );
624
+ return {
625
+ Action: "renderChunk",
626
+ ChunkS3Uri: chunkUri,
627
+ ChunkIndex: event.ChunkIndex,
628
+ Sha256: result.sha256,
629
+ FramesEncoded: result.framesEncoded,
630
+ DurationMs: Date.now() - started
631
+ };
632
+ } finally {
633
+ cleanupDir(work);
634
+ }
635
+ }
636
+ async function handleRenderChunkV2(event, deps) {
637
+ const started = Date.now();
638
+ const s3 = deps?.s3 ?? getS3Client();
639
+ const primitive = deps?.primitives?.renderChunk ?? renderChunk;
640
+ if (!deps?.skipChromeResolution && !process.env.PRODUCER_HEADLESS_SHELL_PATH) {
641
+ process.env.PRODUCER_HEADLESS_SHELL_PATH = await resolveChromeExecutablePath();
642
+ }
643
+ const work = mkdtempSync2(join2(deps?.tmpRoot ?? tmpdir2(), "hf-lambda-chunk-v2-"));
644
+ try {
645
+ const planDir = await downloadAndMaterializePlanV2(
646
+ s3,
647
+ event,
648
+ { role: "chunk", chunkIndex: event.ChunkIndex },
649
+ work
650
+ );
651
+ const chunkOutputBase = join2(
334
652
  work,
335
653
  event.Format === "png-sequence" ? `chunk-${pad(event.ChunkIndex)}` : `chunk-${pad(event.ChunkIndex)}${formatExtension(event.Format)}`
336
654
  );
@@ -354,7 +672,7 @@ async function handleRenderChunk(event, deps) {
354
672
  }
355
673
  }
356
674
  async function uploadChunkOutput(s3, result, prefix, chunkIndex) {
357
- const trimmed = trimTrailingSlash(prefix);
675
+ const trimmed = trimTrailingSlash2(prefix);
358
676
  if (result.outputKind === "file") {
359
677
  const ext = result.outputPath.slice(result.outputPath.lastIndexOf("."));
360
678
  const uri2 = `${trimmed}/chunks/${pad(chunkIndex)}${ext}`;
@@ -368,22 +686,25 @@ async function uploadChunkOutput(s3, result, prefix, chunkIndex) {
368
686
  return uri;
369
687
  }
370
688
  async function handleAssemble(event, deps) {
689
+ if (event.PlanProtocol === "v2") {
690
+ return handleAssembleV2(event, deps);
691
+ }
371
692
  const started = Date.now();
372
693
  const s3 = deps?.s3 ?? getS3Client();
373
694
  const primitive = deps?.primitives?.assemble ?? assemble;
374
- const work = mkdtempSync(join(deps?.tmpRoot ?? tmpdir(), "hf-lambda-assemble-"));
375
- const planTar = join(work, "plan.tar.gz");
376
- const planDir = join(work, "plan");
695
+ const work = mkdtempSync2(join2(deps?.tmpRoot ?? tmpdir2(), "hf-lambda-assemble-"));
696
+ const planTar = join2(work, "plan.tar.gz");
697
+ const planDir = join2(work, "plan");
377
698
  try {
378
699
  await downloadS3ObjectToFile(s3, event.PlanS3Uri, planTar);
379
700
  await untarDirectory(planTar, planDir);
380
701
  const chunkPaths = await downloadChunkObjects(s3, event.ChunkS3Uris, work, event.Format);
381
702
  let audioPath = null;
382
703
  if (event.AudioS3Uri) {
383
- audioPath = join(planDir, "audio.aac");
704
+ audioPath = join2(planDir, "audio.aac");
384
705
  await downloadS3ObjectToFile(s3, event.AudioS3Uri, audioPath);
385
706
  }
386
- const finalOutput = event.Format === "png-sequence" ? join(work, "output-frames") : join(work, `output${formatExtension(event.Format)}`);
707
+ const finalOutput = event.Format === "png-sequence" ? join2(work, "output-frames") : join2(work, `output${formatExtension(event.Format)}`);
387
708
  const result = await primitive(planDir, chunkPaths, audioPath, finalOutput, {
388
709
  cfr: event.Cfr === true
389
710
  });
@@ -405,9 +726,96 @@ async function handleAssemble(event, deps) {
405
726
  cleanupDir(work);
406
727
  }
407
728
  }
729
+ async function handleAssembleV2(event, deps) {
730
+ const started = Date.now();
731
+ const s3 = deps?.s3 ?? getS3Client();
732
+ const primitive = deps?.primitives?.assemble ?? assemble;
733
+ const work = mkdtempSync2(join2(deps?.tmpRoot ?? tmpdir2(), "hf-lambda-assemble-v2-"));
734
+ try {
735
+ const planDir = await downloadAndMaterializePlanV2(s3, event, { role: "assembler" }, work);
736
+ const audioPath = existsSync3(join2(planDir, "audio.aac")) ? join2(planDir, "audio.aac") : null;
737
+ const chunkPaths = await downloadChunkObjects(s3, event.ChunkS3Uris, work, event.Format);
738
+ const finalOutput = event.Format === "png-sequence" ? join2(work, "output-frames") : join2(work, `output${formatExtension(event.Format)}`);
739
+ const result = await primitive(planDir, chunkPaths, audioPath, finalOutput, {
740
+ cfr: event.Cfr === true
741
+ });
742
+ if (event.Format === "png-sequence") {
743
+ const tarball = `${finalOutput}.tar.gz`;
744
+ await tarDirectory(finalOutput, tarball);
745
+ await uploadFileToS3(s3, tarball, event.OutputS3Uri, "application/gzip");
746
+ } else {
747
+ await uploadFileToS3(s3, finalOutput, event.OutputS3Uri);
748
+ }
749
+ return {
750
+ Action: "assemble",
751
+ OutputS3Uri: event.OutputS3Uri,
752
+ FramesEncoded: result.framesEncoded,
753
+ FileSize: result.fileSize,
754
+ DurationMs: Date.now() - started
755
+ };
756
+ } finally {
757
+ cleanupDir(work);
758
+ }
759
+ }
760
+ async function downloadAndMaterializePlanV2(s3, event, target, work) {
761
+ const transportDir = join2(work, "plan-v2");
762
+ mkdirSync3(transportDir, { recursive: true });
763
+ await downloadS3ObjectToFile(s3, event.PlanV2ManifestS3Uri, join2(transportDir, "plan.json"));
764
+ const manifest = readPlanV2Manifest(transportDir);
765
+ if (manifest.planHash !== event.PlanHash) {
766
+ throwPlanHashMismatch(event.PlanHash, manifest.planHash);
767
+ }
768
+ const artifacts = listPlanV2ArtifactsForTarget(manifest, target);
769
+ const uniqueArtifacts = [
770
+ ...new Map(artifacts.map((artifact) => [artifact.sha256, artifact])).values()
771
+ ];
772
+ await mapConcurrent(uniqueArtifacts, 16, async (artifact) => {
773
+ await downloadPlanV2Artifact(s3, event.PlanV2ArtifactS3Prefix, transportDir, artifact);
774
+ });
775
+ const planDir = join2(work, "plan");
776
+ materializePlanV2Target(transportDir, target, planDir);
777
+ return planDir;
778
+ }
779
+ async function downloadPlanV2Artifact(s3, artifactPrefix, planV2Dir, artifact) {
780
+ await downloadS3ObjectToFileVerified(
781
+ s3,
782
+ planV2BlobUri(artifactPrefix, artifact.sha256),
783
+ planV2BlobPath(planV2Dir, artifact.sha256),
784
+ artifact.sha256
785
+ );
786
+ }
787
+ function planV2BlobPath(planV2Dir, digest) {
788
+ return join2(planV2Dir, "artifacts", "sha256", digest.slice(0, 2), digest);
789
+ }
790
+ function planV2BlobUri(prefix, digest) {
791
+ return `${trimTrailingSlash2(prefix)}/${digest.slice(0, 2)}/${digest}`;
792
+ }
793
+ function throwPlanHashMismatch(expected, actual) {
794
+ const error = new Error(
795
+ `PLAN_HASH_MISMATCH: event PlanHash=${expected} did not match v2 manifest planHash=${actual}`
796
+ );
797
+ error.name = "PLAN_HASH_MISMATCH";
798
+ throw error;
799
+ }
800
+ async function mapConcurrent(values, concurrency, fn) {
801
+ let cursor = 0;
802
+ async function worker() {
803
+ while (cursor < values.length) {
804
+ const index = cursor++;
805
+ await fn(values[index]);
806
+ }
807
+ }
808
+ const results = await Promise.allSettled(
809
+ Array.from({ length: Math.min(concurrency, values.length) }, () => worker())
810
+ );
811
+ const failure = results.find(
812
+ (result) => result.status === "rejected"
813
+ );
814
+ if (failure) throw failure.reason;
815
+ }
408
816
  async function downloadChunkObjects(s3, uris, workDir, format) {
409
- const chunksDir = join(workDir, "chunks");
410
- mkdirSync2(chunksDir, { recursive: true });
817
+ const chunksDir = join2(workDir, "chunks");
818
+ mkdirSync3(chunksDir, { recursive: true });
411
819
  const local = new Array(uris.length);
412
820
  await Promise.all(
413
821
  uris.map(async (uri, i) => {
@@ -415,10 +823,10 @@ async function downloadChunkObjects(s3, uris, workDir, format) {
415
823
  throw new Error(`[handler] chunk URI at index ${i} is empty`);
416
824
  }
417
825
  const { key } = parseS3Uri(uri);
418
- const localPath = join(chunksDir, basename(key));
826
+ const localPath = join2(chunksDir, basename(key));
419
827
  await downloadS3ObjectToFile(s3, uri, localPath);
420
828
  if (format === "png-sequence") {
421
- const dirPath = join(chunksDir, `frames-${pad(i)}`);
829
+ const dirPath = join2(chunksDir, `frames-${pad(i)}`);
422
830
  await untarDirectory(localPath, dirPath);
423
831
  local[i] = dirPath;
424
832
  } else {
@@ -433,11 +841,14 @@ function getEventS3Uris(event) {
433
841
  case "plan":
434
842
  return [event.ProjectS3Uri, event.PlanOutputS3Prefix];
435
843
  case "renderChunk":
436
- return [event.PlanS3Uri, event.ChunkOutputS3Prefix];
844
+ return event.PlanProtocol === "v2" ? [event.PlanV2ManifestS3Uri, event.PlanV2ArtifactS3Prefix, event.ChunkOutputS3Prefix] : [event.PlanS3Uri, event.ChunkOutputS3Prefix];
437
845
  case "assemble":
438
- return [event.PlanS3Uri, ...event.ChunkS3Uris, event.OutputS3Uri, event.AudioS3Uri].filter(
439
- (u) => u != null
440
- );
846
+ return [
847
+ ...event.PlanProtocol === "v2" ? [event.PlanV2ManifestS3Uri, event.PlanV2ArtifactS3Prefix] : [event.PlanS3Uri],
848
+ ...event.ChunkS3Uris,
849
+ event.OutputS3Uri,
850
+ event.AudioS3Uri
851
+ ].filter((u) => u != null);
441
852
  }
442
853
  }
443
854
  function validateEventS3Uris(event) {
@@ -457,17 +868,17 @@ function validateEventS3Uris(event) {
457
868
  function pad(n) {
458
869
  return n.toString().padStart(4, "0");
459
870
  }
460
- function trimTrailingSlash(prefix) {
871
+ function trimTrailingSlash2(prefix) {
461
872
  return prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
462
873
  }
463
874
  function cleanupDir(dir) {
464
875
  try {
465
- rmSync2(dir, { recursive: true, force: true });
876
+ rmSync3(dir, { recursive: true, force: true });
466
877
  } catch {
467
878
  }
468
879
  }
469
880
  function verifyPlanHash(planDir, expected) {
470
- const planJsonPath = join(planDir, "plan.json");
881
+ const planJsonPath = join2(planDir, "plan.json");
471
882
  let parsed;
472
883
  try {
473
884
  parsed = JSON.parse(readFileSync(planJsonPath, "utf-8"));
@@ -488,13 +899,13 @@ function verifyPlanHash(planDir, expected) {
488
899
  }
489
900
 
490
901
  // src/sdk/deploySite.ts
491
- import { mkdtempSync as mkdtempSync2, rmSync as rmSync3, statSync as statSync3 } from "node:fs";
492
- import { tmpdir as tmpdir2 } from "node:os";
493
- import { join as join2 } from "node:path";
494
- import { HeadObjectCommand, S3Client as S3Client2 } from "@aws-sdk/client-s3";
902
+ import { mkdtempSync as mkdtempSync3, rmSync as rmSync4, statSync as statSync4 } from "node:fs";
903
+ import { tmpdir as tmpdir3 } from "node:os";
904
+ import { join as join3 } from "node:path";
905
+ import { HeadObjectCommand as HeadObjectCommand2, S3Client as S3Client2 } from "@aws-sdk/client-s3";
495
906
  import { hashProjectDir } from "@hyperframes/producer/distributed";
496
907
  async function deploySite(opts) {
497
- if (!statSync3(opts.projectDir).isDirectory()) {
908
+ if (!statSync4(opts.projectDir).isDirectory()) {
498
909
  throw new Error(`[deploySite] projectDir is not a directory: ${opts.projectDir}`);
499
910
  }
500
911
  const siteId = opts.siteId ?? hashProjectDir(opts.projectDir);
@@ -512,11 +923,11 @@ async function deploySite(opts) {
512
923
  uploaded: false
513
924
  };
514
925
  }
515
- const workdir = mkdtempSync2(join2(tmpdir2(), "hf-deploy-site-"));
926
+ const workdir = mkdtempSync3(join3(tmpdir3(), "hf-deploy-site-"));
516
927
  try {
517
- const tarball = join2(workdir, "project.tar.gz");
928
+ const tarball = join3(workdir, "project.tar.gz");
518
929
  await tarDirectory(opts.projectDir, tarball);
519
- const size = statSync3(tarball).size;
930
+ const size = statSync4(tarball).size;
520
931
  await uploadFileToS3(s3, tarball, projectS3Uri, "application/gzip");
521
932
  return {
522
933
  siteId,
@@ -527,12 +938,12 @@ async function deploySite(opts) {
527
938
  uploaded: true
528
939
  };
529
940
  } finally {
530
- rmSync3(workdir, { recursive: true, force: true });
941
+ rmSync4(workdir, { recursive: true, force: true });
531
942
  }
532
943
  }
533
944
  async function headObject(s3, bucket, key) {
534
945
  try {
535
- const res = await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
946
+ const res = await s3.send(new HeadObjectCommand2({ Bucket: bucket, Key: key }));
536
947
  return {
537
948
  bytes: typeof res.ContentLength === "number" ? res.ContentLength : 0,
538
949
  lastModified: res.LastModified instanceof Date ? res.LastModified.toISOString() : (/* @__PURE__ */ new Date()).toISOString()
@@ -614,7 +1025,8 @@ async function renderToLambda(opts) {
614
1025
  ProjectS3Uri: site.projectS3Uri,
615
1026
  PlanOutputS3Prefix: planOutputS3Prefix,
616
1027
  OutputS3Uri: outputS3Uri,
617
- Config: opts.config
1028
+ Config: opts.config,
1029
+ PlanProtocol: opts.planProtocol ?? "v1"
618
1030
  };
619
1031
  validateStepFunctionsInputSize(input);
620
1032
  const sfn = opts.sfn ?? new SFNClient({ region: opts.region });
@@ -804,7 +1216,7 @@ function summarizeHistory(events, memoryMb) {
804
1216
  }
805
1217
  case "TaskStateExited":
806
1218
  case "MapStateExited":
807
- if (ev.stateExitedEventDetails?.name === "Assemble") {
1219
+ if (isAssembleState(ev.stateExitedEventDetails?.name)) {
808
1220
  assembleComplete = true;
809
1221
  const exitPayload = parseJson(ev.stateExitedEventDetails?.output);
810
1222
  if (exitPayload && typeof exitPayload === "object") {
@@ -883,11 +1295,17 @@ function unwrapLambdaPayload(payload) {
883
1295
  return payload;
884
1296
  }
885
1297
  function applyPayloadFrameCounts(payload, currentLambdaState, bump) {
886
- if (currentLambdaState !== "RenderChunk") return;
1298
+ if (!isRenderChunkState(currentLambdaState)) return;
887
1299
  if (!payload || typeof payload !== "object") return;
888
1300
  const obj = payload;
889
1301
  if (typeof obj.FramesEncoded === "number") bump(obj.FramesEncoded);
890
1302
  }
1303
+ function isRenderChunkState(name) {
1304
+ return name === "RenderChunk" || name === "RenderChunkV2";
1305
+ }
1306
+ function isAssembleState(name) {
1307
+ return name === "Assemble" || name === "AssembleV2";
1308
+ }
891
1309
  function inferBilledMs(payload) {
892
1310
  if (!payload || typeof payload !== "object") return 0;
893
1311
  const obj = payload;
@@ -912,9 +1330,11 @@ function isTerminalFailure(status) {
912
1330
  export {
913
1331
  ChromeBinaryUnavailableError,
914
1332
  InvalidConfigError2 as InvalidConfigError,
1333
+ S3PlanV2ArtifactPublisher,
915
1334
  computeRenderCost,
916
1335
  deploySite,
917
1336
  downloadS3ObjectToFile,
1337
+ downloadS3ObjectToFileVerified,
918
1338
  formatS3Uri,
919
1339
  getRenderProgress,
920
1340
  handler,
@@ -926,6 +1346,7 @@ export {
926
1346
  tarDirectory,
927
1347
  untarDirectory,
928
1348
  unwrapEvent,
1349
+ uploadContentAddressedFileToS3,
929
1350
  uploadFileToS3,
930
1351
  validateDistributedRenderConfig
931
1352
  };