@antelopejs/dms-frontend 0.3.11 → 0.4.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.
@@ -31,6 +31,7 @@ import {
31
31
  type PropType,
32
32
  type Ref,
33
33
  reactive,
34
+ readonly,
34
35
  ref,
35
36
  watch,
36
37
  } from "vue";
@@ -50,6 +51,12 @@ export interface DmsPagePayload {
50
51
  layoutUrl?: string;
51
52
  layout?: DmsPageLayout;
52
53
  shared?: DmsSharedPagePayload;
54
+ /**
55
+ * The frontend module that owns the page's component tree, by the name the
56
+ * backend added it under (its manifest name). Components that module
57
+ * registered with `{ private: true }` resolve in this page's tree only.
58
+ */
59
+ module?: string;
53
60
  [key: string]: unknown;
54
61
  }
55
62
  export interface DmsSharedPagePayload {
@@ -82,6 +89,13 @@ export interface DmsUser extends Record<string, unknown> {}
82
89
  export interface DmsSession {
83
90
  accountId: string;
84
91
  activeTenantId?: string;
92
+ /**
93
+ * When the frontend server last wrote this session, in milliseconds since
94
+ * the epoch. A page restored from history or from the prefetch cache
95
+ * carries an older session than the one the app already holds, and does not
96
+ * replace it.
97
+ */
98
+ updatedAt?: number;
85
99
  }
86
100
  export interface DmsRuntimeConfig extends RuntimeConfig {
87
101
  public: PublicRuntimeConfig;
@@ -220,9 +234,22 @@ export interface DmsPluginRegistrationOptions {
220
234
  clientOnly?: boolean;
221
235
  }
222
236
  export type DmsComponentPreloader = () => Promise<unknown>;
237
+ export interface DmsComponentRegistrationOptions {
238
+ /**
239
+ * Keeps the component out of the application's global components: it
240
+ * resolves by name only in the component trees of the backend pages the
241
+ * registering module owns (see `DmsPagePayload.module`), where it takes
242
+ * precedence over a public component of the same name. Defaults to `false`.
243
+ */
244
+ private?: boolean;
245
+ }
223
246
  export interface DmsFrontendSdk {
224
247
  options: DmsModuleOptions;
225
- registerComponent(name: string, component: Component): void;
248
+ registerComponent(
249
+ name: string,
250
+ component: Component,
251
+ options?: DmsComponentRegistrationOptions,
252
+ ): void;
226
253
  registerPage(
227
254
  name: string,
228
255
  component: Component,
@@ -264,6 +291,11 @@ export interface DmsFrontendModule {
264
291
  setup(sdk: DmsFrontendSdk): void | Promise<void>;
265
292
  }
266
293
  export interface DmsFrontendModuleRegistration {
294
+ /**
295
+ * The module's manifest name, which its backend pages carry as their
296
+ * `module`. Without it, the module's private components never resolve.
297
+ */
298
+ name?: string;
267
299
  module: DmsFrontendModule;
268
300
  options: DmsModuleOptions;
269
301
  }
@@ -283,6 +315,8 @@ export interface DmsRuntimeHooks {
283
315
  interface DmsComponentRegistration {
284
316
  name: string;
285
317
  component: Component;
318
+ /** The registering module, for a private component. */
319
+ module?: string;
286
320
  }
287
321
 
288
322
  interface DmsFrontendEntry {
@@ -305,6 +339,8 @@ interface DmsPluginRegistration {
305
339
  */
306
340
  interface DmsFrontendRegistry {
307
341
  components: Map<string, DmsComponentRegistration>;
342
+ /** Private components, by registering module, then by normalized name. */
343
+ privateComponents: Map<string, Map<string, DmsComponentRegistration>>;
308
344
  pages: Map<string, DmsFrontendEntry>;
309
345
  dynamicPages: Map<string, DmsFrontendEntry>;
310
346
  layouts: Map<string, DmsFrontendEntry>;
@@ -333,6 +369,8 @@ export interface DmsFrontendRuntime {
333
369
  pendingNavigation?: Promise<void>;
334
370
  hasNavigationListener: boolean;
335
371
  pageVersion: number;
372
+ /** Whether the page `<Suspense>` waits on the page being navigated to. */
373
+ pageLoading: Ref<boolean>;
336
374
  /**
337
375
  * Identifies the page on screen (its path, or the error page shown at that
338
376
  * path), whose meta `route.meta` holds.
@@ -351,6 +389,7 @@ export interface DmsFrontendRuntime {
351
389
  function createDmsFrontendRegistry(): DmsFrontendRegistry {
352
390
  return {
353
391
  components: new Map(),
392
+ privateComponents: new Map(),
354
393
  pages: new Map(),
355
394
  dynamicPages: new Map(),
356
395
  layouts: new Map(),
@@ -403,6 +442,7 @@ export function createDmsFrontendRuntime(
403
442
  }),
404
443
  hasNavigationListener: false,
405
444
  pageVersion: 0,
445
+ pageLoading: ref(false),
406
446
  scope: effectScope(true),
407
447
  };
408
448
  }
@@ -1187,23 +1227,82 @@ export function useUserSession<
1187
1227
  };
1188
1228
  }
1189
1229
 
1230
+ /**
1231
+ * Whether the page loaded by the current navigation is still pending: its
1232
+ * chunk, or an async `setup` it awaits, has not resolved yet. A layout reads
1233
+ * it to show its own placeholder in place of the page. Always `false` on the
1234
+ * server and during hydration, which render the page resolved.
1235
+ */
1236
+ export function useDmsPageLoading(): Readonly<Ref<boolean>> {
1237
+ return readonly(useDmsRuntime().pageLoading);
1238
+ }
1239
+
1240
+ /**
1241
+ * The props of the `<Suspense>` around the page that keep `useDmsPageLoading`
1242
+ * in step with it. Cleared before the boundary unmounts, so a page left while
1243
+ * pending never leaves the state set.
1244
+ */
1245
+ export function dmsPageSuspenseProps(): Record<string, () => void> {
1246
+ const loading = useDmsRuntime().pageLoading;
1247
+ const settle = () => {
1248
+ loading.value = false;
1249
+ };
1250
+ return {
1251
+ onPending: () => {
1252
+ loading.value = true;
1253
+ },
1254
+ onResolve: settle,
1255
+ onVnodeBeforeUnmount: settle,
1256
+ };
1257
+ }
1258
+
1190
1259
  function registerDmsComponentEntry(
1191
1260
  entries: Map<string, DmsComponentRegistration>,
1192
- name: string,
1193
- component: Component,
1261
+ registration: DmsComponentRegistration,
1194
1262
  ): void {
1195
- const key = normalizeDmsName(name);
1196
- if (!entries.has(key)) entries.set(key, { name, component });
1263
+ const key = normalizeDmsName(registration.name);
1264
+ if (!entries.has(key)) entries.set(key, registration);
1197
1265
  }
1198
1266
  /** Registers a component unless a higher-priority module already owns its name. */
1199
1267
  export function registerDmsComponent(name: string, component: Component): void {
1200
- registerDmsComponentEntry(registry.components, name, component);
1268
+ registerDmsComponentEntry(registry.components, { name, component });
1201
1269
  }
1202
- export function resolveDmsComponent(name: string): Component | undefined {
1203
- return registry.components.get(normalizeDmsName(name))?.component;
1270
+ /**
1271
+ * Resolves a registered component by name. `owner` is the `module` of the
1272
+ * backend page whose component tree is rendered: the private components that
1273
+ * module registered resolve there, ahead of a public one of the same name.
1274
+ * Without it, only public components resolve.
1275
+ */
1276
+ export function resolveDmsComponent(
1277
+ name: string,
1278
+ owner?: string,
1279
+ ): Component | undefined {
1280
+ const key = normalizeDmsName(name);
1281
+ const own = owner
1282
+ ? registry.privateComponents.get(owner)?.get(key)
1283
+ : undefined;
1284
+ return (own ?? registry.components.get(key))?.component;
1204
1285
  }
1205
1286
  export const getDmsComponent = resolveDmsComponent;
1206
1287
 
1288
+ const PRIVATE_COMPONENT_SEPARATOR = "::";
1289
+
1290
+ function registeredComponents(): DmsComponentRegistration[] {
1291
+ return [
1292
+ ...registry.components.values(),
1293
+ ...[...registry.privateComponents.values()].flatMap((entries) => [
1294
+ ...entries.values(),
1295
+ ]),
1296
+ ];
1297
+ }
1298
+
1299
+ /** Names a registered component across the server render and hydration. */
1300
+ function componentReference({ name, module }: DmsComponentRegistration) {
1301
+ return module === undefined
1302
+ ? name
1303
+ : `${module}${PRIVATE_COMPONENT_SEPARATOR}${name}`;
1304
+ }
1305
+
1207
1306
  /**
1208
1307
  * Records, by registered name, every registered async component a server
1209
1308
  * render reaches, so the client can resolve the same ones before hydrating.
@@ -1213,9 +1312,9 @@ export const getDmsComponent = resolveDmsComponent;
1213
1312
  */
1214
1313
  export function trackDmsAsyncComponents(app: App): Set<string> {
1215
1314
  const names = new Map<Component, string>();
1216
- registry.components.forEach(({ component, name }) => {
1217
- if ((component as DmsAsyncComponent).__asyncLoader)
1218
- names.set(component, name);
1315
+ registeredComponents().forEach((registration) => {
1316
+ if ((registration.component as DmsAsyncComponent).__asyncLoader)
1317
+ names.set(registration.component, componentReference(registration));
1219
1318
  });
1220
1319
  const rendered = new Set<string>();
1221
1320
  app.mixin({
@@ -1258,10 +1357,18 @@ export function trackDmsServerScopes(app: App): () => void {
1258
1357
  export async function resolveDmsAsyncComponents(
1259
1358
  names: readonly string[],
1260
1359
  ): Promise<void> {
1360
+ const components = new Map(
1361
+ registeredComponents().map((registration) => [
1362
+ componentReference(registration),
1363
+ registration.component,
1364
+ ]),
1365
+ );
1261
1366
  await Promise.all(
1262
1367
  names.map((name) =>
1263
1368
  (
1264
- resolveDmsComponent(name) as DmsAsyncComponent | undefined
1369
+ (components.get(name) ?? resolveDmsComponent(name)) as
1370
+ | DmsAsyncComponent
1371
+ | undefined
1265
1372
  )?.__asyncLoader?.(),
1266
1373
  ),
1267
1374
  );
@@ -1303,6 +1410,7 @@ function collectRenderedComponentNames(
1303
1410
 
1304
1411
  async function preloadDmsLayoutComponents(
1305
1412
  layout: DmsPageLayout | undefined,
1413
+ owner: string | undefined,
1306
1414
  ): Promise<void> {
1307
1415
  const names = new Set<string>();
1308
1416
  collectRenderedComponentNames(layout?.layout, names);
@@ -1311,7 +1419,7 @@ async function preloadDmsLayoutComponents(
1311
1419
  });
1312
1420
  await Promise.all(
1313
1421
  [...names].map((name) => {
1314
- const component = resolveDmsComponent(name) as
1422
+ const component = resolveDmsComponent(name, owner) as
1315
1423
  | DmsAsyncComponent
1316
1424
  | undefined;
1317
1425
  return component?.__asyncLoader?.();
@@ -1371,6 +1479,24 @@ export function getDmsLayoutProps(
1371
1479
  };
1372
1480
  }
1373
1481
 
1482
+ /**
1483
+ * Whether a page carries an older session of the same account than the one
1484
+ * the app holds: Inertia restores a page from history, or serves it from the
1485
+ * prefetch cache, with the props it was fetched with, and those must not undo
1486
+ * a later `useUserSession().fetch()`.
1487
+ */
1488
+ function isStaleSession(
1489
+ incoming: DmsSession | undefined,
1490
+ current: DmsSession | null,
1491
+ ): boolean {
1492
+ return (
1493
+ !!incoming &&
1494
+ !!current &&
1495
+ incoming.accountId === current.accountId &&
1496
+ (incoming.updatedAt ?? 0) < (current.updatedAt ?? 0)
1497
+ );
1498
+ }
1499
+
1374
1500
  export function hydrateDmsPageProps(props: DmsPageProps, url?: string): void {
1375
1501
  const runtime = useDmsRuntime();
1376
1502
  if (url) updateRoute(runtime, url);
@@ -1384,10 +1510,12 @@ export function hydrateDmsPageProps(props: DmsPageProps, url?: string): void {
1384
1510
  }
1385
1511
  runtime.pageVersion++;
1386
1512
  runtime.currentError.value = null;
1387
- useDmsState<DmsUser | null>("dms-user", () => null).value =
1388
- props.user ?? null;
1389
- useDmsState<DmsSession | null>("dms-session", () => null).value =
1390
- props.session ?? null;
1513
+ const session = useDmsState<DmsSession | null>("dms-session", () => null);
1514
+ if (!isStaleSession(props.session, session.value)) {
1515
+ useDmsState<DmsUser | null>("dms-user", () => null).value =
1516
+ props.user ?? null;
1517
+ session.value = props.session ?? null;
1518
+ }
1391
1519
  const shared = props.page.shared;
1392
1520
  if (shared) {
1393
1521
  useDmsState<unknown>("dms-siteLayout", () => undefined).value =
@@ -1433,7 +1561,8 @@ export async function preloadDmsPage(props: DmsPageProps): Promise<void> {
1433
1561
  (entry?.component as DmsAsyncComponent | undefined)?.__asyncLoader?.(),
1434
1562
  ]),
1435
1563
  );
1436
- if (!props.error) await preloadDmsLayoutComponents(props.page.layout);
1564
+ if (!props.error)
1565
+ await preloadDmsLayoutComponents(props.page.layout, props.page.module);
1437
1566
  }
1438
1567
 
1439
1568
  export function useDmsInjection<T>(
@@ -1451,14 +1580,29 @@ function registerDmsFrontendEntry(
1451
1580
  if (!entries.has(name)) entries.set(name, { component, preload });
1452
1581
  }
1453
1582
 
1583
+ function privateComponentsOf(
1584
+ target: DmsFrontendRegistry,
1585
+ owner: string,
1586
+ ): Map<string, DmsComponentRegistration> {
1587
+ const entries = target.privateComponents.get(owner) ?? new Map();
1588
+ target.privateComponents.set(owner, entries);
1589
+ return entries;
1590
+ }
1591
+
1454
1592
  function createSdk(
1455
1593
  target: DmsFrontendRegistry,
1456
- options: DmsModuleOptions,
1594
+ { name: owner = "", options }: DmsFrontendModuleRegistration,
1457
1595
  ): DmsFrontendSdk {
1458
1596
  return {
1459
1597
  options,
1460
- registerComponent: (name, component) =>
1461
- registerDmsComponentEntry(target.components, name, component),
1598
+ registerComponent: (name, component, registration = {}) =>
1599
+ registration.private
1600
+ ? registerDmsComponentEntry(privateComponentsOf(target, owner), {
1601
+ name,
1602
+ component,
1603
+ module: owner,
1604
+ })
1605
+ : registerDmsComponentEntry(target.components, { name, component }),
1462
1606
  registerPage: (name, component, preload) => {
1463
1607
  const key = normalizeDmsPageKey(name);
1464
1608
  registerDmsFrontendEntry(target.pages, key, component, preload);
@@ -1519,7 +1663,7 @@ export async function setupFrontendModules(
1519
1663
  runtimeConfig.value.public,
1520
1664
  registration.options.public,
1521
1665
  );
1522
- await registration.module.setup(createSdk(next, registration.options));
1666
+ await registration.module.setup(createSdk(next, registration));
1523
1667
  }
1524
1668
  registry = next;
1525
1669
  }
@@ -0,0 +1,78 @@
1
+ import {
2
+ addCollection,
3
+ addIcon,
4
+ getIcon,
5
+ Icon,
6
+ type IconifyIcon,
7
+ type IconifyJSON,
8
+ iconLoaded,
9
+ } from "@iconify/vue";
10
+ import type { App, Component, ComponentPublicInstance } from "vue";
11
+
12
+ /** The element a server render writes the data of the icons it drew to. */
13
+ export const SSR_ICONS_ID = "dms-ssr-icons";
14
+
15
+ interface IconProps {
16
+ icon?: unknown;
17
+ }
18
+
19
+ /**
20
+ * Draws an Iconify icon in its first render whenever its data is loaded, on
21
+ * the server as in the browser.
22
+ *
23
+ * `@iconify/vue` otherwise renders every icon empty until it mounts, which a
24
+ * server render never does: pages arrived without their icons, which popped in
25
+ * once hydrated. Its `ssr` prop opts out of the wait, but Nuxt UI's `UIcon`
26
+ * does not pass it, so its default is set here, and only for an icon whose
27
+ * data is loaded: an icon still to be fetched waits for the mount as before,
28
+ * so a server render never queries an Iconify API.
29
+ */
30
+ export function drawLoadedIconsAtOnce(): void {
31
+ (Icon as unknown as { props: Record<string, unknown> }).props.ssr = {
32
+ type: Boolean,
33
+ default: ({ icon }: IconProps) =>
34
+ typeof icon !== "string" || iconLoaded(icon),
35
+ };
36
+ }
37
+
38
+ /**
39
+ * Loads whole icon collections, so a server render draws any icon of them,
40
+ * including those only named by backend data, which the client bundle's scan
41
+ * cannot see.
42
+ */
43
+ export function registerDmsIconCollections(collections: IconifyJSON[]): void {
44
+ for (const collection of collections) addCollection(collection);
45
+ }
46
+
47
+ /** Records the name of every Iconify icon a server render reaches. */
48
+ export function trackDmsIcons(app: App): Set<string> {
49
+ const names = new Set<string>();
50
+ app.mixin({
51
+ beforeCreate(this: ComponentPublicInstance) {
52
+ if (this.$.type !== (Icon as Component)) return;
53
+ const { icon } = this.$props as IconProps;
54
+ if (typeof icon === "string") names.add(icon);
55
+ },
56
+ });
57
+ return names;
58
+ }
59
+
60
+ /**
61
+ * The data of the icons a server render drew, which the browser loads before
62
+ * it hydrates: an icon outside the client bundle would otherwise hydrate
63
+ * empty over the server's markup.
64
+ */
65
+ export function serializeDmsIcons(
66
+ names: Iterable<string>,
67
+ ): Record<string, IconifyIcon> {
68
+ return Object.fromEntries(
69
+ [...names].flatMap((name) => {
70
+ const data = getIcon(name);
71
+ return data ? [[name, data]] : [];
72
+ }),
73
+ );
74
+ }
75
+
76
+ export function addDmsIcons(icons: Record<string, IconifyIcon>): void {
77
+ for (const [name, data] of Object.entries(icons)) addIcon(name, data);
78
+ }
@@ -1,4 +1,4 @@
1
- import { clientIp } from "./client-ip.mjs";
1
+ import { clientCountryHeader, clientIp } from "./client-ip.mjs";
2
2
 
3
3
  const FORWARDED_HEADERS = [
4
4
  "user-agent",
@@ -26,12 +26,26 @@ export function publicBackendMessage(data) {
26
26
  : undefined;
27
27
  }
28
28
 
29
- export async function backend(path, request, options = {}) {
30
- const headers = Object.fromEntries(
31
- FORWARDED_HEADERS.flatMap((name) =>
32
- request.headers[name] ? [[name, request.headers[name]]] : [],
29
+ /**
30
+ * The headers of a client request the server relays to the backend.
31
+ *
32
+ * @param request The client request
33
+ * @param names Lowercase names of the request headers to copy when present
34
+ * @returns The client country header, then the copied headers
35
+ */
36
+ export function relayedHeaders(request, names) {
37
+ return {
38
+ ...clientCountryHeader(request),
39
+ ...Object.fromEntries(
40
+ names.flatMap((name) =>
41
+ request.headers[name] ? [[name, request.headers[name]]] : [],
42
+ ),
33
43
  ),
34
- );
44
+ };
45
+ }
46
+
47
+ export async function backend(path, request, options = {}) {
48
+ const headers = relayedHeaders(request, FORWARDED_HEADERS);
35
49
  headers["x-forwarded-for"] = clientIp(request);
36
50
  if (options.token) headers.authorization = `Bearer ${options.token}`;
37
51
  if (options.relay)
@@ -51,6 +51,34 @@ export function clientIp(request) {
51
51
  return chain[Math.max(0, chain.length - hops - 1)] ?? socketIp;
52
52
  }
53
53
 
54
+ const DEFAULT_COUNTRY_HEADER = "CF-IPCountry";
55
+ const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9a-z-]+$/;
56
+
57
+ function countryHeader() {
58
+ const name = (process.env.DMS_COUNTRY_HEADER ?? DEFAULT_COUNTRY_HEADER)
59
+ .trim()
60
+ .toLowerCase();
61
+ return HEADER_NAME.test(name) ? name : undefined;
62
+ }
63
+
64
+ /**
65
+ * The client's country header, as the backend request headers to add.
66
+ *
67
+ * The header is the one `DMS_COUNTRY_HEADER` names (`CF-IPCountry` by
68
+ * default, empty to relay none), the name the DMS reads from
69
+ * `auth.signInCountry.header`. Like `X-Forwarded-For`, it is only believed
70
+ * from a trusted reverse proxy: with `DMS_TRUSTED_PROXY_HOPS` at `0`, any
71
+ * caller could have set it, so nothing is relayed.
72
+ */
73
+ export function clientCountryHeader(request) {
74
+ const name = countryHeader();
75
+ if (!name || !trustedHops()) return {};
76
+ const value = request.headers?.[name];
77
+ return typeof value === "string" && value.trim()
78
+ ? { [name]: value.trim() }
79
+ : {};
80
+ }
81
+
54
82
  /**
55
83
  * Body answered with the 403 of the CSRF origin check.
56
84
  *
@@ -66,6 +66,7 @@ export function publicSession(session) {
66
66
  session: {
67
67
  accountId: session.accountId,
68
68
  activeTenantId: session.activeTenantId,
69
+ updatedAt: session.updatedAt,
69
70
  },
70
71
  };
71
72
  }
@@ -146,9 +146,15 @@ export function assertAccountInvariant(session, accountId) {
146
146
  throw new Error("Account session invariant violated");
147
147
  }
148
148
 
149
+ /**
150
+ * Write `session` as the current one, and as one of the stored accounts.
151
+ * Stamps it with `updatedAt`, which lets the browser tell a page fetched
152
+ * before this write, and restored from its cache, from a current one.
153
+ */
149
154
  export function persistAccountSession(request, response, session, accountId) {
150
155
  const id = accountId ?? session.accountId ?? randomUUID();
151
156
  session.accountId = id;
157
+ session.updatedAt = Date.now();
152
158
  const account = storeAccount(request, response, session, id);
153
159
  assertAccountInvariant(session, account.accountId);
154
160
  writeSession(response, session);
@@ -1,4 +1,4 @@
1
- import { readFile } from "node:fs/promises";
1
+ import { readdir, readFile } from "node:fs/promises";
2
2
  import { createRequire } from "node:module";
3
3
  import { join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
@@ -14,12 +14,16 @@ const READ_METHODS = new Set(["GET", "HEAD"]);
14
14
  const ICON_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
15
15
  const COLLECTION_FILE = /^([^/]+)\.json$/;
16
16
  const ICON_NAME_SEPARATOR = ",";
17
+ const COLLECTION_PACKAGES = join("node_modules", "@iconify-json");
17
18
  // An icon's data only changes with the installed collection, which a
18
19
  // deployment replaces; a week bounds how long an upgraded icon can lag.
19
20
  const ICON_CACHE_CONTROL =
20
21
  "public, max-age=604800, stale-while-revalidate=86400";
21
22
  const ERROR_CACHE_CONTROL = "no-store";
22
23
  const collections = new Map();
24
+ // By renderer module: a development server evaluates a new one after a layer
25
+ // changes, and each must find the collections loaded before it renders.
26
+ const rendererCollections = new WeakMap();
23
27
 
24
28
  function collectionSpecifiers(prefix) {
25
29
  return [
@@ -60,6 +64,44 @@ function collection(root, prefix) {
60
64
  return collections.get(key);
61
65
  }
62
66
 
67
+ /**
68
+ * Every `@iconify-json/<prefix>` collection installed in the workspace under
69
+ * `root`, the ones the server render draws from. The workspace hoists every
70
+ * dependency, a module's collections included.
71
+ */
72
+ export async function installedCollections(root) {
73
+ const prefixes = await readdir(join(root, COLLECTION_PACKAGES)).catch(
74
+ () => [],
75
+ );
76
+ const loaded = await Promise.all(
77
+ prefixes
78
+ .filter((prefix) => ICON_NAME.test(prefix))
79
+ .map((prefix) => collection(root, prefix)),
80
+ );
81
+ return loaded.filter(Boolean);
82
+ }
83
+
84
+ /**
85
+ * Hand a server renderer the installed collections, once per renderer module,
86
+ * before its first render. A collection that fails to load leaves the render
87
+ * with the bundled icons only.
88
+ */
89
+ export function provideIconCollections(renderer, root = WORKSPACE_ROOT) {
90
+ if (!rendererCollections.has(renderer))
91
+ rendererCollections.set(
92
+ renderer,
93
+ installedCollections(root)
94
+ .then((loaded) => renderer.registerDmsIconCollections(loaded))
95
+ .catch((error) =>
96
+ console.error(
97
+ "DMS icon collections not loaded; server renders draw bundled icons only",
98
+ error,
99
+ ),
100
+ ),
101
+ );
102
+ return rendererCollections.get(renderer);
103
+ }
104
+
63
105
  function jsonResponse(status, cacheControl, payload) {
64
106
  return {
65
107
  status,
@@ -5,7 +5,11 @@ import { Readable } from "node:stream";
5
5
  import { pipeline } from "node:stream/promises";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { ofetch } from "ofetch";
8
- import { RequestBodyError, UpstreamError } from "./server/auth/backend.mjs";
8
+ import {
9
+ relayedHeaders,
10
+ RequestBodyError,
11
+ UpstreamError,
12
+ } from "./server/auth/backend.mjs";
9
13
  import { CROSS_ORIGIN_ERROR, isSameOrigin } from "./server/auth/client-ip.mjs";
10
14
  import {
11
15
  handleAuth,
@@ -36,7 +40,7 @@ import { documentStyleTags } from "./server/dev-styles.mjs";
36
40
  import { handleEmailRender, watchEmailBundle } from "./server/email.mjs";
37
41
  import { HOMEPAGE } from "./server/homepage.mjs";
38
42
  import { htmlTag } from "./server/html-tag.mjs";
39
- import { handleIcons, isIconRequest } from "./server/icons.mjs";
43
+ import * as icons from "./server/icons.mjs";
40
44
  import * as requestFailure from "./server/request-failure.mjs";
41
45
  import { handleTester } from "./server/tester.mjs";
42
46
  import {
@@ -75,7 +79,7 @@ const HTML_RENDER_ROUTE = "/api/html/render";
75
79
  const TESTER_ROUTE = /^\/api\/_dms\/tester\/?$/;
76
80
  const LOCAL_ROUTES = [
77
81
  [(pathname) => TESTER_ROUTE.test(pathname), handleTester],
78
- [isIconRequest, handleIcons],
82
+ [icons.isIconRequest, icons.handleIcons],
79
83
  ];
80
84
  const AUTH_PAGE = "/auth";
81
85
  const ONBOARDING_PAGE = "/onboarding";
@@ -117,18 +121,13 @@ async function developmentServer() {
117
121
  }
118
122
 
119
123
  function backendHeaders(request) {
120
- const names = [
124
+ const headers = relayedHeaders(request, [
121
125
  "accept",
122
126
  "content-type",
123
127
  "user-agent",
124
128
  "x-content-language",
125
129
  "x-realtime-session",
126
- ];
127
- const headers = Object.fromEntries(
128
- names.flatMap((name) =>
129
- request.headers[name] ? [[name, request.headers[name]]] : [],
130
- ),
131
- );
130
+ ]);
132
131
  const session = readSession(request);
133
132
  if (session?.accessToken)
134
133
  headers.authorization = `Bearer ${session.accessToken}`;
@@ -297,9 +296,11 @@ function serveAsset(pathname, request, response) {
297
296
  }
298
297
 
299
298
  async function ssrRenderer(devServer) {
300
- if (devServer) return devServer.ssrLoadModule("/ssr-renderer.ts");
301
- productionSsrRenderer ??= import(BUILT_SSR_RENDERER_PATH);
302
- return productionSsrRenderer;
299
+ const renderer = devServer
300
+ ? await devServer.ssrLoadModule("/ssr-renderer.ts")
301
+ : await (productionSsrRenderer ??= import(BUILT_SSR_RENDERER_PATH));
302
+ await icons.provideIconCollections(renderer);
303
+ return renderer;
303
304
  }
304
305
 
305
306
  export async function renderHtml(