qlara 0.1.2 → 0.1.4
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/aws.cjs +62 -7
- package/dist/aws.d.cts +10 -0
- package/dist/aws.d.ts +10 -0
- package/dist/aws.js +63 -7
- package/dist/cli.js +63 -7
- package/package.json +1 -1
- package/src/provider/aws/edge-handler.ts +12 -4
- package/src/provider/aws/renderer.ts +26 -19
package/dist/aws.cjs
CHANGED
|
@@ -95,8 +95,26 @@ function listFiles(dir) {
|
|
|
95
95
|
}
|
|
96
96
|
return files;
|
|
97
97
|
}
|
|
98
|
+
async function listAllKeys(client, bucketName) {
|
|
99
|
+
const keys = /* @__PURE__ */ new Set();
|
|
100
|
+
let continuationToken;
|
|
101
|
+
do {
|
|
102
|
+
const response = await client.send(
|
|
103
|
+
new import_client_s3.ListObjectsV2Command({
|
|
104
|
+
Bucket: bucketName,
|
|
105
|
+
ContinuationToken: continuationToken
|
|
106
|
+
})
|
|
107
|
+
);
|
|
108
|
+
for (const obj of response.Contents || []) {
|
|
109
|
+
if (obj.Key) keys.add(obj.Key);
|
|
110
|
+
}
|
|
111
|
+
continuationToken = response.NextContinuationToken;
|
|
112
|
+
} while (continuationToken);
|
|
113
|
+
return keys;
|
|
114
|
+
}
|
|
98
115
|
async function syncToS3(client, bucketName, buildDir) {
|
|
99
116
|
const files = listFiles(buildDir);
|
|
117
|
+
const newKeys = /* @__PURE__ */ new Set();
|
|
100
118
|
let uploaded = 0;
|
|
101
119
|
const batchSize = 10;
|
|
102
120
|
for (let i = 0; i < files.length; i += batchSize) {
|
|
@@ -104,6 +122,7 @@ async function syncToS3(client, bucketName, buildDir) {
|
|
|
104
122
|
await Promise.all(
|
|
105
123
|
batch.map(async (filePath) => {
|
|
106
124
|
const key = (0, import_node_path.relative)(buildDir, filePath);
|
|
125
|
+
newKeys.add(key);
|
|
107
126
|
const body = (0, import_node_fs.readFileSync)(filePath);
|
|
108
127
|
const contentType = getContentType(filePath);
|
|
109
128
|
const cacheControl = getCacheControl(key);
|
|
@@ -120,7 +139,22 @@ async function syncToS3(client, bucketName, buildDir) {
|
|
|
120
139
|
})
|
|
121
140
|
);
|
|
122
141
|
}
|
|
123
|
-
|
|
142
|
+
const existingKeys = await listAllKeys(client, bucketName);
|
|
143
|
+
const staleKeys = [...existingKeys].filter((key) => !newKeys.has(key));
|
|
144
|
+
let deleted = 0;
|
|
145
|
+
for (let i = 0; i < staleKeys.length; i += 1e3) {
|
|
146
|
+
const batch = staleKeys.slice(i, i + 1e3);
|
|
147
|
+
await client.send(
|
|
148
|
+
new import_client_s3.DeleteObjectsCommand({
|
|
149
|
+
Bucket: bucketName,
|
|
150
|
+
Delete: {
|
|
151
|
+
Objects: batch.map((key) => ({ Key: key }))
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
);
|
|
155
|
+
deleted += batch.length;
|
|
156
|
+
}
|
|
157
|
+
return { uploaded, deleted };
|
|
124
158
|
}
|
|
125
159
|
async function emptyBucket(client, bucketName) {
|
|
126
160
|
let continuationToken;
|
|
@@ -520,14 +554,15 @@ async function bundleEdgeHandler(config) {
|
|
|
520
554
|
define: {
|
|
521
555
|
__QLARA_BUCKET_NAME__: JSON.stringify(config.bucketName),
|
|
522
556
|
__QLARA_RENDERER_ARN__: JSON.stringify(config.rendererArn),
|
|
523
|
-
__QLARA_REGION__: JSON.stringify(config.region)
|
|
557
|
+
__QLARA_REGION__: JSON.stringify(config.region),
|
|
558
|
+
__QLARA_CACHE_TTL__: String(config.cacheTtl)
|
|
524
559
|
},
|
|
525
560
|
// Bundle everything — Lambda@Edge must be self-contained
|
|
526
561
|
external: []
|
|
527
562
|
});
|
|
528
563
|
return createZip(outfile, "edge-handler.js");
|
|
529
564
|
}
|
|
530
|
-
async function bundleRenderer(routeFile) {
|
|
565
|
+
async function bundleRenderer(routeFile, cacheTtl = 3600) {
|
|
531
566
|
(0, import_node_fs2.mkdirSync)(BUNDLE_DIR, { recursive: true });
|
|
532
567
|
const outfile = (0, import_node_path2.join)(BUNDLE_DIR, "renderer.js");
|
|
533
568
|
const alias = {};
|
|
@@ -548,6 +583,9 @@ async function bundleRenderer(routeFile) {
|
|
|
548
583
|
outfile,
|
|
549
584
|
minify: true,
|
|
550
585
|
alias,
|
|
586
|
+
define: {
|
|
587
|
+
__QLARA_CACHE_TTL__: String(cacheTtl)
|
|
588
|
+
},
|
|
551
589
|
external: []
|
|
552
590
|
});
|
|
553
591
|
return createZip(outfile, "renderer.js");
|
|
@@ -764,6 +802,7 @@ function byoiResources(config) {
|
|
|
764
802
|
function aws(awsConfig = {}) {
|
|
765
803
|
const region = "us-east-1";
|
|
766
804
|
const byoi = isByoi(awsConfig);
|
|
805
|
+
const cacheTtl = awsConfig.cacheTtl ?? 3600;
|
|
767
806
|
const stackName = byoi ? "" : awsConfig.stackName || STACK_NAME_PREFIX;
|
|
768
807
|
return {
|
|
769
808
|
name: "aws",
|
|
@@ -839,13 +878,14 @@ function aws(awsConfig = {}) {
|
|
|
839
878
|
console.log(`[qlara/aws] Generated ${fallbacks.length} fallback page(s)`);
|
|
840
879
|
console.log("[qlara/aws] Syncing build output to S3...");
|
|
841
880
|
const s3 = createS3Client(res.region);
|
|
842
|
-
const
|
|
843
|
-
console.log(`[qlara/aws] Uploaded ${
|
|
881
|
+
const { uploaded, deleted } = await syncToS3(s3, res.bucketName, buildDir);
|
|
882
|
+
console.log(`[qlara/aws] Uploaded ${uploaded} files, deleted ${deleted} stale files`);
|
|
844
883
|
console.log("[qlara/aws] Bundling edge handler...");
|
|
845
884
|
const edgeZip = await bundleEdgeHandler({
|
|
846
885
|
bucketName: res.bucketName,
|
|
847
886
|
rendererArn: res.rendererFunctionArn,
|
|
848
|
-
region: res.region
|
|
887
|
+
region: res.region,
|
|
888
|
+
cacheTtl
|
|
849
889
|
});
|
|
850
890
|
const lambda = new import_client_lambda.LambdaClient({ region: res.region });
|
|
851
891
|
console.log("[qlara/aws] Waiting for edge handler to be ready...");
|
|
@@ -922,7 +962,7 @@ function aws(awsConfig = {}) {
|
|
|
922
962
|
const cf = new import_client_cloudfront.CloudFrontClient({ region: res.region });
|
|
923
963
|
await updateCloudFrontEdgeVersion(cf, res.distributionId, newVersionArn);
|
|
924
964
|
console.log("[qlara/aws] Bundling renderer...");
|
|
925
|
-
const rendererZip = await bundleRenderer(config.routeFile);
|
|
965
|
+
const rendererZip = await bundleRenderer(config.routeFile, cacheTtl);
|
|
926
966
|
await (0, import_client_lambda.waitUntilFunctionUpdatedV2)(
|
|
927
967
|
{ client: lambda, maxWaitTime: 120 },
|
|
928
968
|
{ FunctionName: res.rendererFunctionArn }
|
|
@@ -997,6 +1037,21 @@ function aws(awsConfig = {}) {
|
|
|
997
1037
|
`[qlara/aws] Could not update renderer permissions: ${err.message}`
|
|
998
1038
|
);
|
|
999
1039
|
}
|
|
1040
|
+
console.log("[qlara/aws] Warming up renderer...");
|
|
1041
|
+
try {
|
|
1042
|
+
await lambda.send(
|
|
1043
|
+
new import_client_lambda.InvokeCommand({
|
|
1044
|
+
FunctionName: res.rendererFunctionArn,
|
|
1045
|
+
InvocationType: "RequestResponse",
|
|
1046
|
+
Payload: JSON.stringify({ warmup: true })
|
|
1047
|
+
})
|
|
1048
|
+
);
|
|
1049
|
+
console.log("[qlara/aws] Renderer warmed up");
|
|
1050
|
+
} catch (err) {
|
|
1051
|
+
console.warn(
|
|
1052
|
+
`[qlara/aws] Renderer warm-up failed (non-critical): ${err.message}`
|
|
1053
|
+
);
|
|
1054
|
+
}
|
|
1000
1055
|
console.log("[qlara/aws] Invalidating CloudFront cache...");
|
|
1001
1056
|
await cf.send(
|
|
1002
1057
|
new import_client_cloudfront.CreateInvalidationCommand({
|
package/dist/aws.d.cts
CHANGED
|
@@ -3,6 +3,16 @@ import { P as ProviderResources, Q as QlaraProvider } from './types-gl2xFqEX.cjs
|
|
|
3
3
|
interface AwsConfig {
|
|
4
4
|
stackName?: string;
|
|
5
5
|
bucketName?: string;
|
|
6
|
+
/**
|
|
7
|
+
* How long (in seconds) CloudFront should cache dynamically rendered pages.
|
|
8
|
+
* This sets the `s-maxage` value on the Cache-Control header.
|
|
9
|
+
*
|
|
10
|
+
* - Browsers always revalidate with CloudFront (`max-age=0`)
|
|
11
|
+
* - CloudFront serves from edge cache for this duration
|
|
12
|
+
*
|
|
13
|
+
* @default 3600 (1 hour)
|
|
14
|
+
*/
|
|
15
|
+
cacheTtl?: number;
|
|
6
16
|
distributionId?: string;
|
|
7
17
|
distributionDomain?: string;
|
|
8
18
|
edgeFunctionArn?: string;
|
package/dist/aws.d.ts
CHANGED
|
@@ -3,6 +3,16 @@ import { P as ProviderResources, Q as QlaraProvider } from './types-gl2xFqEX.js'
|
|
|
3
3
|
interface AwsConfig {
|
|
4
4
|
stackName?: string;
|
|
5
5
|
bucketName?: string;
|
|
6
|
+
/**
|
|
7
|
+
* How long (in seconds) CloudFront should cache dynamically rendered pages.
|
|
8
|
+
* This sets the `s-maxage` value on the Cache-Control header.
|
|
9
|
+
*
|
|
10
|
+
* - Browsers always revalidate with CloudFront (`max-age=0`)
|
|
11
|
+
* - CloudFront serves from edge cache for this duration
|
|
12
|
+
*
|
|
13
|
+
* @default 3600 (1 hour)
|
|
14
|
+
*/
|
|
15
|
+
cacheTtl?: number;
|
|
6
16
|
distributionId?: string;
|
|
7
17
|
distributionDomain?: string;
|
|
8
18
|
edgeFunctionArn?: string;
|
package/dist/aws.js
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
UpdateFunctionConfigurationCommand,
|
|
16
16
|
PublishVersionCommand,
|
|
17
17
|
AddPermissionCommand,
|
|
18
|
+
InvokeCommand,
|
|
18
19
|
waitUntilFunctionUpdatedV2
|
|
19
20
|
} from "@aws-sdk/client-lambda";
|
|
20
21
|
import {
|
|
@@ -91,8 +92,26 @@ function listFiles(dir) {
|
|
|
91
92
|
}
|
|
92
93
|
return files;
|
|
93
94
|
}
|
|
95
|
+
async function listAllKeys(client, bucketName) {
|
|
96
|
+
const keys = /* @__PURE__ */ new Set();
|
|
97
|
+
let continuationToken;
|
|
98
|
+
do {
|
|
99
|
+
const response = await client.send(
|
|
100
|
+
new ListObjectsV2Command({
|
|
101
|
+
Bucket: bucketName,
|
|
102
|
+
ContinuationToken: continuationToken
|
|
103
|
+
})
|
|
104
|
+
);
|
|
105
|
+
for (const obj of response.Contents || []) {
|
|
106
|
+
if (obj.Key) keys.add(obj.Key);
|
|
107
|
+
}
|
|
108
|
+
continuationToken = response.NextContinuationToken;
|
|
109
|
+
} while (continuationToken);
|
|
110
|
+
return keys;
|
|
111
|
+
}
|
|
94
112
|
async function syncToS3(client, bucketName, buildDir) {
|
|
95
113
|
const files = listFiles(buildDir);
|
|
114
|
+
const newKeys = /* @__PURE__ */ new Set();
|
|
96
115
|
let uploaded = 0;
|
|
97
116
|
const batchSize = 10;
|
|
98
117
|
for (let i = 0; i < files.length; i += batchSize) {
|
|
@@ -100,6 +119,7 @@ async function syncToS3(client, bucketName, buildDir) {
|
|
|
100
119
|
await Promise.all(
|
|
101
120
|
batch.map(async (filePath) => {
|
|
102
121
|
const key = relative(buildDir, filePath);
|
|
122
|
+
newKeys.add(key);
|
|
103
123
|
const body = readFileSync(filePath);
|
|
104
124
|
const contentType = getContentType(filePath);
|
|
105
125
|
const cacheControl = getCacheControl(key);
|
|
@@ -116,7 +136,22 @@ async function syncToS3(client, bucketName, buildDir) {
|
|
|
116
136
|
})
|
|
117
137
|
);
|
|
118
138
|
}
|
|
119
|
-
|
|
139
|
+
const existingKeys = await listAllKeys(client, bucketName);
|
|
140
|
+
const staleKeys = [...existingKeys].filter((key) => !newKeys.has(key));
|
|
141
|
+
let deleted = 0;
|
|
142
|
+
for (let i = 0; i < staleKeys.length; i += 1e3) {
|
|
143
|
+
const batch = staleKeys.slice(i, i + 1e3);
|
|
144
|
+
await client.send(
|
|
145
|
+
new DeleteObjectsCommand({
|
|
146
|
+
Bucket: bucketName,
|
|
147
|
+
Delete: {
|
|
148
|
+
Objects: batch.map((key) => ({ Key: key }))
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
);
|
|
152
|
+
deleted += batch.length;
|
|
153
|
+
}
|
|
154
|
+
return { uploaded, deleted };
|
|
120
155
|
}
|
|
121
156
|
async function emptyBucket(client, bucketName) {
|
|
122
157
|
let continuationToken;
|
|
@@ -515,14 +550,15 @@ async function bundleEdgeHandler(config) {
|
|
|
515
550
|
define: {
|
|
516
551
|
__QLARA_BUCKET_NAME__: JSON.stringify(config.bucketName),
|
|
517
552
|
__QLARA_RENDERER_ARN__: JSON.stringify(config.rendererArn),
|
|
518
|
-
__QLARA_REGION__: JSON.stringify(config.region)
|
|
553
|
+
__QLARA_REGION__: JSON.stringify(config.region),
|
|
554
|
+
__QLARA_CACHE_TTL__: String(config.cacheTtl)
|
|
519
555
|
},
|
|
520
556
|
// Bundle everything — Lambda@Edge must be self-contained
|
|
521
557
|
external: []
|
|
522
558
|
});
|
|
523
559
|
return createZip(outfile, "edge-handler.js");
|
|
524
560
|
}
|
|
525
|
-
async function bundleRenderer(routeFile) {
|
|
561
|
+
async function bundleRenderer(routeFile, cacheTtl = 3600) {
|
|
526
562
|
mkdirSync(BUNDLE_DIR, { recursive: true });
|
|
527
563
|
const outfile = join2(BUNDLE_DIR, "renderer.js");
|
|
528
564
|
const alias = {};
|
|
@@ -543,6 +579,9 @@ async function bundleRenderer(routeFile) {
|
|
|
543
579
|
outfile,
|
|
544
580
|
minify: true,
|
|
545
581
|
alias,
|
|
582
|
+
define: {
|
|
583
|
+
__QLARA_CACHE_TTL__: String(cacheTtl)
|
|
584
|
+
},
|
|
546
585
|
external: []
|
|
547
586
|
});
|
|
548
587
|
return createZip(outfile, "renderer.js");
|
|
@@ -759,6 +798,7 @@ function byoiResources(config) {
|
|
|
759
798
|
function aws(awsConfig = {}) {
|
|
760
799
|
const region = "us-east-1";
|
|
761
800
|
const byoi = isByoi(awsConfig);
|
|
801
|
+
const cacheTtl = awsConfig.cacheTtl ?? 3600;
|
|
762
802
|
const stackName = byoi ? "" : awsConfig.stackName || STACK_NAME_PREFIX;
|
|
763
803
|
return {
|
|
764
804
|
name: "aws",
|
|
@@ -834,13 +874,14 @@ function aws(awsConfig = {}) {
|
|
|
834
874
|
console.log(`[qlara/aws] Generated ${fallbacks.length} fallback page(s)`);
|
|
835
875
|
console.log("[qlara/aws] Syncing build output to S3...");
|
|
836
876
|
const s3 = createS3Client(res.region);
|
|
837
|
-
const
|
|
838
|
-
console.log(`[qlara/aws] Uploaded ${
|
|
877
|
+
const { uploaded, deleted } = await syncToS3(s3, res.bucketName, buildDir);
|
|
878
|
+
console.log(`[qlara/aws] Uploaded ${uploaded} files, deleted ${deleted} stale files`);
|
|
839
879
|
console.log("[qlara/aws] Bundling edge handler...");
|
|
840
880
|
const edgeZip = await bundleEdgeHandler({
|
|
841
881
|
bucketName: res.bucketName,
|
|
842
882
|
rendererArn: res.rendererFunctionArn,
|
|
843
|
-
region: res.region
|
|
883
|
+
region: res.region,
|
|
884
|
+
cacheTtl
|
|
844
885
|
});
|
|
845
886
|
const lambda = new LambdaClient({ region: res.region });
|
|
846
887
|
console.log("[qlara/aws] Waiting for edge handler to be ready...");
|
|
@@ -917,7 +958,7 @@ function aws(awsConfig = {}) {
|
|
|
917
958
|
const cf = new CloudFrontClient({ region: res.region });
|
|
918
959
|
await updateCloudFrontEdgeVersion(cf, res.distributionId, newVersionArn);
|
|
919
960
|
console.log("[qlara/aws] Bundling renderer...");
|
|
920
|
-
const rendererZip = await bundleRenderer(config.routeFile);
|
|
961
|
+
const rendererZip = await bundleRenderer(config.routeFile, cacheTtl);
|
|
921
962
|
await waitUntilFunctionUpdatedV2(
|
|
922
963
|
{ client: lambda, maxWaitTime: 120 },
|
|
923
964
|
{ FunctionName: res.rendererFunctionArn }
|
|
@@ -992,6 +1033,21 @@ function aws(awsConfig = {}) {
|
|
|
992
1033
|
`[qlara/aws] Could not update renderer permissions: ${err.message}`
|
|
993
1034
|
);
|
|
994
1035
|
}
|
|
1036
|
+
console.log("[qlara/aws] Warming up renderer...");
|
|
1037
|
+
try {
|
|
1038
|
+
await lambda.send(
|
|
1039
|
+
new InvokeCommand({
|
|
1040
|
+
FunctionName: res.rendererFunctionArn,
|
|
1041
|
+
InvocationType: "RequestResponse",
|
|
1042
|
+
Payload: JSON.stringify({ warmup: true })
|
|
1043
|
+
})
|
|
1044
|
+
);
|
|
1045
|
+
console.log("[qlara/aws] Renderer warmed up");
|
|
1046
|
+
} catch (err) {
|
|
1047
|
+
console.warn(
|
|
1048
|
+
`[qlara/aws] Renderer warm-up failed (non-critical): ${err.message}`
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
995
1051
|
console.log("[qlara/aws] Invalidating CloudFront cache...");
|
|
996
1052
|
await cf.send(
|
|
997
1053
|
new CreateInvalidationCommand({
|
package/dist/cli.js
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
UpdateFunctionConfigurationCommand,
|
|
27
27
|
PublishVersionCommand,
|
|
28
28
|
AddPermissionCommand,
|
|
29
|
+
InvokeCommand,
|
|
29
30
|
waitUntilFunctionUpdatedV2
|
|
30
31
|
} from "@aws-sdk/client-lambda";
|
|
31
32
|
import {
|
|
@@ -102,8 +103,26 @@ function listFiles(dir) {
|
|
|
102
103
|
}
|
|
103
104
|
return files;
|
|
104
105
|
}
|
|
106
|
+
async function listAllKeys(client, bucketName) {
|
|
107
|
+
const keys = /* @__PURE__ */ new Set();
|
|
108
|
+
let continuationToken;
|
|
109
|
+
do {
|
|
110
|
+
const response = await client.send(
|
|
111
|
+
new ListObjectsV2Command({
|
|
112
|
+
Bucket: bucketName,
|
|
113
|
+
ContinuationToken: continuationToken
|
|
114
|
+
})
|
|
115
|
+
);
|
|
116
|
+
for (const obj of response.Contents || []) {
|
|
117
|
+
if (obj.Key) keys.add(obj.Key);
|
|
118
|
+
}
|
|
119
|
+
continuationToken = response.NextContinuationToken;
|
|
120
|
+
} while (continuationToken);
|
|
121
|
+
return keys;
|
|
122
|
+
}
|
|
105
123
|
async function syncToS3(client, bucketName, buildDir) {
|
|
106
124
|
const files = listFiles(buildDir);
|
|
125
|
+
const newKeys = /* @__PURE__ */ new Set();
|
|
107
126
|
let uploaded = 0;
|
|
108
127
|
const batchSize = 10;
|
|
109
128
|
for (let i = 0; i < files.length; i += batchSize) {
|
|
@@ -111,6 +130,7 @@ async function syncToS3(client, bucketName, buildDir) {
|
|
|
111
130
|
await Promise.all(
|
|
112
131
|
batch.map(async (filePath) => {
|
|
113
132
|
const key = relative(buildDir, filePath);
|
|
133
|
+
newKeys.add(key);
|
|
114
134
|
const body = readFileSync(filePath);
|
|
115
135
|
const contentType = getContentType(filePath);
|
|
116
136
|
const cacheControl = getCacheControl(key);
|
|
@@ -127,7 +147,22 @@ async function syncToS3(client, bucketName, buildDir) {
|
|
|
127
147
|
})
|
|
128
148
|
);
|
|
129
149
|
}
|
|
130
|
-
|
|
150
|
+
const existingKeys = await listAllKeys(client, bucketName);
|
|
151
|
+
const staleKeys = [...existingKeys].filter((key) => !newKeys.has(key));
|
|
152
|
+
let deleted = 0;
|
|
153
|
+
for (let i = 0; i < staleKeys.length; i += 1e3) {
|
|
154
|
+
const batch = staleKeys.slice(i, i + 1e3);
|
|
155
|
+
await client.send(
|
|
156
|
+
new DeleteObjectsCommand({
|
|
157
|
+
Bucket: bucketName,
|
|
158
|
+
Delete: {
|
|
159
|
+
Objects: batch.map((key) => ({ Key: key }))
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
);
|
|
163
|
+
deleted += batch.length;
|
|
164
|
+
}
|
|
165
|
+
return { uploaded, deleted };
|
|
131
166
|
}
|
|
132
167
|
async function emptyBucket(client, bucketName) {
|
|
133
168
|
let continuationToken;
|
|
@@ -526,14 +561,15 @@ async function bundleEdgeHandler(config) {
|
|
|
526
561
|
define: {
|
|
527
562
|
__QLARA_BUCKET_NAME__: JSON.stringify(config.bucketName),
|
|
528
563
|
__QLARA_RENDERER_ARN__: JSON.stringify(config.rendererArn),
|
|
529
|
-
__QLARA_REGION__: JSON.stringify(config.region)
|
|
564
|
+
__QLARA_REGION__: JSON.stringify(config.region),
|
|
565
|
+
__QLARA_CACHE_TTL__: String(config.cacheTtl)
|
|
530
566
|
},
|
|
531
567
|
// Bundle everything — Lambda@Edge must be self-contained
|
|
532
568
|
external: []
|
|
533
569
|
});
|
|
534
570
|
return createZip(outfile, "edge-handler.js");
|
|
535
571
|
}
|
|
536
|
-
async function bundleRenderer(routeFile) {
|
|
572
|
+
async function bundleRenderer(routeFile, cacheTtl = 3600) {
|
|
537
573
|
mkdirSync(BUNDLE_DIR, { recursive: true });
|
|
538
574
|
const outfile = join2(BUNDLE_DIR, "renderer.js");
|
|
539
575
|
const alias = {};
|
|
@@ -554,6 +590,9 @@ async function bundleRenderer(routeFile) {
|
|
|
554
590
|
outfile,
|
|
555
591
|
minify: true,
|
|
556
592
|
alias,
|
|
593
|
+
define: {
|
|
594
|
+
__QLARA_CACHE_TTL__: String(cacheTtl)
|
|
595
|
+
},
|
|
557
596
|
external: []
|
|
558
597
|
});
|
|
559
598
|
return createZip(outfile, "renderer.js");
|
|
@@ -767,6 +806,7 @@ function byoiResources(config) {
|
|
|
767
806
|
function aws(awsConfig = {}) {
|
|
768
807
|
const region = "us-east-1";
|
|
769
808
|
const byoi = isByoi(awsConfig);
|
|
809
|
+
const cacheTtl = awsConfig.cacheTtl ?? 3600;
|
|
770
810
|
const stackName = byoi ? "" : awsConfig.stackName || STACK_NAME_PREFIX;
|
|
771
811
|
return {
|
|
772
812
|
name: "aws",
|
|
@@ -842,13 +882,14 @@ function aws(awsConfig = {}) {
|
|
|
842
882
|
console.log(`[qlara/aws] Generated ${fallbacks.length} fallback page(s)`);
|
|
843
883
|
console.log("[qlara/aws] Syncing build output to S3...");
|
|
844
884
|
const s3 = createS3Client(res.region);
|
|
845
|
-
const
|
|
846
|
-
console.log(`[qlara/aws] Uploaded ${
|
|
885
|
+
const { uploaded, deleted } = await syncToS3(s3, res.bucketName, buildDir);
|
|
886
|
+
console.log(`[qlara/aws] Uploaded ${uploaded} files, deleted ${deleted} stale files`);
|
|
847
887
|
console.log("[qlara/aws] Bundling edge handler...");
|
|
848
888
|
const edgeZip = await bundleEdgeHandler({
|
|
849
889
|
bucketName: res.bucketName,
|
|
850
890
|
rendererArn: res.rendererFunctionArn,
|
|
851
|
-
region: res.region
|
|
891
|
+
region: res.region,
|
|
892
|
+
cacheTtl
|
|
852
893
|
});
|
|
853
894
|
const lambda = new LambdaClient({ region: res.region });
|
|
854
895
|
console.log("[qlara/aws] Waiting for edge handler to be ready...");
|
|
@@ -925,7 +966,7 @@ function aws(awsConfig = {}) {
|
|
|
925
966
|
const cf = new CloudFrontClient({ region: res.region });
|
|
926
967
|
await updateCloudFrontEdgeVersion(cf, res.distributionId, newVersionArn);
|
|
927
968
|
console.log("[qlara/aws] Bundling renderer...");
|
|
928
|
-
const rendererZip = await bundleRenderer(config.routeFile);
|
|
969
|
+
const rendererZip = await bundleRenderer(config.routeFile, cacheTtl);
|
|
929
970
|
await waitUntilFunctionUpdatedV2(
|
|
930
971
|
{ client: lambda, maxWaitTime: 120 },
|
|
931
972
|
{ FunctionName: res.rendererFunctionArn }
|
|
@@ -1000,6 +1041,21 @@ function aws(awsConfig = {}) {
|
|
|
1000
1041
|
`[qlara/aws] Could not update renderer permissions: ${err.message}`
|
|
1001
1042
|
);
|
|
1002
1043
|
}
|
|
1044
|
+
console.log("[qlara/aws] Warming up renderer...");
|
|
1045
|
+
try {
|
|
1046
|
+
await lambda.send(
|
|
1047
|
+
new InvokeCommand({
|
|
1048
|
+
FunctionName: res.rendererFunctionArn,
|
|
1049
|
+
InvocationType: "RequestResponse",
|
|
1050
|
+
Payload: JSON.stringify({ warmup: true })
|
|
1051
|
+
})
|
|
1052
|
+
);
|
|
1053
|
+
console.log("[qlara/aws] Renderer warmed up");
|
|
1054
|
+
} catch (err) {
|
|
1055
|
+
console.warn(
|
|
1056
|
+
`[qlara/aws] Renderer warm-up failed (non-critical): ${err.message}`
|
|
1057
|
+
);
|
|
1058
|
+
}
|
|
1003
1059
|
console.log("[qlara/aws] Invalidating CloudFront cache...");
|
|
1004
1060
|
await cf.send(
|
|
1005
1061
|
new CreateInvalidationCommand({
|
package/package.json
CHANGED
|
@@ -24,6 +24,7 @@ import { LambdaClient, InvokeCommand } from '@aws-sdk/client-lambda';
|
|
|
24
24
|
declare const __QLARA_BUCKET_NAME__: string;
|
|
25
25
|
declare const __QLARA_RENDERER_ARN__: string;
|
|
26
26
|
declare const __QLARA_REGION__: string;
|
|
27
|
+
declare const __QLARA_CACHE_TTL__: number;
|
|
27
28
|
// ── Types (inlined to keep bundle self-contained) ────────────────
|
|
28
29
|
|
|
29
30
|
interface ManifestRoute {
|
|
@@ -238,7 +239,7 @@ function buildHtmlResponse(
|
|
|
238
239
|
{ key: 'Content-Type', value: 'text/html; charset=utf-8' },
|
|
239
240
|
],
|
|
240
241
|
'cache-control': [
|
|
241
|
-
{ key: 'Cache-Control', value:
|
|
242
|
+
{ key: 'Cache-Control', value: `public, max-age=0, s-maxage=${__QLARA_CACHE_TTL__}, stale-while-revalidate=60` },
|
|
242
243
|
],
|
|
243
244
|
};
|
|
244
245
|
|
|
@@ -275,13 +276,20 @@ export async function handler(
|
|
|
275
276
|
|
|
276
277
|
// At this point, the file does NOT exist in S3 (403 from OAC or 404)
|
|
277
278
|
|
|
278
|
-
// 2.
|
|
279
|
+
// 2. Skip non-HTML file requests — these are never dynamic routes
|
|
280
|
+
// e.g. /product/20.txt (RSC flight data), /product/20.json, etc.
|
|
281
|
+
const nonHtmlExt = uri.match(/\.([a-z0-9]+)$/)?.[1];
|
|
282
|
+
if (nonHtmlExt && nonHtmlExt !== 'html') {
|
|
283
|
+
return response;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// 3. Fetch manifest and check if this URL matches a Qlara dynamic route
|
|
279
287
|
const manifest = await getManifest();
|
|
280
288
|
// Strip .html suffix that the URL rewrite function adds before matching
|
|
281
289
|
const cleanUri = uri.replace(/\.html$/, '');
|
|
282
290
|
const match = manifest ? matchRoute(cleanUri, manifest.routes) : null;
|
|
283
291
|
|
|
284
|
-
//
|
|
292
|
+
// 4. If route matches: invoke renderer synchronously to get fully rendered HTML
|
|
285
293
|
if (match) {
|
|
286
294
|
// Try to render with full SEO metadata (synchronous — waits for result)
|
|
287
295
|
const renderedHtml = await invokeRenderer(cleanUri, match);
|
|
@@ -298,6 +306,6 @@ export async function handler(
|
|
|
298
306
|
}
|
|
299
307
|
}
|
|
300
308
|
|
|
301
|
-
//
|
|
309
|
+
// 5. No match or no fallback — return original error
|
|
302
310
|
return response;
|
|
303
311
|
}
|
|
@@ -52,6 +52,9 @@ import type {
|
|
|
52
52
|
// The routes module is resolved by esbuild at deploy time.
|
|
53
53
|
// esbuild's `alias` option maps this import to the developer's route file.
|
|
54
54
|
// At bundle time: '__qlara_routes__' → './qlara.routes.ts' (or wherever the dev put it)
|
|
55
|
+
// Injected at bundle time by esbuild define
|
|
56
|
+
declare const __QLARA_CACHE_TTL__: number;
|
|
57
|
+
|
|
55
58
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
56
59
|
// @ts-ignore — resolved at bundle time by esbuild alias
|
|
57
60
|
import routes from '__qlara_routes__';
|
|
@@ -83,6 +86,7 @@ const FALLBACK_PLACEHOLDER = '__QLARA_FALLBACK__';
|
|
|
83
86
|
function deriveS3Key(uri: string): string {
|
|
84
87
|
const cleanUri = uri.replace(/^\//, '').replace(/\/$/, '');
|
|
85
88
|
if (!cleanUri) return 'index.html';
|
|
89
|
+
if (cleanUri.endsWith('.html')) return cleanUri;
|
|
86
90
|
return `${cleanUri}.html`;
|
|
87
91
|
}
|
|
88
92
|
|
|
@@ -752,20 +756,30 @@ function metadataToRscEntries(metadata: QlaraMetadata): string {
|
|
|
752
756
|
return entries.join('');
|
|
753
757
|
}
|
|
754
758
|
|
|
755
|
-
export async function handler(event: RendererEvent): Promise<RendererResult> {
|
|
759
|
+
export async function handler(event: RendererEvent & { warmup?: boolean }): Promise<RendererResult> {
|
|
760
|
+
// Warmup invocation — just initialize the runtime and return
|
|
761
|
+
if (event.warmup) {
|
|
762
|
+
return { statusCode: 200, body: 'warm' };
|
|
763
|
+
}
|
|
764
|
+
|
|
756
765
|
const { uri, bucket, routePattern, params } = event;
|
|
757
766
|
const region = process.env.AWS_REGION || 'us-east-1';
|
|
758
767
|
|
|
759
768
|
const s3 = new S3Client({ region });
|
|
760
769
|
|
|
761
770
|
try {
|
|
762
|
-
// 0. Check if
|
|
771
|
+
// 0. Check if already rendered + read fallback in parallel
|
|
763
772
|
const s3Key = deriveS3Key(uri);
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
)
|
|
768
|
-
|
|
773
|
+
const fallbackKey = deriveFallbackKey(uri);
|
|
774
|
+
|
|
775
|
+
const [existingResult, fallbackResult] = await Promise.allSettled([
|
|
776
|
+
s3.send(new GetObjectCommand({ Bucket: bucket, Key: s3Key })),
|
|
777
|
+
s3.send(new GetObjectCommand({ Bucket: bucket, Key: fallbackKey })),
|
|
778
|
+
]);
|
|
779
|
+
|
|
780
|
+
// If page already exists, return it (guards against duplicate concurrent requests)
|
|
781
|
+
if (existingResult.status === 'fulfilled') {
|
|
782
|
+
const existingHtml = await existingResult.value.Body?.transformToString('utf-8');
|
|
769
783
|
if (existingHtml) {
|
|
770
784
|
return {
|
|
771
785
|
statusCode: 200,
|
|
@@ -773,26 +787,20 @@ export async function handler(event: RendererEvent): Promise<RendererResult> {
|
|
|
773
787
|
html: existingHtml,
|
|
774
788
|
};
|
|
775
789
|
}
|
|
776
|
-
} catch {
|
|
777
|
-
// Page doesn't exist yet — continue with rendering
|
|
778
790
|
}
|
|
779
791
|
|
|
780
|
-
//
|
|
781
|
-
const fallbackKey = deriveFallbackKey(uri);
|
|
792
|
+
// Extract fallback HTML
|
|
782
793
|
let fallbackHtml: string;
|
|
783
794
|
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
new GetObjectCommand({ Bucket: bucket, Key: fallbackKey })
|
|
787
|
-
);
|
|
788
|
-
fallbackHtml = (await response.Body?.transformToString('utf-8')) || '';
|
|
795
|
+
if (fallbackResult.status === 'fulfilled') {
|
|
796
|
+
fallbackHtml = (await fallbackResult.value.Body?.transformToString('utf-8')) || '';
|
|
789
797
|
if (!fallbackHtml) {
|
|
790
798
|
return {
|
|
791
799
|
statusCode: 500,
|
|
792
800
|
body: JSON.stringify({ error: `Empty fallback at ${fallbackKey}` }),
|
|
793
801
|
};
|
|
794
802
|
}
|
|
795
|
-
}
|
|
803
|
+
} else {
|
|
796
804
|
return {
|
|
797
805
|
statusCode: 500,
|
|
798
806
|
body: JSON.stringify({ error: `Fallback not found: ${fallbackKey}` }),
|
|
@@ -824,8 +832,7 @@ export async function handler(event: RendererEvent): Promise<RendererResult> {
|
|
|
824
832
|
Key: s3Key,
|
|
825
833
|
Body: html,
|
|
826
834
|
ContentType: 'text/html; charset=utf-8',
|
|
827
|
-
CacheControl:
|
|
828
|
-
'public, max-age=0, s-maxage=31536000, stale-while-revalidate=86400',
|
|
835
|
+
CacheControl: `public, max-age=0, s-maxage=${__QLARA_CACHE_TTL__}, stale-while-revalidate=60`,
|
|
829
836
|
})
|
|
830
837
|
);
|
|
831
838
|
|