qlara 0.1.0 → 0.1.2
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 +18 -11
- package/dist/aws.js +20 -13
- package/dist/cli.js +23 -16
- package/package.json +7 -3
- package/src/provider/aws/edge-handler.ts +303 -0
- package/src/provider/aws/renderer.ts +869 -0
- package/src/types.ts +561 -0
package/dist/aws.cjs
CHANGED
|
@@ -464,23 +464,30 @@ var import_esbuild = require("esbuild");
|
|
|
464
464
|
var import_node_fs2 = require("fs");
|
|
465
465
|
var import_node_path2 = require("path");
|
|
466
466
|
var import_node_url = require("url");
|
|
467
|
+
var import_node_module = require("module");
|
|
467
468
|
var import_archiver = __toESM(require("archiver"), 1);
|
|
468
469
|
var import_node_stream = require("stream");
|
|
469
470
|
var import_meta = {};
|
|
470
471
|
var BUNDLE_DIR = (0, import_node_path2.join)(".qlara", "bundles");
|
|
471
|
-
function getModuleDir() {
|
|
472
|
-
if (typeof __dirname !== "undefined") {
|
|
473
|
-
return __dirname;
|
|
474
|
-
}
|
|
475
|
-
return (0, import_node_path2.dirname)((0, import_node_url.fileURLToPath)(import_meta.url));
|
|
476
|
-
}
|
|
477
472
|
function resolveEntry(name) {
|
|
478
|
-
const
|
|
479
|
-
const sameDirTs = (0, import_node_path2.resolve)(
|
|
473
|
+
const thisDir = (0, import_node_path2.dirname)((0, import_node_url.fileURLToPath)(import_meta.url));
|
|
474
|
+
const sameDirTs = (0, import_node_path2.resolve)(thisDir, `${name}.ts`);
|
|
480
475
|
if ((0, import_node_fs2.existsSync)(sameDirTs)) return sameDirTs;
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
476
|
+
try {
|
|
477
|
+
const esmRequire = (0, import_node_module.createRequire)(import_meta.url);
|
|
478
|
+
const pkgJsonPath = esmRequire.resolve("qlara/package.json");
|
|
479
|
+
const pkgRoot = (0, import_node_path2.dirname)(pkgJsonPath);
|
|
480
|
+
const srcTs = (0, import_node_path2.join)(pkgRoot, "src", "provider", "aws", `${name}.ts`);
|
|
481
|
+
if ((0, import_node_fs2.existsSync)(srcTs)) return srcTs;
|
|
482
|
+
} catch {
|
|
483
|
+
}
|
|
484
|
+
const fromDist = (0, import_node_path2.resolve)(thisDir, "..", "src", "provider", "aws", `${name}.ts`);
|
|
485
|
+
if ((0, import_node_fs2.existsSync)(fromDist)) return fromDist;
|
|
486
|
+
throw new Error(
|
|
487
|
+
`[qlara] Could not find ${name}.ts Lambda source file. Searched:
|
|
488
|
+
${sameDirTs}
|
|
489
|
+
${fromDist}`
|
|
490
|
+
);
|
|
484
491
|
}
|
|
485
492
|
async function createZip(filePath, entryName) {
|
|
486
493
|
return new Promise((resolvePromise, reject) => {
|
package/dist/aws.js
CHANGED
|
@@ -460,22 +460,29 @@ import { build } from "esbuild";
|
|
|
460
460
|
import { mkdirSync, existsSync } from "fs";
|
|
461
461
|
import { resolve, join as join2, dirname } from "path";
|
|
462
462
|
import { fileURLToPath } from "url";
|
|
463
|
+
import { createRequire } from "module";
|
|
463
464
|
import archiver from "archiver";
|
|
464
465
|
import { Writable } from "stream";
|
|
465
466
|
var BUNDLE_DIR = join2(".qlara", "bundles");
|
|
466
|
-
function getModuleDir() {
|
|
467
|
-
if (typeof __dirname !== "undefined") {
|
|
468
|
-
return __dirname;
|
|
469
|
-
}
|
|
470
|
-
return dirname(fileURLToPath(import.meta.url));
|
|
471
|
-
}
|
|
472
467
|
function resolveEntry(name) {
|
|
473
|
-
const
|
|
474
|
-
const sameDirTs = resolve(
|
|
468
|
+
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
469
|
+
const sameDirTs = resolve(thisDir, `${name}.ts`);
|
|
475
470
|
if (existsSync(sameDirTs)) return sameDirTs;
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
471
|
+
try {
|
|
472
|
+
const esmRequire = createRequire(import.meta.url);
|
|
473
|
+
const pkgJsonPath = esmRequire.resolve("qlara/package.json");
|
|
474
|
+
const pkgRoot = dirname(pkgJsonPath);
|
|
475
|
+
const srcTs = join2(pkgRoot, "src", "provider", "aws", `${name}.ts`);
|
|
476
|
+
if (existsSync(srcTs)) return srcTs;
|
|
477
|
+
} catch {
|
|
478
|
+
}
|
|
479
|
+
const fromDist = resolve(thisDir, "..", "src", "provider", "aws", `${name}.ts`);
|
|
480
|
+
if (existsSync(fromDist)) return fromDist;
|
|
481
|
+
throw new Error(
|
|
482
|
+
`[qlara] Could not find ${name}.ts Lambda source file. Searched:
|
|
483
|
+
${sameDirTs}
|
|
484
|
+
${fromDist}`
|
|
485
|
+
);
|
|
479
486
|
}
|
|
480
487
|
async function createZip(filePath, entryName) {
|
|
481
488
|
return new Promise((resolvePromise, reject) => {
|
|
@@ -545,7 +552,7 @@ async function bundleRenderer(routeFile) {
|
|
|
545
552
|
var STACK_NAME_PREFIX = "qlara";
|
|
546
553
|
|
|
547
554
|
// src/fallback.ts
|
|
548
|
-
import { readFileSync as
|
|
555
|
+
import { readFileSync as readFileSync3, writeFileSync, readdirSync as readdirSync2, existsSync as existsSync2 } from "fs";
|
|
549
556
|
import { join as join3 } from "path";
|
|
550
557
|
var FALLBACK_FILENAME = "_fallback.html";
|
|
551
558
|
var FALLBACK_PLACEHOLDER = "__QLARA_FALLBACK__";
|
|
@@ -624,7 +631,7 @@ function generateFallbacks(buildDir, routes) {
|
|
|
624
631
|
continue;
|
|
625
632
|
}
|
|
626
633
|
const templatePath = join3(routeDir, files[0]);
|
|
627
|
-
const templateHtml =
|
|
634
|
+
const templateHtml = readFileSync3(templatePath, "utf-8");
|
|
628
635
|
const fallbackHtml = generateFallbackFromTemplate(templateHtml, route.pattern);
|
|
629
636
|
const fallbackPath = join3(routeDir, FALLBACK_FILENAME);
|
|
630
637
|
writeFileSync(fallbackPath, fallbackHtml);
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import { readFileSync as
|
|
4
|
+
import { readFileSync as readFileSync4, writeFileSync as writeFileSync2, existsSync as existsSync3, mkdirSync as mkdirSync2 } from "fs";
|
|
5
5
|
import { join as join4 } from "path";
|
|
6
6
|
|
|
7
7
|
// src/provider/aws/constants.ts
|
|
@@ -471,22 +471,29 @@ import { build } from "esbuild";
|
|
|
471
471
|
import { mkdirSync, existsSync } from "fs";
|
|
472
472
|
import { resolve, join as join2, dirname } from "path";
|
|
473
473
|
import { fileURLToPath } from "url";
|
|
474
|
+
import { createRequire } from "module";
|
|
474
475
|
import archiver from "archiver";
|
|
475
476
|
import { Writable } from "stream";
|
|
476
477
|
var BUNDLE_DIR = join2(".qlara", "bundles");
|
|
477
|
-
function getModuleDir() {
|
|
478
|
-
if (typeof __dirname !== "undefined") {
|
|
479
|
-
return __dirname;
|
|
480
|
-
}
|
|
481
|
-
return dirname(fileURLToPath(import.meta.url));
|
|
482
|
-
}
|
|
483
478
|
function resolveEntry(name) {
|
|
484
|
-
const
|
|
485
|
-
const sameDirTs = resolve(
|
|
479
|
+
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
480
|
+
const sameDirTs = resolve(thisDir, `${name}.ts`);
|
|
486
481
|
if (existsSync(sameDirTs)) return sameDirTs;
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
482
|
+
try {
|
|
483
|
+
const esmRequire = createRequire(import.meta.url);
|
|
484
|
+
const pkgJsonPath = esmRequire.resolve("qlara/package.json");
|
|
485
|
+
const pkgRoot = dirname(pkgJsonPath);
|
|
486
|
+
const srcTs = join2(pkgRoot, "src", "provider", "aws", `${name}.ts`);
|
|
487
|
+
if (existsSync(srcTs)) return srcTs;
|
|
488
|
+
} catch {
|
|
489
|
+
}
|
|
490
|
+
const fromDist = resolve(thisDir, "..", "src", "provider", "aws", `${name}.ts`);
|
|
491
|
+
if (existsSync(fromDist)) return fromDist;
|
|
492
|
+
throw new Error(
|
|
493
|
+
`[qlara] Could not find ${name}.ts Lambda source file. Searched:
|
|
494
|
+
${sameDirTs}
|
|
495
|
+
${fromDist}`
|
|
496
|
+
);
|
|
490
497
|
}
|
|
491
498
|
async function createZip(filePath, entryName) {
|
|
492
499
|
return new Promise((resolvePromise, reject) => {
|
|
@@ -553,7 +560,7 @@ async function bundleRenderer(routeFile) {
|
|
|
553
560
|
}
|
|
554
561
|
|
|
555
562
|
// src/fallback.ts
|
|
556
|
-
import { readFileSync as
|
|
563
|
+
import { readFileSync as readFileSync3, writeFileSync, readdirSync as readdirSync2, existsSync as existsSync2 } from "fs";
|
|
557
564
|
import { join as join3 } from "path";
|
|
558
565
|
var FALLBACK_FILENAME = "_fallback.html";
|
|
559
566
|
var FALLBACK_PLACEHOLDER = "__QLARA_FALLBACK__";
|
|
@@ -632,7 +639,7 @@ function generateFallbacks(buildDir, routes) {
|
|
|
632
639
|
continue;
|
|
633
640
|
}
|
|
634
641
|
const templatePath = join3(routeDir, files[0]);
|
|
635
|
-
const templateHtml =
|
|
642
|
+
const templateHtml = readFileSync3(templatePath, "utf-8");
|
|
636
643
|
const fallbackHtml = generateFallbackFromTemplate(templateHtml, route.pattern);
|
|
637
644
|
const fallbackPath = join3(routeDir, FALLBACK_FILENAME);
|
|
638
645
|
writeFileSync(fallbackPath, fallbackHtml);
|
|
@@ -1076,11 +1083,11 @@ function loadConfig() {
|
|
|
1076
1083
|
console.error("[qlara] Run your framework build first (e.g. `next build`)");
|
|
1077
1084
|
process.exit(1);
|
|
1078
1085
|
}
|
|
1079
|
-
return JSON.parse(
|
|
1086
|
+
return JSON.parse(readFileSync4(CONFIG_PATH, "utf-8"));
|
|
1080
1087
|
}
|
|
1081
1088
|
function loadResources() {
|
|
1082
1089
|
if (!existsSync3(RESOURCES_PATH)) return null;
|
|
1083
|
-
return JSON.parse(
|
|
1090
|
+
return JSON.parse(readFileSync4(RESOURCES_PATH, "utf-8"));
|
|
1084
1091
|
}
|
|
1085
1092
|
function saveResources(resources) {
|
|
1086
1093
|
mkdirSync2(QLARA_DIR, { recursive: true });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qlara",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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": [
|
|
@@ -42,10 +42,14 @@
|
|
|
42
42
|
"types": "./dist/aws.d.ts",
|
|
43
43
|
"import": "./dist/aws.js",
|
|
44
44
|
"require": "./dist/aws.cjs"
|
|
45
|
-
}
|
|
45
|
+
},
|
|
46
|
+
"./package.json": "./package.json"
|
|
46
47
|
},
|
|
47
48
|
"files": [
|
|
48
|
-
"dist"
|
|
49
|
+
"dist",
|
|
50
|
+
"src/provider/aws/edge-handler.ts",
|
|
51
|
+
"src/provider/aws/renderer.ts",
|
|
52
|
+
"src/types.ts"
|
|
49
53
|
],
|
|
50
54
|
"scripts": {
|
|
51
55
|
"build": "tsup",
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lambda@Edge origin-response handler for Qlara.
|
|
3
|
+
*
|
|
4
|
+
* This file is bundled into a self-contained ZIP and deployed to Lambda@Edge.
|
|
5
|
+
* It does NOT run in the developer's Node.js — it runs at CloudFront edge locations.
|
|
6
|
+
*
|
|
7
|
+
* Config values are injected at bundle time via esbuild `define` (Lambda@Edge has no env vars).
|
|
8
|
+
*
|
|
9
|
+
* Flow for a request to /product/5:
|
|
10
|
+
* 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)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
|
|
21
|
+
import { LambdaClient, InvokeCommand } from '@aws-sdk/client-lambda';
|
|
22
|
+
|
|
23
|
+
// ── Injected at bundle time by esbuild define ────────────────────
|
|
24
|
+
declare const __QLARA_BUCKET_NAME__: string;
|
|
25
|
+
declare const __QLARA_RENDERER_ARN__: string;
|
|
26
|
+
declare const __QLARA_REGION__: string;
|
|
27
|
+
// ── Types (inlined to keep bundle self-contained) ────────────────
|
|
28
|
+
|
|
29
|
+
interface ManifestRoute {
|
|
30
|
+
pattern: string;
|
|
31
|
+
paramNames: string[];
|
|
32
|
+
regex: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface QlaraManifest {
|
|
36
|
+
version: 1;
|
|
37
|
+
routes: ManifestRoute[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface RouteMatch {
|
|
41
|
+
route: ManifestRoute;
|
|
42
|
+
params: Record<string, string>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface CloudFrontResponse {
|
|
46
|
+
status: string;
|
|
47
|
+
statusDescription: string;
|
|
48
|
+
headers: Record<string, Array<{ key: string; value: string }>>;
|
|
49
|
+
body?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface CloudFrontRequest {
|
|
53
|
+
uri: string;
|
|
54
|
+
querystring: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface CloudFrontResponseEvent {
|
|
58
|
+
Records: Array<{
|
|
59
|
+
cf: {
|
|
60
|
+
request: CloudFrontRequest;
|
|
61
|
+
response: CloudFrontResponse;
|
|
62
|
+
};
|
|
63
|
+
}>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Constants ────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
const FALLBACK_FILENAME = '_fallback.html';
|
|
69
|
+
const FALLBACK_PLACEHOLDER = '__QLARA_FALLBACK__';
|
|
70
|
+
|
|
71
|
+
// ── Caching ──────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
interface CacheEntry<T> {
|
|
74
|
+
data: T | null;
|
|
75
|
+
expiry: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const MANIFEST_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
79
|
+
const FALLBACK_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
80
|
+
|
|
81
|
+
let manifestCache: CacheEntry<QlaraManifest> = { data: null, expiry: 0 };
|
|
82
|
+
const fallbackCache: Map<string, CacheEntry<string>> = new Map();
|
|
83
|
+
|
|
84
|
+
// ── Route matching (inlined from routes.ts) ──────────────────────
|
|
85
|
+
|
|
86
|
+
function matchRoute(url: string, routes: ManifestRoute[]): RouteMatch | null {
|
|
87
|
+
const cleanUrl = url.split('?')[0].replace(/\/$/, '') || '/';
|
|
88
|
+
|
|
89
|
+
for (const route of routes) {
|
|
90
|
+
const regex = new RegExp(route.regex);
|
|
91
|
+
const match = cleanUrl.match(regex);
|
|
92
|
+
|
|
93
|
+
if (match) {
|
|
94
|
+
const params: Record<string, string> = {};
|
|
95
|
+
route.paramNames.forEach((name, i) => {
|
|
96
|
+
params[name] = match[i + 1];
|
|
97
|
+
});
|
|
98
|
+
return { route, params };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── S3 helpers ───────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
const s3 = new S3Client({ region: __QLARA_REGION__ });
|
|
108
|
+
|
|
109
|
+
async function getManifest(): Promise<QlaraManifest | null> {
|
|
110
|
+
if (manifestCache.data && Date.now() < manifestCache.expiry) {
|
|
111
|
+
return manifestCache.data;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const response = await s3.send(
|
|
116
|
+
new GetObjectCommand({
|
|
117
|
+
Bucket: __QLARA_BUCKET_NAME__,
|
|
118
|
+
Key: 'qlara-manifest.json',
|
|
119
|
+
})
|
|
120
|
+
);
|
|
121
|
+
const body = await response.Body?.transformToString('utf-8');
|
|
122
|
+
if (!body) return null;
|
|
123
|
+
|
|
124
|
+
const manifest = JSON.parse(body) as QlaraManifest;
|
|
125
|
+
manifestCache = { data: manifest, expiry: Date.now() + MANIFEST_CACHE_TTL };
|
|
126
|
+
return manifest;
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Get the fallback HTML for a route.
|
|
134
|
+
* Looks up the _fallback.html file in the route's directory in S3.
|
|
135
|
+
*
|
|
136
|
+
* '/product/:id' → reads 'product/_fallback.html' from S3
|
|
137
|
+
*/
|
|
138
|
+
async function getFallbackHtml(route: ManifestRoute): Promise<string | null> {
|
|
139
|
+
// Derive the fallback S3 key from the route pattern
|
|
140
|
+
const parts = route.pattern.replace(/^\//, '').split('/');
|
|
141
|
+
const dirParts = parts.filter(p => !p.startsWith(':'));
|
|
142
|
+
const fallbackKey = [...dirParts, FALLBACK_FILENAME].join('/');
|
|
143
|
+
|
|
144
|
+
// Check cache
|
|
145
|
+
const cached = fallbackCache.get(fallbackKey);
|
|
146
|
+
if (cached?.data && Date.now() < cached.expiry) {
|
|
147
|
+
return cached.data;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const response = await s3.send(
|
|
152
|
+
new GetObjectCommand({
|
|
153
|
+
Bucket: __QLARA_BUCKET_NAME__,
|
|
154
|
+
Key: fallbackKey,
|
|
155
|
+
})
|
|
156
|
+
);
|
|
157
|
+
const body = await response.Body?.transformToString('utf-8');
|
|
158
|
+
if (!body) return null;
|
|
159
|
+
|
|
160
|
+
fallbackCache.set(fallbackKey, {
|
|
161
|
+
data: body,
|
|
162
|
+
expiry: Date.now() + FALLBACK_CACHE_TTL,
|
|
163
|
+
});
|
|
164
|
+
return body;
|
|
165
|
+
} catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Patch the fallback HTML by replacing __QLARA_FALLBACK__ with actual param values.
|
|
172
|
+
*/
|
|
173
|
+
function patchFallback(html: string, params: Record<string, string>): string {
|
|
174
|
+
let patched = html;
|
|
175
|
+
|
|
176
|
+
// For now, all params use the same placeholder. Replace with the last param value
|
|
177
|
+
// (which is the dynamic segment — e.g., the product ID).
|
|
178
|
+
// For multi-param routes like /blog/:year/:slug, we'd need per-param placeholders.
|
|
179
|
+
const paramValues = Object.values(params);
|
|
180
|
+
const lastParam = paramValues[paramValues.length - 1] || '';
|
|
181
|
+
|
|
182
|
+
patched = patched.replace(new RegExp(FALLBACK_PLACEHOLDER, 'g'), lastParam);
|
|
183
|
+
|
|
184
|
+
return patched;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── Renderer invocation ──────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
const lambda = new LambdaClient({ region: __QLARA_REGION__ });
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Invoke the renderer Lambda synchronously and return the rendered HTML.
|
|
193
|
+
* The renderer fetches metadata from the data source, patches the fallback HTML,
|
|
194
|
+
* uploads to S3, and returns the fully rendered HTML.
|
|
195
|
+
*
|
|
196
|
+
* This ensures the first request for a new page gets full SEO metadata —
|
|
197
|
+
* critical for crawlers that only visit once.
|
|
198
|
+
*
|
|
199
|
+
* Returns null if the renderer fails (caller falls back to unpatched HTML).
|
|
200
|
+
*/
|
|
201
|
+
async function invokeRenderer(uri: string, match: RouteMatch): Promise<string | null> {
|
|
202
|
+
try {
|
|
203
|
+
const result = await lambda.send(
|
|
204
|
+
new InvokeCommand({
|
|
205
|
+
FunctionName: __QLARA_RENDERER_ARN__,
|
|
206
|
+
InvocationType: 'RequestResponse',
|
|
207
|
+
Payload: JSON.stringify({
|
|
208
|
+
uri,
|
|
209
|
+
bucket: __QLARA_BUCKET_NAME__,
|
|
210
|
+
routePattern: match.route.pattern,
|
|
211
|
+
params: match.params,
|
|
212
|
+
}),
|
|
213
|
+
})
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
if (result.FunctionError || !result.Payload) {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const payload = JSON.parse(new TextDecoder().decode(result.Payload));
|
|
221
|
+
return payload.html || null;
|
|
222
|
+
} catch {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── Response builder ─────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
// Lambda@Edge read-only headers — must be preserved from the original response
|
|
230
|
+
const READ_ONLY_HEADERS = ['transfer-encoding', 'via'];
|
|
231
|
+
|
|
232
|
+
function buildHtmlResponse(
|
|
233
|
+
html: string,
|
|
234
|
+
originalResponse: CloudFrontResponse
|
|
235
|
+
): CloudFrontResponse {
|
|
236
|
+
const headers: Record<string, Array<{ key: string; value: string }>> = {
|
|
237
|
+
'content-type': [
|
|
238
|
+
{ key: 'Content-Type', value: 'text/html; charset=utf-8' },
|
|
239
|
+
],
|
|
240
|
+
'cache-control': [
|
|
241
|
+
{ key: 'Cache-Control', value: 'public, max-age=0, must-revalidate' },
|
|
242
|
+
],
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
// Preserve read-only headers from the original response to avoid 502
|
|
246
|
+
for (const headerName of READ_ONLY_HEADERS) {
|
|
247
|
+
if (originalResponse.headers[headerName]) {
|
|
248
|
+
headers[headerName] = originalResponse.headers[headerName];
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
status: '200',
|
|
254
|
+
statusDescription: 'OK',
|
|
255
|
+
headers,
|
|
256
|
+
body: html,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ── Handler ──────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
export async function handler(
|
|
263
|
+
event: CloudFrontResponseEvent
|
|
264
|
+
): Promise<CloudFrontResponse> {
|
|
265
|
+
const record = event.Records[0].cf;
|
|
266
|
+
const response = record.response;
|
|
267
|
+
const request = record.request;
|
|
268
|
+
const uri = request.uri;
|
|
269
|
+
const status = parseInt(response.status, 10);
|
|
270
|
+
|
|
271
|
+
// 1. If response is 200 (file exists in S3), pass through
|
|
272
|
+
if (status !== 403 && status !== 404) {
|
|
273
|
+
return response;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// At this point, the file does NOT exist in S3 (403 from OAC or 404)
|
|
277
|
+
|
|
278
|
+
// 2. Fetch manifest and check if this URL matches a Qlara dynamic route
|
|
279
|
+
const manifest = await getManifest();
|
|
280
|
+
// Strip .html suffix that the URL rewrite function adds before matching
|
|
281
|
+
const cleanUri = uri.replace(/\.html$/, '');
|
|
282
|
+
const match = manifest ? matchRoute(cleanUri, manifest.routes) : null;
|
|
283
|
+
|
|
284
|
+
// 3. If route matches: invoke renderer synchronously to get fully rendered HTML
|
|
285
|
+
if (match) {
|
|
286
|
+
// Try to render with full SEO metadata (synchronous — waits for result)
|
|
287
|
+
const renderedHtml = await invokeRenderer(cleanUri, match);
|
|
288
|
+
|
|
289
|
+
if (renderedHtml) {
|
|
290
|
+
return buildHtmlResponse(renderedHtml, response);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Renderer failed — fall back to unpatched fallback HTML (no SEO, but page still works)
|
|
294
|
+
const fallbackHtml = await getFallbackHtml(match.route);
|
|
295
|
+
if (fallbackHtml) {
|
|
296
|
+
const patchedHtml = patchFallback(fallbackHtml, match.params);
|
|
297
|
+
return buildHtmlResponse(patchedHtml, response);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// 4. No match or no fallback — return original error
|
|
302
|
+
return response;
|
|
303
|
+
}
|