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 +1 -1
- package/src/provider/aws/renderer.ts +321 -5
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
|
870
|
-
//
|
|
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 {
|