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 +1 -1
- package/src/provider/aws/edge-handler.ts +51 -133
package/package.json
CHANGED
|
@@ -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
|
|
10
|
-
*
|
|
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 →
|
|
17
|
-
* b. File doesn't exist → invoke renderer
|
|
18
|
-
* 4.
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
157
|
-
new
|
|
134
|
+
await s3.send(
|
|
135
|
+
new HeadObjectCommand({
|
|
158
136
|
Bucket: __QLARA_BUCKET_NAME__,
|
|
159
|
-
Key:
|
|
137
|
+
Key: key,
|
|
160
138
|
})
|
|
161
139
|
);
|
|
162
|
-
|
|
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
|
|
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
|
|
151
|
+
* Invoke the renderer Lambda synchronously.
|
|
198
152
|
* The renderer fetches metadata from the data source, patches the fallback HTML,
|
|
199
|
-
*
|
|
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
|
-
*
|
|
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<
|
|
158
|
+
async function invokeRenderer(uri: string, match: RouteMatch): Promise<void> {
|
|
207
159
|
try {
|
|
208
|
-
|
|
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
|
|
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
|
|
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
|
|
260
|
-
// e.g. /product/20.txt (RSC flight data),
|
|
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.
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
295
|
-
|
|
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
|
-
//
|
|
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
|
}
|