@uniweb/runtime 0.15.0 → 0.17.0
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/ssr.js +124 -40
- package/dist/ssr.js.map +1 -1
- package/package.json +3 -3
- package/src/collect-records.js +99 -0
- package/src/default-fetcher.js +168 -96
- package/src/isolate-api.js +50 -2
- package/src/page-renderer.js +2 -2
- package/src/prefetch.js +7 -7
- package/src/setup.js +1 -4
- package/src/ssr.js +8 -1
package/dist/ssr.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isRichSchema, deriveCacheKey, resolveDefaultLocale, hasDarkScheme, createUniweb,
|
|
1
|
+
import { isRichSchema, deriveCacheKey, resolveDefaultLocale, hasDarkScheme, createUniweb, substitutePlaceholders, resolveServiceUrl, matchWhere, sortRecords, sortToWire } from "@uniweb/core";
|
|
2
2
|
import React from "react";
|
|
3
3
|
import { renderToString } from "react-dom/server";
|
|
4
4
|
import { sectionDomId } from "@uniweb/core/section-id";
|
|
@@ -11,6 +11,7 @@ import { resolveFetchConfigs } from "@uniweb/core/fetch-config";
|
|
|
11
11
|
import { deriveCacheKey as deriveCacheKey$1 } from "@uniweb/core/datastore";
|
|
12
12
|
import { buildDetailConfig } from "@uniweb/core/detail-url";
|
|
13
13
|
import { resolveDefaultLocale as resolveDefaultLocale$1 } from "@uniweb/core/locale-config";
|
|
14
|
+
import { resolveRecordsService } from "@uniweb/core/records-service";
|
|
14
15
|
function guaranteeItemStructure(item) {
|
|
15
16
|
return {
|
|
16
17
|
title: item.title || "",
|
|
@@ -839,29 +840,35 @@ function generate404Html({ baseHtml, website, siteContent }) {
|
|
|
839
840
|
}
|
|
840
841
|
return { html, hasNotFoundPage: !!notFoundPage };
|
|
841
842
|
}
|
|
842
|
-
function createDefaultFetcher({ basePath = "", dev = false,
|
|
843
|
+
function createDefaultFetcher({ basePath = "", dev = false, fetch: fetchImpl = null } = {}) {
|
|
843
844
|
const doFetch = (input, init) => (fetchImpl || globalThis.fetch)(input, init);
|
|
844
845
|
const pathPrefix = basePath && basePath !== "/" ? basePath.replace(/\/$/, "") : "";
|
|
845
|
-
const
|
|
846
|
-
const
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
if (!queue) {
|
|
852
|
-
queue = [];
|
|
853
|
-
doorQueues.set(url, queue);
|
|
854
|
-
queueMicrotask(() => {
|
|
855
|
-
doorQueues.delete(url);
|
|
856
|
-
flushDoor(url, queue, doFetch);
|
|
846
|
+
const askQueues = /* @__PURE__ */ new Map();
|
|
847
|
+
const askRecords = (request, ctx) => {
|
|
848
|
+
if (typeof request.schema !== "string" || !request.schema) {
|
|
849
|
+
return Promise.resolve({
|
|
850
|
+
data: null,
|
|
851
|
+
error: `the payload offers the records service but carries no Model ref for query "${request.query ?? request.as}" (config.queries) — it cannot be asked`
|
|
857
852
|
});
|
|
858
853
|
}
|
|
859
|
-
|
|
860
|
-
|
|
854
|
+
return new Promise((resolve) => {
|
|
855
|
+
const url = resolveServiceUrl(request.ask, pathPrefix);
|
|
856
|
+
let queue = askQueues.get(url);
|
|
857
|
+
if (!queue) {
|
|
858
|
+
queue = [];
|
|
859
|
+
askQueues.set(url, queue);
|
|
860
|
+
queueMicrotask(() => {
|
|
861
|
+
askQueues.delete(url);
|
|
862
|
+
flushAsked(url, queue, doFetch);
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
queue.push({ request, ctx, resolve });
|
|
866
|
+
});
|
|
867
|
+
};
|
|
861
868
|
return {
|
|
862
869
|
/**
|
|
863
|
-
* The cache identity is the request's ADDRESS — or,
|
|
864
|
-
* the QUESTION (`deriveCacheKey` hashes every operator of an address-less
|
|
870
|
+
* The cache identity is the request's ADDRESS — or, when asked of the
|
|
871
|
+
* records service, the QUESTION (`deriveCacheKey` hashes every operator of an address-less
|
|
865
872
|
* request). Operators evaluated here run over a shared cached value and
|
|
866
873
|
* must NOT split the cache: two pages declaring different `where:` clauses
|
|
867
874
|
* against the same path share one entry — the file is fetched once and
|
|
@@ -872,25 +879,20 @@ function createDefaultFetcher({ basePath = "", dev = false, records = null, fetc
|
|
|
872
879
|
},
|
|
873
880
|
async resolve(request, ctx = {}) {
|
|
874
881
|
if (!request) return { data: null };
|
|
875
|
-
if (request.
|
|
876
|
-
const { path, url,
|
|
882
|
+
if (request.ask) return askRecords(request, ctx);
|
|
883
|
+
const { path, url, transform, body: rawBody } = request;
|
|
877
884
|
let method = (request.method || "GET").toUpperCase();
|
|
878
885
|
if (method !== "GET" && method !== "POST") {
|
|
879
886
|
console.warn(`[default-fetcher] method "${request.method}" is not supported — falling back to GET.`);
|
|
880
887
|
method = "GET";
|
|
881
888
|
}
|
|
882
889
|
let target;
|
|
883
|
-
if (
|
|
884
|
-
target = resolveServiceUrl(endpoint, pathPrefix);
|
|
885
|
-
if (typeof request.locale === "string" && request.locale) {
|
|
886
|
-
target += (target.includes("?") ? "&" : "?") + "locale=" + encodeURIComponent(request.locale);
|
|
887
|
-
}
|
|
888
|
-
} else if (path) {
|
|
890
|
+
if (path) {
|
|
889
891
|
target = pathPrefix && path.startsWith("/") && !path.startsWith("//") ? pathPrefix + path : path;
|
|
890
892
|
} else if (url) {
|
|
891
893
|
target = url;
|
|
892
894
|
} else {
|
|
893
|
-
return { data: [], error: "No path, url or
|
|
895
|
+
return { data: [], error: "No path, url or ask specified" };
|
|
894
896
|
}
|
|
895
897
|
const init = { signal: ctx.signal, method };
|
|
896
898
|
if (method === "POST") {
|
|
@@ -903,8 +905,7 @@ function createDefaultFetcher({ basePath = "", dev = false, records = null, fetc
|
|
|
903
905
|
}
|
|
904
906
|
try {
|
|
905
907
|
const response = await doFetch(target, init);
|
|
906
|
-
const
|
|
907
|
-
const envelope = requestEnvelope ?? (endpoint && laneEnvelope ? laneEnvelope : {});
|
|
908
|
+
const envelope = request.envelope && typeof request.envelope === "object" ? request.envelope : {};
|
|
908
909
|
if (!response.ok) {
|
|
909
910
|
let extracted;
|
|
910
911
|
if (envelope.error) {
|
|
@@ -954,7 +955,7 @@ function createDefaultFetcher({ basePath = "", dev = false, records = null, fetc
|
|
|
954
955
|
}
|
|
955
956
|
};
|
|
956
957
|
}
|
|
957
|
-
function
|
|
958
|
+
function toQuestion(request) {
|
|
958
959
|
const q = { schema: request.schema };
|
|
959
960
|
let where = request.where && typeof request.where === "object" ? request.where : null;
|
|
960
961
|
let scope = typeof request.scope === "string" && request.scope ? request.scope : null;
|
|
@@ -969,19 +970,22 @@ function doorQuestion(request) {
|
|
|
969
970
|
if (sort) q.sort = sort;
|
|
970
971
|
if (typeof request.limit === "number" && request.limit > 0) q.limit = request.limit;
|
|
971
972
|
if (request.depth === "brief" || request.depth === "full") q.depth = request.depth;
|
|
973
|
+
if (typeof request.cursor === "string" && request.cursor) q.cursor = request.cursor;
|
|
972
974
|
return q;
|
|
973
975
|
}
|
|
974
|
-
const
|
|
976
|
+
const OPERATOR_ALIAS = { nin: "not_in" };
|
|
975
977
|
function renameOperators(where) {
|
|
976
978
|
if (Array.isArray(where)) return where.map(renameOperators);
|
|
977
979
|
if (!where || typeof where !== "object") return where;
|
|
978
980
|
const out = {};
|
|
979
981
|
for (const [key, value] of Object.entries(where)) {
|
|
980
|
-
out[
|
|
982
|
+
out[OPERATOR_ALIAS[key] ?? key] = value && typeof value === "object" ? renameOperators(value) : value;
|
|
981
983
|
}
|
|
982
984
|
return out;
|
|
983
985
|
}
|
|
984
|
-
|
|
986
|
+
const MAX_PAGES = 50;
|
|
987
|
+
async function flushAsked(url, queue, doFetch) {
|
|
988
|
+
const pending = /* @__PURE__ */ new Map();
|
|
985
989
|
const body = {};
|
|
986
990
|
const keys = [];
|
|
987
991
|
for (const entry of queue) {
|
|
@@ -989,7 +993,7 @@ async function flushDoor(url, queue, doFetch) {
|
|
|
989
993
|
let key = base;
|
|
990
994
|
for (let n = 2; key in body; n += 1) key = `${base}#${n}`;
|
|
991
995
|
keys.push(key);
|
|
992
|
-
body[key] =
|
|
996
|
+
body[key] = toQuestion(entry.request);
|
|
993
997
|
}
|
|
994
998
|
let parsed;
|
|
995
999
|
try {
|
|
@@ -999,7 +1003,13 @@ async function flushDoor(url, queue, doFetch) {
|
|
|
999
1003
|
body: JSON.stringify(body)
|
|
1000
1004
|
});
|
|
1001
1005
|
if (!response.ok) {
|
|
1002
|
-
|
|
1006
|
+
let detail = null;
|
|
1007
|
+
try {
|
|
1008
|
+
const problem = safeParseJSON(await response.text());
|
|
1009
|
+
if (problem && typeof problem.detail === "string" && problem.detail) detail = problem.detail;
|
|
1010
|
+
} catch {
|
|
1011
|
+
}
|
|
1012
|
+
const error = detail ? `HTTP ${response.status}: ${detail}` : `HTTP ${response.status}: ${response.statusText}`;
|
|
1003
1013
|
for (const entry of queue) entry.resolve({ data: null, error });
|
|
1004
1014
|
return;
|
|
1005
1015
|
}
|
|
@@ -1012,20 +1022,63 @@ async function flushDoor(url, queue, doFetch) {
|
|
|
1012
1022
|
const data = parsed && typeof parsed.data === "object" && parsed.data ? parsed.data : {};
|
|
1013
1023
|
const errors = parsed && typeof parsed.errors === "object" && parsed.errors ? parsed.errors : {};
|
|
1014
1024
|
const depths = parsed && typeof parsed.depths === "object" && parsed.depths ? parsed.depths : {};
|
|
1025
|
+
const cursors = parsed && typeof parsed.cursors === "object" && parsed.cursors ? parsed.cursors : {};
|
|
1026
|
+
const limits = parsed && typeof parsed.limits === "object" && parsed.limits ? parsed.limits : {};
|
|
1015
1027
|
queue.forEach((entry, i) => {
|
|
1016
1028
|
const key = keys[i];
|
|
1017
1029
|
if (key in errors) {
|
|
1018
1030
|
const e = errors[key];
|
|
1019
|
-
|
|
1031
|
+
const detail = typeof e === "string" ? e : e?.detail || e?.message || JSON.stringify(e);
|
|
1032
|
+
const out = { data: null, error: detail };
|
|
1033
|
+
if (e && typeof e === "object" && typeof e.code === "string") out.code = e.code;
|
|
1034
|
+
entry.resolve(out);
|
|
1020
1035
|
return;
|
|
1021
1036
|
}
|
|
1022
1037
|
if (!(key in data)) {
|
|
1023
|
-
entry.resolve({ data: null, error: `the records
|
|
1038
|
+
entry.resolve({ data: null, error: `the records service answered without the key "${key}"` });
|
|
1024
1039
|
return;
|
|
1025
1040
|
}
|
|
1026
1041
|
const depth = depths[key] === "brief" || depths[key] === "full" ? depths[key] : entry.request.depth === "brief" || entry.request.depth === "full" ? entry.request.depth : void 0;
|
|
1027
|
-
|
|
1042
|
+
const cursor = typeof cursors[key] === "string" && cursors[key] ? cursors[key] : null;
|
|
1043
|
+
const bound = typeof limits[key] === "number" ? limits[key] : void 0;
|
|
1044
|
+
const rows = Array.isArray(data[key]) ? data[key] : data[key];
|
|
1045
|
+
if (cursor && entry.request.exhaustive && Array.isArray(rows)) {
|
|
1046
|
+
const acc = entry.collected ? entry.collected.concat(rows) : rows.slice();
|
|
1047
|
+
const page = (entry.page || 1) + 1;
|
|
1048
|
+
if (page <= MAX_PAGES) {
|
|
1049
|
+
pending.set(entry, { cursor, collected: acc, page, depth, bound });
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
entry.resolve({ data: acc, meta: withMeta({ depth, bound, truncated: true, pages: MAX_PAGES }) });
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
const collected = entry.collected ? entry.collected.concat(Array.isArray(rows) ? rows : []) : rows;
|
|
1056
|
+
const meta = withMeta({
|
|
1057
|
+
depth,
|
|
1058
|
+
bound,
|
|
1059
|
+
// ⭐ A cursor IS the truncation signal, and it is the only one for a query
|
|
1060
|
+
// that declared no `limit`: `limits` is reported only when an author's own
|
|
1061
|
+
// limit was clamped (the records contract §5).
|
|
1062
|
+
truncated: cursor ? true : void 0,
|
|
1063
|
+
pages: entry.page && entry.page > 1 ? entry.page : void 0
|
|
1064
|
+
});
|
|
1065
|
+
entry.resolve(meta ? { data: collected, meta } : { data: collected });
|
|
1028
1066
|
});
|
|
1067
|
+
if (pending.size === 0) return;
|
|
1068
|
+
await Promise.all([...pending].map(([entry, state]) => {
|
|
1069
|
+
const next = {
|
|
1070
|
+
...entry,
|
|
1071
|
+
request: { ...entry.request, cursor: state.cursor },
|
|
1072
|
+
collected: state.collected,
|
|
1073
|
+
page: state.page
|
|
1074
|
+
};
|
|
1075
|
+
return flushAsked(url, [next], doFetch);
|
|
1076
|
+
}));
|
|
1077
|
+
}
|
|
1078
|
+
function withMeta(fields) {
|
|
1079
|
+
const out = {};
|
|
1080
|
+
for (const [k, v] of Object.entries(fields)) if (v !== void 0) out[k] = v;
|
|
1081
|
+
return Object.keys(out).length ? out : void 0;
|
|
1029
1082
|
}
|
|
1030
1083
|
function applyOperators(data, request, { dev = false } = {}) {
|
|
1031
1084
|
if (!Array.isArray(data)) return data;
|
|
@@ -1105,7 +1158,7 @@ function resolvePageFetchConfigs(content, route, { locale = null } = {}) {
|
|
|
1105
1158
|
locale,
|
|
1106
1159
|
defaultLocale: resolveDefaultLocale$1(content?.config) ?? null,
|
|
1107
1160
|
queries: content?.config?.queries ?? null,
|
|
1108
|
-
|
|
1161
|
+
services: content?.config?.services ?? null,
|
|
1109
1162
|
variables: binding?.variables ?? null
|
|
1110
1163
|
};
|
|
1111
1164
|
const out = /* @__PURE__ */ new Map();
|
|
@@ -1139,7 +1192,6 @@ async function executeFetchConfigs(configs, { content, fetch: fetch2 = null, dev
|
|
|
1139
1192
|
}
|
|
1140
1193
|
const fetcher = createDefaultFetcher({
|
|
1141
1194
|
basePath: content?.config?.base || "",
|
|
1142
|
-
records: content?.config?.records ?? null,
|
|
1143
1195
|
dev,
|
|
1144
1196
|
fetch: fetch2
|
|
1145
1197
|
});
|
|
@@ -1191,10 +1243,42 @@ async function prefetchAndHydrate({ website, content, route, locale = null, fetc
|
|
|
1191
1243
|
hydrateDataStore(website, fetched);
|
|
1192
1244
|
return fetched;
|
|
1193
1245
|
}
|
|
1246
|
+
async function collectSiteRecords(content, { locale, fetch: fetch2, signal, only = null } = {}) {
|
|
1247
|
+
const config = content?.config;
|
|
1248
|
+
const services = config?.services ?? null;
|
|
1249
|
+
const queries = config?.queries ?? null;
|
|
1250
|
+
if (!queries || typeof queries !== "object") return empty();
|
|
1251
|
+
if (!resolveRecordsService(services, locale)) return empty();
|
|
1252
|
+
const names = Object.keys(queries).filter((n) => only ? only.includes(n) : true);
|
|
1253
|
+
if (names.length === 0) return empty();
|
|
1254
|
+
const configs = resolveFetchConfigs(
|
|
1255
|
+
names.map((name) => ({ query: name, as: name })),
|
|
1256
|
+
{ services, queries, locale, defaultLocale: config?.defaultLanguage ?? locale }
|
|
1257
|
+
);
|
|
1258
|
+
const fetcher = createDefaultFetcher({ fetch: fetch2, basePath: config?.base ?? "" });
|
|
1259
|
+
const records = {};
|
|
1260
|
+
const errors = {};
|
|
1261
|
+
const meta = {};
|
|
1262
|
+
await Promise.all([...configs].map(async ([name, cfg]) => {
|
|
1263
|
+
if (!cfg.ask) return;
|
|
1264
|
+
const result = await fetcher.resolve({ ...cfg, depth: "brief", exhaustive: true }, { signal });
|
|
1265
|
+
if (result?.error) {
|
|
1266
|
+
errors[name] = result.error;
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
records[name] = Array.isArray(result?.data) ? result.data : [];
|
|
1270
|
+
if (result?.meta) meta[name] = result.meta;
|
|
1271
|
+
}));
|
|
1272
|
+
return { records, errors: Object.keys(errors).length ? errors : null, meta };
|
|
1273
|
+
}
|
|
1274
|
+
function empty() {
|
|
1275
|
+
return { records: {}, errors: null, meta: {} };
|
|
1276
|
+
}
|
|
1194
1277
|
export {
|
|
1195
1278
|
applyDefaults,
|
|
1196
1279
|
applySchemas,
|
|
1197
1280
|
classifyRenderError,
|
|
1281
|
+
collectSiteRecords,
|
|
1198
1282
|
createPageRenderer,
|
|
1199
1283
|
escapeHtml,
|
|
1200
1284
|
executeFetchConfigs,
|