qlara 0.1.5 → 0.1.7

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
@@ -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
  }
package/dist/aws.js CHANGED
@@ -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
  }
package/dist/cli.js CHANGED
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qlara",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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,23 +1,27 @@
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. The handler NEVER generates responses —
10
+ * it always forwards the request to S3 origin. This ensures CloudFront caches
11
+ * the S3 origin response natively, with identical behavior for build-time and
12
+ * renderer-generated pages.
13
+ *
9
14
  * Flow for a request to /product/5:
10
15
  * 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)
16
+ * 2. CloudFront checks edge cache miss fires this origin-request handler
17
+ * 3. Handler checks S3 for product/5.html:
18
+ * a. File exists → forward to S3 origin (CloudFront caches the S3 response)
19
+ * b. File doesn't exist invoke renderer (uploads to S3) forward to S3 origin
20
+ * 4. CloudFront caches the S3 response at the edge
21
+ * 5. Subsequent requests hit CloudFront edge cache directly (~10-30ms)
18
22
  */
19
23
 
20
- import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
24
+ import { S3Client, HeadObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
21
25
  import { LambdaClient, InvokeCommand } from '@aws-sdk/client-lambda';
22
26
 
23
27
  // ── Injected at bundle time by esbuild define ────────────────────
@@ -43,30 +47,24 @@ interface RouteMatch {
43
47
  params: Record<string, string>;
44
48
  }
45
49
 
46
- interface CloudFrontResponse {
47
- status: string;
48
- statusDescription: string;
49
- headers: Record<string, Array<{ key: string; value: string }>>;
50
- body?: string;
51
- }
52
-
53
50
  interface CloudFrontRequest {
54
51
  uri: string;
55
52
  querystring: string;
53
+ method: string;
54
+ headers: Record<string, Array<{ key: string; value: string }>>;
55
+ origin?: Record<string, unknown>;
56
56
  }
57
57
 
58
- interface CloudFrontResponseEvent {
58
+ interface CloudFrontRequestEvent {
59
59
  Records: Array<{
60
60
  cf: {
61
61
  request: CloudFrontRequest;
62
- response: CloudFrontResponse;
63
62
  };
64
63
  }>;
65
64
  }
66
65
 
67
66
  // ── Constants ────────────────────────────────────────────────────
68
67
 
69
- const FALLBACK_FILENAME = '_fallback.html';
70
68
  const FALLBACK_PLACEHOLDER = '__QLARA_FALLBACK__';
71
69
 
72
70
  // ── Caching ──────────────────────────────────────────────────────
@@ -77,10 +75,8 @@ interface CacheEntry<T> {
77
75
  }
78
76
 
79
77
  const MANIFEST_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
80
- const FALLBACK_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
81
78
 
82
79
  let manifestCache: CacheEntry<QlaraManifest> = { data: null, expiry: 0 };
83
- const fallbackCache: Map<string, CacheEntry<string>> = new Map();
84
80
 
85
81
  // ── Route matching (inlined from routes.ts) ──────────────────────
86
82
 
@@ -131,77 +127,37 @@ async function getManifest(): Promise<QlaraManifest | null> {
131
127
  }
132
128
 
133
129
  /**
134
- * Get the fallback HTML for a route.
135
- * Looks up the _fallback.html file in the route's directory in S3.
136
- *
137
- * '/product/:id' → reads 'product/_fallback.html' from S3
130
+ * Check if a file exists in S3 using HEAD request (no body transfer).
138
131
  */
139
- async function getFallbackHtml(route: ManifestRoute): Promise<string | null> {
140
- // Derive the fallback S3 key from the route pattern
141
- const parts = route.pattern.replace(/^\//, '').split('/');
142
- const dirParts = parts.filter(p => !p.startsWith(':'));
143
- const fallbackKey = [...dirParts, FALLBACK_FILENAME].join('/');
144
-
145
- // Check cache
146
- const cached = fallbackCache.get(fallbackKey);
147
- if (cached?.data && Date.now() < cached.expiry) {
148
- return cached.data;
149
- }
150
-
132
+ async function fileExistsInS3(key: string): Promise<boolean> {
151
133
  try {
152
- const response = await s3.send(
153
- new GetObjectCommand({
134
+ await s3.send(
135
+ new HeadObjectCommand({
154
136
  Bucket: __QLARA_BUCKET_NAME__,
155
- Key: fallbackKey,
137
+ Key: key,
156
138
  })
157
139
  );
158
- const body = await response.Body?.transformToString('utf-8');
159
- if (!body) return null;
160
-
161
- fallbackCache.set(fallbackKey, {
162
- data: body,
163
- expiry: Date.now() + FALLBACK_CACHE_TTL,
164
- });
165
- return body;
140
+ return true;
166
141
  } catch {
167
- return null;
142
+ return false;
168
143
  }
169
144
  }
170
145
 
171
- /**
172
- * Patch the fallback HTML by replacing __QLARA_FALLBACK__ with actual param values.
173
- */
174
- function patchFallback(html: string, params: Record<string, string>): string {
175
- let patched = html;
176
-
177
- // For now, all params use the same placeholder. Replace with the last param value
178
- // (which is the dynamic segment — e.g., the product ID).
179
- // For multi-param routes like /blog/:year/:slug, we'd need per-param placeholders.
180
- const paramValues = Object.values(params);
181
- const lastParam = paramValues[paramValues.length - 1] || '';
182
-
183
- patched = patched.replace(new RegExp(FALLBACK_PLACEHOLDER, 'g'), lastParam);
184
-
185
- return patched;
186
- }
187
-
188
146
  // ── Renderer invocation ──────────────────────────────────────────
189
147
 
190
148
  const lambda = new LambdaClient({ region: __QLARA_REGION__ });
191
149
 
192
150
  /**
193
- * Invoke the renderer Lambda synchronously and return the rendered HTML.
151
+ * Invoke the renderer Lambda synchronously.
194
152
  * The renderer fetches metadata from the data source, patches the fallback HTML,
195
- * uploads to S3, and returns the fully rendered HTML.
153
+ * and uploads the final HTML to S3.
196
154
  *
197
- * This ensures the first request for a new page gets full SEO metadata —
198
- * critical for crawlers that only visit once.
199
- *
200
- * Returns null if the renderer fails (caller falls back to unpatched HTML).
155
+ * We wait for the renderer to complete so the file exists in S3 before
156
+ * CloudFront forwards the request to origin.
201
157
  */
202
- async function invokeRenderer(uri: string, match: RouteMatch): Promise<string | null> {
158
+ async function invokeRenderer(uri: string, match: RouteMatch): Promise<void> {
203
159
  try {
204
- const result = await lambda.send(
160
+ await lambda.send(
205
161
  new InvokeCommand({
206
162
  FunctionName: __QLARA_RENDERER_ARN__,
207
163
  InvocationType: 'RequestResponse',
@@ -213,99 +169,61 @@ async function invokeRenderer(uri: string, match: RouteMatch): Promise<string |
213
169
  }),
214
170
  })
215
171
  );
216
-
217
- if (result.FunctionError || !result.Payload) {
218
- return null;
219
- }
220
-
221
- const payload = JSON.parse(new TextDecoder().decode(result.Payload));
222
- return payload.html || null;
223
172
  } catch {
224
- return null;
225
- }
226
- }
227
-
228
- // ── Response builder ─────────────────────────────────────────────
229
-
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
- }
173
+ // Renderer failed — S3 will return 403/404, which is acceptable
251
174
  }
252
-
253
- return {
254
- status: '200',
255
- statusDescription: 'OK',
256
- headers,
257
- body: html,
258
- };
259
175
  }
260
176
 
261
177
  // ── Handler ──────────────────────────────────────────────────────
262
178
 
179
+ /**
180
+ * Origin-request handler.
181
+ *
182
+ * ALWAYS returns the request object (forward to S3 origin).
183
+ * Never generates responses — CloudFront caches S3 origin responses natively.
184
+ *
185
+ * For dynamic routes where the file doesn't exist yet, the renderer is invoked
186
+ * synchronously to upload the HTML to S3 before forwarding.
187
+ */
263
188
  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;
189
+ event: CloudFrontRequestEvent
190
+ ): Promise<CloudFrontRequest> {
191
+ const request = event.Records[0].cf.request;
269
192
  const uri = request.uri;
270
- const status = parseInt(response.status, 10);
271
193
 
272
- // 1. If response is 200 (file exists in S3), pass through
273
- if (status !== 403 && status !== 404) {
274
- return response;
194
+ // 1. Non-HTML file requests forward to S3 origin directly
195
+ // e.g. /product/20.txt (RSC flight data), JS, CSS, images, etc.
196
+ const nonHtmlExt = uri.match(/\.([a-z0-9]+)$/)?.[1];
197
+ if (nonHtmlExt && nonHtmlExt !== 'html') {
198
+ return request;
275
199
  }
276
200
 
277
- // At this point, the file does NOT exist in S3 (403 from OAC or 404)
201
+ // 2. Check if the HTML file exists in S3 (HEAD no body transfer)
202
+ const s3Key = uri.replace(/^\//, '');
203
+ let fileExists = false;
278
204
 
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;
205
+ if (s3Key) {
206
+ fileExists = await fileExistsInS3(s3Key);
284
207
  }
285
208
 
286
- // 3. Fetch manifest and check if this URL matches a Qlara dynamic route
287
- const manifest = await getManifest();
288
- // Strip .html suffix that the URL rewrite function adds before matching
209
+ // 3. File exists forward to S3 (CloudFront will cache the S3 response)
210
+ if (fileExists) {
211
+ return request;
212
+ }
213
+
214
+ // 4. File missing → check if this matches a dynamic route
289
215
  const cleanUri = uri.replace(/\.html$/, '');
216
+ const manifest = await getManifest();
290
217
  const match = manifest ? matchRoute(cleanUri, manifest.routes) : null;
291
218
 
292
- // 4. If route matches: invoke renderer synchronously to get fully rendered HTML
293
219
  if (match) {
294
- // Try to render with full SEO metadata (synchronous waits for result)
295
- const renderedHtml = await invokeRenderer(cleanUri, match);
296
-
297
- if (renderedHtml) {
298
- return buildHtmlResponse(renderedHtml, response);
299
- }
300
-
301
- // Renderer failed — fall back to unpatched fallback HTML (no SEO, but page still works)
302
- const fallbackHtml = await getFallbackHtml(match.route);
303
- if (fallbackHtml) {
304
- const patchedHtml = patchFallback(fallbackHtml, match.params);
305
- return buildHtmlResponse(patchedHtml, response);
306
- }
220
+ // 5. Invoke renderer synchronously it uploads the HTML to S3
221
+ await invokeRenderer(cleanUri, match);
222
+ // File now exists in S3 — forward to S3 origin
307
223
  }
308
224
 
309
- // 5. No match or no fallback — return original error
310
- return response;
225
+ // 6. Forward to S3 origin
226
+ // - If renderer succeeded: S3 returns 200 → CloudFront caches ✅
227
+ // - If renderer failed or no match: S3 returns 403/404
228
+ return request;
311
229
  }