@antelopejs/dms-frontend 0.9.1 → 0.9.3

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.
Files changed (103) hide show
  1. package/README.md +9 -1
  2. package/dist/cancellation.js +2 -1
  3. package/dist/config.js +11 -7
  4. package/dist/derived-outputs.js +6 -113
  5. package/dist/dev-modules.js +3 -2
  6. package/dist/discovery.js +19 -12
  7. package/dist/fs-sync.js +1 -2
  8. package/dist/internal/guards.d.ts +5 -0
  9. package/dist/internal/guards.js +22 -0
  10. package/dist/internal/locale-catalogs.d.ts +6 -0
  11. package/dist/internal/locale-catalogs.js +115 -0
  12. package/dist/layer-aliases.js +2 -1
  13. package/dist/layers.js +2 -1
  14. package/dist/locale-files.js +9 -4
  15. package/dist/manifest-secrets.js +11 -4
  16. package/dist/manifest.js +11 -5
  17. package/dist/materialize.js +6 -5
  18. package/dist/module-set.js +2 -4
  19. package/dist/ports.js +8 -1
  20. package/dist/private-values.js +26 -17
  21. package/dist/project-modules.js +17 -11
  22. package/dist/renderer-range.js +2 -1
  23. package/dist/server-process.js +17 -16
  24. package/dist/update-check.js +23 -16
  25. package/dist/verify-source-runner.js +2 -2
  26. package/dist/workspace-setup.js +1 -2
  27. package/dist/workspace.js +11 -10
  28. package/package.json +7 -4
  29. package/skills/dms-frontend/SKILL.md +1 -1
  30. package/skills/dms-frontend/cases.json +77 -0
  31. package/templates/vue/app-runtime.ts +88 -7
  32. package/templates/vue/client-chunking.mjs +0 -5
  33. package/templates/vue/client-server-guard.mjs +10 -13
  34. package/templates/vue/entry-weight.mjs +12 -14
  35. package/templates/vue/frontend-module.ts +159 -3563
  36. package/templates/vue/frontend-runtime/async-data-entry.ts +320 -0
  37. package/templates/vue/frontend-runtime/async-data.ts +404 -0
  38. package/templates/vue/frontend-runtime/components.ts +386 -0
  39. package/templates/vue/frontend-runtime/cookies.ts +125 -0
  40. package/templates/vue/frontend-runtime/data-access.ts +132 -0
  41. package/templates/vue/frontend-runtime/dms-error.ts +11 -0
  42. package/templates/vue/frontend-runtime/errors.ts +145 -0
  43. package/templates/vue/frontend-runtime/fetch.ts +306 -0
  44. package/templates/vue/frontend-runtime/module-registration.ts +247 -0
  45. package/templates/vue/frontend-runtime/paged-data.ts +191 -0
  46. package/templates/vue/frontend-runtime/payload.ts +172 -0
  47. package/templates/vue/frontend-runtime/route.ts +328 -0
  48. package/templates/vue/frontend-runtime/sdk.ts +300 -0
  49. package/templates/vue/frontend-runtime/state.ts +167 -0
  50. package/templates/vue/frontend-runtime/types.ts +361 -0
  51. package/templates/vue/frontend-runtime/ui.ts +247 -0
  52. package/templates/vue/globals.d.ts +5 -0
  53. package/templates/vue/head-order.mjs +11 -13
  54. package/templates/vue/icon-bundle.ts +31 -0
  55. package/templates/vue/message-subset.ts +65 -0
  56. package/templates/vue/network-error.ts +4 -0
  57. package/templates/vue/prepare-types.mjs +0 -8
  58. package/templates/vue/server/auth/backend.mjs +4 -2
  59. package/templates/vue/server/auth/establish-endpoints.mjs +14 -9
  60. package/templates/vue/server/auth/session.mjs +4 -3
  61. package/templates/vue/server/auth-hooks.mjs +6 -6
  62. package/templates/vue/server/backend-proxies.mjs +6 -6
  63. package/templates/vue/server/backend-response.mjs +15 -14
  64. package/templates/vue/server/built-files.mjs +10 -8
  65. package/templates/vue/server/cli-report.mjs +7 -5
  66. package/templates/vue/server/client-error.mjs +11 -8
  67. package/templates/vue/server/client-manifest.mjs +9 -8
  68. package/templates/vue/server/content.mjs +2 -1
  69. package/templates/vue/server/dev-private-files.mjs +20 -15
  70. package/templates/vue/server/dev-styles.mjs +22 -25
  71. package/templates/vue/server/email-bundle-watch.mjs +0 -12
  72. package/templates/vue/server/email.mjs +20 -15
  73. package/templates/vue/server/favicon.mjs +12 -11
  74. package/templates/vue/server/homepage.mjs +8 -10
  75. package/templates/vue/server/icons.mjs +18 -9
  76. package/templates/vue/server/in-place-render.mjs +6 -8
  77. package/templates/vue/server/listen-host.mjs +2 -2
  78. package/templates/vue/server/module-event.mjs +5 -5
  79. package/templates/vue/server/module-routes.mjs +11 -7
  80. package/templates/vue/server/module-server.mjs +8 -7
  81. package/templates/vue/server/module-testing.mjs +7 -7
  82. package/templates/vue/server/page-props.mjs +14 -14
  83. package/templates/vue/server/path-patterns.mjs +4 -4
  84. package/templates/vue/server/proxy-compression.mjs +21 -13
  85. package/templates/vue/server/proxy-headers.mjs +5 -3
  86. package/templates/vue/server/proxy-redirects.mjs +7 -6
  87. package/templates/vue/server/proxy-transfers.mjs +15 -11
  88. package/templates/vue/server/public-endpoints.mjs +27 -19
  89. package/templates/vue/server/public-options.mjs +9 -7
  90. package/templates/vue/server/request-failure.mjs +4 -6
  91. package/templates/vue/server/request-routing.mjs +8 -11
  92. package/templates/vue/server/request-target.mjs +6 -6
  93. package/templates/vue/server/route-rules.mjs +7 -5
  94. package/templates/vue/server/static-files.mjs +19 -10
  95. package/templates/vue/server/web-body.mjs +3 -4
  96. package/templates/vue/server.mjs +32 -19
  97. package/templates/vue/sfc-global-types.mjs +27 -24
  98. package/templates/vue/ssr-renderer.ts +10 -1
  99. package/templates/vue/ui-inertia-page.mjs +7 -7
  100. package/templates/vue/ui-kbd.mjs +10 -10
  101. package/templates/vue/ui-lazy-icons.mjs +44 -0
  102. package/templates/vue/ui-request-state.mjs +9 -9
  103. package/templates/vue/vite.config.ts +2 -0
@@ -0,0 +1,386 @@
1
+ import { router as inertiaRouter } from "@inertiajs/vue3";
2
+ import {
3
+ type App,
4
+ type Component,
5
+ type ComponentPublicInstance,
6
+ type EffectScope,
7
+ getCurrentScope,
8
+ } from "vue";
9
+ import {
10
+ type DmsAsyncComponent,
11
+ type DmsComponentRegistration,
12
+ type DmsFrontendEntry,
13
+ type DmsFrontendRuntime,
14
+ } from "./module-registration";
15
+ import { registry, useDmsRuntime } from "./state";
16
+ import {
17
+ type DmsPageLayout,
18
+ type DmsPageProps,
19
+ type DmsSession,
20
+ type DmsSharedPagePayload,
21
+ type DmsUser,
22
+ } from "./types";
23
+ import { useDmsState } from "./payload";
24
+ import { updateRoute } from "./route";
25
+ import { dropUnusedAsyncData } from "./data-access";
26
+
27
+ /**
28
+ * The key a component name is registered and resolved under: case, a leading
29
+ * `lazy` and every non-alphanumeric character ignored, so PascalCase,
30
+ * kebab-case and lowercase spellings match. Unlike `normalizeDmsName`, a
31
+ * leading `Dms` is kept: it is the DMS module's prefix, and dropping it would
32
+ * make `DmsBanner` and another module's `Banner` the same component.
33
+ */
34
+ function normalizeDmsComponentName(name: string): string {
35
+ return name
36
+ .replace(/^lazy/i, "")
37
+ .replace(/[^a-z0-9]/gi, "")
38
+ .toLowerCase();
39
+ }
40
+
41
+ function moduleLabel(module: string | undefined): string {
42
+ return module ? `module "${module}"` : "an unnamed module";
43
+ }
44
+
45
+ /**
46
+ * A component registered under a name already taken is ignored: the first
47
+ * registration, from the module of higher priority, keeps the name. That is
48
+ * how a module replaces another's component, by registering its full name
49
+ * without a prefix, and only a collision neither side asked for is reported.
50
+ */
51
+ export function registerDmsComponentEntry(
52
+ entries: Map<string, DmsComponentRegistration>,
53
+ registration: DmsComponentRegistration,
54
+ ): void {
55
+ const key = normalizeDmsComponentName(registration.name);
56
+ const existing = entries.get(key);
57
+ if (!existing) {
58
+ entries.set(key, registration);
59
+ return;
60
+ }
61
+ if (existing.unprefixed || registration.unprefixed) return;
62
+ console.warn(
63
+ `[dms] Component "${registration.name}" from ${moduleLabel(registration.module)} is ignored: ` +
64
+ `${moduleLabel(existing.module)} already registers "${existing.name}". ` +
65
+ "Give the modules different component prefixes.",
66
+ );
67
+ }
68
+ /** Registers a component unless a higher-priority module already owns its name. */
69
+ export function registerDmsComponent(name: string, component: Component): void {
70
+ registerDmsComponentEntry(registry.components, { name, component });
71
+ }
72
+ /**
73
+ * Resolves a registered component by its full name, prefix included. Case,
74
+ * a leading `lazy` and non-alphanumeric characters are ignored.
75
+ */
76
+ export function resolveDmsComponent(name: string): Component | undefined {
77
+ return registry.components.get(normalizeDmsComponentName(name))?.component;
78
+ }
79
+
80
+ /**
81
+ * Records, by registered name, every registered async component a server
82
+ * render reaches, so the client can resolve the same ones before hydrating.
83
+ * Inertia re-renders the tree once on boot; an async component still loading
84
+ * at that moment makes Vue drop its server-rendered markup ("Skipping lazy
85
+ * hydration") and leave it empty until its chunk arrives.
86
+ */
87
+ export function trackDmsAsyncComponents(app: App): Set<string> {
88
+ const names = new Map<Component, string>();
89
+ registry.components.forEach(({ component, name }) => {
90
+ if ((component as DmsAsyncComponent).__asyncLoader)
91
+ names.set(component, name);
92
+ });
93
+ const rendered = new Set<string>();
94
+ app.mixin({
95
+ beforeCreate(this: ComponentPublicInstance) {
96
+ const name = names.get(this.$.type as Component);
97
+ if (name) rendered.add(name);
98
+ },
99
+ });
100
+ return rendered;
101
+ }
102
+
103
+ /**
104
+ * Records the scope of every component a server render creates, and returns
105
+ * the function that stops them once the render is over.
106
+ *
107
+ * A server app is never mounted, so it is never unmounted either: Vue leaves
108
+ * the scope of each component it rendered running, and nothing handed to
109
+ * `onScopeDispose` is ever released. Nuxt UI's `useRuntimeHook` registers in
110
+ * a process-wide hook registry and only unregisters there, so every server
111
+ * render of the dashboard sidebar stayed reachable, its whole component tree
112
+ * and page props with it, until the server ran out of heap.
113
+ *
114
+ * The component's scope is read through `getCurrentScope`: Vue makes it the
115
+ * active scope before it calls `beforeCreate`, and the instance's own `scope`
116
+ * field is internal, absent from the typings a generated workspace checks.
117
+ */
118
+ export function trackDmsServerScopes(app: App): () => void {
119
+ const scopes: EffectScope[] = [];
120
+ app.mixin({
121
+ beforeCreate() {
122
+ const scope = getCurrentScope();
123
+ if (scope) scopes.push(scope);
124
+ },
125
+ });
126
+ return () => {
127
+ for (const scope of scopes.splice(0)) scope.stop();
128
+ };
129
+ }
130
+
131
+ export async function resolveDmsAsyncComponents(
132
+ names: readonly string[],
133
+ ): Promise<void> {
134
+ await Promise.all(
135
+ names.map((name) =>
136
+ (
137
+ resolveDmsComponent(name) as DmsAsyncComponent | undefined
138
+ )?.__asyncLoader?.(),
139
+ ),
140
+ );
141
+ }
142
+ export async function preloadDmsComponents(names: string[]): Promise<void> {
143
+ const registry = useDmsRuntime().appContext?.vueApp._context.components ?? {};
144
+ await Promise.all(
145
+ names.map((name) =>
146
+ (registry[name] as DmsAsyncComponent | undefined)?.__asyncLoader?.(),
147
+ ),
148
+ );
149
+ }
150
+ export function normalizeDmsName(name: string): string {
151
+ return name
152
+ .replace(/^lazy/i, "")
153
+ .replace(/^dms[-_]?/i, "")
154
+ .replace(/[^a-z0-9]/gi, "")
155
+ .toLowerCase();
156
+ }
157
+
158
+ function collectRenderedComponentNames(
159
+ value: unknown,
160
+ names: Set<string>,
161
+ ): void {
162
+ if (!value || typeof value !== "object") return;
163
+ if (Array.isArray(value)) {
164
+ value.forEach((entry) => {
165
+ collectRenderedComponentNames(entry, names);
166
+ });
167
+ return;
168
+ }
169
+ const definition = value as Record<string, unknown>;
170
+ for (const key of ["component", "componentName"]) {
171
+ if (typeof definition[key] === "string") names.add(definition[key]);
172
+ }
173
+ // A child places a component definition: `{ id, component: { … } }`.
174
+ if (typeof definition.component === "object")
175
+ collectRenderedComponentNames(definition.component, names);
176
+ collectRenderedComponentNames(definition.children, names);
177
+ }
178
+
179
+ async function preloadDmsLayoutComponents(
180
+ layout: DmsPageLayout | undefined,
181
+ ): Promise<void> {
182
+ const names = new Set<string>();
183
+ collectRenderedComponentNames(layout?.layout, names);
184
+ Object.values(layout?.components ?? {}).forEach((component) => {
185
+ collectRenderedComponentNames(component, names);
186
+ });
187
+ await Promise.all(
188
+ [...names].map((name) => {
189
+ const component = resolveDmsComponent(name) as
190
+ | DmsAsyncComponent
191
+ | undefined;
192
+ return component?.__asyncLoader?.();
193
+ }),
194
+ );
195
+ }
196
+
197
+ export function normalizeDmsPageKey(name: string): string {
198
+ return name.split(/[?#]/, 1)[0].replace(/^\/+|\/+$/g, "");
199
+ }
200
+ const CATCH_ALL_PAGE_KEY = "[...slug]";
201
+
202
+ function findDmsPageEntry(props: DmsPageProps): DmsFrontendEntry | undefined {
203
+ const name = [
204
+ props.page.componentName,
205
+ props.page.route?.fullSlug,
206
+ props.path,
207
+ ].find(
208
+ (candidate) =>
209
+ candidate && registry.pages.has(normalizeDmsPageKey(candidate)),
210
+ );
211
+ return name
212
+ ? registry.pages.get(normalizeDmsPageKey(name))
213
+ : registry.dynamicPages.get(CATCH_ALL_PAGE_KEY);
214
+ }
215
+
216
+ /** The path a frontend module registered for a typed backend refusal. */
217
+ export function resolveDmsAccessRedirect(code: string): string | undefined {
218
+ return registry.accessRedirects.get(code);
219
+ }
220
+ export function hasDmsPage(name: string): boolean {
221
+ return registry.pages.has(normalizeDmsPageKey(name));
222
+ }
223
+ export function getDmsPage(props: DmsPageProps): Component | undefined {
224
+ if (props.error) return registry.errorPage?.component;
225
+ return findDmsPageEntry(props)?.component;
226
+ }
227
+ export function getDmsDynamicPage(name = "default"): Component | undefined {
228
+ return registry.dynamicPages.get(name)?.component;
229
+ }
230
+ export function getDmsLayout(props: DmsPageProps): Component | undefined {
231
+ const layout = props.page.layout;
232
+ const name = layout?.layout?.componentName ?? layout?.componentName;
233
+ return name
234
+ ? registry.layouts.get(normalizeDmsName(name))?.component
235
+ : undefined;
236
+ }
237
+ export function getDmsLayoutProps(
238
+ props: DmsPageProps,
239
+ ): Record<string, unknown> {
240
+ const definition = props.page.layout?.layout;
241
+ return {
242
+ ...(definition?.options ?? {}),
243
+ icon: props.page.route?.icon,
244
+ title: props.page.route?.displayName,
245
+ description: props.page.route?.description,
246
+ };
247
+ }
248
+
249
+ /**
250
+ * Whether a page carries an older session of the same account than the one
251
+ * the app holds: Inertia restores a page from history, or serves it from the
252
+ * prefetch cache, with the props it was fetched with, and those must not undo
253
+ * a later `useDmsUserSession().fetch()`.
254
+ */
255
+ function isStaleSession(
256
+ incoming: DmsSession | undefined,
257
+ current: DmsSession | null,
258
+ ): boolean {
259
+ return (
260
+ !!incoming &&
261
+ !!current &&
262
+ incoming.accountId === current.accountId &&
263
+ (incoming.updatedAt ?? 0) < (current.updatedAt ?? 0)
264
+ );
265
+ }
266
+
267
+ /**
268
+ * Copies the site layout of the page props into the state. A visit carries
269
+ * the layout it was sent over from page to page (a once prop): the same copy
270
+ * is applied once, so the state keeps what a later refresh wrote into it, and
271
+ * `dms-lastRefreshTime` tells when the layout was last received.
272
+ */
273
+ export function hydrateDmsSharedProps(
274
+ shared: DmsSharedPagePayload | undefined,
275
+ ): void {
276
+ const runtime = useDmsRuntime();
277
+ if (!shared || shared === runtime.appliedShared) return;
278
+ // A page from the history carries the layout it was shown with, which the
279
+ // state has replaced since: see `keepSharedOverHistory`.
280
+ if (runtime.restoringHistory) return;
281
+ runtime.appliedShared = shared;
282
+ useDmsState<unknown>("dms-siteLayout", () => undefined).value =
283
+ shared.siteLayout;
284
+ useDmsState<unknown>("dms-pageTree", () => undefined).value =
285
+ shared.siteLayoutTree;
286
+ useDmsState<unknown>("dms-quickActions", () => undefined).value =
287
+ shared.quickActions;
288
+ useDmsState<unknown>("dms-modules", () => undefined).value = shared.modules;
289
+ useDmsState("dms-isOwner", () => false).value = shared.isOwner ?? false;
290
+ useDmsState("dms-lastRefreshTime", () => 0).value = Date.now();
291
+ }
292
+
293
+ /**
294
+ * Keeps the newest site layout when Back or Forward puts a page back. The
295
+ * page carries the layout it was shown with, which a later reload replaced
296
+ * (a menu change the backend announced); it is not copied into the state, and
297
+ * the page is given the current one, so the next visit carries that over
298
+ * rather than the older one.
299
+ */
300
+ export function keepSharedOverHistory(): () => void {
301
+ const runtime = useDmsRuntime();
302
+ const onPopstate = (event: PopStateEvent) => {
303
+ // Inertia restores only the entries it wrote, which hold a page.
304
+ if ((event.state as { page?: unknown } | null)?.page)
305
+ runtime.restoringHistory = true;
306
+ };
307
+ window.addEventListener("popstate", onPopstate);
308
+ const stopListening = inertiaRouter.on("navigate", (event) => {
309
+ if (!runtime.restoringHistory) return;
310
+ runtime.restoringHistory = false;
311
+ const current = runtime.appliedShared;
312
+ const restored = (event.detail.page.props as Partial<DmsPageProps>).shared;
313
+ if (current && restored !== current)
314
+ inertiaRouter.replaceProp("shared", current);
315
+ });
316
+ return () => {
317
+ window.removeEventListener("popstate", onPopstate);
318
+ stopListening();
319
+ };
320
+ }
321
+
322
+ export function hydrateDmsPageProps(props: DmsPageProps, url?: string): void {
323
+ const runtime = useDmsRuntime();
324
+ if (url) updateRoute(runtime, url);
325
+ // Pages are keyed by path: another path mounts another page, which declares
326
+ // its own meta, and so does an error page shown in place of the page. A
327
+ // visit that only changes the query keeps the page, and so its meta.
328
+ const pageKey = props.error ? `error:${props.path}` : props.path;
329
+ if (pageKey !== runtime.pageKey) {
330
+ runtime.pageKey = pageKey;
331
+ runtime.route.meta = {};
332
+ }
333
+ dropUnusedAsyncData(runtime);
334
+ runtime.currentError.value = null;
335
+ const session = useDmsState<DmsSession | null>("dms-session", () => null);
336
+ if (!isStaleSession(props.session, session.value)) {
337
+ useDmsState<DmsUser | null>("dms-user", () => null).value =
338
+ props.user ?? null;
339
+ session.value = props.session ?? null;
340
+ }
341
+ hydrateDmsSharedProps(props.shared);
342
+ const layoutUrl = props.page.route?.layoutUrl ?? props.page.layoutUrl;
343
+ const pageLayouts = useDmsState<Record<string, DmsPageLayout>>(
344
+ "dms-pageLayouts",
345
+ () => ({}),
346
+ );
347
+ // Each navigation is a new authorization snapshot. Never retain layouts
348
+ // from an earlier account, tenant, or permission context.
349
+ pageLayouts.value =
350
+ layoutUrl && props.page.layout ? { [layoutUrl]: props.page.layout } : {};
351
+ }
352
+ /**
353
+ * Ends the hydration of the page the server rendered. The server's data no
354
+ * component took over is dropped: a later page must not show it as current.
355
+ */
356
+ export function endHydration(runtime: DmsFrontendRuntime): void {
357
+ runtime.hydrating = false;
358
+ runtime.hydratedAsyncData.clear();
359
+ runtime.hydratedAsyncErrors.clear();
360
+ }
361
+
362
+ export function getDmsErrorPage(): Component | undefined {
363
+ return registry.errorPage?.component;
364
+ }
365
+
366
+ export async function preloadDmsPage(props: DmsPageProps): Promise<void> {
367
+ const layoutName =
368
+ props.page.layout?.layout?.componentName ??
369
+ props.page.layout?.componentName;
370
+ const page = findDmsPageEntry(props) ?? registry.dynamicPages.get("default");
371
+ const layout = layoutName
372
+ ? registry.layouts.get(normalizeDmsName(layoutName))
373
+ : undefined;
374
+ const entries = props.error ? [registry.errorPage] : [page, layout];
375
+ // `preload` only warms the module; the async wrapper stays unresolved until
376
+ // its own loader runs. Hydrating an unresolved wrapper defers it, and the
377
+ // first parent render (Inertia's initial swap) then makes Vue drop the
378
+ // server-rendered markup and render the subtree from scratch.
379
+ await Promise.all(
380
+ entries.flatMap((entry) => [
381
+ entry?.preload?.(),
382
+ (entry?.component as DmsAsyncComponent | undefined)?.__asyncLoader?.(),
383
+ ]),
384
+ );
385
+ if (!props.error) await preloadDmsLayoutComponents(props.page.layout);
386
+ }
@@ -0,0 +1,125 @@
1
+ import { computed, type Ref, ref } from "vue";
2
+ import { type DmsCookieOptions } from "./types";
3
+ import { type DmsFrontendRuntime } from "./module-registration";
4
+ import { useDmsRuntime } from "./state";
5
+
6
+ const COOKIE_STATE_PREFIX = "dms-cookie:";
7
+ // A browser tab runs one application, so its cookie refs are shared by every
8
+ // caller; a server shares them within the request it renders instead.
9
+ const browserCookieRefs = new Map<string, Ref<unknown>>();
10
+
11
+ function readCookie(
12
+ source: string | undefined,
13
+ name: string,
14
+ ): string | undefined {
15
+ const match = source
16
+ ?.split(/;\s*/)
17
+ .find((entry) => entry.startsWith(`${name}=`));
18
+ return match ? decodeURIComponent(match.slice(name.length + 1)) : undefined;
19
+ }
20
+
21
+ function parseCookieValue<T>(
22
+ stored: string | undefined,
23
+ options: DmsCookieOptions<T>,
24
+ ): T {
25
+ if (stored === undefined) return (options.default?.() ?? null) as T;
26
+ try {
27
+ return JSON.parse(stored) as T;
28
+ } catch {
29
+ return stored as T;
30
+ }
31
+ }
32
+
33
+ export function writeCookie<T>(
34
+ name: string,
35
+ next: T,
36
+ options: DmsCookieOptions<T>,
37
+ ) {
38
+ const attributes = [
39
+ `path=${options.path ?? "/"}`,
40
+ options.maxAge === undefined ? "" : `max-age=${options.maxAge}`,
41
+ options.sameSite ? `samesite=${options.sameSite}` : "",
42
+ options.secure ? "secure" : "",
43
+ ].filter(Boolean);
44
+ // biome-ignore lint/suspicious/noDocumentCookie: reactive cookie refs require synchronous writes.
45
+ document.cookie = `${name}=${encodeURIComponent(JSON.stringify(next ?? null))}; ${attributes.join("; ")}`;
46
+ }
47
+
48
+ function createCookieRef<T>(
49
+ name: string,
50
+ source: string | undefined,
51
+ options: DmsCookieOptions<T>,
52
+ isServer: boolean,
53
+ ): Ref<T> {
54
+ const value = ref(
55
+ parseCookieValue(readCookie(source, name), options),
56
+ ) as Ref<T>;
57
+ return computed({
58
+ get: () => value.value,
59
+ set: (next) => {
60
+ value.value = next;
61
+ if (!isServer) writeCookie(name, next, options);
62
+ },
63
+ });
64
+ }
65
+
66
+ // The language a visitor picks, kept by the browser so that a reload renders
67
+ // in it. A signed-in user's own language still comes first.
68
+ export const LOCALE_COOKIE = "dms_locale";
69
+ export const LOCALE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
70
+
71
+ /**
72
+ * The language this browser picked: read from `document.cookie`, or in a
73
+ * server render from the request. Only one of `supportedLocales` counts.
74
+ */
75
+ export function readDmsLocaleCookie(
76
+ runtime: DmsFrontendRuntime,
77
+ supportedLocales: string[],
78
+ ): string | undefined {
79
+ const source =
80
+ typeof document === "undefined" ? runtime.requestCookies : document.cookie;
81
+ try {
82
+ const locale = parseCookieValue<unknown>(
83
+ readCookie(source, LOCALE_COOKIE),
84
+ {},
85
+ );
86
+ return typeof locale === "string" && supportedLocales.includes(locale)
87
+ ? locale
88
+ : undefined;
89
+ } catch {
90
+ // A value that is not URI-encoded: as if the browser had picked none.
91
+ return undefined;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * A cookie as a ref, shared by every caller of the same name — so a plugin and
97
+ * a page reading one preference see each other's writes — and read on the
98
+ * server from the request being rendered, so the server HTML matches the client.
99
+ */
100
+ export function useDmsCookie<T = string | null>(
101
+ name: string,
102
+ options: DmsCookieOptions<T> = {},
103
+ ): Ref<T> {
104
+ if (typeof document !== "undefined") {
105
+ if (!browserCookieRefs.has(name))
106
+ browserCookieRefs.set(
107
+ name,
108
+ createCookieRef(name, document.cookie, options, false) as Ref<unknown>,
109
+ );
110
+ return browserCookieRefs.get(name) as Ref<T>;
111
+ }
112
+ const runtime = useDmsRuntime();
113
+ const key = `${COOKIE_STATE_PREFIX}${name}`;
114
+ if (!runtime.sharedState.has(key))
115
+ runtime.sharedState.set(
116
+ key,
117
+ createCookieRef(
118
+ name,
119
+ runtime.requestCookies,
120
+ options,
121
+ true,
122
+ ) as Ref<unknown>,
123
+ );
124
+ return runtime.sharedState.get(key) as Ref<T>;
125
+ }
@@ -0,0 +1,132 @@
1
+ import {
2
+ computed,
3
+ getCurrentInstance,
4
+ type MaybeRefOrGetter,
5
+ onServerPrefetch,
6
+ toRaw,
7
+ toValue,
8
+ } from "vue";
9
+ import { type DmsAsyncDataKey, type DmsData } from "./types";
10
+ import { useDmsRuntime } from "./state";
11
+ import {
12
+ asyncDataEntries,
13
+ MAX_UNUSED_ASYNC_DATA,
14
+ scopedKey,
15
+ } from "./async-data-entry";
16
+ import { type DmsFrontendRuntime } from "./module-registration";
17
+
18
+ /**
19
+ * The data another caller loads under `key`, as Nuxt's `useNuxtData`: to
20
+ * update it in place without reading it again, from what a write answered
21
+ * (an optimistic update) or what a realtime event says changed. It follows
22
+ * the key in the runtime's scope (`setDmsAsyncDataScope`): `undefined` until
23
+ * a caller loads the key, its data once there, `undefined` again once the
24
+ * browser lets the key go (past the most recent keys it keeps) or the scope
25
+ * changes. It does not load the key, nor keep it.
26
+ */
27
+ export function useDmsData<T>(
28
+ key: MaybeRefOrGetter<DmsAsyncDataKey>,
29
+ ): DmsData<T> {
30
+ const runtime = useDmsRuntime();
31
+ const entry = () => {
32
+ const name = toValue(key);
33
+ const result =
34
+ name == null
35
+ ? undefined
36
+ : runtime.asyncData.get(scopedKey(runtime, String(name)));
37
+ return result && asyncDataEntries.get(result);
38
+ };
39
+ // A component after the caller, in the page or in its subtree, renders the
40
+ // data the browser hydrates it with: the server waits for the caller's run
41
+ // too. One before it has none on either side. Whether the key is there as
42
+ // the setup runs is the same on both sides, and so are the async
43
+ // boundaries `useId()` counts.
44
+ const loaded = entry();
45
+ if (loaded && getCurrentInstance()) onServerPrefetch(() => loaded.settled());
46
+ return {
47
+ data: computed({
48
+ get() {
49
+ // An entry only ever holds its own key's data: a caller showing the
50
+ // previous key's data meanwhile keeps it in its own refs.
51
+ return (entry()?.result.data.value ?? undefined) as T | undefined;
52
+ },
53
+ set(value) {
54
+ const current = entry();
55
+ if (!current) return;
56
+ current.result.data.value = value ?? null;
57
+ },
58
+ }),
59
+ };
60
+ }
61
+
62
+ /** Reads the data of the keys given, or of every key, again. */
63
+ export async function refreshDmsData(keys?: string | string[]): Promise<void> {
64
+ const runtime = useDmsRuntime();
65
+ const selected = keys
66
+ ? (Array.isArray(keys) ? keys : [keys]).map((key) =>
67
+ scopedKey(runtime, key),
68
+ )
69
+ : [...runtime.asyncData.keys()];
70
+ await Promise.all(
71
+ selected.map((key) => runtime.asyncData.get(key)?.refresh()),
72
+ );
73
+ }
74
+
75
+ /**
76
+ * Reads again the data the components on screen show, but those that opted
77
+ * out (`refreshOnPageRefresh: false`) and those waiting for `execute()`:
78
+ * what a page calls once something changed what it shows, after a save or
79
+ * on a realtime event, instead of each component refreshing its own.
80
+ */
81
+ export async function refreshDmsPageData(): Promise<void> {
82
+ const runtime = useDmsRuntime();
83
+ await Promise.all(
84
+ [...runtime.boundAsyncData].map((entry) => entry.pageRefresh()),
85
+ );
86
+ }
87
+
88
+ /**
89
+ * Puts the keys of the async data in a scope, such as a digest of the
90
+ * account and the workspace: the same key in another scope is another
91
+ * entry, never the data of the previous one. Moving to another scope drops
92
+ * the data of every key, aborting its runs; the components on screen then
93
+ * show their default until they load again. Call it on both sides before
94
+ * the page renders, with the same value, so the browser finds the data the
95
+ * server transferred; the scope is written in the page with each key, so a
96
+ * digest rather than an identifier. Call it from a plugin registered with
97
+ * `{ enforce: "pre" }`: a plugin of another module that reads data at setup
98
+ * would otherwise run first, and the new scope abort its read.
99
+ *
100
+ * @param scope The scope, or `undefined` for none
101
+ */
102
+ export function setDmsAsyncDataScope(scope: string | undefined): void {
103
+ const runtime = useDmsRuntime();
104
+ if (runtime.asyncDataScope === scope) return;
105
+ runtime.asyncDataScope = scope;
106
+ for (const result of runtime.asyncData.values()) result.clear();
107
+ runtime.asyncData.clear();
108
+ }
109
+
110
+ /**
111
+ * Drops the data no component shows any more, but the most recently shown:
112
+ * a return to a key shows its data at once, and the data does not pile up
113
+ * over a session.
114
+ */
115
+ export function dropUnusedAsyncData(runtime: DmsFrontendRuntime): void {
116
+ const unused = [...runtime.asyncData]
117
+ .map(([key, result]) => ({ key, entry: asyncDataEntries.get(result) }))
118
+ .filter(({ entry }) => entry?.unused())
119
+ .sort((left, right) => right.entry!.lastShown() - left.entry!.lastShown());
120
+ for (const { key } of unused.slice(MAX_UNUSED_ASYNC_DATA))
121
+ runtime.asyncData.delete(key);
122
+ }
123
+
124
+ export function serializeDmsAsyncData(
125
+ runtime: DmsFrontendRuntime,
126
+ ): Record<string, unknown> {
127
+ return Object.fromEntries(
128
+ [...runtime.asyncData.entries()]
129
+ .filter(([, entry]) => entry.status.value === "success")
130
+ .map(([key, entry]) => [key, toRaw(entry.data.value)]),
131
+ );
132
+ }
@@ -0,0 +1,11 @@
1
+ import { type DmsErrorData } from "./types";
2
+
3
+ export function createDmsError(
4
+ input: string | DmsErrorData,
5
+ ): Error & DmsErrorData {
6
+ const details = typeof input === "string" ? { message: input } : input;
7
+ return Object.assign(
8
+ new Error(details.message ?? details.statusMessage),
9
+ details,
10
+ );
11
+ }