qlara 0.1.9 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qlara",
3
- "version": "0.1.9",
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,
@@ -788,6 +788,317 @@ function extractRscFlightData(html: string): string | null {
788
788
  return chunks.join('');
789
789
  }
790
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
+
791
1102
  export async function handler(event: RendererEvent & { warmup?: boolean }): Promise<RendererResult> {
792
1103
  // Warmup invocation — just initialize the runtime and return
793
1104
  if (event.warmup) {
@@ -845,9 +1156,10 @@ export async function handler(event: RendererEvent & { warmup?: boolean }): Prom
845
1156
 
846
1157
  // 3. Call the metaDataGenerator to fetch metadata from the data source
847
1158
  const routeDef = routes?.find((r: { route: string }) => r.route === routePattern);
1159
+ let metadata: QlaraMetadata | null = null;
848
1160
 
849
1161
  if (routeDef?.metaDataGenerator) {
850
- const metadata = await routeDef.metaDataGenerator(params);
1162
+ metadata = await routeDef.metaDataGenerator(params);
851
1163
  if (metadata) {
852
1164
  // 4. Patch the HTML with real metadata
853
1165
  html = patchMetadata(html, metadata);
@@ -866,9 +1178,8 @@ export async function handler(event: RendererEvent & { warmup?: boolean }): Prom
866
1178
  );
867
1179
 
868
1180
  // 6. Framework-specific post-render uploads
869
- // Next.js: extract RSC flight data and upload as .txt for client-side navigation.
870
- // Next.js fetches .txt instead of .html when using <Link> / client-side nav.
871
- // Without this, client-side nav falls back to a full page reload (slow).
1181
+ // Next.js: extract RSC flight data, upload .txt for client-side navigation,
1182
+ // and generate per-segment prefetch files (Next.js 16+).
872
1183
  if (__QLARA_FRAMEWORK__ === 'next') {
873
1184
  const rscData = extractRscFlightData(html);
874
1185
  if (rscData) {
@@ -883,6 +1194,11 @@ export async function handler(event: RendererEvent & { warmup?: boolean }): Prom
883
1194
  })
884
1195
  );
885
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);
886
1202
  }
887
1203
 
888
1204
  return {