qlara 0.1.8 → 0.1.10

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
@@ -562,7 +562,7 @@ async function bundleEdgeHandler(config) {
562
562
  });
563
563
  return createZip(outfile, "edge-handler.js");
564
564
  }
565
- async function bundleRenderer(routeFile, cacheTtl = 3600) {
565
+ async function bundleRenderer(routeFile, cacheTtl = 3600, framework) {
566
566
  (0, import_node_fs2.mkdirSync)(BUNDLE_DIR, { recursive: true });
567
567
  const outfile = (0, import_node_path2.join)(BUNDLE_DIR, "renderer.js");
568
568
  const alias = {};
@@ -584,7 +584,8 @@ async function bundleRenderer(routeFile, cacheTtl = 3600) {
584
584
  minify: true,
585
585
  alias,
586
586
  define: {
587
- __QLARA_CACHE_TTL__: String(cacheTtl)
587
+ __QLARA_CACHE_TTL__: String(cacheTtl),
588
+ __QLARA_FRAMEWORK__: JSON.stringify(framework || "")
588
589
  },
589
590
  external: []
590
591
  });
@@ -964,7 +965,7 @@ function aws(awsConfig = {}) {
964
965
  const cf = new import_client_cloudfront.CloudFrontClient({ region: res.region });
965
966
  await updateCloudFrontEdgeVersion(cf, res.distributionId, newVersionArn);
966
967
  console.log("[qlara/aws] Bundling renderer...");
967
- const rendererZip = await bundleRenderer(config.routeFile, cacheTtl);
968
+ const rendererZip = await bundleRenderer(config.routeFile, cacheTtl, config.framework);
968
969
  await (0, import_client_lambda.waitUntilFunctionUpdatedV2)(
969
970
  { client: lambda, maxWaitTime: 120 },
970
971
  { FunctionName: res.rendererFunctionArn }
package/dist/aws.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { P as ProviderResources, Q as QlaraProvider } from './types-gl2xFqEX.cjs';
1
+ import { P as ProviderResources, Q as QlaraProvider } from './types--KPPgCtc.cjs';
2
2
 
3
3
  interface AwsConfig {
4
4
  stackName?: string;
package/dist/aws.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { P as ProviderResources, Q as QlaraProvider } from './types-gl2xFqEX.js';
1
+ import { P as ProviderResources, Q as QlaraProvider } from './types--KPPgCtc.js';
2
2
 
3
3
  interface AwsConfig {
4
4
  stackName?: string;
package/dist/aws.js CHANGED
@@ -558,7 +558,7 @@ async function bundleEdgeHandler(config) {
558
558
  });
559
559
  return createZip(outfile, "edge-handler.js");
560
560
  }
561
- async function bundleRenderer(routeFile, cacheTtl = 3600) {
561
+ async function bundleRenderer(routeFile, cacheTtl = 3600, framework) {
562
562
  mkdirSync(BUNDLE_DIR, { recursive: true });
563
563
  const outfile = join2(BUNDLE_DIR, "renderer.js");
564
564
  const alias = {};
@@ -580,7 +580,8 @@ async function bundleRenderer(routeFile, cacheTtl = 3600) {
580
580
  minify: true,
581
581
  alias,
582
582
  define: {
583
- __QLARA_CACHE_TTL__: String(cacheTtl)
583
+ __QLARA_CACHE_TTL__: String(cacheTtl),
584
+ __QLARA_FRAMEWORK__: JSON.stringify(framework || "")
584
585
  },
585
586
  external: []
586
587
  });
@@ -960,7 +961,7 @@ function aws(awsConfig = {}) {
960
961
  const cf = new CloudFrontClient({ region: res.region });
961
962
  await updateCloudFrontEdgeVersion(cf, res.distributionId, newVersionArn);
962
963
  console.log("[qlara/aws] Bundling renderer...");
963
- const rendererZip = await bundleRenderer(config.routeFile, cacheTtl);
964
+ const rendererZip = await bundleRenderer(config.routeFile, cacheTtl, config.framework);
964
965
  await waitUntilFunctionUpdatedV2(
965
966
  { client: lambda, maxWaitTime: 120 },
966
967
  { FunctionName: res.rendererFunctionArn }
package/dist/cli.js CHANGED
@@ -569,7 +569,7 @@ async function bundleEdgeHandler(config) {
569
569
  });
570
570
  return createZip(outfile, "edge-handler.js");
571
571
  }
572
- async function bundleRenderer(routeFile, cacheTtl = 3600) {
572
+ async function bundleRenderer(routeFile, cacheTtl = 3600, framework) {
573
573
  mkdirSync(BUNDLE_DIR, { recursive: true });
574
574
  const outfile = join2(BUNDLE_DIR, "renderer.js");
575
575
  const alias = {};
@@ -591,7 +591,8 @@ async function bundleRenderer(routeFile, cacheTtl = 3600) {
591
591
  minify: true,
592
592
  alias,
593
593
  define: {
594
- __QLARA_CACHE_TTL__: String(cacheTtl)
594
+ __QLARA_CACHE_TTL__: String(cacheTtl),
595
+ __QLARA_FRAMEWORK__: JSON.stringify(framework || "")
595
596
  },
596
597
  external: []
597
598
  });
@@ -968,7 +969,7 @@ function aws(awsConfig = {}) {
968
969
  const cf = new CloudFrontClient({ region: res.region });
969
970
  await updateCloudFrontEdgeVersion(cf, res.distributionId, newVersionArn);
970
971
  console.log("[qlara/aws] Bundling renderer...");
971
- const rendererZip = await bundleRenderer(config.routeFile, cacheTtl);
972
+ const rendererZip = await bundleRenderer(config.routeFile, cacheTtl, config.framework);
972
973
  await waitUntilFunctionUpdatedV2(
973
974
  { client: lambda, maxWaitTime: 120 },
974
975
  { FunctionName: res.rendererFunctionArn }
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { a as QlaraPluginConfig, b as QlaraRoute, c as QlaraManifest, M as ManifestRoute, R as RouteMatch } from './types-gl2xFqEX.cjs';
2
- export { P as ProviderResources, d as QlaraAlternateLinkDescriptor, e as QlaraAlternateURLs, f as QlaraAppLinks, g as QlaraAppLinksAndroid, h as QlaraAppLinksApple, i as QlaraAppLinksWeb, j as QlaraAppLinksWindows, k as QlaraAppleImage, l as QlaraAppleImageDescriptor, m as QlaraAppleWebApp, n as QlaraAuthor, o as QlaraDeployConfig, p as QlaraFacebook, q as QlaraFormatDetection, r as QlaraIcon, s as QlaraIconDescriptor, t as QlaraIcons, u as QlaraItunesApp, v as QlaraMetaDataGenerator, w as QlaraMetadata, x as QlaraOGAudio, y as QlaraOGAudioDescriptor, z as QlaraOGImage, A as QlaraOGImageDescriptor, B as QlaraOGVideo, C as QlaraOGVideoDescriptor, D as QlaraOpenGraph, E as QlaraOpenGraphArticle, F as QlaraOpenGraphBase, G as QlaraOpenGraphBook, H as QlaraOpenGraphMusicAlbum, I as QlaraOpenGraphMusicPlaylist, J as QlaraOpenGraphMusicRadioStation, K as QlaraOpenGraphMusicSong, L as QlaraOpenGraphProfile, N as QlaraOpenGraphVideoEpisode, O as QlaraOpenGraphVideoMovie, S as QlaraOpenGraphVideoOther, T as QlaraOpenGraphVideoTVShow, U as QlaraOpenGraphWebsite, V as QlaraPinterest, Q as QlaraProvider, W as QlaraReferrer, X as QlaraRobots, Y as QlaraRobotsInfo, Z as QlaraRouteDefinition, _ as QlaraRoutes, $ as QlaraTwitter, a0 as QlaraTwitterApp, a1 as QlaraTwitterAppDescriptor, a2 as QlaraTwitterBase, a3 as QlaraTwitterImage, a4 as QlaraTwitterImageDescriptor, a5 as QlaraTwitterPlayer, a6 as QlaraTwitterPlayerDescriptor, a7 as QlaraTwitterSummary, a8 as QlaraTwitterSummaryLargeImage, a9 as QlaraVerification } from './types-gl2xFqEX.cjs';
1
+ import { a as QlaraPluginConfig, b as QlaraRoute, c as QlaraManifest, M as ManifestRoute, R as RouteMatch } from './types--KPPgCtc.cjs';
2
+ export { P as ProviderResources, d as QlaraAlternateLinkDescriptor, e as QlaraAlternateURLs, f as QlaraAppLinks, g as QlaraAppLinksAndroid, h as QlaraAppLinksApple, i as QlaraAppLinksWeb, j as QlaraAppLinksWindows, k as QlaraAppleImage, l as QlaraAppleImageDescriptor, m as QlaraAppleWebApp, n as QlaraAuthor, o as QlaraDeployConfig, p as QlaraFacebook, q as QlaraFormatDetection, r as QlaraIcon, s as QlaraIconDescriptor, t as QlaraIcons, u as QlaraItunesApp, v as QlaraMetaDataGenerator, w as QlaraMetadata, x as QlaraOGAudio, y as QlaraOGAudioDescriptor, z as QlaraOGImage, A as QlaraOGImageDescriptor, B as QlaraOGVideo, C as QlaraOGVideoDescriptor, D as QlaraOpenGraph, E as QlaraOpenGraphArticle, F as QlaraOpenGraphBase, G as QlaraOpenGraphBook, H as QlaraOpenGraphMusicAlbum, I as QlaraOpenGraphMusicPlaylist, J as QlaraOpenGraphMusicRadioStation, K as QlaraOpenGraphMusicSong, L as QlaraOpenGraphProfile, N as QlaraOpenGraphVideoEpisode, O as QlaraOpenGraphVideoMovie, S as QlaraOpenGraphVideoOther, T as QlaraOpenGraphVideoTVShow, U as QlaraOpenGraphWebsite, V as QlaraPinterest, Q as QlaraProvider, W as QlaraReferrer, X as QlaraRobots, Y as QlaraRobotsInfo, Z as QlaraRouteDefinition, _ as QlaraRoutes, $ as QlaraTwitter, a0 as QlaraTwitterApp, a1 as QlaraTwitterAppDescriptor, a2 as QlaraTwitterBase, a3 as QlaraTwitterImage, a4 as QlaraTwitterImageDescriptor, a5 as QlaraTwitterPlayer, a6 as QlaraTwitterPlayerDescriptor, a7 as QlaraTwitterSummary, a8 as QlaraTwitterSummaryLargeImage, a9 as QlaraVerification } from './types--KPPgCtc.cjs';
3
3
 
4
4
  declare function validateConfig(config: QlaraPluginConfig, routes: QlaraRoute[]): void;
5
5
 
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { a as QlaraPluginConfig, b as QlaraRoute, c as QlaraManifest, M as ManifestRoute, R as RouteMatch } from './types-gl2xFqEX.js';
2
- export { P as ProviderResources, d as QlaraAlternateLinkDescriptor, e as QlaraAlternateURLs, f as QlaraAppLinks, g as QlaraAppLinksAndroid, h as QlaraAppLinksApple, i as QlaraAppLinksWeb, j as QlaraAppLinksWindows, k as QlaraAppleImage, l as QlaraAppleImageDescriptor, m as QlaraAppleWebApp, n as QlaraAuthor, o as QlaraDeployConfig, p as QlaraFacebook, q as QlaraFormatDetection, r as QlaraIcon, s as QlaraIconDescriptor, t as QlaraIcons, u as QlaraItunesApp, v as QlaraMetaDataGenerator, w as QlaraMetadata, x as QlaraOGAudio, y as QlaraOGAudioDescriptor, z as QlaraOGImage, A as QlaraOGImageDescriptor, B as QlaraOGVideo, C as QlaraOGVideoDescriptor, D as QlaraOpenGraph, E as QlaraOpenGraphArticle, F as QlaraOpenGraphBase, G as QlaraOpenGraphBook, H as QlaraOpenGraphMusicAlbum, I as QlaraOpenGraphMusicPlaylist, J as QlaraOpenGraphMusicRadioStation, K as QlaraOpenGraphMusicSong, L as QlaraOpenGraphProfile, N as QlaraOpenGraphVideoEpisode, O as QlaraOpenGraphVideoMovie, S as QlaraOpenGraphVideoOther, T as QlaraOpenGraphVideoTVShow, U as QlaraOpenGraphWebsite, V as QlaraPinterest, Q as QlaraProvider, W as QlaraReferrer, X as QlaraRobots, Y as QlaraRobotsInfo, Z as QlaraRouteDefinition, _ as QlaraRoutes, $ as QlaraTwitter, a0 as QlaraTwitterApp, a1 as QlaraTwitterAppDescriptor, a2 as QlaraTwitterBase, a3 as QlaraTwitterImage, a4 as QlaraTwitterImageDescriptor, a5 as QlaraTwitterPlayer, a6 as QlaraTwitterPlayerDescriptor, a7 as QlaraTwitterSummary, a8 as QlaraTwitterSummaryLargeImage, a9 as QlaraVerification } from './types-gl2xFqEX.js';
1
+ import { a as QlaraPluginConfig, b as QlaraRoute, c as QlaraManifest, M as ManifestRoute, R as RouteMatch } from './types--KPPgCtc.js';
2
+ export { P as ProviderResources, d as QlaraAlternateLinkDescriptor, e as QlaraAlternateURLs, f as QlaraAppLinks, g as QlaraAppLinksAndroid, h as QlaraAppLinksApple, i as QlaraAppLinksWeb, j as QlaraAppLinksWindows, k as QlaraAppleImage, l as QlaraAppleImageDescriptor, m as QlaraAppleWebApp, n as QlaraAuthor, o as QlaraDeployConfig, p as QlaraFacebook, q as QlaraFormatDetection, r as QlaraIcon, s as QlaraIconDescriptor, t as QlaraIcons, u as QlaraItunesApp, v as QlaraMetaDataGenerator, w as QlaraMetadata, x as QlaraOGAudio, y as QlaraOGAudioDescriptor, z as QlaraOGImage, A as QlaraOGImageDescriptor, B as QlaraOGVideo, C as QlaraOGVideoDescriptor, D as QlaraOpenGraph, E as QlaraOpenGraphArticle, F as QlaraOpenGraphBase, G as QlaraOpenGraphBook, H as QlaraOpenGraphMusicAlbum, I as QlaraOpenGraphMusicPlaylist, J as QlaraOpenGraphMusicRadioStation, K as QlaraOpenGraphMusicSong, L as QlaraOpenGraphProfile, N as QlaraOpenGraphVideoEpisode, O as QlaraOpenGraphVideoMovie, S as QlaraOpenGraphVideoOther, T as QlaraOpenGraphVideoTVShow, U as QlaraOpenGraphWebsite, V as QlaraPinterest, Q as QlaraProvider, W as QlaraReferrer, X as QlaraRobots, Y as QlaraRobotsInfo, Z as QlaraRouteDefinition, _ as QlaraRoutes, $ as QlaraTwitter, a0 as QlaraTwitterApp, a1 as QlaraTwitterAppDescriptor, a2 as QlaraTwitterBase, a3 as QlaraTwitterImage, a4 as QlaraTwitterImageDescriptor, a5 as QlaraTwitterPlayer, a6 as QlaraTwitterPlayerDescriptor, a7 as QlaraTwitterSummary, a8 as QlaraTwitterSummaryLargeImage, a9 as QlaraVerification } from './types--KPPgCtc.js';
3
3
 
4
4
  declare function validateConfig(config: QlaraPluginConfig, routes: QlaraRoute[]): void;
5
5
 
@@ -153,7 +153,8 @@ function withQlara(qlaraConfig) {
153
153
  },
154
154
  outputDir,
155
155
  routeFile: (0, import_node_path.resolve)(qlaraConfig.routeFile),
156
- env
156
+ env,
157
+ framework: "next"
157
158
  };
158
159
  (0, import_node_fs.mkdirSync)(QLARA_DIR, { recursive: true });
159
160
  (0, import_node_fs.writeFileSync)(
@@ -1,5 +1,5 @@
1
1
  import { NextConfig } from 'next';
2
- import { a as QlaraPluginConfig } from '../types-gl2xFqEX.cjs';
2
+ import { a as QlaraPluginConfig } from '../types--KPPgCtc.cjs';
3
3
 
4
4
  /**
5
5
  * Wrap a Next.js config with Qlara.
@@ -1,5 +1,5 @@
1
1
  import { NextConfig } from 'next';
2
- import { a as QlaraPluginConfig } from '../types-gl2xFqEX.js';
2
+ import { a as QlaraPluginConfig } from '../types--KPPgCtc.js';
3
3
 
4
4
  /**
5
5
  * Wrap a Next.js config with Qlara.
@@ -84,7 +84,8 @@ function withQlara(qlaraConfig) {
84
84
  },
85
85
  outputDir,
86
86
  routeFile: resolve(qlaraConfig.routeFile),
87
- env
87
+ env,
88
+ framework: "next"
88
89
  };
89
90
  mkdirSync(QLARA_DIR, { recursive: true });
90
91
  writeFileSync(
@@ -368,6 +368,12 @@ interface QlaraPluginConfig {
368
368
  provider: QlaraProvider;
369
369
  /** Env var names to forward to the renderer Lambda (values read from process.env at build time) */
370
370
  env?: string[];
371
+ /**
372
+ * The framework identifier. Set automatically by framework plugins (e.g. 'next' by withQlara).
373
+ * Used by the renderer to enable framework-specific post-render behavior
374
+ * (e.g. generating .txt RSC flight data files for Next.js client-side navigation).
375
+ */
376
+ framework?: string;
371
377
  }
372
378
  interface QlaraProvider {
373
379
  name: string;
@@ -394,6 +400,11 @@ interface QlaraDeployConfig {
394
400
  routeFile: string;
395
401
  /** Environment variables for the renderer Lambda (key-value pairs resolved at build time) */
396
402
  env?: Record<string, string>;
403
+ /**
404
+ * The framework identifier (e.g. 'next'). Set by framework plugins.
405
+ * Passed to the renderer to enable framework-specific post-render behavior.
406
+ */
407
+ framework?: string;
397
408
  }
398
409
  interface QlaraManifest {
399
410
  version: 1;
@@ -368,6 +368,12 @@ interface QlaraPluginConfig {
368
368
  provider: QlaraProvider;
369
369
  /** Env var names to forward to the renderer Lambda (values read from process.env at build time) */
370
370
  env?: string[];
371
+ /**
372
+ * The framework identifier. Set automatically by framework plugins (e.g. 'next' by withQlara).
373
+ * Used by the renderer to enable framework-specific post-render behavior
374
+ * (e.g. generating .txt RSC flight data files for Next.js client-side navigation).
375
+ */
376
+ framework?: string;
371
377
  }
372
378
  interface QlaraProvider {
373
379
  name: string;
@@ -394,6 +400,11 @@ interface QlaraDeployConfig {
394
400
  routeFile: string;
395
401
  /** Environment variables for the renderer Lambda (key-value pairs resolved at build time) */
396
402
  env?: Record<string, string>;
403
+ /**
404
+ * The framework identifier (e.g. 'next'). Set by framework plugins.
405
+ * Passed to the renderer to enable framework-specific post-render behavior.
406
+ */
407
+ framework?: string;
397
408
  }
398
409
  interface QlaraManifest {
399
410
  version: 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qlara",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
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": [
@@ -15,7 +15,7 @@
15
15
  * route definitions, each with a pattern and a metaDataGenerator function.
16
16
  */
17
17
 
18
- import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
18
+ import { S3Client, PutObjectCommand, GetObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
19
19
  import type {
20
20
  QlaraMetadata,
21
21
  QlaraOpenGraph,
@@ -54,6 +54,7 @@ import type {
54
54
  // At bundle time: '__qlara_routes__' → './qlara.routes.ts' (or wherever the dev put it)
55
55
  // Injected at bundle time by esbuild define
56
56
  declare const __QLARA_CACHE_TTL__: number;
57
+ declare const __QLARA_FRAMEWORK__: string;
57
58
 
58
59
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
59
60
  // @ts-ignore — resolved at bundle time by esbuild alias
@@ -759,6 +760,345 @@ function metadataToRscEntries(metadata: QlaraMetadata): string {
759
760
  return entries.join('');
760
761
  }
761
762
 
763
+ /**
764
+ * Extract RSC flight data from rendered HTML (Next.js-specific).
765
+ *
766
+ * Next.js embeds RSC data inside <script>self.__next_f.push([1,"..."])</script> blocks.
767
+ * The .txt file is these payloads concatenated with JSON string escapes resolved.
768
+ * Next.js's client-side router fetches the .txt file for client-side navigation
769
+ * instead of the full .html — so we must generate it for renderer-created pages.
770
+ *
771
+ * Only called when __QLARA_FRAMEWORK__ === 'next'.
772
+ */
773
+ function extractRscFlightData(html: string): string | null {
774
+ const chunks: string[] = [];
775
+ const regex = /self\.__next_f\.push\(\[1,"((?:[^"\\]|\\.)*)"\]\)/g;
776
+ let match;
777
+
778
+ while ((match = regex.exec(html)) !== null) {
779
+ // Unescape the JSON string: \" → ", \\ → \, \n → newline
780
+ const unescaped = match[1]
781
+ .replace(/\\n/g, '\n')
782
+ .replace(/\\"/g, '"')
783
+ .replace(/\\\\/g, '\\');
784
+ chunks.push(unescaped);
785
+ }
786
+
787
+ if (chunks.length === 0) return null;
788
+ return chunks.join('');
789
+ }
790
+
791
+ // ── Per-segment prefetch file generation (Next.js 16+) ───────────
792
+ //
793
+ // Next.js 16 introduced per-segment prefetch files in a subdirectory
794
+ // per page (e.g. product/1/__next._tree.txt). The renderer must generate
795
+ // these for renderer-created pages so they match build-time pages.
796
+ //
797
+ // Approach: discover segment files from a build-time reference page on S3,
798
+ // classify each file, and copy/patch as needed. If no reference page exists
799
+ // (Next.js 15 or no build-time pages), segment generation is skipped.
800
+
801
+ /** Cache reference segment directory per route prefix across warm invocations */
802
+ const referencePageCache = new Map<string, string | null>();
803
+
804
+ /**
805
+ * Find a build-time page's segment directory in S3 to use as a template.
806
+ * Returns the S3 key prefix (e.g. 'product/1/') or null.
807
+ */
808
+ async function findReferenceSegmentDir(bucket: string, routePrefix: string): Promise<string | null> {
809
+ const cached = referencePageCache.get(routePrefix);
810
+ if (cached !== undefined) return cached;
811
+
812
+ try {
813
+ const response = await s3.send(new ListObjectsV2Command({
814
+ Bucket: bucket,
815
+ Prefix: `${routePrefix}/`,
816
+ MaxKeys: 200,
817
+ }));
818
+
819
+ for (const obj of response.Contents || []) {
820
+ const key = obj.Key || '';
821
+ if (key.endsWith('/__next._tree.txt')) {
822
+ const dir = key.slice(0, key.length - '__next._tree.txt'.length);
823
+ referencePageCache.set(routePrefix, dir);
824
+ return dir;
825
+ }
826
+ }
827
+ } catch {
828
+ // S3 error — skip segment generation
829
+ }
830
+
831
+ referencePageCache.set(routePrefix, null);
832
+ return null;
833
+ }
834
+
835
+ type SegmentFileType = 'shared' | 'tree' | 'head' | 'full' | 'page';
836
+
837
+ /**
838
+ * Classify a segment file by its name.
839
+ */
840
+ function classifySegmentFile(name: string): SegmentFileType {
841
+ if (name === '__next._tree.txt') return 'tree';
842
+ if (name === '__next._head.txt') return 'head';
843
+ if (name === '__next._full.txt') return 'full';
844
+ if (name.includes('__PAGE__')) return 'page';
845
+ return 'shared';
846
+ }
847
+
848
+ /**
849
+ * List all segment file names in a reference directory.
850
+ */
851
+ async function listReferenceSegmentFiles(bucket: string, refDir: string): Promise<string[]> {
852
+ try {
853
+ const response = await s3.send(new ListObjectsV2Command({
854
+ Bucket: bucket,
855
+ Prefix: `${refDir}__next.`,
856
+ MaxKeys: 50,
857
+ }));
858
+
859
+ return (response.Contents || [])
860
+ .map(obj => obj.Key || '')
861
+ .filter(key => key.endsWith('.txt'))
862
+ .map(key => key.slice(refDir.length));
863
+ } catch {
864
+ return [];
865
+ }
866
+ }
867
+
868
+ /**
869
+ * Patch the _tree.txt segment: replace the dynamic paramKey value.
870
+ */
871
+ function patchTreeSegment(template: string, newValue: string): string {
872
+ return template.replace(
873
+ /"paramType":"d","paramKey":"[^"]*"/,
874
+ `"paramType":"d","paramKey":"${newValue}"`
875
+ );
876
+ }
877
+
878
+ /**
879
+ * Patch the __PAGE__.txt segment: replace param values in the component props.
880
+ */
881
+ function patchPageSegment(template: string, params: Record<string, string>): string {
882
+ let result = template;
883
+ for (const [key, value] of Object.entries(params)) {
884
+ // RSC wire format: ["$","$L2",null,{"id":"OLD"}]
885
+ result = result.replace(
886
+ new RegExp(`"${key}":"[^"]*"`),
887
+ `"${key}":"${value}"`
888
+ );
889
+ }
890
+ return result;
891
+ }
892
+
893
+ /**
894
+ * Generate the _head.txt segment from a reference template and new metadata.
895
+ * Extracts preamble (module declarations) and buildId from the reference,
896
+ * rebuilds line 0 with new metadata.
897
+ */
898
+ function generateHeadSegment(template: string, metadata: QlaraMetadata): string {
899
+ // Split into lines — preamble is everything before the line starting with '0:'
900
+ const lines = template.split('\n');
901
+ const preambleLines: string[] = [];
902
+ let line0 = '';
903
+
904
+ for (const line of lines) {
905
+ if (line.startsWith('0:')) {
906
+ line0 = line;
907
+ } else if (line.trim()) {
908
+ preambleLines.push(line);
909
+ }
910
+ }
911
+
912
+ // Extract buildId from reference
913
+ const buildIdMatch = line0.match(/"buildId":"([^"]+)"/);
914
+ const buildId = buildIdMatch ? buildIdMatch[1] : '';
915
+
916
+ // Extract the viewport/charset meta tags from the reference (preserve them)
917
+ // These are inside the rsc at children[1] (the $L2 viewport boundary)
918
+ // We keep the structure identical but replace the metadata children
919
+ const viewportMatch = line0.match(/"children":\[(\["\\?\$","meta"[^\]]*\](?:,\["\\?\$","meta"[^\]]*\])*)\]/);
920
+ const viewportTags = viewportMatch ? viewportMatch[1] : '["\$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]';
921
+
922
+ // Build metadata RSC children array (standard JSON, not HTML-escaped)
923
+ const metaChildren: string[] = [];
924
+ let idx = 0;
925
+
926
+ // Title
927
+ metaChildren.push(`["$","title","${idx++}",{"children":"${escapeJson(metadata.title)}"}]`);
928
+
929
+ // Description
930
+ if (metadata.description) {
931
+ metaChildren.push(`["$","meta","${idx++}",{"name":"description","content":"${escapeJson(metadata.description)}"}]`);
932
+ }
933
+
934
+ // Open Graph
935
+ if (metadata.openGraph) {
936
+ const og = metadata.openGraph;
937
+ if (og.title) metaChildren.push(`["$","meta","${idx++}",{"property":"og:title","content":"${escapeJson(og.title)}"}]`);
938
+ if (og.description) metaChildren.push(`["$","meta","${idx++}",{"property":"og:description","content":"${escapeJson(og.description)}"}]`);
939
+ if (og.url) metaChildren.push(`["$","meta","${idx++}",{"property":"og:url","content":"${escapeJson(og.url)}"}]`);
940
+ if (og.siteName) metaChildren.push(`["$","meta","${idx++}",{"property":"og:site_name","content":"${escapeJson(og.siteName)}"}]`);
941
+ if (og.type) metaChildren.push(`["$","meta","${idx++}",{"property":"og:type","content":"${escapeJson(og.type)}"}]`);
942
+ for (const img of toArray(og.images)) {
943
+ if (typeof img === 'string') {
944
+ metaChildren.push(`["$","meta","${idx++}",{"property":"og:image","content":"${escapeJson(img)}"}]`);
945
+ } else {
946
+ metaChildren.push(`["$","meta","${idx++}",{"property":"og:image","content":"${escapeJson(img.url)}"}]`);
947
+ if (img.alt) metaChildren.push(`["$","meta","${idx++}",{"property":"og:image:alt","content":"${escapeJson(img.alt)}"}]`);
948
+ if (img.width !== undefined) metaChildren.push(`["$","meta","${idx++}",{"property":"og:image:width","content":"${String(img.width)}"}]`);
949
+ if (img.height !== undefined) metaChildren.push(`["$","meta","${idx++}",{"property":"og:image:height","content":"${String(img.height)}"}]`);
950
+ }
951
+ }
952
+ }
953
+
954
+ // Twitter
955
+ if (metadata.twitter) {
956
+ const tw = metadata.twitter;
957
+ const card = ('card' in tw && tw.card) ? tw.card : 'summary';
958
+ metaChildren.push(`["$","meta","${idx++}",{"name":"twitter:card","content":"${escapeJson(card)}"}]`);
959
+ if (tw.title) metaChildren.push(`["$","meta","${idx++}",{"name":"twitter:title","content":"${escapeJson(tw.title)}"}]`);
960
+ if (tw.description) metaChildren.push(`["$","meta","${idx++}",{"name":"twitter:description","content":"${escapeJson(tw.description)}"}]`);
961
+ for (const img of toArray(tw.images)) {
962
+ if (typeof img === 'string') {
963
+ metaChildren.push(`["$","meta","${idx++}",{"name":"twitter:image","content":"${escapeJson(img)}"}]`);
964
+ } else {
965
+ metaChildren.push(`["$","meta","${idx++}",{"name":"twitter:image","content":"${escapeJson(img.url)}"}]`);
966
+ if (img.alt) metaChildren.push(`["$","meta","${idx++}",{"name":"twitter:image:alt","content":"${escapeJson(img.alt)}"}]`);
967
+ }
968
+ }
969
+ }
970
+
971
+ // Reconstruct the _head.txt content by replacing the metadata children
972
+ // in the reference template's line 0 structure
973
+ const metaChildrenStr = metaChildren.join(',');
974
+
975
+ // The _head.txt line 0 structure (from analysis):
976
+ // 0:{"buildId":"...","rsc":["$","$1","h",{"children":[null,["$","$L2",null,{"children":[viewport tags]}],["$","div",null,{"hidden":true,"children":["$","$L3",null,{"children":["$","$4",null,{"name":"Next.Metadata","children":[METADATA HERE]}]}]}],null]}],"loading":null,"isPartial":false}
977
+ // Replace the "Next.Metadata" children array
978
+ const newLine0 = line0.replace(
979
+ /("name":"Next\.Metadata","children":\[)[\s\S]*?(\]\})/,
980
+ `$1${metaChildrenStr}$2`
981
+ );
982
+
983
+ return preambleLines.join('\n') + '\n' + newLine0 + '\n';
984
+ }
985
+
986
+ /**
987
+ * Generate per-segment prefetch files for a renderer-created page (Next.js 16+).
988
+ * Reads segment files from a build-time reference page, patches page-specific data,
989
+ * and uploads to the new page's subdirectory.
990
+ *
991
+ * Skips gracefully if no reference page exists (Next.js 15 or first deploy).
992
+ */
993
+ async function generateSegmentFiles(
994
+ bucket: string,
995
+ uri: string,
996
+ params: Record<string, string>,
997
+ rscData: string | null,
998
+ metadata: QlaraMetadata | null,
999
+ ): Promise<void> {
1000
+ const cleanUri = uri.replace(/^\//, '').replace(/\/$/, '');
1001
+ const parts = cleanUri.split('/');
1002
+ const routePrefix = parts.slice(0, -1).join('/'); // 'product'
1003
+ const segmentDir = `${cleanUri}/`; // 'product/42/'
1004
+
1005
+ // Find a build-time page with segment files to use as reference
1006
+ const refDir = await findReferenceSegmentDir(bucket, routePrefix);
1007
+ if (!refDir) return; // No segment files on S3 — Next.js 15 or no build-time pages
1008
+
1009
+ // List all segment files in the reference directory
1010
+ const fileNames = await listReferenceSegmentFiles(bucket, refDir);
1011
+ if (fileNames.length === 0) return;
1012
+
1013
+ // Read all reference files we need (skip _full — we use rscData directly)
1014
+ const filesToRead = fileNames.filter(name => classifySegmentFile(name) !== 'full');
1015
+ const readResults = await Promise.allSettled(
1016
+ filesToRead.map(async (name) => {
1017
+ const result = await s3.send(new GetObjectCommand({
1018
+ Bucket: bucket,
1019
+ Key: `${refDir}${name}`,
1020
+ }));
1021
+ return {
1022
+ name,
1023
+ content: await result.Body?.transformToString('utf-8') || '',
1024
+ };
1025
+ })
1026
+ );
1027
+
1028
+ const refMap = new Map<string, string>();
1029
+ for (const result of readResults) {
1030
+ if (result.status === 'fulfilled' && result.value.content) {
1031
+ refMap.set(result.value.name, result.value.content);
1032
+ }
1033
+ }
1034
+
1035
+ // Generate each segment file
1036
+ const paramValue = parts[parts.length - 1]; // last path segment = dynamic param value
1037
+ const uploads: Promise<unknown>[] = [];
1038
+ const cacheControl = `public, max-age=0, s-maxage=${__QLARA_CACHE_TTL__}, stale-while-revalidate=60`;
1039
+
1040
+ for (const name of fileNames) {
1041
+ let content: string | null = null;
1042
+ const type = classifySegmentFile(name);
1043
+
1044
+ switch (type) {
1045
+ case 'shared':
1046
+ content = refMap.get(name) || null;
1047
+ break;
1048
+ case 'tree': {
1049
+ const template = refMap.get(name);
1050
+ if (template) {
1051
+ content = patchTreeSegment(template, paramValue);
1052
+ }
1053
+ break;
1054
+ }
1055
+ case 'head': {
1056
+ const template = refMap.get(name);
1057
+ if (template && metadata) {
1058
+ content = generateHeadSegment(template, metadata);
1059
+ }
1060
+ break;
1061
+ }
1062
+ case 'full':
1063
+ content = rscData;
1064
+ break;
1065
+ case 'page': {
1066
+ const template = refMap.get(name);
1067
+ if (template) {
1068
+ content = patchPageSegment(template, params);
1069
+ }
1070
+ break;
1071
+ }
1072
+ }
1073
+
1074
+ if (content) {
1075
+ uploads.push(
1076
+ s3.send(new PutObjectCommand({
1077
+ Bucket: bucket,
1078
+ Key: `${segmentDir}${name}`,
1079
+ Body: content,
1080
+ ContentType: 'text/plain; charset=utf-8',
1081
+ CacheControl: cacheControl,
1082
+ }))
1083
+ );
1084
+ }
1085
+ }
1086
+
1087
+ await Promise.all(uploads);
1088
+ }
1089
+
1090
+ /**
1091
+ * Escape a string for use inside a JSON string value.
1092
+ */
1093
+ function escapeJson(str: string): string {
1094
+ return str
1095
+ .replace(/\\/g, '\\\\')
1096
+ .replace(/"/g, '\\"')
1097
+ .replace(/\n/g, '\\n')
1098
+ .replace(/\r/g, '\\r')
1099
+ .replace(/\t/g, '\\t');
1100
+ }
1101
+
762
1102
  export async function handler(event: RendererEvent & { warmup?: boolean }): Promise<RendererResult> {
763
1103
  // Warmup invocation — just initialize the runtime and return
764
1104
  if (event.warmup) {
@@ -816,16 +1156,17 @@ export async function handler(event: RendererEvent & { warmup?: boolean }): Prom
816
1156
 
817
1157
  // 3. Call the metaDataGenerator to fetch metadata from the data source
818
1158
  const routeDef = routes?.find((r: { route: string }) => r.route === routePattern);
1159
+ let metadata: QlaraMetadata | null = null;
819
1160
 
820
1161
  if (routeDef?.metaDataGenerator) {
821
- const metadata = await routeDef.metaDataGenerator(params);
1162
+ metadata = await routeDef.metaDataGenerator(params);
822
1163
  if (metadata) {
823
1164
  // 4. Patch the HTML with real metadata
824
1165
  html = patchMetadata(html, metadata);
825
1166
  }
826
1167
  }
827
1168
 
828
- // 5. Upload to S3
1169
+ // 5. Upload HTML to S3
829
1170
  await s3.send(
830
1171
  new PutObjectCommand({
831
1172
  Bucket: bucket,
@@ -836,6 +1177,30 @@ export async function handler(event: RendererEvent & { warmup?: boolean }): Prom
836
1177
  })
837
1178
  );
838
1179
 
1180
+ // 6. Framework-specific post-render uploads
1181
+ // Next.js: extract RSC flight data, upload .txt for client-side navigation,
1182
+ // and generate per-segment prefetch files (Next.js 16+).
1183
+ if (__QLARA_FRAMEWORK__ === 'next') {
1184
+ const rscData = extractRscFlightData(html);
1185
+ if (rscData) {
1186
+ const txtKey = s3Key.replace(/\.html$/, '.txt');
1187
+ await s3.send(
1188
+ new PutObjectCommand({
1189
+ Bucket: bucket,
1190
+ Key: txtKey,
1191
+ Body: rscData,
1192
+ ContentType: 'text/plain; charset=utf-8',
1193
+ CacheControl: `public, max-age=0, s-maxage=${__QLARA_CACHE_TTL__}, stale-while-revalidate=60`,
1194
+ })
1195
+ );
1196
+ }
1197
+
1198
+ // 7. Generate per-segment prefetch files (Next.js 16+ Segment Cache)
1199
+ // Reads templates from a build-time reference page, patches page-specific data.
1200
+ // Skips automatically for Next.js 15 (no segment files on S3).
1201
+ await generateSegmentFiles(bucket, uri, params, rscData, metadata);
1202
+ }
1203
+
839
1204
  return {
840
1205
  statusCode: 200,
841
1206
  body: JSON.stringify({
package/src/types.ts CHANGED
@@ -513,6 +513,12 @@ export interface QlaraPluginConfig {
513
513
  provider: QlaraProvider;
514
514
  /** Env var names to forward to the renderer Lambda (values read from process.env at build time) */
515
515
  env?: string[];
516
+ /**
517
+ * The framework identifier. Set automatically by framework plugins (e.g. 'next' by withQlara).
518
+ * Used by the renderer to enable framework-specific post-render behavior
519
+ * (e.g. generating .txt RSC flight data files for Next.js client-side navigation).
520
+ */
521
+ framework?: string;
516
522
  }
517
523
 
518
524
  export interface QlaraProvider {
@@ -542,6 +548,11 @@ export interface QlaraDeployConfig {
542
548
  routeFile: string;
543
549
  /** Environment variables for the renderer Lambda (key-value pairs resolved at build time) */
544
550
  env?: Record<string, string>;
551
+ /**
552
+ * The framework identifier (e.g. 'next'). Set by framework plugins.
553
+ * Passed to the renderer to enable framework-specific post-render behavior.
554
+ */
555
+ framework?: string;
545
556
  }
546
557
 
547
558
  export interface QlaraManifest {