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 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 moduleDir = getModuleDir();
479
- const sameDirTs = (0, import_node_path2.resolve)(moduleDir, `${name}.ts`);
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
- const srcTs = (0, import_node_path2.resolve)(moduleDir, "..", "src", "provider", "aws", `${name}.ts`);
482
- if ((0, import_node_fs2.existsSync)(srcTs)) return srcTs;
483
- return (0, import_node_path2.resolve)(moduleDir, `${name}.js`);
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 moduleDir = getModuleDir();
474
- const sameDirTs = resolve(moduleDir, `${name}.ts`);
468
+ const thisDir = dirname(fileURLToPath(import.meta.url));
469
+ const sameDirTs = resolve(thisDir, `${name}.ts`);
475
470
  if (existsSync(sameDirTs)) return sameDirTs;
476
- const srcTs = resolve(moduleDir, "..", "src", "provider", "aws", `${name}.ts`);
477
- if (existsSync(srcTs)) return srcTs;
478
- return resolve(moduleDir, `${name}.js`);
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 readFileSync2, writeFileSync, readdirSync as readdirSync2, existsSync as existsSync2 } from "fs";
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 = readFileSync2(templatePath, "utf-8");
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 readFileSync3, writeFileSync as writeFileSync2, existsSync as existsSync3, mkdirSync as mkdirSync2 } from "fs";
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 moduleDir = getModuleDir();
485
- const sameDirTs = resolve(moduleDir, `${name}.ts`);
479
+ const thisDir = dirname(fileURLToPath(import.meta.url));
480
+ const sameDirTs = resolve(thisDir, `${name}.ts`);
486
481
  if (existsSync(sameDirTs)) return sameDirTs;
487
- const srcTs = resolve(moduleDir, "..", "src", "provider", "aws", `${name}.ts`);
488
- if (existsSync(srcTs)) return srcTs;
489
- return resolve(moduleDir, `${name}.js`);
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 readFileSync2, writeFileSync, readdirSync as readdirSync2, existsSync as existsSync2 } from "fs";
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 = readFileSync2(templatePath, "utf-8");
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(readFileSync3(CONFIG_PATH, "utf-8"));
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(readFileSync3(RESOURCES_PATH, "utf-8"));
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.0",
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
+ }