qlara 0.1.4 → 0.1.6

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
@@ -69,12 +69,12 @@ function getContentType(filePath) {
69
69
  const ext = (0, import_node_path.extname)(filePath).toLowerCase();
70
70
  return CONTENT_TYPES[ext] || "application/octet-stream";
71
71
  }
72
- function getCacheControl(key) {
72
+ function getCacheControl(key, cacheTtl) {
73
73
  if (key.includes("_next/static/") || key.includes(".chunk.")) {
74
74
  return "public, max-age=31536000, immutable";
75
75
  }
76
76
  if (key.endsWith(".html")) {
77
- return "public, max-age=0, must-revalidate";
77
+ return `public, max-age=0, s-maxage=${cacheTtl}, stale-while-revalidate=60`;
78
78
  }
79
79
  return "public, max-age=86400";
80
80
  }
@@ -112,7 +112,7 @@ async function listAllKeys(client, bucketName) {
112
112
  } while (continuationToken);
113
113
  return keys;
114
114
  }
115
- async function syncToS3(client, bucketName, buildDir) {
115
+ async function syncToS3(client, bucketName, buildDir, cacheTtl = 3600) {
116
116
  const files = listFiles(buildDir);
117
117
  const newKeys = /* @__PURE__ */ new Set();
118
118
  let uploaded = 0;
@@ -125,7 +125,7 @@ async function syncToS3(client, bucketName, buildDir) {
125
125
  newKeys.add(key);
126
126
  const body = (0, import_node_fs.readFileSync)(filePath);
127
127
  const contentType = getContentType(filePath);
128
- const cacheControl = getCacheControl(key);
128
+ const cacheControl = getCacheControl(key, cacheTtl);
129
129
  await client.send(
130
130
  new import_client_s3.PutObjectCommand({
131
131
  Bucket: bucketName,
@@ -449,7 +449,7 @@ function buildTemplate(config) {
449
449
  CachePolicyId: { Ref: "CachePolicy" },
450
450
  LambdaFunctionAssociations: [
451
451
  {
452
- EventType: "origin-response",
452
+ EventType: "origin-request",
453
453
  LambdaFunctionARN: { Ref: "EdgeHandlerVersion" },
454
454
  IncludeBody: false
455
455
  }
@@ -764,7 +764,7 @@ async function updateCloudFrontEdgeVersion(cf, distributionId, newVersionArn) {
764
764
  const lambdaAssociations = config.DefaultCacheBehavior?.LambdaFunctionAssociations;
765
765
  if (lambdaAssociations?.Items) {
766
766
  for (const assoc of lambdaAssociations.Items) {
767
- if (assoc.EventType === "origin-response") {
767
+ if (assoc.EventType === "origin-request") {
768
768
  assoc.LambdaFunctionARN = newVersionArn;
769
769
  }
770
770
  }
@@ -878,7 +878,7 @@ function aws(awsConfig = {}) {
878
878
  console.log(`[qlara/aws] Generated ${fallbacks.length} fallback page(s)`);
879
879
  console.log("[qlara/aws] Syncing build output to S3...");
880
880
  const s3 = createS3Client(res.region);
881
- const { uploaded, deleted } = await syncToS3(s3, res.bucketName, buildDir);
881
+ const { uploaded, deleted } = await syncToS3(s3, res.bucketName, buildDir, cacheTtl);
882
882
  console.log(`[qlara/aws] Uploaded ${uploaded} files, deleted ${deleted} stale files`);
883
883
  console.log("[qlara/aws] Bundling edge handler...");
884
884
  const edgeZip = await bundleEdgeHandler({
@@ -896,7 +896,8 @@ function aws(awsConfig = {}) {
896
896
  await lambda.send(
897
897
  new import_client_lambda.UpdateFunctionConfigurationCommand({
898
898
  FunctionName: res.edgeFunctionArn,
899
- Timeout: 30
899
+ Timeout: 30,
900
+ MemorySize: 512
900
901
  })
901
902
  );
902
903
  await (0, import_client_lambda.waitUntilFunctionUpdatedV2)(
@@ -983,7 +984,7 @@ function aws(awsConfig = {}) {
983
984
  new import_client_lambda.UpdateFunctionConfigurationCommand({
984
985
  FunctionName: res.rendererFunctionArn,
985
986
  Layers: [],
986
- MemorySize: 256,
987
+ MemorySize: 512,
987
988
  Timeout: 30,
988
989
  Environment: {
989
990
  Variables: config.env ?? {}
package/dist/aws.js CHANGED
@@ -66,12 +66,12 @@ function getContentType(filePath) {
66
66
  const ext = extname(filePath).toLowerCase();
67
67
  return CONTENT_TYPES[ext] || "application/octet-stream";
68
68
  }
69
- function getCacheControl(key) {
69
+ function getCacheControl(key, cacheTtl) {
70
70
  if (key.includes("_next/static/") || key.includes(".chunk.")) {
71
71
  return "public, max-age=31536000, immutable";
72
72
  }
73
73
  if (key.endsWith(".html")) {
74
- return "public, max-age=0, must-revalidate";
74
+ return `public, max-age=0, s-maxage=${cacheTtl}, stale-while-revalidate=60`;
75
75
  }
76
76
  return "public, max-age=86400";
77
77
  }
@@ -109,7 +109,7 @@ async function listAllKeys(client, bucketName) {
109
109
  } while (continuationToken);
110
110
  return keys;
111
111
  }
112
- async function syncToS3(client, bucketName, buildDir) {
112
+ async function syncToS3(client, bucketName, buildDir, cacheTtl = 3600) {
113
113
  const files = listFiles(buildDir);
114
114
  const newKeys = /* @__PURE__ */ new Set();
115
115
  let uploaded = 0;
@@ -122,7 +122,7 @@ async function syncToS3(client, bucketName, buildDir) {
122
122
  newKeys.add(key);
123
123
  const body = readFileSync(filePath);
124
124
  const contentType = getContentType(filePath);
125
- const cacheControl = getCacheControl(key);
125
+ const cacheControl = getCacheControl(key, cacheTtl);
126
126
  await client.send(
127
127
  new PutObjectCommand({
128
128
  Bucket: bucketName,
@@ -446,7 +446,7 @@ function buildTemplate(config) {
446
446
  CachePolicyId: { Ref: "CachePolicy" },
447
447
  LambdaFunctionAssociations: [
448
448
  {
449
- EventType: "origin-response",
449
+ EventType: "origin-request",
450
450
  LambdaFunctionARN: { Ref: "EdgeHandlerVersion" },
451
451
  IncludeBody: false
452
452
  }
@@ -760,7 +760,7 @@ async function updateCloudFrontEdgeVersion(cf, distributionId, newVersionArn) {
760
760
  const lambdaAssociations = config.DefaultCacheBehavior?.LambdaFunctionAssociations;
761
761
  if (lambdaAssociations?.Items) {
762
762
  for (const assoc of lambdaAssociations.Items) {
763
- if (assoc.EventType === "origin-response") {
763
+ if (assoc.EventType === "origin-request") {
764
764
  assoc.LambdaFunctionARN = newVersionArn;
765
765
  }
766
766
  }
@@ -874,7 +874,7 @@ function aws(awsConfig = {}) {
874
874
  console.log(`[qlara/aws] Generated ${fallbacks.length} fallback page(s)`);
875
875
  console.log("[qlara/aws] Syncing build output to S3...");
876
876
  const s3 = createS3Client(res.region);
877
- const { uploaded, deleted } = await syncToS3(s3, res.bucketName, buildDir);
877
+ const { uploaded, deleted } = await syncToS3(s3, res.bucketName, buildDir, cacheTtl);
878
878
  console.log(`[qlara/aws] Uploaded ${uploaded} files, deleted ${deleted} stale files`);
879
879
  console.log("[qlara/aws] Bundling edge handler...");
880
880
  const edgeZip = await bundleEdgeHandler({
@@ -892,7 +892,8 @@ function aws(awsConfig = {}) {
892
892
  await lambda.send(
893
893
  new UpdateFunctionConfigurationCommand({
894
894
  FunctionName: res.edgeFunctionArn,
895
- Timeout: 30
895
+ Timeout: 30,
896
+ MemorySize: 512
896
897
  })
897
898
  );
898
899
  await waitUntilFunctionUpdatedV2(
@@ -979,7 +980,7 @@ function aws(awsConfig = {}) {
979
980
  new UpdateFunctionConfigurationCommand({
980
981
  FunctionName: res.rendererFunctionArn,
981
982
  Layers: [],
982
- MemorySize: 256,
983
+ MemorySize: 512,
983
984
  Timeout: 30,
984
985
  Environment: {
985
986
  Variables: config.env ?? {}
package/dist/cli.js CHANGED
@@ -77,12 +77,12 @@ function getContentType(filePath) {
77
77
  const ext = extname(filePath).toLowerCase();
78
78
  return CONTENT_TYPES[ext] || "application/octet-stream";
79
79
  }
80
- function getCacheControl(key) {
80
+ function getCacheControl(key, cacheTtl) {
81
81
  if (key.includes("_next/static/") || key.includes(".chunk.")) {
82
82
  return "public, max-age=31536000, immutable";
83
83
  }
84
84
  if (key.endsWith(".html")) {
85
- return "public, max-age=0, must-revalidate";
85
+ return `public, max-age=0, s-maxage=${cacheTtl}, stale-while-revalidate=60`;
86
86
  }
87
87
  return "public, max-age=86400";
88
88
  }
@@ -120,7 +120,7 @@ async function listAllKeys(client, bucketName) {
120
120
  } while (continuationToken);
121
121
  return keys;
122
122
  }
123
- async function syncToS3(client, bucketName, buildDir) {
123
+ async function syncToS3(client, bucketName, buildDir, cacheTtl = 3600) {
124
124
  const files = listFiles(buildDir);
125
125
  const newKeys = /* @__PURE__ */ new Set();
126
126
  let uploaded = 0;
@@ -133,7 +133,7 @@ async function syncToS3(client, bucketName, buildDir) {
133
133
  newKeys.add(key);
134
134
  const body = readFileSync(filePath);
135
135
  const contentType = getContentType(filePath);
136
- const cacheControl = getCacheControl(key);
136
+ const cacheControl = getCacheControl(key, cacheTtl);
137
137
  await client.send(
138
138
  new PutObjectCommand({
139
139
  Bucket: bucketName,
@@ -457,7 +457,7 @@ function buildTemplate(config) {
457
457
  CachePolicyId: { Ref: "CachePolicy" },
458
458
  LambdaFunctionAssociations: [
459
459
  {
460
- EventType: "origin-response",
460
+ EventType: "origin-request",
461
461
  LambdaFunctionARN: { Ref: "EdgeHandlerVersion" },
462
462
  IncludeBody: false
463
463
  }
@@ -768,7 +768,7 @@ async function updateCloudFrontEdgeVersion(cf, distributionId, newVersionArn) {
768
768
  const lambdaAssociations = config.DefaultCacheBehavior?.LambdaFunctionAssociations;
769
769
  if (lambdaAssociations?.Items) {
770
770
  for (const assoc of lambdaAssociations.Items) {
771
- if (assoc.EventType === "origin-response") {
771
+ if (assoc.EventType === "origin-request") {
772
772
  assoc.LambdaFunctionARN = newVersionArn;
773
773
  }
774
774
  }
@@ -882,7 +882,7 @@ function aws(awsConfig = {}) {
882
882
  console.log(`[qlara/aws] Generated ${fallbacks.length} fallback page(s)`);
883
883
  console.log("[qlara/aws] Syncing build output to S3...");
884
884
  const s3 = createS3Client(res.region);
885
- const { uploaded, deleted } = await syncToS3(s3, res.bucketName, buildDir);
885
+ const { uploaded, deleted } = await syncToS3(s3, res.bucketName, buildDir, cacheTtl);
886
886
  console.log(`[qlara/aws] Uploaded ${uploaded} files, deleted ${deleted} stale files`);
887
887
  console.log("[qlara/aws] Bundling edge handler...");
888
888
  const edgeZip = await bundleEdgeHandler({
@@ -900,7 +900,8 @@ function aws(awsConfig = {}) {
900
900
  await lambda.send(
901
901
  new UpdateFunctionConfigurationCommand({
902
902
  FunctionName: res.edgeFunctionArn,
903
- Timeout: 30
903
+ Timeout: 30,
904
+ MemorySize: 512
904
905
  })
905
906
  );
906
907
  await waitUntilFunctionUpdatedV2(
@@ -987,7 +988,7 @@ function aws(awsConfig = {}) {
987
988
  new UpdateFunctionConfigurationCommand({
988
989
  FunctionName: res.rendererFunctionArn,
989
990
  Layers: [],
990
- MemorySize: 256,
991
+ MemorySize: 512,
991
992
  Timeout: 30,
992
993
  Environment: {
993
994
  Variables: config.env ?? {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qlara",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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": [
@@ -1,20 +1,21 @@
1
1
  /**
2
- * Lambda@Edge origin-response handler for Qlara.
2
+ * Lambda@Edge origin-request handler for Qlara.
3
3
  *
4
4
  * This file is bundled into a self-contained ZIP and deployed to Lambda@Edge.
5
5
  * It does NOT run in the developer's Node.js — it runs at CloudFront edge locations.
6
6
  *
7
7
  * Config values are injected at bundle time via esbuild `define` (Lambda@Edge has no env vars).
8
8
  *
9
+ * Runs as an **origin-request** trigger so that generated responses are cached by CloudFront.
10
+ * (Origin-response generated responses are NOT cached — documented AWS behavior.)
11
+ *
9
12
  * Flow for a request to /product/5:
10
13
  * 1. CloudFront viewer-request rewrites /product/5 → /product/5.html
11
- * 2. S3 returns 403 (file doesn't exist, OAC treats missing as 403)
12
- * 3. This origin-response handler intercepts:
13
- * a. Invokes the renderer Lambda synchronously
14
- * b. Renderer fetches metadata from the data source, patches fallback HTML with SEO metadata
15
- * c. Renderer uploads product/5.html to S3 and returns the rendered HTML
16
- * d. Edge handler serves the fully rendered HTML (first request gets full SEO)
17
- * e. Subsequent requests hit S3 directly (page is cached)
14
+ * 2. CloudFront checks edge cache miss fires this origin-request handler
15
+ * 3. Handler checks S3 for product/5.html:
16
+ * a. File exists return it directly (CloudFront caches it)
17
+ * b. File doesn't exist invoke renderer return rendered HTML (CloudFront caches it)
18
+ * 4. Subsequent requests hit CloudFront edge cache directly (~10-30ms)
18
19
  */
19
20
 
20
21
  import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
@@ -48,18 +49,21 @@ interface CloudFrontResponse {
48
49
  statusDescription: string;
49
50
  headers: Record<string, Array<{ key: string; value: string }>>;
50
51
  body?: string;
52
+ bodyEncoding?: string;
51
53
  }
52
54
 
53
55
  interface CloudFrontRequest {
54
56
  uri: string;
55
57
  querystring: string;
58
+ method: string;
59
+ headers: Record<string, Array<{ key: string; value: string }>>;
60
+ origin?: Record<string, unknown>;
56
61
  }
57
62
 
58
- interface CloudFrontResponseEvent {
63
+ interface CloudFrontRequestEvent {
59
64
  Records: Array<{
60
65
  cf: {
61
66
  request: CloudFrontRequest;
62
- response: CloudFrontResponse;
63
67
  };
64
68
  }>;
65
69
  }
@@ -227,66 +231,62 @@ async function invokeRenderer(uri: string, match: RouteMatch): Promise<string |
227
231
 
228
232
  // ── Response builder ─────────────────────────────────────────────
229
233
 
230
- // Lambda@Edge read-only headers — must be preserved from the original response
231
- const READ_ONLY_HEADERS = ['transfer-encoding', 'via'];
232
-
233
- function buildHtmlResponse(
234
- html: string,
235
- originalResponse: CloudFrontResponse
236
- ): CloudFrontResponse {
237
- const headers: Record<string, Array<{ key: string; value: string }>> = {
238
- 'content-type': [
239
- { key: 'Content-Type', value: 'text/html; charset=utf-8' },
240
- ],
241
- 'cache-control': [
242
- { key: 'Cache-Control', value: `public, max-age=0, s-maxage=${__QLARA_CACHE_TTL__}, stale-while-revalidate=60` },
243
- ],
244
- };
245
-
246
- // Preserve read-only headers from the original response to avoid 502
247
- for (const headerName of READ_ONLY_HEADERS) {
248
- if (originalResponse.headers[headerName]) {
249
- headers[headerName] = originalResponse.headers[headerName];
250
- }
251
- }
252
-
234
+ function buildHtmlResponse(html: string): CloudFrontResponse {
253
235
  return {
254
236
  status: '200',
255
237
  statusDescription: 'OK',
256
- headers,
238
+ headers: {
239
+ 'content-type': [
240
+ { key: 'Content-Type', value: 'text/html; charset=utf-8' },
241
+ ],
242
+ 'cache-control': [
243
+ { key: 'Cache-Control', value: `public, max-age=0, s-maxage=${__QLARA_CACHE_TTL__}, stale-while-revalidate=60` },
244
+ ],
245
+ },
257
246
  body: html,
247
+ bodyEncoding: 'text',
258
248
  };
259
249
  }
260
250
 
261
251
  // ── Handler ──────────────────────────────────────────────────────
262
252
 
263
253
  export async function handler(
264
- event: CloudFrontResponseEvent
265
- ): Promise<CloudFrontResponse> {
266
- const record = event.Records[0].cf;
267
- const response = record.response;
268
- const request = record.request;
254
+ event: CloudFrontRequestEvent
255
+ ): Promise<CloudFrontRequest | CloudFrontResponse> {
256
+ const request = event.Records[0].cf.request;
269
257
  const uri = request.uri;
270
- const status = parseInt(response.status, 10);
271
258
 
272
- // 1. If response is 200 (file exists in S3), pass through
273
- if (status !== 403 && status !== 404) {
274
- return response;
275
- }
276
-
277
- // At this point, the file does NOT exist in S3 (403 from OAC or 404)
278
-
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.
259
+ // 1. Non-HTML file requests forward to S3 origin (handled normally by CloudFront)
260
+ // e.g. /product/20.txt (RSC flight data), /product/20.json, JS, CSS, images, etc.
281
261
  const nonHtmlExt = uri.match(/\.([a-z0-9]+)$/)?.[1];
282
262
  if (nonHtmlExt && nonHtmlExt !== 'html') {
283
- return response;
263
+ return request;
264
+ }
265
+
266
+ // 2. Try to get the file from S3 directly
267
+ const s3Key = uri.replace(/^\//, '');
268
+ if (s3Key) {
269
+ try {
270
+ const s3Response = await s3.send(
271
+ new GetObjectCommand({
272
+ Bucket: __QLARA_BUCKET_NAME__,
273
+ Key: s3Key,
274
+ })
275
+ );
276
+ const body = await s3Response.Body?.transformToString('utf-8');
277
+ if (body) {
278
+ // File exists in S3 → return it directly (CloudFront will cache this)
279
+ return buildHtmlResponse(body);
280
+ }
281
+ } catch {
282
+ // File doesn't exist in S3 — continue to dynamic route handling
283
+ }
284
284
  }
285
285
 
286
286
  // 3. Fetch manifest and check if this URL matches a Qlara dynamic route
287
- const manifest = await getManifest();
288
287
  // Strip .html suffix that the URL rewrite function adds before matching
289
288
  const cleanUri = uri.replace(/\.html$/, '');
289
+ const manifest = await getManifest();
290
290
  const match = manifest ? matchRoute(cleanUri, manifest.routes) : null;
291
291
 
292
292
  // 4. If route matches: invoke renderer synchronously to get fully rendered HTML
@@ -295,17 +295,17 @@ export async function handler(
295
295
  const renderedHtml = await invokeRenderer(cleanUri, match);
296
296
 
297
297
  if (renderedHtml) {
298
- return buildHtmlResponse(renderedHtml, response);
298
+ return buildHtmlResponse(renderedHtml);
299
299
  }
300
300
 
301
301
  // Renderer failed — fall back to unpatched fallback HTML (no SEO, but page still works)
302
302
  const fallbackHtml = await getFallbackHtml(match.route);
303
303
  if (fallbackHtml) {
304
304
  const patchedHtml = patchFallback(fallbackHtml, match.params);
305
- return buildHtmlResponse(patchedHtml, response);
305
+ return buildHtmlResponse(patchedHtml);
306
306
  }
307
307
  }
308
308
 
309
- // 5. No match or no fallback — return original error
310
- return response;
309
+ // 5. No match or no fallback — forward to origin (S3 will return 403/404)
310
+ return request;
311
311
  }
@@ -79,6 +79,9 @@ interface RendererResult {
79
79
 
80
80
  const FALLBACK_PLACEHOLDER = '__QLARA_FALLBACK__';
81
81
 
82
+ // Module-scope S3 client — reused across warm invocations (avoids recreating TCP/TLS connections)
83
+ const s3 = new S3Client({ region: process.env.AWS_REGION || 'us-east-1' });
84
+
82
85
  /**
83
86
  * Derive the S3 key for a rendered page.
84
87
  * Matches the Next.js static export convention: /product/42 → product/42.html
@@ -763,9 +766,6 @@ export async function handler(event: RendererEvent & { warmup?: boolean }): Prom
763
766
  }
764
767
 
765
768
  const { uri, bucket, routePattern, params } = event;
766
- const region = process.env.AWS_REGION || 'us-east-1';
767
-
768
- const s3 = new S3Client({ region });
769
769
 
770
770
  try {
771
771
  // 0. Check if already rendered + read fallback in parallel