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 +2 -2
- package/dist/aws.js +2 -2
- package/dist/cli.js +2 -2
- package/package.json +1 -1
- package/src/provider/aws/edge-handler.ts +67 -149
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
771
|
+
if (assoc.EventType === "origin-request") {
|
|
772
772
|
assoc.LambdaFunctionARN = newVersionArn;
|
|
773
773
|
}
|
|
774
774
|
}
|
package/package.json
CHANGED
|
@@ -1,23 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Lambda@Edge origin-
|
|
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.
|
|
12
|
-
* 3.
|
|
13
|
-
* a.
|
|
14
|
-
* b.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
153
|
-
new
|
|
134
|
+
await s3.send(
|
|
135
|
+
new HeadObjectCommand({
|
|
154
136
|
Bucket: __QLARA_BUCKET_NAME__,
|
|
155
|
-
Key:
|
|
137
|
+
Key: key,
|
|
156
138
|
})
|
|
157
139
|
);
|
|
158
|
-
|
|
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
|
|
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
|
|
151
|
+
* Invoke the renderer Lambda synchronously.
|
|
194
152
|
* The renderer fetches metadata from the data source, patches the fallback HTML,
|
|
195
|
-
*
|
|
153
|
+
* and uploads the final HTML to S3.
|
|
196
154
|
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
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<
|
|
158
|
+
async function invokeRenderer(uri: string, match: RouteMatch): Promise<void> {
|
|
203
159
|
try {
|
|
204
|
-
|
|
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
|
|
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:
|
|
265
|
-
): Promise<
|
|
266
|
-
const
|
|
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.
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
280
|
-
|
|
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.
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
//
|
|
295
|
-
|
|
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
|
-
//
|
|
310
|
-
|
|
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
|
}
|