qlara 0.1.6 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qlara",
3
- "version": "0.1.6",
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": [
@@ -6,19 +6,22 @@
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.)
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.
11
13
  *
12
14
  * Flow for a request to /product/5:
13
15
  * 1. CloudFront viewer-request rewrites /product/5 → /product/5.html
14
16
  * 2. CloudFront checks edge cache → miss → fires this origin-request handler
15
17
  * 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
+ * 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)
19
22
  */
20
23
 
21
- import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
24
+ import { S3Client, HeadObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
22
25
  import { LambdaClient, InvokeCommand } from '@aws-sdk/client-lambda';
23
26
 
24
27
  // ── Injected at bundle time by esbuild define ────────────────────
@@ -44,14 +47,6 @@ interface RouteMatch {
44
47
  params: Record<string, string>;
45
48
  }
46
49
 
47
- interface CloudFrontResponse {
48
- status: string;
49
- statusDescription: string;
50
- headers: Record<string, Array<{ key: string; value: string }>>;
51
- body?: string;
52
- bodyEncoding?: string;
53
- }
54
-
55
50
  interface CloudFrontRequest {
56
51
  uri: string;
57
52
  querystring: string;
@@ -70,7 +65,6 @@ interface CloudFrontRequestEvent {
70
65
 
71
66
  // ── Constants ────────────────────────────────────────────────────
72
67
 
73
- const FALLBACK_FILENAME = '_fallback.html';
74
68
  const FALLBACK_PLACEHOLDER = '__QLARA_FALLBACK__';
75
69
 
76
70
  // ── Caching ──────────────────────────────────────────────────────
@@ -81,10 +75,8 @@ interface CacheEntry<T> {
81
75
  }
82
76
 
83
77
  const MANIFEST_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
84
- const FALLBACK_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
85
78
 
86
79
  let manifestCache: CacheEntry<QlaraManifest> = { data: null, expiry: 0 };
87
- const fallbackCache: Map<string, CacheEntry<string>> = new Map();
88
80
 
89
81
  // ── Route matching (inlined from routes.ts) ──────────────────────
90
82
 
@@ -135,77 +127,37 @@ async function getManifest(): Promise<QlaraManifest | null> {
135
127
  }
136
128
 
137
129
  /**
138
- * Get the fallback HTML for a route.
139
- * Looks up the _fallback.html file in the route's directory in S3.
140
- *
141
- * '/product/:id' → reads 'product/_fallback.html' from S3
130
+ * Check if a file exists in S3 using HEAD request (no body transfer).
142
131
  */
143
- async function getFallbackHtml(route: ManifestRoute): Promise<string | null> {
144
- // Derive the fallback S3 key from the route pattern
145
- const parts = route.pattern.replace(/^\//, '').split('/');
146
- const dirParts = parts.filter(p => !p.startsWith(':'));
147
- const fallbackKey = [...dirParts, FALLBACK_FILENAME].join('/');
148
-
149
- // Check cache
150
- const cached = fallbackCache.get(fallbackKey);
151
- if (cached?.data && Date.now() < cached.expiry) {
152
- return cached.data;
153
- }
154
-
132
+ async function fileExistsInS3(key: string): Promise<boolean> {
155
133
  try {
156
- const response = await s3.send(
157
- new GetObjectCommand({
134
+ await s3.send(
135
+ new HeadObjectCommand({
158
136
  Bucket: __QLARA_BUCKET_NAME__,
159
- Key: fallbackKey,
137
+ Key: key,
160
138
  })
161
139
  );
162
- const body = await response.Body?.transformToString('utf-8');
163
- if (!body) return null;
164
-
165
- fallbackCache.set(fallbackKey, {
166
- data: body,
167
- expiry: Date.now() + FALLBACK_CACHE_TTL,
168
- });
169
- return body;
140
+ return true;
170
141
  } catch {
171
- return null;
142
+ return false;
172
143
  }
173
144
  }
174
145
 
175
- /**
176
- * Patch the fallback HTML by replacing __QLARA_FALLBACK__ with actual param values.
177
- */
178
- function patchFallback(html: string, params: Record<string, string>): string {
179
- let patched = html;
180
-
181
- // For now, all params use the same placeholder. Replace with the last param value
182
- // (which is the dynamic segment — e.g., the product ID).
183
- // For multi-param routes like /blog/:year/:slug, we'd need per-param placeholders.
184
- const paramValues = Object.values(params);
185
- const lastParam = paramValues[paramValues.length - 1] || '';
186
-
187
- patched = patched.replace(new RegExp(FALLBACK_PLACEHOLDER, 'g'), lastParam);
188
-
189
- return patched;
190
- }
191
-
192
146
  // ── Renderer invocation ──────────────────────────────────────────
193
147
 
194
148
  const lambda = new LambdaClient({ region: __QLARA_REGION__ });
195
149
 
196
150
  /**
197
- * Invoke the renderer Lambda synchronously and return the rendered HTML.
151
+ * Invoke the renderer Lambda synchronously.
198
152
  * The renderer fetches metadata from the data source, patches the fallback HTML,
199
- * uploads to S3, and returns the fully rendered HTML.
200
- *
201
- * This ensures the first request for a new page gets full SEO metadata —
202
- * critical for crawlers that only visit once.
153
+ * and uploads the final HTML to S3.
203
154
  *
204
- * 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.
205
157
  */
206
- async function invokeRenderer(uri: string, match: RouteMatch): Promise<string | null> {
158
+ async function invokeRenderer(uri: string, match: RouteMatch): Promise<void> {
207
159
  try {
208
- const result = await lambda.send(
160
+ await lambda.send(
209
161
  new InvokeCommand({
210
162
  FunctionName: __QLARA_RENDERER_ARN__,
211
163
  InvocationType: 'RequestResponse',
@@ -217,95 +169,61 @@ async function invokeRenderer(uri: string, match: RouteMatch): Promise<string |
217
169
  }),
218
170
  })
219
171
  );
220
-
221
- if (result.FunctionError || !result.Payload) {
222
- return null;
223
- }
224
-
225
- const payload = JSON.parse(new TextDecoder().decode(result.Payload));
226
- return payload.html || null;
227
172
  } catch {
228
- return null;
173
+ // Renderer failed — S3 will return 403/404, which is acceptable
229
174
  }
230
175
  }
231
176
 
232
- // ── Response builder ─────────────────────────────────────────────
233
-
234
- function buildHtmlResponse(html: string): CloudFrontResponse {
235
- return {
236
- status: '200',
237
- statusDescription: 'OK',
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
- },
246
- body: html,
247
- bodyEncoding: 'text',
248
- };
249
- }
250
-
251
177
  // ── Handler ──────────────────────────────────────────────────────
252
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
+ */
253
188
  export async function handler(
254
189
  event: CloudFrontRequestEvent
255
- ): Promise<CloudFrontRequest | CloudFrontResponse> {
190
+ ): Promise<CloudFrontRequest> {
256
191
  const request = event.Records[0].cf.request;
257
192
  const uri = request.uri;
258
193
 
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.
194
+ // 1. Non-HTML file requests → forward to S3 origin directly
195
+ // e.g. /product/20.txt (RSC flight data), JS, CSS, images, etc.
261
196
  const nonHtmlExt = uri.match(/\.([a-z0-9]+)$/)?.[1];
262
197
  if (nonHtmlExt && nonHtmlExt !== 'html') {
263
198
  return request;
264
199
  }
265
200
 
266
- // 2. Try to get the file from S3 directly
201
+ // 2. Check if the HTML file exists in S3 (HEAD — no body transfer)
267
202
  const s3Key = uri.replace(/^\//, '');
203
+ let fileExists = false;
204
+
268
205
  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
- }
206
+ fileExists = await fileExistsInS3(s3Key);
207
+ }
208
+
209
+ // 3. File exists → forward to S3 (CloudFront will cache the S3 response)
210
+ if (fileExists) {
211
+ return request;
284
212
  }
285
213
 
286
- // 3. Fetch manifest and check if this URL matches a Qlara dynamic route
287
- // Strip .html suffix that the URL rewrite function adds before matching
214
+ // 4. File missing check if this matches a dynamic route
288
215
  const cleanUri = uri.replace(/\.html$/, '');
289
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);
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);
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 — forward to origin (S3 will return 403/404)
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
310
228
  return request;
311
229
  }