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 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
- return uploaded;
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 fileCount = await syncToS3(s3, res.bucketName, buildDir);
843
- console.log(`[qlara/aws] Uploaded ${fileCount} files to S3`);
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
- return uploaded;
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 fileCount = await syncToS3(s3, res.bucketName, buildDir);
838
- console.log(`[qlara/aws] Uploaded ${fileCount} files to S3`);
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
- return uploaded;
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 fileCount = await syncToS3(s3, res.bucketName, buildDir);
846
- console.log(`[qlara/aws] Uploaded ${fileCount} files to S3`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qlara",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Runtime ISR for static React apps — dynamic routing and SEO metadata for statically exported Next.js apps on AWS",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -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: 'public, max-age=0, must-revalidate' },
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. Fetch manifest and check if this URL matches a Qlara dynamic route
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
- // 3. If route matches: invoke renderer synchronously to get fully rendered HTML
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
- // 4. No match or no fallback — return original error
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 the page was already rendered (guards against duplicate concurrent requests)
771
+ // 0. Check if already rendered + read fallback in parallel
763
772
  const s3Key = deriveS3Key(uri);
764
- try {
765
- const existing = await s3.send(
766
- new GetObjectCommand({ Bucket: bucket, Key: s3Key })
767
- );
768
- const existingHtml = await existing.Body?.transformToString('utf-8');
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
- // 1. Read the fallback HTML from S3
781
- const fallbackKey = deriveFallbackKey(uri);
792
+ // Extract fallback HTML
782
793
  let fallbackHtml: string;
783
794
 
784
- try {
785
- const response = await s3.send(
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
- } catch {
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