@projectdochelp/s3te 1.0.0

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,578 @@
1
+ import {
2
+ buildEnvironmentRuntimeConfig
3
+ } from "../../core/src/index.mjs";
4
+
5
+ function cfName(...parts) {
6
+ return parts.join("");
7
+ }
8
+
9
+ function sanitizeLogicalId(value) {
10
+ return value.replace(/[^A-Za-z0-9]/g, "");
11
+ }
12
+
13
+ function websiteEndpoint(bucketNameExpression) {
14
+ return {
15
+ "Fn::Join": [
16
+ "",
17
+ [
18
+ bucketNameExpression,
19
+ ".s3-website-",
20
+ { Ref: "AWS::Region" },
21
+ ".amazonaws.com"
22
+ ]
23
+ ]
24
+ };
25
+ }
26
+
27
+ function handlerPath(name) {
28
+ return `packages/aws-adapter/src/runtime/${name}.handler`;
29
+ }
30
+
31
+ function lambdaCode(keyParameter) {
32
+ return {
33
+ S3Bucket: { Ref: "ArtifactBucket" },
34
+ S3Key: { Ref: keyParameter }
35
+ };
36
+ }
37
+
38
+ function lambdaRuntimeProperties(runtimeConfig, roleRef, name, keyParameter, handlerName, extra = {}) {
39
+ return {
40
+ Type: "AWS::Lambda::Function",
41
+ Properties: {
42
+ FunctionName: name,
43
+ Role: { "Fn::GetAtt": [roleRef, "Arn"] },
44
+ Runtime: runtimeConfig.lambda.runtime,
45
+ Handler: handlerPath(handlerName),
46
+ Architectures: [runtimeConfig.lambda.architecture],
47
+ Code: lambdaCode(keyParameter),
48
+ ...extra
49
+ }
50
+ };
51
+ }
52
+
53
+ function createExecutionRole(roleName) {
54
+ return {
55
+ Type: "AWS::IAM::Role",
56
+ Properties: {
57
+ RoleName: roleName,
58
+ AssumeRolePolicyDocument: {
59
+ Version: "2012-10-17",
60
+ Statement: [
61
+ {
62
+ Effect: "Allow",
63
+ Principal: {
64
+ Service: ["lambda.amazonaws.com"]
65
+ },
66
+ Action: ["sts:AssumeRole"]
67
+ }
68
+ ]
69
+ },
70
+ ManagedPolicyArns: [
71
+ "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
72
+ ],
73
+ Policies: [
74
+ {
75
+ PolicyName: `${roleName}_access`,
76
+ PolicyDocument: {
77
+ Version: "2012-10-17",
78
+ Statement: [
79
+ {
80
+ Effect: "Allow",
81
+ Action: [
82
+ "ssm:GetParameter",
83
+ "ssm:PutParameter",
84
+ "s3:GetObject",
85
+ "s3:PutObject",
86
+ "s3:DeleteObject",
87
+ "s3:ListBucket",
88
+ "dynamodb:BatchWriteItem",
89
+ "dynamodb:DeleteItem",
90
+ "dynamodb:GetItem",
91
+ "dynamodb:PutItem",
92
+ "dynamodb:Query",
93
+ "dynamodb:Scan",
94
+ "dynamodb:UpdateItem",
95
+ "lambda:InvokeFunction",
96
+ "states:StartExecution",
97
+ "cloudfront:CreateInvalidation"
98
+ ],
99
+ Resource: "*"
100
+ }
101
+ ]
102
+ }
103
+ }
104
+ ]
105
+ }
106
+ };
107
+ }
108
+
109
+ export function buildCloudFormationTemplate({ config, environment, features = [] }) {
110
+ const runtimeConfig = buildEnvironmentRuntimeConfig(config, environment);
111
+ const resources = {};
112
+ const outputs = {};
113
+ const featureSet = new Set(features);
114
+
115
+ const functionNames = {
116
+ sourceDispatcher: `${runtimeConfig.stackPrefix}_s3te_source_dispatcher`,
117
+ renderWorker: `${runtimeConfig.stackPrefix}_s3te_render_worker`,
118
+ invalidationScheduler: `${runtimeConfig.stackPrefix}_s3te_invalidation_scheduler`,
119
+ invalidationExecutor: `${runtimeConfig.stackPrefix}_s3te_invalidation_executor`,
120
+ contentMirror: `${runtimeConfig.stackPrefix}_s3te_content_mirror`
121
+ };
122
+
123
+ const parameters = {
124
+ ArtifactBucket: {
125
+ Type: "String"
126
+ },
127
+ SourceDispatcherArtifactKey: {
128
+ Type: "String"
129
+ },
130
+ RenderWorkerArtifactKey: {
131
+ Type: "String"
132
+ },
133
+ InvalidationSchedulerArtifactKey: {
134
+ Type: "String"
135
+ },
136
+ InvalidationExecutorArtifactKey: {
137
+ Type: "String"
138
+ },
139
+ ContentMirrorArtifactKey: {
140
+ Type: "String",
141
+ Default: ""
142
+ },
143
+ RuntimeManifestValue: {
144
+ Type: "String",
145
+ Default: "{}"
146
+ },
147
+ WebinySourceTableStreamArn: {
148
+ Type: "String",
149
+ Default: ""
150
+ }
151
+ };
152
+
153
+ resources.ExecutionRole = createExecutionRole(`${runtimeConfig.stackPrefix}_s3te_lambda_runtime`);
154
+
155
+ resources.DependencyTable = {
156
+ Type: "AWS::DynamoDB::Table",
157
+ Properties: {
158
+ TableName: runtimeConfig.tables.dependency,
159
+ BillingMode: "PAY_PER_REQUEST",
160
+ AttributeDefinitions: [
161
+ { AttributeName: "sourceId", AttributeType: "S" },
162
+ { AttributeName: "dependencyKey", AttributeType: "S" }
163
+ ],
164
+ KeySchema: [
165
+ { AttributeName: "sourceId", KeyType: "HASH" },
166
+ { AttributeName: "dependencyKey", KeyType: "RANGE" }
167
+ ],
168
+ GlobalSecondaryIndexes: [
169
+ {
170
+ IndexName: "dependencyKey-index",
171
+ KeySchema: [
172
+ { AttributeName: "dependencyKey", KeyType: "HASH" },
173
+ { AttributeName: "sourceId", KeyType: "RANGE" }
174
+ ],
175
+ Projection: { ProjectionType: "ALL" }
176
+ }
177
+ ]
178
+ }
179
+ };
180
+
181
+ resources.ContentTable = {
182
+ Type: "AWS::DynamoDB::Table",
183
+ Properties: {
184
+ TableName: runtimeConfig.tables.content,
185
+ BillingMode: "PAY_PER_REQUEST",
186
+ AttributeDefinitions: [
187
+ { AttributeName: "id", AttributeType: "S" },
188
+ { AttributeName: "contentId", AttributeType: "S" }
189
+ ],
190
+ KeySchema: [
191
+ { AttributeName: "id", KeyType: "HASH" }
192
+ ],
193
+ GlobalSecondaryIndexes: [
194
+ {
195
+ IndexName: config.aws.contentStore.contentIdIndexName,
196
+ KeySchema: [
197
+ { AttributeName: "contentId", KeyType: "HASH" }
198
+ ],
199
+ Projection: { ProjectionType: "ALL" }
200
+ }
201
+ ]
202
+ }
203
+ };
204
+
205
+ resources.InvalidationTable = {
206
+ Type: "AWS::DynamoDB::Table",
207
+ Properties: {
208
+ TableName: runtimeConfig.tables.invalidation,
209
+ BillingMode: "PAY_PER_REQUEST",
210
+ AttributeDefinitions: [
211
+ { AttributeName: "distributionId", AttributeType: "S" },
212
+ { AttributeName: "requestId", AttributeType: "S" }
213
+ ],
214
+ KeySchema: [
215
+ { AttributeName: "distributionId", KeyType: "HASH" },
216
+ { AttributeName: "requestId", KeyType: "RANGE" }
217
+ ]
218
+ }
219
+ };
220
+
221
+ resources.RuntimeManifestParameter = {
222
+ Type: "AWS::SSM::Parameter",
223
+ Properties: {
224
+ Name: runtimeConfig.runtimeParameterName,
225
+ Type: "String",
226
+ Tier: "Advanced",
227
+ Value: { Ref: "RuntimeManifestValue" }
228
+ }
229
+ };
230
+
231
+ resources.InvalidationExecutor = lambdaRuntimeProperties(
232
+ runtimeConfig,
233
+ "ExecutionRole",
234
+ functionNames.invalidationExecutor,
235
+ "InvalidationExecutorArtifactKey",
236
+ "invalidation-executor",
237
+ {
238
+ Timeout: 300,
239
+ Environment: {
240
+ Variables: {
241
+ S3TE_ENVIRONMENT: environment,
242
+ S3TE_INVALIDATION_TABLE: runtimeConfig.tables.invalidation
243
+ }
244
+ }
245
+ }
246
+ );
247
+
248
+ resources.InvalidationStateMachine = {
249
+ Type: "AWS::StepFunctions::StateMachine",
250
+ Properties: {
251
+ StateMachineName: `${runtimeConfig.stackPrefix}_s3te_invalidation`,
252
+ RoleArn: { "Fn::GetAtt": ["ExecutionRole", "Arn"] },
253
+ DefinitionString: JSON.stringify({
254
+ Comment: "S3TE invalidation debounce state machine",
255
+ StartAt: "Wait",
256
+ States: {
257
+ Wait: {
258
+ Type: "Wait",
259
+ Seconds: config.aws.invalidationStore.debounceSeconds,
260
+ Next: "RunExecutor"
261
+ },
262
+ RunExecutor: {
263
+ Type: "Task",
264
+ Resource: "arn:aws:states:::lambda:invoke",
265
+ Parameters: {
266
+ FunctionName: "${InvalidationExecutorArn}",
267
+ "Payload.$": "$"
268
+ },
269
+ End: true
270
+ }
271
+ }
272
+ }),
273
+ DefinitionSubstitutions: {
274
+ InvalidationExecutorArn: { "Fn::GetAtt": ["InvalidationExecutor", "Arn"] }
275
+ }
276
+ }
277
+ };
278
+
279
+ resources.InvalidationScheduler = lambdaRuntimeProperties(
280
+ runtimeConfig,
281
+ "ExecutionRole",
282
+ functionNames.invalidationScheduler,
283
+ "InvalidationSchedulerArtifactKey",
284
+ "invalidation-scheduler",
285
+ {
286
+ Timeout: 300,
287
+ Environment: {
288
+ Variables: {
289
+ S3TE_ENVIRONMENT: environment,
290
+ S3TE_INVALIDATION_TABLE: runtimeConfig.tables.invalidation,
291
+ S3TE_DEBOUNCE_SECONDS: String(config.aws.invalidationStore.debounceSeconds),
292
+ S3TE_INVALIDATION_STATE_MACHINE_ARN: { Ref: "InvalidationStateMachine" }
293
+ }
294
+ }
295
+ }
296
+ );
297
+
298
+ resources.RenderWorker = lambdaRuntimeProperties(
299
+ runtimeConfig,
300
+ "ExecutionRole",
301
+ functionNames.renderWorker,
302
+ "RenderWorkerArtifactKey",
303
+ "render-worker",
304
+ {
305
+ Timeout: 900,
306
+ MemorySize: 1024,
307
+ Environment: {
308
+ Variables: {
309
+ S3TE_ENVIRONMENT: environment,
310
+ S3TE_RUNTIME_PARAMETER: runtimeConfig.runtimeParameterName,
311
+ S3TE_DEPENDENCY_TABLE: runtimeConfig.tables.dependency,
312
+ S3TE_CONTENT_TABLE: runtimeConfig.tables.content,
313
+ S3TE_INVALIDATION_SCHEDULER_NAME: functionNames.invalidationScheduler
314
+ }
315
+ }
316
+ }
317
+ );
318
+
319
+ resources.SourceDispatcher = lambdaRuntimeProperties(
320
+ runtimeConfig,
321
+ "ExecutionRole",
322
+ functionNames.sourceDispatcher,
323
+ "SourceDispatcherArtifactKey",
324
+ "source-dispatcher",
325
+ {
326
+ Timeout: 300,
327
+ MemorySize: 512,
328
+ Environment: {
329
+ Variables: {
330
+ S3TE_ENVIRONMENT: environment,
331
+ S3TE_RUNTIME_PARAMETER: runtimeConfig.runtimeParameterName,
332
+ S3TE_RENDER_WORKER_NAME: functionNames.renderWorker,
333
+ S3TE_RENDER_EXTENSIONS: runtimeConfig.rendering.renderExtensions.join(",")
334
+ }
335
+ }
336
+ }
337
+ );
338
+
339
+ if (featureSet.has("webiny") && runtimeConfig.integrations.webiny.enabled) {
340
+ resources.ContentMirror = lambdaRuntimeProperties(
341
+ runtimeConfig,
342
+ "ExecutionRole",
343
+ functionNames.contentMirror,
344
+ "ContentMirrorArtifactKey",
345
+ "content-mirror",
346
+ {
347
+ Timeout: 300,
348
+ MemorySize: 512,
349
+ Environment: {
350
+ Variables: {
351
+ S3TE_ENVIRONMENT: environment,
352
+ S3TE_CONTENT_TABLE: runtimeConfig.tables.content,
353
+ S3TE_RELEVANT_MODELS: runtimeConfig.integrations.webiny.relevantModels.join(","),
354
+ S3TE_WEBINY_TENANT: runtimeConfig.integrations.webiny.tenant ?? "",
355
+ S3TE_RENDER_WORKER_NAME: functionNames.renderWorker
356
+ }
357
+ }
358
+ }
359
+ );
360
+
361
+ resources.ContentMirrorEventSourceMapping = {
362
+ Type: "AWS::Lambda::EventSourceMapping",
363
+ Properties: {
364
+ BatchSize: 10,
365
+ StartingPosition: "LATEST",
366
+ EventSourceArn: { Ref: "WebinySourceTableStreamArn" },
367
+ FunctionName: { Ref: "ContentMirror" }
368
+ }
369
+ };
370
+ }
371
+
372
+ outputs.StackName = { Value: runtimeConfig.stackName };
373
+ outputs.RuntimeManifestParameterName = {
374
+ Value: runtimeConfig.runtimeParameterName
375
+ };
376
+ outputs.DependencyTableName = { Value: runtimeConfig.tables.dependency };
377
+ outputs.ContentTableName = { Value: runtimeConfig.tables.content };
378
+ outputs.InvalidationTableName = { Value: runtimeConfig.tables.invalidation };
379
+ outputs.SourceDispatcherFunctionName = { Value: functionNames.sourceDispatcher };
380
+ outputs.RenderWorkerFunctionName = { Value: functionNames.renderWorker };
381
+ outputs.InvalidationSchedulerFunctionName = { Value: functionNames.invalidationScheduler };
382
+ outputs.InvalidationExecutorFunctionName = { Value: functionNames.invalidationExecutor };
383
+
384
+ if (resources.ContentMirror) {
385
+ outputs.ContentMirrorFunctionName = { Value: functionNames.contentMirror };
386
+ }
387
+
388
+ for (const [variantName, variantConfig] of Object.entries(runtimeConfig.variants)) {
389
+ const codeBucketLogicalId = cfName(sanitizeLogicalId(variantName), "CodeBucket");
390
+ resources[codeBucketLogicalId] = {
391
+ Type: "AWS::S3::Bucket",
392
+ DependsOn: ["SourceDispatcherPermission"],
393
+ Properties: {
394
+ BucketName: variantConfig.codeBucket,
395
+ NotificationConfiguration: {
396
+ LambdaConfigurations: [
397
+ {
398
+ Event: "s3:ObjectCreated:*",
399
+ Function: { "Fn::GetAtt": ["SourceDispatcher", "Arn"] }
400
+ },
401
+ {
402
+ Event: "s3:ObjectRemoved:*",
403
+ Function: { "Fn::GetAtt": ["SourceDispatcher", "Arn"] }
404
+ }
405
+ ]
406
+ }
407
+ }
408
+ };
409
+ outputs[`${codeBucketLogicalId}Name`] = { Value: { Ref: codeBucketLogicalId } };
410
+
411
+ for (const [languageCode, languageConfig] of Object.entries(variantConfig.languages)) {
412
+ const suffix = sanitizeLogicalId(`${variantName}${languageCode}`);
413
+ const outputBucketLogicalId = `${suffix}OutputBucket`;
414
+ const distributionLogicalId = `${suffix}Distribution`;
415
+ const bucketPolicyLogicalId = `${suffix}OutputBucketPolicy`;
416
+ const websiteOriginId = `${suffix}Origin`;
417
+
418
+ resources[outputBucketLogicalId] = {
419
+ Type: "AWS::S3::Bucket",
420
+ Properties: {
421
+ BucketName: languageConfig.targetBucket,
422
+ WebsiteConfiguration: {
423
+ IndexDocument: variantConfig.routing.indexDocument,
424
+ ErrorDocument: variantConfig.routing.notFoundDocument
425
+ },
426
+ PublicAccessBlockConfiguration: {
427
+ BlockPublicAcls: false,
428
+ BlockPublicPolicy: false,
429
+ IgnorePublicAcls: false,
430
+ RestrictPublicBuckets: false
431
+ }
432
+ }
433
+ };
434
+
435
+ resources[bucketPolicyLogicalId] = {
436
+ Type: "AWS::S3::BucketPolicy",
437
+ Properties: {
438
+ Bucket: { Ref: outputBucketLogicalId },
439
+ PolicyDocument: {
440
+ Version: "2012-10-17",
441
+ Statement: [
442
+ {
443
+ Effect: "Allow",
444
+ Principal: "*",
445
+ Action: ["s3:GetObject"],
446
+ Resource: {
447
+ "Fn::Join": [
448
+ "",
449
+ ["arn:aws:s3:::", { Ref: outputBucketLogicalId }, "/*"]
450
+ ]
451
+ }
452
+ }
453
+ ]
454
+ }
455
+ }
456
+ };
457
+
458
+ resources[distributionLogicalId] = {
459
+ Type: "AWS::CloudFront::Distribution",
460
+ Properties: {
461
+ DistributionConfig: {
462
+ Enabled: true,
463
+ Aliases: languageConfig.cloudFrontAliases,
464
+ DefaultRootObject: variantConfig.routing.indexDocument,
465
+ HttpVersion: "http2",
466
+ IPV6Enabled: true,
467
+ Origins: [
468
+ {
469
+ Id: websiteOriginId,
470
+ DomainName: websiteEndpoint({ Ref: outputBucketLogicalId }),
471
+ CustomOriginConfig: {
472
+ HTTPPort: 80,
473
+ HTTPSPort: 443,
474
+ OriginProtocolPolicy: "http-only"
475
+ }
476
+ }
477
+ ],
478
+ DefaultCacheBehavior: {
479
+ TargetOriginId: websiteOriginId,
480
+ ViewerProtocolPolicy: "redirect-to-https",
481
+ AllowedMethods: ["GET", "HEAD"],
482
+ CachedMethods: ["GET", "HEAD"],
483
+ Compress: true,
484
+ ForwardedValues: {
485
+ QueryString: false,
486
+ Cookies: { Forward: "none" }
487
+ }
488
+ },
489
+ ViewerCertificate: {
490
+ AcmCertificateArn: runtimeConfig.certificateArn,
491
+ SslSupportMethod: "sni-only",
492
+ MinimumProtocolVersion: "TLSv1.2_2021"
493
+ }
494
+ }
495
+ }
496
+ };
497
+
498
+ outputs[`${outputBucketLogicalId}Name`] = { Value: { Ref: outputBucketLogicalId } };
499
+ outputs[`${distributionLogicalId}Id`] = { Value: { Ref: distributionLogicalId } };
500
+ outputs[`${distributionLogicalId}Domain`] = { Value: { "Fn::GetAtt": [distributionLogicalId, "DomainName"] } };
501
+
502
+ if (runtimeConfig.route53HostedZoneId) {
503
+ for (const alias of languageConfig.cloudFrontAliases) {
504
+ const aliasSuffix = sanitizeLogicalId(alias);
505
+ const aRecordLogicalId = `${distributionLogicalId}${aliasSuffix}ARecord`;
506
+ const aaaaRecordLogicalId = `${distributionLogicalId}${aliasSuffix}AAAARecord`;
507
+
508
+ resources[aRecordLogicalId] = {
509
+ Type: "AWS::Route53::RecordSet",
510
+ Properties: {
511
+ HostedZoneId: runtimeConfig.route53HostedZoneId,
512
+ Name: alias,
513
+ Type: "A",
514
+ AliasTarget: {
515
+ DNSName: { "Fn::GetAtt": [distributionLogicalId, "DomainName"] },
516
+ HostedZoneId: "Z2FDTNDATAQYW2"
517
+ }
518
+ }
519
+ };
520
+
521
+ resources[aaaaRecordLogicalId] = {
522
+ Type: "AWS::Route53::RecordSet",
523
+ Properties: {
524
+ HostedZoneId: runtimeConfig.route53HostedZoneId,
525
+ Name: alias,
526
+ Type: "AAAA",
527
+ AliasTarget: {
528
+ DNSName: { "Fn::GetAtt": [distributionLogicalId, "DomainName"] },
529
+ HostedZoneId: "Z2FDTNDATAQYW2"
530
+ }
531
+ }
532
+ };
533
+ }
534
+ }
535
+ }
536
+ }
537
+
538
+ resources.SourceDispatcherPermission = {
539
+ Type: "AWS::Lambda::Permission",
540
+ Properties: {
541
+ Action: "lambda:InvokeFunction",
542
+ FunctionName: { Ref: "SourceDispatcher" },
543
+ Principal: "s3.amazonaws.com"
544
+ }
545
+ };
546
+
547
+ return {
548
+ AWSTemplateFormatVersion: "2010-09-09",
549
+ Description: `S3TE environment stack for ${config.project.name} (${environment})`,
550
+ Parameters: parameters,
551
+ Resources: resources,
552
+ Outputs: outputs
553
+ };
554
+ }
555
+
556
+ export function buildTemporaryDeployStackTemplate() {
557
+ return {
558
+ AWSTemplateFormatVersion: "2010-09-09",
559
+ Description: "Temporary S3TE packaging stack for Lambda deployment artifacts",
560
+ Resources: {
561
+ ArtifactBucket: {
562
+ Type: "AWS::S3::Bucket",
563
+ DeletionPolicy: "Delete",
564
+ UpdateReplacePolicy: "Delete",
565
+ Properties: {
566
+ VersioningConfiguration: {
567
+ Status: "Suspended"
568
+ }
569
+ }
570
+ }
571
+ },
572
+ Outputs: {
573
+ ArtifactBucketName: {
574
+ Value: { Ref: "ArtifactBucket" }
575
+ }
576
+ }
577
+ };
578
+ }
@@ -0,0 +1,111 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ const CRC_TABLE = (() => {
5
+ const table = new Uint32Array(256);
6
+ for (let index = 0; index < 256; index += 1) {
7
+ let value = index;
8
+ for (let bit = 0; bit < 8; bit += 1) {
9
+ value = (value & 1) ? (0xEDB88320 ^ (value >>> 1)) : (value >>> 1);
10
+ }
11
+ table[index] = value >>> 0;
12
+ }
13
+ return table;
14
+ })();
15
+
16
+ function crc32(buffer) {
17
+ let crc = 0xFFFFFFFF;
18
+ for (const byte of buffer) {
19
+ crc = CRC_TABLE[(crc ^ byte) & 0xFF] ^ (crc >>> 8);
20
+ }
21
+ return (crc ^ 0xFFFFFFFF) >>> 0;
22
+ }
23
+
24
+ function dosDateTime(date) {
25
+ const year = Math.max(date.getFullYear(), 1980) - 1980;
26
+ const month = date.getMonth() + 1;
27
+ const day = date.getDate();
28
+ const hours = date.getHours();
29
+ const minutes = date.getMinutes();
30
+ const seconds = Math.floor(date.getSeconds() / 2);
31
+ return {
32
+ date: (year << 9) | (month << 5) | day,
33
+ time: (hours << 11) | (minutes << 5) | seconds
34
+ };
35
+ }
36
+
37
+ function createLocalHeader(nameBuffer, dataBuffer, modifiedAt) {
38
+ const { date, time } = dosDateTime(modifiedAt);
39
+ const header = Buffer.alloc(30);
40
+ header.writeUInt32LE(0x04034b50, 0);
41
+ header.writeUInt16LE(20, 4);
42
+ header.writeUInt16LE(0, 6);
43
+ header.writeUInt16LE(0, 8);
44
+ header.writeUInt16LE(time, 10);
45
+ header.writeUInt16LE(date, 12);
46
+ header.writeUInt32LE(crc32(dataBuffer), 14);
47
+ header.writeUInt32LE(dataBuffer.length, 18);
48
+ header.writeUInt32LE(dataBuffer.length, 22);
49
+ header.writeUInt16LE(nameBuffer.length, 26);
50
+ header.writeUInt16LE(0, 28);
51
+ return header;
52
+ }
53
+
54
+ function createCentralHeader(nameBuffer, dataBuffer, modifiedAt, offset) {
55
+ const { date, time } = dosDateTime(modifiedAt);
56
+ const header = Buffer.alloc(46);
57
+ header.writeUInt32LE(0x02014b50, 0);
58
+ header.writeUInt16LE(20, 4);
59
+ header.writeUInt16LE(20, 6);
60
+ header.writeUInt16LE(0, 8);
61
+ header.writeUInt16LE(0, 10);
62
+ header.writeUInt16LE(time, 12);
63
+ header.writeUInt16LE(date, 14);
64
+ header.writeUInt32LE(crc32(dataBuffer), 16);
65
+ header.writeUInt32LE(dataBuffer.length, 20);
66
+ header.writeUInt32LE(dataBuffer.length, 24);
67
+ header.writeUInt16LE(nameBuffer.length, 28);
68
+ header.writeUInt16LE(0, 30);
69
+ header.writeUInt16LE(0, 32);
70
+ header.writeUInt16LE(0, 34);
71
+ header.writeUInt16LE(0, 36);
72
+ header.writeUInt32LE(0, 38);
73
+ header.writeUInt32LE(offset, 42);
74
+ return header;
75
+ }
76
+
77
+ function createEndOfCentralDirectory(recordCount, centralSize, centralOffset) {
78
+ const footer = Buffer.alloc(22);
79
+ footer.writeUInt32LE(0x06054b50, 0);
80
+ footer.writeUInt16LE(0, 4);
81
+ footer.writeUInt16LE(0, 6);
82
+ footer.writeUInt16LE(recordCount, 8);
83
+ footer.writeUInt16LE(recordCount, 10);
84
+ footer.writeUInt32LE(centralSize, 12);
85
+ footer.writeUInt32LE(centralOffset, 16);
86
+ footer.writeUInt16LE(0, 20);
87
+ return footer;
88
+ }
89
+
90
+ export async function writeZipArchive(outputPath, entries) {
91
+ const localParts = [];
92
+ const centralParts = [];
93
+ let offset = 0;
94
+
95
+ for (const entry of entries) {
96
+ const nameBuffer = Buffer.from(entry.name.replace(/\\/g, "/"));
97
+ const dataBuffer = Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(entry.data);
98
+ const modifiedAt = entry.modifiedAt ?? new Date();
99
+ const localHeader = createLocalHeader(nameBuffer, dataBuffer, modifiedAt);
100
+ localParts.push(localHeader, nameBuffer, dataBuffer);
101
+ centralParts.push(createCentralHeader(nameBuffer, dataBuffer, modifiedAt, offset), nameBuffer);
102
+ offset += localHeader.length + nameBuffer.length + dataBuffer.length;
103
+ }
104
+
105
+ const centralOffset = offset;
106
+ const centralSize = centralParts.reduce((sum, part) => sum + part.length, 0);
107
+ const footer = createEndOfCentralDirectory(entries.length, centralSize, centralOffset);
108
+ const archive = Buffer.concat([...localParts, ...centralParts, footer]);
109
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
110
+ await fs.writeFile(outputPath, archive);
111
+ }