@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 CHANGED
@@ -1,4 +1,4 @@
1
- import { isRichSchema, deriveCacheKey, resolveDefaultLocale, hasDarkScheme, createUniweb, resolveServiceUrl, substitutePlaceholders, matchWhere, sortRecords, sortToWire } from "@uniweb/core";
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, records = null, fetch: fetchImpl = null } = {}) {
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 stampedArrayKey = records?.envelope && typeof records.envelope === "object" && typeof records.envelope.records === "string" && records.envelope.records.length ? records.envelope.records : null;
846
- const laneEnvelope = stampedArrayKey ? { list: stampedArrayKey } : null;
847
- const doorQueues = /* @__PURE__ */ new Map();
848
- const askDoor = (request, ctx) => new Promise((resolve) => {
849
- const url = resolveServiceUrl(request.door, pathPrefix);
850
- let queue = doorQueues.get(url);
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
- queue.push({ request, ctx, resolve });
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, on a question door,
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.door) return askDoor(request, ctx);
876
- const { path, url, endpoint, transform, body: rawBody } = request;
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 (endpoint) {
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 endpoint specified" };
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 requestEnvelope = request.envelope && typeof request.envelope === "object" ? request.envelope : null;
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 doorQuestion(request) {
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 DOOR_OPERATOR = { nin: "not_in" };
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[DOOR_OPERATOR[key] ?? key] = value && typeof value === "object" ? renameOperators(value) : value;
982
+ out[OPERATOR_ALIAS[key] ?? key] = value && typeof value === "object" ? renameOperators(value) : value;
981
983
  }
982
984
  return out;
983
985
  }
984
- async function flushDoor(url, queue, doFetch) {
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] = doorQuestion(entry.request);
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
- const error = `HTTP ${response.status}: ${response.statusText}`;
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
- entry.resolve({ data: null, error: typeof e === "string" ? e : e?.message || JSON.stringify(e) });
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 door answered without the key "${key}"` });
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
- entry.resolve(depth ? { data: data[key], meta: { depth } } : { data: data[key] });
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
- records: content?.config?.records ?? null,
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,