@hyperframes/aws-lambda 0.8.181 → 0.8.184
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/_chunks/{chunk-PK7PO3MH.js → chunk-5FLGGUWE.js} +1 -1
- package/dist/_chunks/{chunk-AK7J3QQN.js → chunk-QCYPNODV.js} +113 -57
- package/dist/_chunks/{chunk-ZY5YIH5W.js → chunk-SGNVVPIS.js} +187 -17
- package/dist/cdk/index.js +38 -7
- package/dist/events.d.ts +16 -3
- package/dist/handler.d.ts +4 -3
- package/dist/handler.js +2 -2
- package/dist/index.js +3 -3
- package/dist/s3Stitch.d.ts +5 -2
- package/dist/s3Transport.d.ts +20 -8
- package/dist/sdk/index.js +2 -2
- package/package.json +3 -2
- package/prebuilt/handler-zip.json +4 -4
- package/prebuilt/template.yaml +149 -71
- package/scripts/pin-handler-zip.ts +2 -13
- package/scripts/prebuilt-template.mjs +19 -0
- package/scripts/build-zip.test.ts +0 -77
- package/scripts/pin-handler-zip.test.ts +0 -101
- package/scripts/probe-beginframe.test.ts +0 -80
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
formatS3Uri,
|
|
8
8
|
isSiteManifestUri,
|
|
9
9
|
mapConcurrent,
|
|
10
|
+
multipartUpload,
|
|
10
11
|
parseS3Uri,
|
|
11
12
|
presignVerifiedS3GetUrl,
|
|
12
13
|
putContentStoreFile,
|
|
@@ -14,20 +15,23 @@ import {
|
|
|
14
15
|
untarDirectory,
|
|
15
16
|
uploadContentAddressedFileToS3,
|
|
16
17
|
uploadFileToS3
|
|
17
|
-
} from "./chunk-
|
|
18
|
+
} from "./chunk-SGNVVPIS.js";
|
|
18
19
|
import {
|
|
19
20
|
CONTENT_STORE_PREFIX
|
|
20
21
|
} from "./chunk-YS3KY3C5.js";
|
|
21
22
|
|
|
22
23
|
// src/handler.ts
|
|
23
24
|
import { existsSync as existsSync2, mkdirSync as mkdirSync2, mkdtempSync as mkdtempSync2, readFileSync, rmSync as rmSync2, statSync as statSync2 } from "node:fs";
|
|
24
|
-
import { tmpdir as tmpdir2 } from "node:os";
|
|
25
|
+
import { availableParallelism, tmpdir as tmpdir2 } from "node:os";
|
|
25
26
|
import { basename, join as join2 } from "node:path";
|
|
26
27
|
import { GetAccountSettingsCommand, LambdaClient } from "@aws-sdk/client-lambda";
|
|
27
28
|
import { S3Client } from "@aws-sdk/client-s3";
|
|
28
29
|
import {
|
|
29
30
|
assemble,
|
|
31
|
+
buildPlanAudio,
|
|
30
32
|
chunksAreStitchedMp4,
|
|
33
|
+
plansSeparateAudio,
|
|
34
|
+
stitchesSeparateAudio,
|
|
31
35
|
DEFAULT_MAX_PARALLEL_CHUNKS,
|
|
32
36
|
listPlanV2ArtifactsForTarget,
|
|
33
37
|
materializePlanV2Target,
|
|
@@ -109,9 +113,6 @@ async function loadSparticuzChromium() {
|
|
|
109
113
|
|
|
110
114
|
// src/s3Stitch.ts
|
|
111
115
|
import {
|
|
112
|
-
AbortMultipartUploadCommand,
|
|
113
|
-
CompleteMultipartUploadCommand,
|
|
114
|
-
CreateMultipartUploadCommand,
|
|
115
116
|
GetObjectCommand,
|
|
116
117
|
UploadPartCommand,
|
|
117
118
|
UploadPartCopyCommand
|
|
@@ -185,41 +186,29 @@ async function sendParts(s3, parts, target) {
|
|
|
185
186
|
(result) => result.status === "rejected"
|
|
186
187
|
);
|
|
187
188
|
if (failure) throw failure.reason;
|
|
188
|
-
return settled.map((result) =>
|
|
189
|
+
return settled.map((result, index) => ({
|
|
190
|
+
ETag: result.value,
|
|
191
|
+
PartNumber: index + 1
|
|
192
|
+
}));
|
|
189
193
|
}
|
|
190
194
|
async function uploadParts(s3, parts, destUri, contentType) {
|
|
191
195
|
const { bucket, key } = parseS3Uri(destUri);
|
|
192
|
-
|
|
193
|
-
|
|
196
|
+
await multipartUpload(
|
|
197
|
+
s3,
|
|
198
|
+
{ Bucket: bucket, Key: key, ContentType: contentType },
|
|
199
|
+
(target) => sendParts(s3, parts, target)
|
|
194
200
|
);
|
|
195
|
-
if (!created.UploadId) throw new Error(`[s3Stitch] no upload id for ${destUri}`);
|
|
196
|
-
const target = { Bucket: bucket, Key: key, UploadId: created.UploadId };
|
|
197
|
-
try {
|
|
198
|
-
const etags = await sendParts(s3, parts, target);
|
|
199
|
-
await s3.send(
|
|
200
|
-
new CompleteMultipartUploadCommand({
|
|
201
|
-
...target,
|
|
202
|
-
MultipartUpload: { Parts: etags.map((ETag, index) => ({ ETag, PartNumber: index + 1 })) }
|
|
203
|
-
})
|
|
204
|
-
);
|
|
205
|
-
} catch (error) {
|
|
206
|
-
await s3.send(new AbortMultipartUploadCommand(target)).catch((abortError) => {
|
|
207
|
-
console.error(
|
|
208
|
-
`[s3Stitch] abort failed for ${destUri}; parts remain until a lifecycle rule removes them`,
|
|
209
|
-
abortError
|
|
210
|
-
);
|
|
211
|
-
});
|
|
212
|
-
throw error;
|
|
213
|
-
}
|
|
214
201
|
}
|
|
215
|
-
async function stitchMp4InS3(s3, chunkUris, destUri, minPartBytes = MIN_PART_BYTES) {
|
|
202
|
+
async function stitchMp4InS3(s3, chunkUris, destUri, { audioUri, minPartBytes = MIN_PART_BYTES } = {}) {
|
|
203
|
+
const sourceUris = audioUri ? [audioUri, ...chunkUris] : chunkUris;
|
|
216
204
|
const layouts = await Promise.all(
|
|
217
|
-
|
|
205
|
+
sourceUris.map((uri) => readMp4Layout((start, end) => getRange(s3, uri, start, end)))
|
|
218
206
|
);
|
|
207
|
+
const header = audioUri ? buildStitchedHeader(layouts.slice(1), layouts[0]) : buildStitchedHeader(layouts);
|
|
219
208
|
const segments = [
|
|
220
|
-
{ bytes:
|
|
209
|
+
{ bytes: header },
|
|
221
210
|
...layouts.map((layout, i) => ({
|
|
222
|
-
uri:
|
|
211
|
+
uri: sourceUris[i],
|
|
223
212
|
start: layout.payloadStart,
|
|
224
213
|
end: layout.payloadEnd
|
|
225
214
|
}))
|
|
@@ -365,6 +354,8 @@ async function handler(event, deps) {
|
|
|
365
354
|
switch (unwrapped.Action) {
|
|
366
355
|
case "plan":
|
|
367
356
|
return await handlePlan(unwrapped, deps);
|
|
357
|
+
case "audio":
|
|
358
|
+
return await handleAudio(unwrapped, deps);
|
|
368
359
|
case "renderChunk":
|
|
369
360
|
return await handleRenderChunk(unwrapped, deps);
|
|
370
361
|
case "assemble":
|
|
@@ -422,7 +413,7 @@ function unwrapEvent(event) {
|
|
|
422
413
|
);
|
|
423
414
|
}
|
|
424
415
|
function isLambdaAction(value) {
|
|
425
|
-
return value === "plan" || value === "renderChunk" || value === "assemble";
|
|
416
|
+
return value === "plan" || value === "audio" || value === "renderChunk" || value === "assemble";
|
|
426
417
|
}
|
|
427
418
|
function validatePlanProtocolShape(event) {
|
|
428
419
|
const raw = event;
|
|
@@ -434,7 +425,7 @@ function validatePlanProtocolShape(event) {
|
|
|
434
425
|
error.name = "PLAN_PROTOCOL_UNSUPPORTED";
|
|
435
426
|
throw error;
|
|
436
427
|
}
|
|
437
|
-
if (event.Action === "plan") return;
|
|
428
|
+
if (event.Action === "plan" || event.Action === "audio") return;
|
|
438
429
|
const effectiveProtocol = protocol ?? "v2";
|
|
439
430
|
const hasV1Locator = typeof raw.PlanS3Uri === "string";
|
|
440
431
|
const hasV2Manifest = typeof raw.PlanV2ManifestS3Uri === "string";
|
|
@@ -447,11 +438,6 @@ function validatePlanProtocolShape(event) {
|
|
|
447
438
|
error.name = "PLAN_PROTOCOL_UNSUPPORTED";
|
|
448
439
|
throw error;
|
|
449
440
|
}
|
|
450
|
-
if (effectiveProtocol === "v2" && event.Action === "assemble" && event.AudioS3Uri !== null) {
|
|
451
|
-
const error = new Error("[handler] v2 assemble audio must be materialized from the manifest");
|
|
452
|
-
error.name = "PLAN_PROTOCOL_UNSUPPORTED";
|
|
453
|
-
throw error;
|
|
454
|
-
}
|
|
455
441
|
}
|
|
456
442
|
function logEvent(payload) {
|
|
457
443
|
console.log(JSON.stringify(payload));
|
|
@@ -466,6 +452,13 @@ function summarizeEvent(event) {
|
|
|
466
452
|
format: event.Config.format,
|
|
467
453
|
fps: event.Config.fps
|
|
468
454
|
};
|
|
455
|
+
case "audio":
|
|
456
|
+
return {
|
|
457
|
+
projectS3Uri: event.ProjectS3Uri,
|
|
458
|
+
planOutputS3Prefix: event.PlanOutputS3Prefix,
|
|
459
|
+
format: event.Config.format,
|
|
460
|
+
fps: event.Config.fps
|
|
461
|
+
};
|
|
469
462
|
case "renderChunk":
|
|
470
463
|
return {
|
|
471
464
|
planProtocol: event.PlanProtocol ?? "v2",
|
|
@@ -539,10 +532,7 @@ async function handlePlan(event, deps) {
|
|
|
539
532
|
const config = await planConfig(event, deps);
|
|
540
533
|
const s3 = deps?.s3 ?? getS3Client();
|
|
541
534
|
const primitive = deps?.primitives?.plan ?? plan;
|
|
542
|
-
|
|
543
|
-
const chromePath = await resolveChromeExecutablePath();
|
|
544
|
-
process.env.PRODUCER_HEADLESS_SHELL_PATH = chromePath;
|
|
545
|
-
}
|
|
535
|
+
await ensureChromePath(deps);
|
|
546
536
|
const work = mkdtempSync2(join2(deps?.tmpRoot ?? tmpdir2(), "hf-lambda-plan-"));
|
|
547
537
|
const planDir = join2(work, "plan");
|
|
548
538
|
try {
|
|
@@ -601,9 +591,7 @@ async function handlePlanV2(event, deps) {
|
|
|
601
591
|
const config = await planConfig(event, deps);
|
|
602
592
|
const s3 = deps?.s3 ?? getS3Client();
|
|
603
593
|
const primitive = deps?.primitives?.planV2WithPublisher ?? planV2WithPublisher;
|
|
604
|
-
|
|
605
|
-
process.env.PRODUCER_HEADLESS_SHELL_PATH = await resolveChromeExecutablePath();
|
|
606
|
-
}
|
|
594
|
+
await ensureChromePath(deps);
|
|
607
595
|
const work = mkdtempSync2(join2(deps?.tmpRoot ?? tmpdir2(), "hf-lambda-plan-v2-"));
|
|
608
596
|
try {
|
|
609
597
|
const { projectDir, remoteFiles } = await fetchProject(s3, event.ProjectS3Uri, work, true);
|
|
@@ -612,10 +600,12 @@ async function handlePlanV2(event, deps) {
|
|
|
612
600
|
planOutputS3Prefix: event.PlanOutputS3Prefix,
|
|
613
601
|
temporaryRoot: work
|
|
614
602
|
});
|
|
603
|
+
const separateAudio = config.workerConcurrency > 1 && plansSeparateAudio(config, { deferAudio: true });
|
|
615
604
|
const manifest = await primitive(projectDir, config, publisher, {
|
|
616
605
|
stagingParentDir: work,
|
|
617
606
|
remoteFiles,
|
|
618
|
-
chunksCarryAudio: true
|
|
607
|
+
chunksCarryAudio: true,
|
|
608
|
+
deferAudio: separateAudio
|
|
619
609
|
});
|
|
620
610
|
return {
|
|
621
611
|
Action: "plan",
|
|
@@ -624,7 +614,10 @@ async function handlePlanV2(event, deps) {
|
|
|
624
614
|
PlanV2ArtifactS3Prefix: publisher.artifactPrefix,
|
|
625
615
|
PlanHash: manifest.planHash,
|
|
626
616
|
ChunkCount: manifest.chunkCount,
|
|
627
|
-
MaxChunkConcurrency: Math.min(
|
|
617
|
+
MaxChunkConcurrency: Math.min(
|
|
618
|
+
config.workerConcurrency - (separateAudio ? 1 : 0),
|
|
619
|
+
manifest.chunkCount
|
|
620
|
+
),
|
|
628
621
|
TotalFrames: manifest.totalFrames,
|
|
629
622
|
Fps: manifest.fps,
|
|
630
623
|
Width: manifest.width,
|
|
@@ -632,6 +625,7 @@ async function handlePlanV2(event, deps) {
|
|
|
632
625
|
Format: manifest.format,
|
|
633
626
|
HasAudio: manifest.artifacts.some((artifact) => isPlanAudioArtifactPath(artifact.path)),
|
|
634
627
|
AudioS3Uri: null,
|
|
628
|
+
SeparateAudio: separateAudio,
|
|
635
629
|
FfmpegVersion: manifest.ffmpegVersion,
|
|
636
630
|
ProducerVersion: manifest.producerVersion,
|
|
637
631
|
DurationMs: Date.now() - started
|
|
@@ -640,6 +634,49 @@ async function handlePlanV2(event, deps) {
|
|
|
640
634
|
cleanupDir(work);
|
|
641
635
|
}
|
|
642
636
|
}
|
|
637
|
+
async function ensureChromePath(deps) {
|
|
638
|
+
if (deps?.skipChromeResolution || process.env.PRODUCER_HEADLESS_SHELL_PATH) return;
|
|
639
|
+
process.env.PRODUCER_HEADLESS_SHELL_PATH = await resolveChromeExecutablePath();
|
|
640
|
+
}
|
|
641
|
+
async function handleAudio(event, deps) {
|
|
642
|
+
const started = Date.now();
|
|
643
|
+
const none = () => ({
|
|
644
|
+
Action: "audio",
|
|
645
|
+
HasAudio: false,
|
|
646
|
+
AudioS3Uri: null,
|
|
647
|
+
DurationMs: Date.now() - started
|
|
648
|
+
});
|
|
649
|
+
if (!plansSeparateAudio(event.Config, { deferAudio: true })) return none();
|
|
650
|
+
const s3 = deps?.s3 ?? getS3Client();
|
|
651
|
+
await ensureChromePath(deps);
|
|
652
|
+
const work = mkdtempSync2(join2(deps?.tmpRoot ?? tmpdir2(), "hf-lambda-audio-"));
|
|
653
|
+
try {
|
|
654
|
+
const { projectDir, remoteFiles } = await fetchProject(s3, event.ProjectS3Uri, work, true);
|
|
655
|
+
const audioPath = join2(work, "audio.m4a");
|
|
656
|
+
const build = deps?.primitives?.buildPlanAudio ?? buildPlanAudio;
|
|
657
|
+
const { hasAudio } = await build(projectDir, event.Config, join2(work, "plan"), audioPath, {
|
|
658
|
+
remoteFiles
|
|
659
|
+
});
|
|
660
|
+
if (!hasAudio) return none();
|
|
661
|
+
const audioUri = `${trimTrailingSlash2(event.PlanOutputS3Prefix)}/audio/${PLAN_AUDIO_RELATIVE_PATH}`;
|
|
662
|
+
await uploadFileToS3(s3, audioPath, audioUri, "audio/mp4");
|
|
663
|
+
return {
|
|
664
|
+
Action: "audio",
|
|
665
|
+
HasAudio: true,
|
|
666
|
+
AudioS3Uri: audioUri,
|
|
667
|
+
DurationMs: Date.now() - started
|
|
668
|
+
};
|
|
669
|
+
} finally {
|
|
670
|
+
cleanupDir(work);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
var LAMBDA_MB_PER_CAPTURE_BROWSER = 2048;
|
|
674
|
+
function lambdaMaxCaptureWorkers() {
|
|
675
|
+
const memoryMb = Number(process.env.AWS_LAMBDA_FUNCTION_MEMORY_SIZE);
|
|
676
|
+
if (!(memoryMb > 0)) return void 0;
|
|
677
|
+
const byMemory = Math.floor(memoryMb / LAMBDA_MB_PER_CAPTURE_BROWSER);
|
|
678
|
+
return Math.max(1, Math.min(byMemory, availableParallelism() - 1));
|
|
679
|
+
}
|
|
643
680
|
async function handleRenderChunk(event, deps) {
|
|
644
681
|
if (event.PlanProtocol !== "v1") {
|
|
645
682
|
return handleRenderChunkV2(event, deps);
|
|
@@ -647,10 +684,7 @@ async function handleRenderChunk(event, deps) {
|
|
|
647
684
|
const started = Date.now();
|
|
648
685
|
const s3 = deps?.s3 ?? getS3Client();
|
|
649
686
|
const primitive = deps?.primitives?.renderChunk ?? renderChunk;
|
|
650
|
-
|
|
651
|
-
const chromePath = await resolveChromeExecutablePath();
|
|
652
|
-
process.env.PRODUCER_HEADLESS_SHELL_PATH = chromePath;
|
|
653
|
-
}
|
|
687
|
+
await ensureChromePath(deps);
|
|
654
688
|
const work = mkdtempSync2(join2(deps?.tmpRoot ?? tmpdir2(), "hf-lambda-chunk-"));
|
|
655
689
|
const planTar = join2(work, "plan.tar.gz");
|
|
656
690
|
const planDir = join2(work, "plan");
|
|
@@ -662,7 +696,15 @@ async function handleRenderChunk(event, deps) {
|
|
|
662
696
|
work,
|
|
663
697
|
event.Format === "png-sequence" ? `chunk-${pad(event.ChunkIndex)}` : `chunk-${pad(event.ChunkIndex)}${formatExtension(event.Format)}`
|
|
664
698
|
);
|
|
665
|
-
const result = await primitive(planDir, event.ChunkIndex, chunkOutputBase
|
|
699
|
+
const result = await primitive(planDir, event.ChunkIndex, chunkOutputBase, {
|
|
700
|
+
maxCaptureWorkers: lambdaMaxCaptureWorkers()
|
|
701
|
+
});
|
|
702
|
+
logEvent({
|
|
703
|
+
event: "chunk_capture",
|
|
704
|
+
chunkIndex: event.ChunkIndex,
|
|
705
|
+
workers: result.workers,
|
|
706
|
+
captureMode: result.captureMode
|
|
707
|
+
});
|
|
666
708
|
const chunkUri = await uploadChunkOutput(
|
|
667
709
|
s3,
|
|
668
710
|
result,
|
|
@@ -686,9 +728,7 @@ async function handleRenderChunkV2(event, deps) {
|
|
|
686
728
|
const started = Date.now();
|
|
687
729
|
const s3 = deps?.s3 ?? getS3Client();
|
|
688
730
|
const primitive = deps?.primitives?.renderChunk ?? renderChunk;
|
|
689
|
-
|
|
690
|
-
process.env.PRODUCER_HEADLESS_SHELL_PATH = await resolveChromeExecutablePath();
|
|
691
|
-
}
|
|
731
|
+
await ensureChromePath(deps);
|
|
692
732
|
const work = mkdtempSync2(join2(deps?.tmpRoot ?? tmpdir2(), "hf-lambda-chunk-v2-"));
|
|
693
733
|
try {
|
|
694
734
|
const { planDir } = await downloadAndMaterializePlanV2(
|
|
@@ -701,7 +741,15 @@ async function handleRenderChunkV2(event, deps) {
|
|
|
701
741
|
work,
|
|
702
742
|
event.Format === "png-sequence" ? `chunk-${pad(event.ChunkIndex)}` : `chunk-${pad(event.ChunkIndex)}${formatExtension(event.Format)}`
|
|
703
743
|
);
|
|
704
|
-
const result = await primitive(planDir, event.ChunkIndex, chunkOutputBase
|
|
744
|
+
const result = await primitive(planDir, event.ChunkIndex, chunkOutputBase, {
|
|
745
|
+
maxCaptureWorkers: lambdaMaxCaptureWorkers()
|
|
746
|
+
});
|
|
747
|
+
logEvent({
|
|
748
|
+
event: "chunk_capture",
|
|
749
|
+
chunkIndex: event.ChunkIndex,
|
|
750
|
+
workers: result.workers,
|
|
751
|
+
captureMode: result.captureMode
|
|
752
|
+
});
|
|
705
753
|
const chunkUri = await uploadChunkOutput(
|
|
706
754
|
s3,
|
|
707
755
|
result,
|
|
@@ -788,6 +836,11 @@ async function handleAssembleV2(event, deps) {
|
|
|
788
836
|
{ role: "assembler" },
|
|
789
837
|
work
|
|
790
838
|
);
|
|
839
|
+
if (event.AudioS3Uri !== null && !stitchesSeparateAudio(planDir)) {
|
|
840
|
+
const error = new Error("[handler] v2 assemble audio must be materialized from the manifest");
|
|
841
|
+
error.name = "PLAN_PROTOCOL_UNSUPPORTED";
|
|
842
|
+
throw error;
|
|
843
|
+
}
|
|
791
844
|
if (chunksAreStitchedMp4(planDir, event.Cfr === true)) {
|
|
792
845
|
return await stitchChunksInS3(s3, event, manifest, started);
|
|
793
846
|
}
|
|
@@ -832,7 +885,9 @@ async function stitchChunksInS3(s3, event, plan2, started) {
|
|
|
832
885
|
);
|
|
833
886
|
}
|
|
834
887
|
const stitchStarted = Date.now();
|
|
835
|
-
const stitched = await stitchMp4InS3(s3, event.ChunkS3Uris, event.OutputS3Uri
|
|
888
|
+
const stitched = await stitchMp4InS3(s3, event.ChunkS3Uris, event.OutputS3Uri, {
|
|
889
|
+
audioUri: event.AudioS3Uri ?? void 0
|
|
890
|
+
});
|
|
836
891
|
logEvent({
|
|
837
892
|
event: "assemble_stages",
|
|
838
893
|
mode: "s3-stitch",
|
|
@@ -936,6 +991,7 @@ async function downloadChunkObjects(s3, uris, workDir, format) {
|
|
|
936
991
|
function getEventS3Uris(event) {
|
|
937
992
|
switch (event.Action) {
|
|
938
993
|
case "plan":
|
|
994
|
+
case "audio":
|
|
939
995
|
return [event.ProjectS3Uri, event.PlanOutputS3Prefix];
|
|
940
996
|
case "renderChunk":
|
|
941
997
|
return event.PlanProtocol !== "v1" ? [event.PlanV2ManifestS3Uri, event.PlanV2ArtifactS3Prefix, event.ChunkOutputS3Prefix] : [event.PlanS3Uri, event.ChunkOutputS3Prefix];
|
|
@@ -16,13 +16,23 @@ import { createHash } from "node:crypto";
|
|
|
16
16
|
import { dirname } from "node:path";
|
|
17
17
|
import { pipeline } from "node:stream/promises";
|
|
18
18
|
import {
|
|
19
|
+
AbortMultipartUploadCommand,
|
|
20
|
+
CompleteMultipartUploadCommand,
|
|
19
21
|
CopyObjectCommand,
|
|
22
|
+
CreateMultipartUploadCommand,
|
|
20
23
|
GetObjectCommand,
|
|
21
24
|
HeadObjectCommand,
|
|
22
|
-
PutObjectCommand
|
|
25
|
+
PutObjectCommand,
|
|
26
|
+
UploadPartCommand,
|
|
27
|
+
UploadPartCopyCommand
|
|
23
28
|
} from "@aws-sdk/client-s3";
|
|
24
29
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
25
30
|
import * as tar from "tar";
|
|
31
|
+
var MAX_SINGLE_PUT_BYTES = 5 * 1024 ** 3;
|
|
32
|
+
var MAX_PARTS = 1e4;
|
|
33
|
+
var MIN_FILE_PART_BYTES = 64 * 1024 * 1024;
|
|
34
|
+
var FILE_PARTS_IN_FLIGHT = 8;
|
|
35
|
+
var PART_ATTEMPTS = 3;
|
|
26
36
|
function parseS3Uri(uri) {
|
|
27
37
|
if (!uri.startsWith("s3://")) {
|
|
28
38
|
throw new Error(`[s3Transport] expected s3:// URI, got: ${JSON.stringify(uri)}`);
|
|
@@ -72,7 +82,8 @@ async function presignVerifiedS3GetUrl(client, uri, expectedSha256, expectedSize
|
|
|
72
82
|
new HeadObjectCommand({ Bucket: bucket, Key: key, ChecksumMode: "ENABLED" })
|
|
73
83
|
);
|
|
74
84
|
const expectedChecksum = Buffer.from(expectedSha256, "hex").toString("base64");
|
|
75
|
-
|
|
85
|
+
const attested = head.ChecksumSHA256 === expectedChecksum || isDigestCheckedMultipart(head, expectedSha256);
|
|
86
|
+
if (!attested || head.ContentLength !== expectedSize) {
|
|
76
87
|
const error = new Error(
|
|
77
88
|
`[s3Transport] PLAN_ARTIFACT_DIGEST_MISMATCH: ${uri} expected ${expectedSha256} (${expectedSize} bytes), S3 holds checksum ${String(head.ChecksumSHA256)} (${String(head.ContentLength)} bytes)`
|
|
78
89
|
);
|
|
@@ -83,12 +94,23 @@ async function presignVerifiedS3GetUrl(client, uri, expectedSha256, expectedSize
|
|
|
83
94
|
expiresIn: expiresInSeconds
|
|
84
95
|
});
|
|
85
96
|
}
|
|
97
|
+
function isDigestCheckedMultipart(head, expectedSha256) {
|
|
98
|
+
return head.ChecksumType === "COMPOSITE" && (head.ContentLength ?? 0) > MAX_SINGLE_PUT_BYTES && head.Metadata?.sha256 === expectedSha256;
|
|
99
|
+
}
|
|
86
100
|
async function uploadFileToS3(client, localPath, uri, contentType) {
|
|
87
101
|
if (!existsSync(localPath)) {
|
|
88
102
|
throw new Error(`[s3Transport] upload source missing: ${localPath}`);
|
|
89
103
|
}
|
|
90
104
|
const { bucket, key } = parseS3Uri(uri);
|
|
91
105
|
const size = statSync(localPath).size;
|
|
106
|
+
if (size > MAX_SINGLE_PUT_BYTES) {
|
|
107
|
+
await uploadFileInParts(client, localPath, size, {
|
|
108
|
+
Bucket: bucket,
|
|
109
|
+
Key: key,
|
|
110
|
+
ContentType: contentType
|
|
111
|
+
});
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
92
114
|
await client.send(
|
|
93
115
|
new PutObjectCommand({
|
|
94
116
|
Bucket: bucket,
|
|
@@ -111,8 +133,25 @@ async function putImmutableFile(client, localPath, uri, expectedSha256, contentT
|
|
|
111
133
|
const size = statSync(localPath).size;
|
|
112
134
|
const existing = await inspectContentAddressedObject(client, bucket, key, size, expectedSha256);
|
|
113
135
|
if (existing.state === "matching")
|
|
114
|
-
return { outcome: "reused", lastModified: existing.lastModified };
|
|
136
|
+
return { outcome: "reused", lastModified: existing.lastModified, size };
|
|
115
137
|
if (existing.state === "conflict") throwImmutableObjectConflict(uri);
|
|
138
|
+
try {
|
|
139
|
+
if (size > MAX_SINGLE_PUT_BYTES) {
|
|
140
|
+
const create2 = { Bucket: bucket, Key: key, ContentType: contentType };
|
|
141
|
+
await uploadFileInParts(client, localPath, size, create2, expectedSha256);
|
|
142
|
+
} else {
|
|
143
|
+
await putImmutableBody(client, localPath, bucket, key, size, expectedSha256, contentType);
|
|
144
|
+
}
|
|
145
|
+
return { outcome: "uploaded" };
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (!isS3PreconditionFailed(error)) throw error;
|
|
148
|
+
const raced = await inspectContentAddressedObject(client, bucket, key, size, expectedSha256);
|
|
149
|
+
if (raced.state === "matching") return { outcome: "reused" };
|
|
150
|
+
if (raced.state === "conflict") throwImmutableObjectConflict(uri);
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
async function putImmutableBody(client, localPath, bucket, key, size, expectedSha256, contentType) {
|
|
116
155
|
const body = createReadStream(localPath);
|
|
117
156
|
try {
|
|
118
157
|
await client.send(
|
|
@@ -124,23 +163,119 @@ async function putImmutableFile(client, localPath, uri, expectedSha256, contentT
|
|
|
124
163
|
ContentLength: size,
|
|
125
164
|
Metadata: { sha256: expectedSha256 },
|
|
126
165
|
ChecksumSHA256: Buffer.from(expectedSha256, "hex").toString("base64"),
|
|
127
|
-
// HEAD followed by an unconditional PUT can overwrite a conflicting
|
|
128
|
-
// object published by a concurrent planner. Conditional create makes
|
|
129
|
-
// immutable CAS and fixed-key manifest publication race-safe.
|
|
130
166
|
IfNoneMatch: "*"
|
|
131
167
|
})
|
|
132
168
|
);
|
|
133
|
-
return { outcome: "uploaded" };
|
|
134
|
-
} catch (error) {
|
|
135
|
-
if (!isS3PreconditionFailed(error)) throw error;
|
|
136
|
-
const raced = await inspectContentAddressedObject(client, bucket, key, size, expectedSha256);
|
|
137
|
-
if (raced.state === "matching") return { outcome: "reused" };
|
|
138
|
-
if (raced.state === "conflict") throwImmutableObjectConflict(uri);
|
|
139
|
-
throw error;
|
|
140
169
|
} finally {
|
|
141
170
|
body.destroy();
|
|
142
171
|
}
|
|
143
172
|
}
|
|
173
|
+
async function multipartUpload(client, create2, sendParts, complete = {}) {
|
|
174
|
+
const created = await client.send(new CreateMultipartUploadCommand(create2));
|
|
175
|
+
const uri = formatS3Uri({ bucket: create2.Bucket, key: create2.Key });
|
|
176
|
+
if (!created.UploadId) throw new Error(`[s3Transport] no multipart upload id for ${uri}`);
|
|
177
|
+
const target = { Bucket: create2.Bucket, Key: create2.Key, UploadId: created.UploadId };
|
|
178
|
+
try {
|
|
179
|
+
const parts = await sendParts(target);
|
|
180
|
+
await client.send(
|
|
181
|
+
new CompleteMultipartUploadCommand({
|
|
182
|
+
...target,
|
|
183
|
+
MultipartUpload: { Parts: parts },
|
|
184
|
+
IfNoneMatch: complete.ifNoneMatch,
|
|
185
|
+
MpuObjectSize: complete.objectSize
|
|
186
|
+
})
|
|
187
|
+
);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
await client.send(new AbortMultipartUploadCommand(target)).catch((abortError) => {
|
|
190
|
+
console.error(
|
|
191
|
+
`[s3Transport] abort failed for ${uri}; parts remain until a lifecycle rule removes them`,
|
|
192
|
+
abortError
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async function uploadFileInParts(client, localPath, size, create2, expectedSha256) {
|
|
199
|
+
const partBytes = Math.max(MIN_FILE_PART_BYTES, Math.ceil(size / MAX_PARTS));
|
|
200
|
+
const sendParts = async (target) => {
|
|
201
|
+
const whole = createHash("sha256");
|
|
202
|
+
const parts = [];
|
|
203
|
+
const inFlight = /* @__PURE__ */ new Set();
|
|
204
|
+
let failure;
|
|
205
|
+
try {
|
|
206
|
+
for (let start = 0; start < size && !failure; start += partBytes) {
|
|
207
|
+
if (inFlight.size >= FILE_PARTS_IN_FLIGHT) await Promise.race(inFlight);
|
|
208
|
+
const range = { start, end: Math.min(start + partBytes, size) };
|
|
209
|
+
const checksum = await hashRange(localPath, range, whole);
|
|
210
|
+
const sent = sendFilePart(client, target, parts.length + 1, localPath, range, checksum);
|
|
211
|
+
parts.push(sent);
|
|
212
|
+
const slot = sent.then(
|
|
213
|
+
() => void inFlight.delete(slot),
|
|
214
|
+
(error) => {
|
|
215
|
+
failure ??= { error };
|
|
216
|
+
inFlight.delete(slot);
|
|
217
|
+
}
|
|
218
|
+
);
|
|
219
|
+
inFlight.add(slot);
|
|
220
|
+
}
|
|
221
|
+
} finally {
|
|
222
|
+
await Promise.all(inFlight);
|
|
223
|
+
}
|
|
224
|
+
if (failure) throw failure.error;
|
|
225
|
+
const actual = whole.digest("hex");
|
|
226
|
+
if (expectedSha256 && actual !== expectedSha256) {
|
|
227
|
+
const error = new Error(
|
|
228
|
+
`[s3Transport] PLAN_ARTIFACT_DIGEST_MISMATCH: ${localPath} changed: expected ${expectedSha256}, read ${actual}`
|
|
229
|
+
);
|
|
230
|
+
error.name = "PLAN_ARTIFACT_DIGEST_MISMATCH";
|
|
231
|
+
throw error;
|
|
232
|
+
}
|
|
233
|
+
return Promise.all(parts);
|
|
234
|
+
};
|
|
235
|
+
await multipartUpload(
|
|
236
|
+
client,
|
|
237
|
+
{
|
|
238
|
+
...create2,
|
|
239
|
+
Metadata: expectedSha256 ? { sha256: expectedSha256 } : void 0,
|
|
240
|
+
ChecksumAlgorithm: "SHA256"
|
|
241
|
+
},
|
|
242
|
+
sendParts,
|
|
243
|
+
{ ifNoneMatch: expectedSha256 ? "*" : void 0, objectSize: size }
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
async function hashRange(path, { start, end }, whole) {
|
|
247
|
+
const part = createHash("sha256");
|
|
248
|
+
let read = 0;
|
|
249
|
+
for await (const chunk of createReadStream(path, { start, end: end - 1 })) {
|
|
250
|
+
whole.update(chunk);
|
|
251
|
+
part.update(chunk);
|
|
252
|
+
read += chunk.length;
|
|
253
|
+
}
|
|
254
|
+
if (read !== end - start) throw new Error(`[s3Transport] ${path} shrank while uploading`);
|
|
255
|
+
return part.digest("base64");
|
|
256
|
+
}
|
|
257
|
+
async function sendFilePart(client, target, partNumber, path, { start, end }, checksum) {
|
|
258
|
+
for (let attempt = 1; ; attempt++) {
|
|
259
|
+
const body = createReadStream(path, { start, end: end - 1 });
|
|
260
|
+
try {
|
|
261
|
+
const result = await client.send(
|
|
262
|
+
new UploadPartCommand({
|
|
263
|
+
...target,
|
|
264
|
+
PartNumber: partNumber,
|
|
265
|
+
Body: body,
|
|
266
|
+
ContentLength: end - start,
|
|
267
|
+
ChecksumSHA256: checksum
|
|
268
|
+
})
|
|
269
|
+
);
|
|
270
|
+
if (!result.ETag) throw new Error(`[s3Transport] part ${partNumber} returned no ETag`);
|
|
271
|
+
return { ETag: result.ETag, PartNumber: partNumber, ChecksumSHA256: checksum };
|
|
272
|
+
} catch (error) {
|
|
273
|
+
if (attempt >= PART_ATTEMPTS) throw error;
|
|
274
|
+
} finally {
|
|
275
|
+
body.destroy();
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
144
279
|
async function mapConcurrent(values, concurrency, fn) {
|
|
145
280
|
let cursor = 0;
|
|
146
281
|
async function worker() {
|
|
@@ -184,8 +319,8 @@ async function inspectContentAddressedObject(client, bucket, key, expectedSize,
|
|
|
184
319
|
}
|
|
185
320
|
async function putContentStoreFile(client, localPath, bucket, sha256) {
|
|
186
321
|
const uri = contentStoreUri(bucket, sha256);
|
|
187
|
-
const { outcome, lastModified } = await putImmutableFile(client, localPath, uri, sha256);
|
|
188
|
-
if (lastModified) await refreshAgingObject(client, uri, sha256, lastModified);
|
|
322
|
+
const { outcome, lastModified, size } = await putImmutableFile(client, localPath, uri, sha256);
|
|
323
|
+
if (lastModified) await refreshAgingObject(client, uri, sha256, lastModified, size ?? 0);
|
|
189
324
|
return outcome;
|
|
190
325
|
}
|
|
191
326
|
async function ensureContentStoreObject(client, bucket, sha256, sizeBytes) {
|
|
@@ -193,7 +328,9 @@ async function ensureContentStoreObject(client, bucket, sha256, sizeBytes) {
|
|
|
193
328
|
const { key } = parseS3Uri(uri);
|
|
194
329
|
const existing = await inspectContentAddressedObject(client, bucket, key, sizeBytes, sha256);
|
|
195
330
|
if (existing.state === "matching") {
|
|
196
|
-
if (existing.lastModified)
|
|
331
|
+
if (existing.lastModified) {
|
|
332
|
+
await refreshAgingObject(client, uri, sha256, existing.lastModified, sizeBytes);
|
|
333
|
+
}
|
|
197
334
|
return;
|
|
198
335
|
}
|
|
199
336
|
if (existing.state === "conflict") throwImmutableObjectConflict(uri);
|
|
@@ -209,10 +346,42 @@ async function presignS3GetUrl(client, uri, expiresInSeconds) {
|
|
|
209
346
|
expiresIn: expiresInSeconds
|
|
210
347
|
});
|
|
211
348
|
}
|
|
212
|
-
async function refreshAgingObject(client, uri, sha256, lastModified) {
|
|
349
|
+
async function refreshAgingObject(client, uri, sha256, lastModified, size) {
|
|
213
350
|
const { bucket, key } = parseS3Uri(uri);
|
|
214
351
|
const ageMs = Date.now() - lastModified.getTime();
|
|
215
352
|
if (ageMs < CONTENT_STORE_REFRESH_AFTER_DAYS * 864e5) return;
|
|
353
|
+
if (size > MAX_SINGLE_PUT_BYTES) {
|
|
354
|
+
const create2 = {
|
|
355
|
+
Bucket: bucket,
|
|
356
|
+
Key: key,
|
|
357
|
+
Metadata: { sha256 },
|
|
358
|
+
ChecksumAlgorithm: "SHA256"
|
|
359
|
+
};
|
|
360
|
+
await multipartUpload(client, create2, async (target) => {
|
|
361
|
+
const parts = [];
|
|
362
|
+
for (let start = 0; start < size; start += MAX_SINGLE_PUT_BYTES) {
|
|
363
|
+
const end = Math.min(start + MAX_SINGLE_PUT_BYTES, size);
|
|
364
|
+
const PartNumber = parts.length + 1;
|
|
365
|
+
const { CopyPartResult } = await client.send(
|
|
366
|
+
new UploadPartCopyCommand({
|
|
367
|
+
...target,
|
|
368
|
+
PartNumber,
|
|
369
|
+
CopySource: `${bucket}/${key}`,
|
|
370
|
+
CopySourceRange: `bytes=${start}-${end - 1}`
|
|
371
|
+
})
|
|
372
|
+
);
|
|
373
|
+
if (!CopyPartResult?.ETag)
|
|
374
|
+
throw new Error(`[s3Transport] copy part ${PartNumber} of ${uri} returned no ETag`);
|
|
375
|
+
parts.push({
|
|
376
|
+
ETag: CopyPartResult.ETag,
|
|
377
|
+
PartNumber,
|
|
378
|
+
ChecksumSHA256: CopyPartResult.ChecksumSHA256
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
return parts;
|
|
382
|
+
});
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
216
385
|
await client.send(
|
|
217
386
|
new CopyObjectCommand({
|
|
218
387
|
Bucket: bucket,
|
|
@@ -361,6 +530,7 @@ export {
|
|
|
361
530
|
presignVerifiedS3GetUrl,
|
|
362
531
|
uploadFileToS3,
|
|
363
532
|
uploadContentAddressedFileToS3,
|
|
533
|
+
multipartUpload,
|
|
364
534
|
mapConcurrent,
|
|
365
535
|
sha256File,
|
|
366
536
|
putContentStoreFile,
|