@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
@@ -1,3564 +1,160 @@
1
- import {
2
- Link as InertiaLink,
3
- type InertiaLinkProps,
4
- router as inertiaRouter,
5
- usePage,
6
- } from "@inertiajs/vue3";
7
- import {
8
- useHead as useUnhead,
9
- useSeoMeta as useUnheadSeoMeta,
10
- } from "@unhead/vue";
11
- import { defu } from "defu";
12
- import { type FetchOptions, ofetch } from "ofetch";
13
- import {
14
- type App,
15
- type AppConfig,
16
- type Component,
17
- type ComponentInternalInstance,
18
- type ComponentPublicInstance,
19
- type ComputedRef,
20
- camelize,
21
- capitalize,
22
- computed,
23
- defineComponent,
24
- type EffectScope,
25
- effectScope,
26
- getCurrentInstance,
27
- getCurrentScope,
28
- h,
29
- hasInjectionContext,
30
- type InjectionKey,
31
- inject,
32
- isRef,
33
- type MaybeRefOrGetter,
34
- nextTick,
35
- onBeforeMount,
36
- onMounted,
37
- onScopeDispose,
38
- onServerPrefetch,
39
- type Plugin,
40
- type PropType,
41
- type Ref,
42
- reactive,
43
- readonly,
44
- ref,
45
- type ShallowRef,
46
- shallowReactive,
47
- shallowRef,
48
- toRaw,
49
- toValue,
50
- watch,
51
- } from "vue";
52
- import type { Composer } from "vue-i18n";
53
- import { useI18n as useVueI18n } from "vue-i18n";
54
- import {
55
- appConfigChanges,
56
- assignInPlace,
57
- mergeLayerAppConfigs,
58
- withAppConfigChanges,
59
- } from "./layer-app-config";
60
- import type { DmsPublicOptionsById } from "./public-options";
61
-
62
- export interface DmsPageRoute {
63
- displayName?: string;
64
- description?: string;
65
- fullSlug?: string;
66
- icon?: string;
67
- layoutUrl?: string;
68
- }
69
- export interface DmsPagePayload {
70
- route?: DmsPageRoute;
71
- componentName?: string;
72
- layoutUrl?: string;
73
- layout?: DmsPageLayout;
74
- [key: string]: unknown;
75
- }
76
- export interface DmsSharedPagePayload {
77
- siteLayout?: unknown;
78
- siteLayoutTree?: unknown;
79
- quickActions?: unknown;
80
- modules?: unknown;
81
- isOwner?: boolean;
82
- }
83
- export interface DmsLayoutDefinition {
84
- componentName?: string;
85
- options?: Record<string, unknown>;
86
- }
87
- export interface DmsPageLayout {
88
- componentName?: string;
89
- layout?: DmsLayoutDefinition;
90
- [key: string]: unknown;
91
- }
92
- export interface DmsPageProps {
93
- path: string;
94
- page: DmsPagePayload;
95
- /**
96
- * The site layout. An Inertia once prop: a visit carries it over from the
97
- * previous page until it expires, unless the server sends a newer one.
98
- */
99
- shared?: DmsSharedPagePayload;
100
- user?: DmsUser;
101
- session?: DmsSession;
102
- error?: DmsErrorData;
103
- }
104
- export interface PublicRuntimeConfig extends Record<string, unknown> {}
105
- export interface RuntimeConfig extends Record<string, unknown> {}
106
- export interface DmsAppConfig extends Record<string, unknown> {}
107
- export interface DmsUser extends Record<string, unknown> {}
108
- export interface DmsSession {
109
- accountId: string;
110
- activeTenantId?: string;
111
- /**
112
- * When the frontend server last wrote this session, in milliseconds since
113
- * the epoch. A page restored from history or from the prefetch cache
114
- * carries an older session than the one the app already holds, and does not
115
- * replace it.
116
- */
117
- updatedAt?: number;
118
- }
119
- export interface DmsRuntimeConfig extends RuntimeConfig {
120
- public: PublicRuntimeConfig;
121
- }
122
- export interface DmsModuleOptions {
123
- public: Record<string, unknown>;
124
- }
125
- export type DmsLocaleLoader = (
126
- locale: string,
127
- ) => Promise<Record<string, unknown>>;
128
- export type DmsNavigationOptions = Pick<
129
- InertiaLinkProps,
130
- | "async"
131
- | "component"
132
- | "data"
133
- | "except"
134
- | "headers"
135
- | "method"
136
- | "onBefore"
137
- | "onCancel"
138
- | "onCancelToken"
139
- | "onError"
140
- | "onFinish"
141
- | "onProgress"
142
- | "onStart"
143
- | "onSuccess"
144
- | "only"
145
- | "preserveScroll"
146
- | "preserveState"
147
- | "preserveUrl"
148
- | "queryStringArrayFormat"
149
- | "replace"
150
- | "viewTransition"
151
- >;
152
- export interface RouteLocationObject {
153
- path?: string;
154
- query?: Record<string, unknown>;
155
- hash?: string;
156
- }
157
- export type LocationQueryValue = string | null;
158
- export type LocationQuery = Record<
159
- string,
160
- LocationQueryValue | LocationQueryValue[]
161
- >;
162
- export type LocationQueryRaw = Record<
163
- string,
164
- | LocationQueryValue
165
- | number
166
- | undefined
167
- | (LocationQueryValue | number | undefined)[]
168
- >;
169
- export type RouteLocationRaw = string | RouteLocationObject;
170
- export interface DmsRoute {
171
- name?: string;
172
- fullPath: string;
173
- path: string;
174
- query: Record<string, string>;
175
- params: Record<string, string>;
176
- meta: Record<string, unknown>;
177
- matched: unknown[];
178
- }
179
- export interface DmsRouter {
180
- currentRoute: Ref<DmsRoute>;
181
- push(to: RouteLocationRaw, options?: DmsNavigationOptions): Promise<void>;
182
- replace(to: RouteLocationRaw, options?: DmsNavigationOptions): Promise<void>;
183
- back(): void;
184
- }
185
- export type DmsAsyncDataStatus = "idle" | "pending" | "success" | "error";
186
- /**
187
- * What a new run does with the one still in flight: `cancel` aborts it and
188
- * starts over, `defer` waits for it instead.
189
- */
190
- export type DmsAsyncDataDedupe = "cancel" | "defer";
191
- export interface DmsAsyncDataRefreshOptions {
192
- dedupe?: DmsAsyncDataDedupe;
193
- }
194
- export interface DmsAsyncData<T> {
195
- data: Ref<T | null>;
196
- /**
197
- * The key the data is loaded under, a reactive key's current one; none for
198
- * a call without a key.
199
- */
200
- key: Readonly<Ref<string | undefined>>;
201
- /**
202
- * `true` while `data` is still the previous key's, shown in place of the
203
- * default until the current key's first answer (a reactive key only).
204
- */
205
- placeholder: Readonly<Ref<boolean>>;
206
- /**
207
- * The last run's failure. It stays while the next run is in flight, and
208
- * goes back to `null` once a run answers.
209
- */
210
- error: Ref<unknown>;
211
- pending: Ref<boolean>;
212
- status: Ref<DmsAsyncDataStatus>;
213
- execute(options?: DmsAsyncDataRefreshOptions): Promise<void>;
214
- refresh(options?: DmsAsyncDataRefreshOptions): Promise<void>;
215
- /** Aborts the run in flight and puts the data back to its default. */
216
- clear(): void;
217
- }
218
- /** Usable at once, or awaited until its data is there. */
219
- export type DmsAsyncDataResult<T> = DmsAsyncData<T> & Promise<DmsAsyncData<T>>;
220
- export interface DmsAsyncDataContext {
221
- /**
222
- * Aborted once the result is no longer wanted: a newer run replaced it, it
223
- * was cleared, or no component shows its key any more.
224
- */
225
- signal: AbortSignal;
226
- }
227
- export type DmsAsyncDataHandler<T> = (
228
- context: DmsAsyncDataContext,
229
- ) => Promise<T>;
230
- /** A key, or `null` (or `undefined`) while there is nothing to load. */
231
- export type DmsAsyncDataKey = string | null | undefined;
232
- /** What `useDmsData` gives of a key's data. */
233
- export interface DmsData<T> {
234
- /**
235
- * The data loaded under the key, shared with every caller showing it:
236
- * `undefined` while the key has none of its own (no caller loaded it yet,
237
- * or it shows the previous key's data meanwhile). Assigning it replaces the
238
- * key's data; a key nobody loaded keeps none.
239
- */
240
- data: Ref<T | undefined>;
241
- }
242
- /** What `transform` knows of the run whose result it transforms. */
243
- export interface DmsAsyncDataTransformContext {
244
- /** The key the data is loaded under; none for a call without a key. */
245
- key: string | undefined;
246
- }
247
- /** What `useDmsFetch`'s `transform` knows of the request it answers. */
248
- export interface DmsFetchTransformContext extends DmsAsyncDataTransformContext {
249
- /**
250
- * The URL requested, as given (before the base URL and the query), even
251
- * when a reactive URL changed since.
252
- */
253
- url: string;
254
- }
255
- /**
256
- * The request options a ref or a getter may stand for: read again for each
257
- * request, and part of the key the response is shared under.
258
- */
259
- type DmsFetchRequestSources = "method" | "query" | "params" | "body";
260
- export interface UseAsyncDataOptions<T> {
261
- default?: () => T;
262
- /** `false` waits for `execute()` (a dialog, a drawer) before the first run. */
263
- immediate?: boolean;
264
- /**
265
- * A client navigation does not wait for the data. A server render still
266
- * does: its HTML shows the data, and the browser takes it over.
267
- */
268
- lazy?: boolean;
269
- /** `false` leaves the data to the browser: the server renders the default. */
270
- server?: boolean;
271
- watch?: false | unknown[];
272
- transform?: (
273
- value: unknown,
274
- context: DmsAsyncDataTransformContext,
275
- ) => T | Promise<T>;
276
- pick?: string[];
277
- /**
278
- * `true` tracks changes inside the value too. By default only the
279
- * replacement of the whole value triggers, as in Nuxt 4.
280
- */
281
- deep?: boolean;
282
- dedupe?: DmsAsyncDataDedupe;
283
- /**
284
- * `false` leaves the data out of `refreshDmsPageData()`, which reads again
285
- * the data of the components on screen once the page changed (a save, a
286
- * realtime event).
287
- */
288
- refreshOnPageRefresh?: boolean;
289
- /**
290
- * Reads again every this many milliseconds, in the browser, while the
291
- * caller shows the data and the page is visible. A ref or a getter changes
292
- * the interval as it changes; `0`, `null` or `undefined` stops the polling
293
- * until it has a value again. A call outside a component (a plugin, a
294
- * callback) polls for as long as the application runs.
295
- */
296
- refreshInterval?: MaybeRefOrGetter<number | null | undefined>;
297
- }
298
- /**
299
- * `useDmsFetch`'s options: ofetch's request options, of which the method, the
300
- * query, the params and the body may be refs or getters, and the async data
301
- * options.
302
- */
303
- export interface UseFetchOptions<T>
304
- extends
305
- Omit<FetchOptions, DmsFetchRequestSources>,
306
- Omit<UseAsyncDataOptions<T>, "transform"> {
307
- /**
308
- * The key the response is shared and transferred under, in place of the
309
- * one the request gives: its method, base URL, URL, query and body. A ref
310
- * or a getter moves the caller to the data of each key it takes; `null` or
311
- * `undefined` requests nothing until it has a value.
312
- */
313
- key?: MaybeRefOrGetter<DmsAsyncDataKey>;
314
- method?: MaybeRefOrGetter<FetchOptions["method"]>;
315
- query?: MaybeRefOrGetter<FetchOptions["query"]>;
316
- params?: MaybeRefOrGetter<FetchOptions["params"]>;
317
- body?: MaybeRefOrGetter<FetchOptions["body"]>;
318
- transform?: (
319
- value: unknown,
320
- context: DmsFetchTransformContext,
321
- ) => T | Promise<T>;
322
- /** Replaces the fetch function; it receives the run's signal. */
323
- $fetch?: typeof ofetch;
324
- }
325
- export interface DmsErrorData {
326
- statusCode?: number;
327
- statusMessage?: string;
328
- message?: string;
329
- data?: unknown;
330
- fatal?: boolean;
331
- }
332
- /** What a server render hands the browser along with its HTML. */
333
- export interface DmsPayload {
334
- data: Record<string, unknown>;
335
- errors: Record<string, DmsErrorData>;
336
- state: Record<string, unknown>;
337
- }
338
- export interface DmsStateOptions {
339
- /**
340
- * Hands the server's value to the browser, which then starts from it
341
- * instead of calling `init` again. Never for tokens or secrets: the value
342
- * is written into the page.
343
- */
344
- transfer?: boolean;
345
- }
346
- export interface DmsUserSession<TUser = DmsUser, TSession = DmsSession> {
347
- user: Ref<TUser | null>;
348
- session: Ref<TSession | null>;
349
- loggedIn: Ref<boolean>;
350
- fetch(): Promise<void>;
351
- clear(): Promise<void>;
352
- }
353
- export interface DmsCookieOptions<T> {
354
- default?: () => T;
355
- maxAge?: number;
356
- path?: string;
357
- sameSite?: "strict" | "lax" | "none";
358
- secure?: boolean;
359
- }
360
- type DmsLinkPrefetchMode = "mount" | "hover" | "click";
361
- type DmsLinkPrefetch = boolean | DmsLinkPrefetchMode | DmsLinkPrefetchMode[];
362
- export interface DmsAppContext {
363
- provide<T>(key: string, value: T): void;
364
- vueApp: App;
365
- runWithContext<T>(callback: () => T): T;
366
- hook(name: string, callback: () => void | Promise<void>): void;
367
- $i18n: DmsI18n;
368
- }
369
- export interface DmsLocale {
370
- code: string;
371
- name?: string;
372
- }
373
- export type DmsI18n = Composer & {
374
- locales: ComputedRef<DmsLocale[]>;
375
- setLocale(locale: string): Promise<void>;
376
- };
377
- export type DmsPluginSetup = (context: DmsAppContext) => void | Promise<void>;
378
- export interface DmsMiddlewareRegistrationOptions {
379
- global?: boolean;
380
- /**
381
- * Runs at the same time as the parallel middlewares next to it, for one
382
- * that reads nothing another middleware sets (a check against the backend,
383
- * say). Their results still apply in registration order: the first that
384
- * redirects or aborts wins, and the next middlewares wait for all of them.
385
- */
386
- parallel?: boolean;
387
- }
388
- export interface DmsPluginRegistrationOptions {
389
- clientOnly?: boolean;
390
- /**
391
- * When the plugin runs, as Nuxt's `enforce`: every module's `"pre"`
392
- * plugins first, then the `"default"` ones, then the `"post"` ones, each
393
- * group in the modules' priority order. A plugin that the others depend on
394
- * whatever their module's priority, such as the one setting the async data
395
- * scope (`setDmsAsyncDataScope`), is `"pre"`.
396
- */
397
- enforce?: "pre" | "default" | "post";
398
- }
399
- export type DmsComponentPreloader = () => Promise<unknown>;
400
- export interface DmsComponentRegistrationOptions {
401
- /**
402
- * Registers the component under `name` as given, without the module's
403
- * `componentPrefix`: the way to replace a component another module
404
- * registers, from a module of higher priority. Defaults to `true`.
405
- */
406
- prefix?: boolean;
407
- }
408
- export interface DmsFrontendSdk {
409
- options: DmsModuleOptions;
410
- registerComponent(
411
- name: string,
412
- component: Component,
413
- options?: DmsComponentRegistrationOptions,
414
- ): void;
415
- registerPage(
416
- name: string,
417
- component: Component,
418
- preload?: DmsComponentPreloader,
419
- ): void;
420
- registerDynamicPage(
421
- name: string,
422
- component: Component,
423
- preload?: DmsComponentPreloader,
424
- ): void;
425
- registerLayout(
426
- name: string,
427
- component: Component,
428
- preload?: DmsComponentPreloader,
429
- ): void;
430
- registerErrorPage(
431
- component: Component,
432
- preload?: DmsComponentPreloader,
433
- ): void;
434
- registerPlugin(
435
- setup: DmsPluginSetup,
436
- options?: DmsPluginRegistrationOptions,
437
- ): void;
438
- registerMiddleware(
439
- name: string,
440
- handler: DmsMiddleware,
441
- options?: DmsMiddlewareRegistrationOptions,
442
- ): void;
443
- /**
444
- * Sends a page visit the backend refuses with a typed 403 (its body is
445
- * `code`) to `path` instead of the error page. The server answers with the
446
- * redirect before anything renders, so a reload lands on `path` directly.
447
- */
448
- registerAccessRedirect(code: string, path: string): void;
449
- provide<T>(key: string | InjectionKey<T>, value: T): void;
450
- use(plugin: Plugin): void;
451
- }
452
- export interface DmsFrontendModule {
453
- /**
454
- * Put in front of the name of every component the module registers, so
455
- * the components of two modules never collide: with `"Acme"`,
456
- * `registerComponent("Banner", ...)` registers `AcmeBanner`, the name the
457
- * backend sends and templates use.
458
- */
459
- componentPrefix?: string;
460
- setup(sdk: DmsFrontendSdk): void | Promise<void>;
461
- }
462
- export interface DmsFrontendModuleRegistration {
463
- /** The module's id in the generated module registry. */
464
- id?: string;
465
- /** The module's manifest name, which collision warnings name it by. */
466
- name?: string;
467
- module: DmsFrontendModule;
468
- options: DmsModuleOptions;
469
- }
470
-
471
- /**
472
- * The registrations with the public options the server runs with in place of
473
- * the build's: `ajs dms start` computes them from the project's configuration
474
- * and the deployment's variables. Unset, the build's options stand.
475
- */
476
- export function withPublicOptions(
477
- registrations: DmsFrontendModuleRegistration[],
478
- byId: DmsPublicOptionsById | undefined,
479
- ): DmsFrontendModuleRegistration[] {
480
- if (!byId) return registrations;
481
- return registrations.map((registration) => {
482
- const options = registration.id ? byId[registration.id] : undefined;
483
- return options
484
- ? {
485
- ...registration,
486
- options: { ...registration.options, public: options },
487
- }
488
- : registration;
489
- });
490
- }
491
- /** A layer's `app/app.config.ts`, as `app-configs.generated.ts` lists them. */
492
- export interface DmsLayerAppConfig {
493
- /** The id of the frontend module the layer belongs to. */
494
- module: string;
495
- config: Record<string, unknown>;
496
- }
497
- // Async middleware without a redirect naturally infers Promise<void>.
498
- // biome-ignore lint/suspicious/noConfusingVoidType: preserve that valid handler signature.
499
- export type DmsMiddlewareResult = void | false | RouteLocationRaw;
500
- export type DmsMiddleware = (
501
- to: DmsRoute,
502
- from: DmsRoute,
503
- ) => DmsMiddlewareResult | Promise<DmsMiddlewareResult>;
504
- export type DmsRuntimeHook = (...args: unknown[]) => void | Promise<void>;
505
- export interface DmsRuntimeHooks {
506
- hook(name: string, callback: DmsRuntimeHook): () => void;
507
- callHook(name: string, ...args: unknown[]): Promise<void>;
508
- }
509
-
510
- interface DmsComponentRegistration {
511
- name: string;
512
- component: Component;
513
- /** The registering module, for a collision warning. */
514
- module?: string;
515
- /** Registered without the module's prefix, to replace another module's. */
516
- unprefixed?: boolean;
517
- }
518
-
519
- interface DmsFrontendEntry {
520
- component: Component;
521
- preload?: DmsComponentPreloader;
522
- }
523
-
524
- interface DmsAsyncComponent {
525
- __asyncLoader?: DmsComponentPreloader;
526
- }
527
-
528
- interface DmsPluginRegistration {
529
- setup: DmsPluginSetup;
530
- clientOnly: boolean;
531
- enforce: NonNullable<DmsPluginRegistrationOptions["enforce"]>;
532
- }
533
-
534
- const PLUGIN_ORDER = { pre: 0, default: 1, post: 2 } as const;
535
-
536
- /**
537
- * What the frontend modules registered through their SDK. Modules are set up
538
- * by descending priority, so the first one to claim a name keeps it.
539
- */
540
- interface DmsFrontendRegistry {
541
- components: Map<string, DmsComponentRegistration>;
542
- pages: Map<string, DmsFrontendEntry>;
543
- dynamicPages: Map<string, DmsFrontendEntry>;
544
- layouts: Map<string, DmsFrontendEntry>;
545
- errorPage?: DmsFrontendEntry;
546
- plugins: Plugin[];
547
- pluginSetups: DmsPluginRegistration[];
548
- middleware: DmsMiddleware[];
549
- namedMiddleware: Map<string, DmsMiddleware>;
550
- /** The middlewares registered with `parallel: true`. */
551
- parallelMiddleware: WeakSet<DmsMiddleware>;
552
- accessRedirects: Map<string, string>;
553
- injections: Map<string | symbol, unknown>;
554
- }
555
-
556
- export interface DmsFrontendRuntime {
557
- sharedState: Map<string, Ref<unknown>>;
558
- /** Reactive: `useDmsData` follows a key as it appears and is dropped. */
559
- asyncData: Map<string, DmsAsyncData<unknown>>;
560
- /** The server's data, which the browser takes over once per key. */
561
- hydratedAsyncData: Map<string, unknown>;
562
- /** The server's failures, which the browser takes over once per key. */
563
- hydratedAsyncErrors: Map<string, DmsErrorData>;
564
- /** The state keys a server render hands to the browser. */
565
- transferredState: Set<string>;
566
- /**
567
- * Whether the browser is still hydrating the page the server rendered,
568
- * whose first render must match the server's HTML.
569
- */
570
- hydrating: boolean;
571
- /**
572
- * The runs a server render started outside any component, which no
573
- * `onServerPrefetch` waits for.
574
- */
575
- serverPrefetches: Set<Promise<void>>;
576
- /** The runs in flight, which a server render aborts once it is over. */
577
- asyncDataRuns: Set<AbortController>;
578
- /** The data some component or caller shows: `refreshDmsPageData`'s. */
579
- boundAsyncData: Set<{ pageRefresh(): Promise<void> | undefined }>;
580
- /** Orders the data no caller shows, the least recently shown first. */
581
- asyncDataClock: number;
582
- /**
583
- * The account and workspace the keys belong to (`setDmsAsyncDataScope`):
584
- * the same key of another scope is another entry.
585
- */
586
- asyncDataScope?: string;
587
- /**
588
- * Runs an async data handler; a server render marks the requests it makes
589
- * as transferred (see `inAsyncDataHandler`).
590
- */
591
- runAsyncDataHandler?: <R>(run: () => R) => R;
592
- /** Whether the code running is an async data handler's. */
593
- inAsyncDataHandler?: () => boolean;
594
- /** Whether to warn about what a development render does wrong. */
595
- devWarnings?: boolean;
596
- runtimeHooks: Map<string, DmsRuntimeHook[]>;
597
- serverFetch?: typeof ofetch;
598
- currentError: Ref<Error | null>;
599
- route: DmsRoute;
600
- currentRoutePattern?: string;
601
- appContext?: DmsAppContext;
602
- isServer: boolean;
603
- serverRedirect?: string;
604
- pendingNavigation?: Promise<void>;
605
- hasNavigationListener: boolean;
606
- /** Whether the page `<Suspense>` waits on the page being navigated to. */
607
- pageLoading: Ref<boolean>;
608
- /** The site layout last copied into the state, as the page props hold it. */
609
- appliedShared?: DmsSharedPagePayload;
610
- /** Whether Inertia is putting back a page from the browser history. */
611
- restoringHistory?: boolean;
612
- /**
613
- * Identifies the page on screen (its path, or the error page shown at that
614
- * path), whose meta `route.meta` holds.
615
- */
616
- pageKey?: string;
617
- /** The `Cookie` header of the request a server runtime renders. */
618
- requestCookies?: string;
619
- /**
620
- * Owns the effects plugins create while they set up. A server render stops
621
- * it once done: nothing else would, since a server app is never unmounted.
622
- */
623
- scope: EffectScope;
624
- /** The Inertia page a server runtime renders: see `useDmsInertiaPage`. */
625
- inertiaPage?: DmsInertiaPage;
626
- }
627
-
628
- /** Inertia's page, as `usePage()` returns it. */
629
- export type DmsInertiaPage = ReturnType<typeof usePage>;
630
-
631
- /** Creates the empty registry one setup of the frontend modules fills. */
632
- function createDmsFrontendRegistry(): DmsFrontendRegistry {
633
- return {
634
- components: new Map(),
635
- pages: new Map(),
636
- dynamicPages: new Map(),
637
- layouts: new Map(),
638
- plugins: [],
639
- pluginSetups: [],
640
- middleware: [],
641
- namedMiddleware: new Map(),
642
- parallelMiddleware: new WeakSet(),
643
- accessRedirects: new Map(),
644
- injections: new Map(),
645
- };
646
- }
647
-
648
- // Replaced, never refilled, by each setup of the modules: see
649
- // `setupFrontendModules`.
650
- let registry = createDmsFrontendRegistry();
651
- const runtimeConfig = ref<DmsRuntimeConfig>({
652
- public: {},
653
- } as DmsRuntimeConfig);
654
- const appConfig = ref<Record<string, unknown>>({});
655
- // What the last setup of the modules changed in the layers' app configs, which
656
- // a development update of those configs keeps: see `updateDmsAppConfig`.
657
- let appConfigSetupChanges: Record<string, unknown> = {};
658
- const DMS_RUNTIME_KEY: InjectionKey<DmsFrontendRuntime> = Symbol("dms-runtime");
659
- type DmsRuntimeContext = DmsAppContext["runWithContext"];
660
- const runtimeContexts = new WeakMap<DmsFrontendRuntime, DmsRuntimeContext>();
661
- let serverRuntimeResolver: (() => DmsFrontendRuntime | undefined) | undefined;
662
- let browserRuntime: DmsFrontendRuntime | undefined;
663
-
664
- /** Creates mutable runtime state owned by one Vue application. */
665
- export function createDmsFrontendRuntime(
666
- serverFetch?: typeof ofetch,
667
- hydratedAsyncData?: Record<string, unknown>,
668
- isServer = false,
669
- requestCookies?: string,
670
- ): DmsFrontendRuntime {
671
- const runtime: DmsFrontendRuntime = {
672
- requestCookies,
673
- sharedState: new Map(),
674
- asyncData: shallowReactive(new Map()),
675
- hydratedAsyncData: new Map(Object.entries(hydratedAsyncData ?? {})),
676
- hydratedAsyncErrors: new Map(),
677
- transferredState: new Set(),
678
- // A browser handed the server's data, even none, hydrates its HTML.
679
- hydrating: !isServer && hydratedAsyncData !== undefined,
680
- serverPrefetches: new Set(),
681
- asyncDataRuns: new Set(),
682
- boundAsyncData: new Set(),
683
- asyncDataClock: 0,
684
- runtimeHooks: new Map(),
685
- serverFetch,
686
- isServer,
687
- currentError: ref<Error | null>(null),
688
- route: reactive<DmsRoute>({
689
- fullPath: "/",
690
- path: "/",
691
- query: {},
692
- params: {},
693
- meta: {},
694
- matched: [],
695
- }),
696
- hasNavigationListener: false,
697
- pageLoading: ref(false),
698
- scope: effectScope(true),
699
- };
700
- // A server render stops its scope once it has answered: whatever it still
701
- // fetches then would reach nobody.
702
- runtime.scope.run(() =>
703
- onScopeDispose(() => {
704
- for (const run of runtime.asyncDataRuns) run.abort();
705
- }),
706
- );
707
- return runtime;
708
- }
709
-
710
- /** Makes a runtime available before application plugins are installed. */
711
- export function provideDmsFrontendRuntime(
712
- app: App,
713
- runtime: DmsFrontendRuntime,
714
- ): void {
715
- app.provide(DMS_RUNTIME_KEY, runtime);
716
- runtimeContexts.set(runtime, (callback) => app.runWithContext(callback));
717
- if (typeof window !== "undefined") browserRuntime = runtime;
718
- }
719
-
720
- /** Registers the request-local runtime resolver used by the SSR entry point. */
721
- export function setDmsServerRuntimeResolver(
722
- resolver: () => DmsFrontendRuntime | undefined,
723
- ): void {
724
- serverRuntimeResolver = resolver;
725
- }
726
-
727
- function useDmsRuntime(): DmsFrontendRuntime {
728
- const instance = getCurrentInstance();
729
- const provided = instance?.appContext.provides[DMS_RUNTIME_KEY as symbol] as
730
- | DmsFrontendRuntime
731
- | undefined;
732
- if (provided) return provided;
733
- const contextual = hasInjectionContext()
734
- ? inject(DMS_RUNTIME_KEY, undefined)
735
- : undefined;
736
- if (contextual) return contextual;
737
- if (typeof window !== "undefined" && browserRuntime) return browserRuntime;
738
- const serverRuntime = serverRuntimeResolver?.();
739
- if (serverRuntime) return serverRuntime;
740
- throw new Error("DMS runtime is unavailable outside a Vue application");
741
- }
742
-
743
- /** Returns lifecycle hooks isolated to the current application runtime. */
744
- export function useDmsRuntimeHooks(): DmsRuntimeHooks {
745
- const runtime = useDmsRuntime();
746
- return {
747
- hook(name, callback) {
748
- runtime.runtimeHooks.set(name, [
749
- ...(runtime.runtimeHooks.get(name) ?? []),
750
- callback,
751
- ]);
752
- return () =>
753
- runtime.runtimeHooks.set(
754
- name,
755
- (runtime.runtimeHooks.get(name) ?? []).filter(
756
- (entry) => entry !== callback,
757
- ),
758
- );
759
- },
760
- async callHook(name, ...args) {
761
- for (const callback of runtime.runtimeHooks.get(name) ?? [])
762
- await callback(...args);
763
- },
764
- };
765
- }
766
-
767
- function extractRouteParams(
768
- pattern: string | undefined,
769
- path: string,
770
- ): Record<string, string> {
771
- if (!pattern) return {};
772
- const patternSegments = pattern.split("/").filter(Boolean);
773
- const pathSegments = path.split("/").filter(Boolean);
774
- if (patternSegments.length !== pathSegments.length) return {};
775
- const params: Record<string, string> = {};
776
- const matches = patternSegments.every((segment, index) => {
777
- if (!segment.startsWith(":")) return segment === pathSegments[index];
778
- params[segment.slice(1)] = decodeURIComponent(pathSegments[index] ?? "");
779
- return true;
780
- });
781
- return matches ? params : {};
782
- }
783
-
784
- function parseRoute(runtime: DmsFrontendRuntime, url: string): DmsRoute {
785
- const parsed = new URL(
786
- url,
787
- typeof window === "undefined"
788
- ? "http://frontend.local"
789
- : window.location.origin,
790
- );
791
- return {
792
- name: parsed.pathname,
793
- fullPath: `${parsed.pathname}${parsed.search}${parsed.hash}`,
794
- path: parsed.pathname,
795
- query: Object.fromEntries(parsed.searchParams),
796
- params: extractRouteParams(runtime.currentRoutePattern, parsed.pathname),
797
- meta: {},
798
- matched: [{}],
799
- };
800
- }
801
-
802
- function hasSameEntries(
803
- left: Record<string, string>,
804
- right: Record<string, string>,
805
- ): boolean {
806
- const keys = Object.keys(left);
807
- return (
808
- keys.length === Object.keys(right).length &&
809
- keys.every((key) => right[key] === left[key])
810
- );
811
- }
812
-
813
- // Every parse builds a new query, params and matched: assigning them as they
814
- // come would re-run each watcher of the route on every call of useDmsRoute,
815
- // though the URL did not change. They are only replaced when their content did.
816
- function updateRoute(runtime: DmsFrontendRuntime, url: string): void {
817
- // The meta is the page's, not the URL's: a page declares it, then reads its
818
- // route, and the middleware the meta selected must still find it.
819
- const {
820
- meta: _pageMeta,
821
- query,
822
- params,
823
- matched,
824
- ...location
825
- } = parseRoute(runtime, url);
826
- const route = runtime.route;
827
- if (!hasSameEntries(query, route.query)) route.query = query;
828
- if (!hasSameEntries(params, route.params)) route.params = params;
829
- if (matched.length !== route.matched.length) route.matched = matched;
830
- Object.assign(route, location);
831
- }
832
-
833
- const URL_BASE = "http://frontend.local";
834
-
835
- // The URL the browser shows: it may be ahead of the route, which follows
836
- // Inertia's page, when a component wrote it in place (history.replaceState).
837
- function currentUrl(runtime: DmsFrontendRuntime): string {
838
- if (runtime.isServer || typeof window === "undefined")
839
- return new URL(runtime.route.fullPath, URL_BASE).href;
840
- return window.location.href;
841
- }
842
-
843
- // One spelling per URL: `a b`, `a+b` and `a%20b` are the same query, as are
844
- // `:` and `%3A`. The origin is kept, so a URL of another site never matches
845
- // the current one, whatever its path.
846
- function normalizeUrl(url: string, base: string): string {
847
- const parsed = new URL(url, base);
848
- const search = parsed.searchParams.toString();
849
- return `${parsed.origin}${parsed.pathname}${search ? `?${search}` : ""}${parsed.hash}`;
850
- }
851
-
852
- function locationToUrl(
853
- runtime: DmsFrontendRuntime,
854
- to: RouteLocationRaw,
855
- ): string {
856
- if (typeof to === "string") return to;
857
- const origin =
858
- typeof window === "undefined"
859
- ? "http://frontend.local"
860
- : window.location.origin;
861
- const parsed = new URL(to.path ?? runtime.route.path, origin);
862
- if (to.query !== undefined) {
863
- parsed.search = Object.entries(to.query)
864
- .flatMap(([key, value]) =>
865
- (Array.isArray(value) ? value : [value])
866
- .filter((item) => item !== undefined)
867
- .map((item) =>
868
- item === null
869
- ? encodeURIComponent(key)
870
- : `${encodeURIComponent(key)}=${encodeURIComponent(String(item))}`,
871
- ),
872
- )
873
- .join("&");
874
- }
875
- parsed.hash = to.hash ?? "";
876
- return `${parsed.pathname}${parsed.search}${parsed.hash}`;
877
- }
878
-
879
- async function runMiddleware(
880
- runtime: DmsFrontendRuntime,
881
- to: DmsRoute,
882
- ): Promise<DmsMiddlewareResult> {
883
- const declared = Array.isArray(to.meta.middleware) ? to.meta.middleware : [];
884
- const metadataNames = Object.keys(to.meta).filter((name) => to.meta[name]);
885
- const declaredHandlers = [...declared, ...metadataNames]
886
- .map((name) => registry.namedMiddleware.get(String(name)))
887
- .filter((handler): handler is DmsMiddleware => !!handler);
888
- const run = (handler: DmsMiddleware) => {
889
- const runWithContext = runtimeContexts.get(runtime);
890
- return runWithContext
891
- ? runWithContext(() => handler(to, runtime.route))
892
- : handler(to, runtime.route);
893
- };
894
- for (const group of middlewareGroups([
895
- ...registry.middleware,
896
- ...declaredHandlers,
897
- ])) {
898
- // A group of parallel middlewares starts together; its results apply in
899
- // order, as a sequence's would.
900
- const results = await Promise.all(group.map(run));
901
- const result = results.find((value) => value !== undefined);
902
- if (result !== undefined) return result;
903
- }
904
- }
905
-
906
- /** The middlewares in order, each run of parallel ones as one group. */
907
- function middlewareGroups(handlers: DmsMiddleware[]): DmsMiddleware[][] {
908
- const groups: DmsMiddleware[][] = [];
909
- for (const handler of handlers) {
910
- const last = groups.at(-1);
911
- const parallel = registry.parallelMiddleware.has(handler);
912
- if (parallel && last && registry.parallelMiddleware.has(last[0]))
913
- last.push(handler);
914
- else groups.push([handler]);
915
- }
916
- return groups;
917
- }
918
-
919
- // What only a request to the server can honour: a client-side visit sends
920
- // none and has none of these events.
921
- const SERVER_VISIT_OPTIONS = [
922
- "async",
923
- "component",
924
- "data",
925
- "except",
926
- "headers",
927
- "method",
928
- "onBefore",
929
- "onCancel",
930
- "onCancelToken",
931
- "onProgress",
932
- "onStart",
933
- "only",
934
- "preserveUrl",
935
- "queryStringArrayFormat",
936
- ] as const satisfies readonly (keyof DmsNavigationOptions)[];
937
-
938
- /**
939
- * Whether a visit to another query or hash of the page on screen can stay in
940
- * the browser. The page props depend on the path alone: the server would
941
- * answer with the same page, the site layout included. An error page is left
942
- * through the server, which renders the page in its place, and so is the
943
- * same path on another site.
944
- */
945
- function staysOnPage(
946
- runtime: DmsFrontendRuntime,
947
- url: string,
948
- options: DmsNavigationOptions,
949
- ): boolean {
950
- return (
951
- new URL(url, currentUrl(runtime)).origin ===
952
- new URL(currentUrl(runtime)).origin &&
953
- !runtime.pageKey?.startsWith("error:") &&
954
- runtime.currentError.value === null &&
955
- SERVER_VISIT_OPTIONS.every((name) => options[name] === undefined)
956
- );
957
- }
958
-
959
- /**
960
- * An Inertia client-side visit: the URL and the history entry change, the
961
- * page keeps its props, and no request is made.
962
- */
963
- function visitInBrowser(
964
- url: string,
965
- options: DmsNavigationOptions,
966
- ): Promise<void> {
967
- const clientVisit = options.replace
968
- ? inertiaRouter.replace
969
- : inertiaRouter.push;
970
- return new Promise<void>((resolve) => {
971
- clientVisit.call(inertiaRouter, {
972
- url,
973
- preserveScroll: options.preserveScroll ?? true,
974
- preserveState: options.preserveState ?? true,
975
- viewTransition: options.viewTransition,
976
- onSuccess: options.onSuccess,
977
- onError: options.onError,
978
- onFinish: (visit) => {
979
- (options.onFinish as ((visit: unknown) => void) | undefined)?.(visit);
980
- resolve();
981
- },
982
- } as Parameters<typeof inertiaRouter.push>[0]);
983
- });
984
- }
985
-
986
- async function visit(
987
- runtime: DmsFrontendRuntime,
988
- to: RouteLocationRaw,
989
- options: DmsNavigationOptions = {},
990
- ): Promise<void> {
991
- const url = locationToUrl(runtime, to);
992
- const target = parseRoute(runtime, url);
993
- const result = await runMiddleware(runtime, target);
994
- if (result === false) return;
995
- if (result !== undefined) return visit(runtime, result, options);
996
- if (runtime.isServer) {
997
- runtime.serverRedirect = url;
998
- return;
999
- }
1000
- // Like Vue Router, a navigation that only changes the query or hash keeps
1001
- // the page mounted: without preserveState, Inertia remounts the page
1002
- // component on every visit and its local state (open tabs, selection,
1003
- // inputs) is lost.
1004
- const samePath = target.path === runtime.route.path;
1005
- if (samePath && staysOnPage(runtime, url, options)) {
1006
- await visitInBrowser(url, options);
1007
- return;
1008
- }
1009
- await new Promise<void>((resolve) =>
1010
- inertiaRouter.visit(url, {
1011
- ...options,
1012
- preserveState: options.preserveState ?? samePath,
1013
- preserveScroll: options.preserveScroll ?? samePath,
1014
- onFinish: (completedVisit) => {
1015
- options.onFinish?.(completedVisit);
1016
- resolve();
1017
- },
1018
- }),
1019
- );
1020
- }
1021
-
1022
- export function useDmsRoute(pattern?: string): DmsRoute {
1023
- const runtime = useDmsRuntime();
1024
- runtime.currentRoutePattern = pattern;
1025
- // A server render answers one request, whose URL the runtime was given with
1026
- // its page, and never changes it. Inertia's page is one ref for the whole
1027
- // process, written by each render as it starts: after an await it may hold
1028
- // another request's URL, and while the plugins set up, the previous one's.
1029
- if (runtime.isServer) {
1030
- updateRoute(runtime, runtime.route.fullPath);
1031
- return runtime.route;
1032
- }
1033
- const page = usePage();
1034
- // The browser's plugins set up before Inertia has a page: until then the
1035
- // runtime's route is the initial page's.
1036
- if (page.url !== undefined) updateRoute(runtime, page.url);
1037
- watch(
1038
- () => page.url,
1039
- (url) => updateRoute(runtime, url),
1040
- );
1041
- return runtime.route;
1042
- }
1043
-
1044
- /**
1045
- * Inertia's page, as `usePage()` returns it, for the request being rendered.
1046
- * Inertia keeps a single page for the whole process, written by each server
1047
- * render as it starts: read after an await, it may be another request's. Nuxt
1048
- * UI's Link and route stub read this one instead (see `vite.config.ts`).
1049
- */
1050
- export function useDmsInertiaPage(): DmsInertiaPage {
1051
- const runtime = useDmsRuntime();
1052
- if (runtime.isServer && runtime.inertiaPage) return runtime.inertiaPage;
1053
- return usePage();
1054
- }
1055
-
1056
- export function useDmsRouter(): DmsRouter {
1057
- const runtime = useDmsRuntime();
1058
- const navigate = (to: RouteLocationRaw, options?: DmsNavigationOptions) => {
1059
- const current = currentUrl(runtime);
1060
- return normalizeUrl(locationToUrl(runtime, to), current) ===
1061
- normalizeUrl(current, current)
1062
- ? Promise.resolve()
1063
- : visit(runtime, to, options);
1064
- };
1065
- return {
1066
- currentRoute: computed(() => runtime.route),
1067
- push: navigate,
1068
- replace: (to, options) => navigate(to, { ...options, replace: true }),
1069
- back: () => window.history.back(),
1070
- };
1071
- }
1072
-
1073
- export function navigateDms(
1074
- to: RouteLocationRaw,
1075
- options?: DmsNavigationOptions,
1076
- ): Promise<void> {
1077
- return visit(useDmsRuntime(), to, options);
1078
- }
1079
-
1080
- /**
1081
- * The options of a request a runtime makes. A server render does not retry a
1082
- * failed read, as ofetch would once: the page waits for it, and a backend
1083
- * failing once mostly fails twice. A browser keeps ofetch's retries, and a
1084
- * caller that names its own keeps them everywhere.
1085
- */
1086
- function runtimeFetchOptions(
1087
- runtime: DmsFrontendRuntime,
1088
- options: FetchOptions,
1089
- ): FetchOptions {
1090
- return runtime.isServer && options.retry === undefined
1091
- ? { ...options, retry: 0 }
1092
- : options;
1093
- }
1094
-
1095
- const warnedServerReads = new Set<string>();
1096
-
1097
- /**
1098
- * Warns, in a development server render, about a backend read no async data
1099
- * handler makes: an immediate watcher's, a setup's own `await $fetch`, a
1100
- * middleware's. The server waits for it, but the page carries none of it, so
1101
- * the browser reads it again and may hydrate with other data. Code that hands
1102
- * the answer over itself (`useDmsState` with `transfer: true`) looks the same
1103
- * from here, so the message names that exception rather than guess.
1104
- */
1105
- function warnUntransferredRead(
1106
- runtime: DmsFrontendRuntime,
1107
- request: string,
1108
- ): void {
1109
- if (
1110
- !runtime.isServer ||
1111
- !runtime.devWarnings ||
1112
- !runtime.inAsyncDataHandler ||
1113
- runtime.inAsyncDataHandler() ||
1114
- warnedServerReads.has(request)
1115
- )
1116
- return;
1117
- warnedServerReads.add(request);
1118
- console.warn(
1119
- `DMS: ${request} was read during a server render outside useDmsFetch and useDmsAsyncData ` +
1120
- "(an immediate watcher, a setup's own await, a middleware): unless the code hands the " +
1121
- "answer over (useDmsState with transfer: true), the page does not carry it and the " +
1122
- "browser reads it again. Load it with useDmsFetch or useDmsAsyncData, in a middleware " +
1123
- "or a plugin too, or leave it to the browser (server: false, onMounted).",
1124
- );
1125
- }
1126
-
1127
- function createDmsFetch(defaults: FetchOptions = {}): typeof ofetch {
1128
- const execute = (request: string, options?: FetchOptions) => {
1129
- const runtime = useDmsRuntime();
1130
- warnUntransferredRead(runtime, request);
1131
- return (runtime.serverFetch ?? ofetch)(
1132
- request,
1133
- runtimeFetchOptions(runtime, { ...defaults, ...options }),
1134
- );
1135
- };
1136
- return Object.assign(execute, {
1137
- create: (options: FetchOptions) =>
1138
- createDmsFetch({ ...defaults, ...options }),
1139
- native: ofetch.native,
1140
- raw: ofetch.raw,
1141
- }) as typeof ofetch;
1142
- }
1143
-
1144
- export const $fetch = createDmsFetch();
1145
-
1146
- function pickValue<T>(value: unknown, keys?: string[]): T {
1147
- if (!keys || typeof value !== "object" || value === null) return value as T;
1148
- return Object.fromEntries(
1149
- keys
1150
- .filter((key) => key in value)
1151
- .map((key) => [key, Reflect.get(value, key)]),
1152
- ) as T;
1153
- }
1154
-
1155
- /** JSON with sorted keys, or nothing for a value JSON cannot stand for. */
1156
- function stableJson(value: unknown): string | undefined {
1157
- if (value === undefined || value === null) return "null";
1158
- if (value instanceof Date) return JSON.stringify(value);
1159
- if (typeof value !== "object")
1160
- return ["string", "number", "boolean"].includes(typeof value)
1161
- ? JSON.stringify(value)
1162
- : undefined;
1163
- if (Array.isArray(value)) {
1164
- const items = value.map(stableJson);
1165
- return items.includes(undefined) ? undefined : `[${items.join(",")}]`;
1166
- }
1167
- const prototype = Object.getPrototypeOf(value) as unknown;
1168
- if (prototype !== Object.prototype && prototype !== null) return undefined;
1169
- const record = value as Record<string, unknown>;
1170
- const members = Object.keys(record)
1171
- .filter((name) => record[name] !== undefined)
1172
- .sort()
1173
- .map((name) => {
1174
- const member = stableJson(record[name]);
1175
- return member === undefined
1176
- ? undefined
1177
- : `${JSON.stringify(name)}:${member}`;
1178
- });
1179
- return members.includes(undefined) ? undefined : `{${members.join(",")}}`;
1180
- }
1181
-
1182
- const FETCH_KEY_PREFIX = "$dmsfetch:";
1183
- const DEFAULT_FETCH_METHOD = "GET";
1184
-
1185
- // cyrb53's mixing constants, with its two 32-bit lanes both kept: a 64-bit
1186
- // digest, as short as Nuxt's keys and as unlikely to collide within a page.
1187
- const HASH_SEEDS = [0xdeadbeef, 0x41c6ce57];
1188
- const HASH_MULTIPLIERS = [2654435761, 1597334677];
1189
- const HASH_FINALIZERS = [2246822507, 3266489909];
1190
- const HASH_SHIFTS = [16, 13];
1191
- const HASH_RADIX = 36;
1192
- const HASH_LANE_LENGTH = 7;
1193
-
1194
- /**
1195
- * A short digest of a text, the same in a server and in a browser: pure
1196
- * JavaScript, so no `node:crypto` reaches a browser bundle.
1197
- */
1198
- function hashText(text: string): string {
1199
- let [first, second] = HASH_SEEDS;
1200
- for (let index = 0; index < text.length; index++) {
1201
- const code = text.charCodeAt(index);
1202
- first = Math.imul(first ^ code, HASH_MULTIPLIERS[0]);
1203
- second = Math.imul(second ^ code, HASH_MULTIPLIERS[1]);
1204
- }
1205
- first = Math.imul(first ^ (first >>> HASH_SHIFTS[0]), HASH_FINALIZERS[0]);
1206
- first ^= Math.imul(second ^ (second >>> HASH_SHIFTS[1]), HASH_FINALIZERS[1]);
1207
- second = Math.imul(second ^ (second >>> HASH_SHIFTS[0]), HASH_FINALIZERS[0]);
1208
- second ^= Math.imul(first ^ (first >>> HASH_SHIFTS[1]), HASH_FINALIZERS[1]);
1209
- return [second, first]
1210
- .map((lane) =>
1211
- (lane >>> 0).toString(HASH_RADIX).padStart(HASH_LANE_LENGTH, "0"),
1212
- )
1213
- .join("");
1214
- }
1215
-
1216
- /**
1217
- * The key a request's response is shared and transferred under: a digest of
1218
- * its method, base URL, URL, query and params, and body, the headers aside. A
1219
- * server render writes the key into its HTML, which must not show the URL, a
1220
- * token in its query or a body. A body only a request can read, such as a
1221
- * FormData, gives none.
1222
- */
1223
- function requestKey(url: string, options: FetchOptions): string | undefined {
1224
- // ofetch sends the params and the query together, the query winning.
1225
- const query =
1226
- options.query || options.params
1227
- ? { ...options.params, ...options.query }
1228
- : null;
1229
- const request = stableJson([
1230
- (options.method ?? DEFAULT_FETCH_METHOD).toUpperCase(),
1231
- options.baseURL ?? "",
1232
- url,
1233
- query,
1234
- options.body ?? null,
1235
- ]);
1236
- return request === undefined
1237
- ? undefined
1238
- : `${FETCH_KEY_PREFIX}${hashText(request)}`;
1239
- }
1240
-
1241
- /** The parts of a request its key is computed from. */
1242
- export type DmsRequestKeyOptions = Pick<
1243
- FetchOptions,
1244
- "method" | "baseURL" | "query" | "params" | "body"
1245
- >;
1246
-
1247
- /**
1248
- * The key `useDmsFetch` loads a request's response under, without the scope
1249
- * (`setDmsAsyncDataScope`): what `refreshDmsData(keys)` takes, and what a
1250
- * test compares with the keys it finds. `undefined` for a body only a
1251
- * request can read, such as a FormData.
1252
- *
1253
- * @param url The URL as given to `useDmsFetch`, before the base URL and the query
1254
- * @param options The method, base URL, query, params and body of the request
1255
- */
1256
- export function dmsRequestKey(
1257
- url: string,
1258
- options: DmsRequestKeyOptions = {},
1259
- ): string | undefined {
1260
- return requestKey(url, options);
1261
- }
1262
-
1263
- /** A URL to request, or nothing to request while it is null or undefined. */
1264
- type DmsFetchRequest = string | null | undefined;
1265
-
1266
- function isReactiveSource(value: unknown): boolean {
1267
- return isRef(value) || typeof value === "function";
1268
- }
1269
-
1270
- /** The options given, without the ones left undefined. */
1271
- function definedOptions(options: FetchOptions): FetchOptions {
1272
- return Object.fromEntries(
1273
- Object.entries(options).filter(([, value]) => value !== undefined),
1274
- ) as FetchOptions;
1275
- }
1276
-
1277
- /**
1278
- * Fetches a URL as `useDmsAsyncData` loads its data, under the key of the
1279
- * request, so that the callers of one request share it and a server render
1280
- * transfers it. A ref or a getter for the URL, the method, the query, the
1281
- * params or the body moves the caller to the key of each request; a URL that
1282
- * is null or undefined requests nothing.
1283
- */
1284
- export function useDmsFetch<T>(
1285
- request: MaybeRefOrGetter<DmsFetchRequest>,
1286
- options: UseFetchOptions<T> = {},
1287
- ): DmsAsyncDataResult<T> {
1288
- const runtime = useDmsRuntime();
1289
- const {
1290
- key,
1291
- immediate,
1292
- lazy,
1293
- server,
1294
- watch: sources,
1295
- default: fallback,
1296
- transform,
1297
- pick,
1298
- deep,
1299
- dedupe,
1300
- refreshOnPageRefresh,
1301
- refreshInterval,
1302
- $fetch: fetcher,
1303
- method,
1304
- query,
1305
- params,
1306
- body,
1307
- ...fixedOptions
1308
- } = options;
1309
- const url = () => toValue(request);
1310
- const requestOptions = (): FetchOptions =>
1311
- definedOptions({
1312
- ...fixedOptions,
1313
- method: toValue(method),
1314
- query: toValue(query),
1315
- params: toValue(params),
1316
- body: toValue(body),
1317
- });
1318
- // A `$fetch` of the caller's own applies its own retries. The transform
1319
- // runs here, where the request a response answers is known: a reactive URL
1320
- // may have changed since it was sent.
1321
- const handler: DmsAsyncDataHandler<T> = async ({ signal }) => {
1322
- const target = url() as string;
1323
- const sent = requestOptions();
1324
- const runKey =
1325
- key === undefined
1326
- ? requestKey(target, sent)
1327
- : (toValue(key) ?? undefined);
1328
- const value: unknown = await (fetcher
1329
- ? fetcher(target, { ...sent, signal })
1330
- : (runtime.serverFetch ?? ofetch)(
1331
- target,
1332
- runtimeFetchOptions(runtime, { ...sent, signal }),
1333
- ));
1334
- if (!transform) return value as T;
1335
- return transform(value, { key: runKey, url: target });
1336
- };
1337
- const asyncOptions: UseAsyncDataOptions<T> = {
1338
- immediate,
1339
- lazy,
1340
- server,
1341
- watch: sources,
1342
- default: fallback,
1343
- pick,
1344
- deep,
1345
- dedupe,
1346
- refreshOnPageRefresh,
1347
- refreshInterval,
1348
- };
1349
- const reactiveSources = [request, method, query, params, body].filter(
1350
- isReactiveSource,
1351
- );
1352
- const isReactive = reactiveSources.length > 0;
1353
- // The request gives its key, which follows the request as it changes.
1354
- const derived = (): string | null | undefined => {
1355
- const target = url();
1356
- return target == null ? null : requestKey(target, requestOptions());
1357
- };
1358
- if (key === undefined && derived() !== undefined)
1359
- return useDmsAsyncData(
1360
- isReactive ? derived : derived(),
1361
- handler,
1362
- asyncOptions,
1363
- );
1364
- // The key the caller names, or none for a body without one, stays put while
1365
- // the request changes: the request is watched instead.
1366
- const watched =
1367
- isReactive && sources !== false
1368
- ? [...reactiveSources, ...(sources ?? [])]
1369
- : sources;
1370
- if (key === undefined)
1371
- return useDmsAsyncData(handler, undefined, {
1372
- ...asyncOptions,
1373
- watch: watched,
1374
- });
1375
- const named = () => (url() == null ? null : toValue(key));
1376
- const followsKey = isReactiveSource(request) || isReactiveSource(key);
1377
- return useDmsAsyncData(followsKey ? named : named(), handler, {
1378
- ...asyncOptions,
1379
- watch: watched,
1380
- });
1381
- }
1382
-
1383
- interface AsyncDataBinding<T> {
1384
- handler: DmsAsyncDataHandler<T>;
1385
- options: UseAsyncDataOptions<T>;
1386
- }
1387
-
1388
- interface AsyncDataRun {
1389
- promise: Promise<void>;
1390
- controller: AbortController;
1391
- }
1392
-
1393
- interface AsyncDataBindingHandle {
1394
- unbind(): void;
1395
- }
1396
-
1397
- /** What a key holds before any run of the browser's own. */
1398
- interface AsyncDataInitialState<T> {
1399
- data: T | null;
1400
- error: unknown;
1401
- status: DmsAsyncDataStatus;
1402
- }
1403
-
1404
- /** What the components showing one key share: its data and its runs. */
1405
- interface AsyncDataEntry<T> {
1406
- result: DmsAsyncData<T>;
1407
- bind(binding: AsyncDataBinding<T>): AsyncDataBindingHandle;
1408
- /** A run: the one in flight, or a new one. */
1409
- load(): Promise<void>;
1410
- /** The first run, once the current turn is over. */
1411
- schedule(): void;
1412
- /** Resolves once no run is in flight, including the runs that replace it. */
1413
- settled(): Promise<void>;
1414
- unused(): boolean;
1415
- /** When a caller last stopped showing the data (`asyncDataClock`). */
1416
- lastShown(): number;
1417
- /**
1418
- * Whether another caller shows the data and its last run answered within
1419
- * `JOIN_WITHOUT_READ_MS`: a caller joining it then reads nothing again.
1420
- */
1421
- justReadForAnother(): boolean;
1422
- running(): boolean;
1423
- /** `refreshDmsPageData`'s run, for data a caller shows and opted into. */
1424
- pageRefresh(): Promise<void> | undefined;
1425
- /**
1426
- * Holds the server's state back, as pending, until the next turn or
1427
- * `adoptNow`, whichever comes first.
1428
- */
1429
- adoptLater(state: AsyncDataInitialState<T>): void;
1430
- adoptNow(): void;
1431
- /** Resolves once the state held back is adopted; nothing when none is. */
1432
- adoption(): Promise<void> | undefined;
1433
- }
1434
-
1435
- const DEFAULT_DEDUPE: DmsAsyncDataDedupe = "cancel";
1436
- /**
1437
- * How many keys no component shows any more a browser keeps, the most
1438
- * recently shown, for a return to them to show their data at once.
1439
- */
1440
- const MAX_UNUSED_ASYNC_DATA = 50;
1441
- /**
1442
- * How long after a run answered a caller that joins a key another caller
1443
- * shows takes the data as it is, instead of reading it again: two components
1444
- * of one page sharing a read mount a moment apart.
1445
- */
1446
- const JOIN_WITHOUT_READ_MS = 2000;
1447
-
1448
- const asyncDataEntries = new WeakMap<
1449
- DmsAsyncData<unknown>,
1450
- AsyncDataEntry<unknown>
1451
- >();
1452
-
1453
- function createAsyncDataEntry<T>(
1454
- runtime: DmsFrontendRuntime,
1455
- key: string | undefined,
1456
- initial: AsyncDataInitialState<T>,
1457
- deep: boolean,
1458
- ): AsyncDataEntry<T> {
1459
- const data = (
1460
- deep ? ref(initial.data) : shallowRef(initial.data)
1461
- ) as Ref<T | null>;
1462
- const error = ref<unknown>(initial.error);
1463
- const status = ref<DmsAsyncDataStatus>(initial.status);
1464
- const pending = computed(() => status.value === "pending");
1465
- const bindings: AsyncDataBinding<T>[] = [];
1466
- let run: AsyncDataRun | undefined;
1467
- let scheduled: Promise<void> | undefined;
1468
- let heldBack: AsyncDataInitialState<T> | undefined;
1469
- let adoption: Promise<void> | undefined;
1470
- let lastShown = 0;
1471
- // When the last run answered, to spare a caller joining at once a second read.
1472
- let answeredAt = 0;
1473
- const adoptNow = () => {
1474
- if (!heldBack) return;
1475
- data.value = heldBack.data;
1476
- error.value = heldBack.error;
1477
- status.value = heldBack.status;
1478
- heldBack = undefined;
1479
- };
1480
- const handle = (binding: AsyncDataBinding<T>, signal: AbortSignal) => {
1481
- const call = () => binding.handler({ signal });
1482
- return runtime.runAsyncDataHandler
1483
- ? runtime.runAsyncDataHandler(call)
1484
- : call();
1485
- };
1486
- const start = (binding: AsyncDataBinding<T>): Promise<void> => {
1487
- heldBack = undefined;
1488
- run?.controller.abort();
1489
- const controller = new AbortController();
1490
- const current: AsyncDataRun = { controller, promise: Promise.resolve() };
1491
- run = current;
1492
- runtime.asyncDataRuns.add(controller);
1493
- // The last failure stays until this run answers, as in Nuxt.
1494
- status.value = "pending";
1495
- // A run another one replaced, or that nobody waits for any more, settles
1496
- // nothing, even when its handler ignores the signal.
1497
- current.promise = (async () => {
1498
- try {
1499
- const value = await handle(binding, controller.signal);
1500
- const transformed = binding.options.transform
1501
- ? await binding.options.transform(value, { key })
1502
- : value;
1503
- if (run !== current) return;
1504
- data.value = pickValue<T>(transformed, binding.options.pick);
1505
- error.value = null;
1506
- status.value = "success";
1507
- answeredAt = Date.now();
1508
- } catch (reason) {
1509
- if (run !== current) return;
1510
- // The data of the last success stays on screen next to the failure.
1511
- error.value = reason;
1512
- status.value = "error";
1513
- } finally {
1514
- runtime.asyncDataRuns.delete(controller);
1515
- if (run === current) run = undefined;
1516
- }
1517
- })();
1518
- return current.promise;
1519
- };
1520
- const refresh = (options: DmsAsyncDataRefreshOptions = {}) => {
1521
- const binding = bindings.at(-1);
1522
- if (!binding) return Promise.resolve();
1523
- const dedupe = options.dedupe ?? binding.options.dedupe ?? DEFAULT_DEDUPE;
1524
- return run && dedupe === "defer" ? run.promise : start(binding);
1525
- };
1526
- const load = (): Promise<void> => {
1527
- if (run) return run.promise;
1528
- const binding = bindings.at(-1);
1529
- return binding ? start(binding) : Promise.resolve();
1530
- };
1531
- const result: DmsAsyncData<T> = {
1532
- data,
1533
- key: computed(() => key),
1534
- placeholder: computed(() => false),
1535
- error,
1536
- pending,
1537
- status,
1538
- execute: refresh,
1539
- refresh,
1540
- clear() {
1541
- heldBack = undefined;
1542
- run?.controller.abort();
1543
- run = undefined;
1544
- data.value = bindings.at(-1)?.options.default?.() ?? null;
1545
- error.value = null;
1546
- status.value = "idle";
1547
- },
1548
- };
1549
- const entry: AsyncDataEntry<T> = {
1550
- result,
1551
- bind(binding) {
1552
- bindings.push(binding);
1553
- runtime.boundAsyncData.add(entry);
1554
- const unbind = () => {
1555
- const index = bindings.indexOf(binding);
1556
- if (index === -1) return;
1557
- bindings.splice(index, 1);
1558
- if (bindings.length > 0) return;
1559
- lastShown = ++runtime.asyncDataClock;
1560
- runtime.boundAsyncData.delete(entry);
1561
- if (!run) return;
1562
- run.controller.abort();
1563
- run = undefined;
1564
- // Back to what the last answer left.
1565
- status.value =
1566
- error.value != null
1567
- ? "error"
1568
- : data.value === null
1569
- ? "idle"
1570
- : "success";
1571
- };
1572
- return { unbind };
1573
- },
1574
- load,
1575
- schedule() {
1576
- // Started after the caller's turn, an explicit refresh in that turn
1577
- // replaces this first run instead of cancelling it mid-request.
1578
- scheduled ??= Promise.resolve().then(() => {
1579
- scheduled = undefined;
1580
- return load();
1581
- });
1582
- },
1583
- async settled() {
1584
- await scheduled;
1585
- await adoption;
1586
- while (run) await run.promise;
1587
- },
1588
- unused: () => bindings.length === 0,
1589
- lastShown: () => lastShown,
1590
- justReadForAnother: () =>
1591
- bindings.length > 1 && Date.now() - answeredAt < JOIN_WITHOUT_READ_MS,
1592
- running: () => run !== undefined,
1593
- pageRefresh() {
1594
- const wanted = bindings.some(
1595
- (binding) => binding.options.refreshOnPageRefresh !== false,
1596
- );
1597
- // Data a caller waits to `execute()` stays unread.
1598
- if (!wanted || (status.value === "idle" && !run)) return undefined;
1599
- return refresh();
1600
- },
1601
- adoptLater(state) {
1602
- heldBack = state;
1603
- status.value = "pending";
1604
- adoption = Promise.resolve().then(adoptNow);
1605
- },
1606
- adoptNow,
1607
- adoption: () => (heldBack ? adoption : undefined),
1608
- };
1609
- return entry;
1610
- }
1611
-
1612
- /**
1613
- * The state the server's render left a key in, if it rendered the key: its
1614
- * data, or its failure next to the default it rendered.
1615
- */
1616
- function serverAsyncDataState<T>(
1617
- runtime: DmsFrontendRuntime,
1618
- key: string,
1619
- fallback: AsyncDataInitialState<T>,
1620
- ): AsyncDataInitialState<T> | undefined {
1621
- if (runtime.hydratedAsyncData.has(key))
1622
- return {
1623
- data: runtime.hydratedAsyncData.get(key) as T,
1624
- error: null,
1625
- status: "success",
1626
- };
1627
- const failure = runtime.hydratedAsyncErrors.get(key);
1628
- if (!failure) return undefined;
1629
- return {
1630
- data: fallback.data,
1631
- error: createDmsError(failure),
1632
- status: "error",
1633
- };
1634
- }
1635
-
1636
- /**
1637
- * The key an entry is stored and transferred under: the caller's key, in
1638
- * the runtime's scope (`setDmsAsyncDataScope`).
1639
- */
1640
- function scopedKey(runtime: DmsFrontendRuntime, key: string): string {
1641
- return runtime.asyncDataScope === undefined
1642
- ? key
1643
- : `${runtime.asyncDataScope}|${key}`;
1644
- }
1645
-
1646
- function asyncDataEntry<T>(
1647
- runtime: DmsFrontendRuntime,
1648
- key: string | undefined,
1649
- options: UseAsyncDataOptions<T>,
1650
- inComponent: boolean,
1651
- ): AsyncDataEntry<T> {
1652
- const stored = key === undefined ? undefined : scopedKey(runtime, key);
1653
- const shared =
1654
- stored === undefined ? undefined : runtime.asyncData.get(stored);
1655
- if (shared) return asyncDataEntries.get(shared) as AsyncDataEntry<T>;
1656
- const fallback: AsyncDataInitialState<T> = {
1657
- data: options.default?.() ?? null,
1658
- error: null,
1659
- status: "idle",
1660
- };
1661
- const server =
1662
- stored === undefined
1663
- ? undefined
1664
- : serverAsyncDataState<T>(runtime, stored, fallback);
1665
- const entry = createAsyncDataEntry<T>(
1666
- runtime,
1667
- key,
1668
- server && !inComponent ? server : fallback,
1669
- options.deep === true,
1670
- );
1671
- // A component's setup saw the server's run in flight, not its result: the
1672
- // browser holds the result back as long, see `startAsyncData`.
1673
- if (server && inComponent) entry.adoptLater(server);
1674
- if (stored === undefined) return entry;
1675
- runtime.hydratedAsyncData.delete(stored);
1676
- runtime.hydratedAsyncErrors.delete(stored);
1677
- const result = entry.result as DmsAsyncData<unknown>;
1678
- runtime.asyncData.set(stored, result);
1679
- asyncDataEntries.set(result, entry as AsyncDataEntry<unknown>);
1680
- return entry;
1681
- }
1682
-
1683
- /**
1684
- * Loads data once for every component that shows the same key, with Nuxt's
1685
- * semantics: a server render waits for it before rendering, and transfers it,
1686
- * whether or not the caller awaits it; `lazy` only spares client navigations
1687
- * the wait; `server: false` leaves it to the browser.
1688
- *
1689
- * In the browser, data loaded before is shown at once and read again in the
1690
- * background on each mount and each return to its key (stale while
1691
- * revalidate), except while the page the server rendered hydrates: its data
1692
- * is the server's, just read.
1693
- *
1694
- * A call without a key cannot be transferred: the server leaves it to the
1695
- * browser, as `server: false` does, so the page hydrates with what it shows.
1696
- */
1697
- export function useDmsAsyncData<T>(
1698
- keyOrHandler: MaybeRefOrGetter<DmsAsyncDataKey> | DmsAsyncDataHandler<T>,
1699
- handler?: DmsAsyncDataHandler<T>,
1700
- callerOptions: UseAsyncDataOptions<T> = {},
1701
- ): DmsAsyncDataResult<T> {
1702
- const runtime = useDmsRuntime();
1703
- // A call outside any scope (a plugin past its first await, a callback)
1704
- // belongs to the application: its watchers and its polling live as long.
1705
- if (getCurrentScope() || !runtime.scope.active)
1706
- return createAsyncDataCall(runtime, keyOrHandler, handler, callerOptions);
1707
- return runtime.scope.run(() =>
1708
- createAsyncDataCall(runtime, keyOrHandler, handler, callerOptions),
1709
- )!;
1710
- }
1711
-
1712
- function createAsyncDataCall<T>(
1713
- runtime: DmsFrontendRuntime,
1714
- keyOrHandler: MaybeRefOrGetter<DmsAsyncDataKey> | DmsAsyncDataHandler<T>,
1715
- handler: DmsAsyncDataHandler<T> | undefined,
1716
- callerOptions: UseAsyncDataOptions<T>,
1717
- ): DmsAsyncDataResult<T> {
1718
- // With a handler second, the first argument is the key: a string, or a ref
1719
- // or getter whose changes move the caller to the data of another key.
1720
- const keySource = handler
1721
- ? (keyOrHandler as MaybeRefOrGetter<DmsAsyncDataKey>)
1722
- : undefined;
1723
- const execute = handler ?? keyOrHandler;
1724
- if (typeof execute !== "function")
1725
- throw new Error("useDmsAsyncData requires a handler");
1726
- const keyless = keySource === undefined;
1727
- if (keyless && runtime.isServer && runtime.devWarnings)
1728
- console.warn(
1729
- "DMS: useDmsAsyncData without a key, or useDmsFetch with a body it cannot key, " +
1730
- "is left to the browser: give it a key for the server to render it.",
1731
- );
1732
- const options: UseAsyncDataOptions<T> = keyless
1733
- ? { ...callerOptions, server: false }
1734
- : callerOptions;
1735
- const inComponent = getCurrentInstance() !== null;
1736
- const consumer = shallowRef(
1737
- attachAsyncData(
1738
- runtime,
1739
- keyless ? undefined : (toValue(keySource) ?? null),
1740
- execute as DmsAsyncDataHandler<T>,
1741
- options,
1742
- inComponent,
1743
- ),
1744
- );
1745
- if (getCurrentScope()) onScopeDispose(() => consumer.value.unbind());
1746
- if (options.watch && options.watch.length)
1747
- refreshOnWatchedChange(consumer, options.watch);
1748
- // Registered in the browser too, though only a server calls it: Vue makes
1749
- // a component that prefetches an async boundary, which shifts the ids
1750
- // `useId()` gives the components after it, and both sides must agree. It
1751
- // waits for the key the caller is on by then, which its setup may have set.
1752
- let prefetched = false;
1753
- if (inComponent && options.immediate !== false && options.server !== false)
1754
- onServerPrefetch(() => {
1755
- prefetched = true;
1756
- return consumer.value.entry.settled();
1757
- });
1758
- pollAsyncData(runtime, consumer, options);
1759
- const { entry } = consumer.value;
1760
- const waited = startAsyncData(runtime, entry, options, inComponent);
1761
- const result =
1762
- keySource == null || typeof keySource === "string"
1763
- ? entry.result
1764
- : followAsyncDataKey(
1765
- runtime,
1766
- consumer,
1767
- keySource,
1768
- execute as DmsAsyncDataHandler<T>,
1769
- options,
1770
- inComponent,
1771
- () => !prefetched,
1772
- );
1773
- return Object.assign(
1774
- Promise.resolve(waited).then(() => result),
1775
- result,
1776
- );
1777
- }
1778
-
1779
- /**
1780
- * Reads the data again when a watched source changes, but when the caller's
1781
- * key moved in the same turn: the new key's own read already has the change.
1782
- */
1783
- function refreshOnWatchedChange<T>(
1784
- consumer: ShallowRef<AsyncDataConsumer<T>>,
1785
- sources: unknown[],
1786
- ): void {
1787
- let moved = false;
1788
- watch(
1789
- () => consumer.value.entry,
1790
- () => {
1791
- moved = true;
1792
- void nextTick(() => {
1793
- moved = false;
1794
- });
1795
- },
1796
- { flush: "sync" },
1797
- );
1798
- watch(sources as never[], () => {
1799
- if (!moved) void consumer.value.entry.result.refresh();
1800
- });
1801
- }
1802
-
1803
- /**
1804
- * Reads the data again every `refreshInterval`, in the browser, following
1805
- * the interval as it changes: none, `0` or `null` stops it.
1806
- */
1807
- function pollAsyncData<T>(
1808
- runtime: DmsFrontendRuntime,
1809
- consumer: ShallowRef<AsyncDataConsumer<T>>,
1810
- options: UseAsyncDataOptions<T>,
1811
- ): void {
1812
- const interval = options.refreshInterval;
1813
- if (runtime.isServer || interval === undefined) return;
1814
- let timer: ReturnType<typeof setInterval> | undefined;
1815
- const stop = () => {
1816
- clearInterval(timer);
1817
- timer = undefined;
1818
- };
1819
- watch(
1820
- () => toValue(interval),
1821
- (period) => {
1822
- stop();
1823
- if (period && period > 0)
1824
- timer = setInterval(() => pollOnce(consumer, options), period);
1825
- },
1826
- { immediate: true },
1827
- );
1828
- if (getCurrentScope()) onScopeDispose(stop);
1829
- }
1830
-
1831
- function pollOnce<T>(
1832
- consumer: ShallowRef<AsyncDataConsumer<T>>,
1833
- options: UseAsyncDataOptions<T>,
1834
- ): void {
1835
- if (typeof document !== "undefined" && document.visibilityState === "hidden")
1836
- return;
1837
- const result = consumer.value.entry.result;
1838
- // Data a caller waits to `execute()` is not read before it is.
1839
- if (result.status.value === "idle" && options.immediate === false) return;
1840
- void result.refresh({ dedupe: "defer" });
1841
- }
1842
-
1843
- /**
1844
- * Starts the run a caller needs, where its side needs it, and returns what
1845
- * awaiting the call waits for.
1846
- */
1847
- function startAsyncData<T>(
1848
- runtime: DmsFrontendRuntime,
1849
- entry: AsyncDataEntry<T>,
1850
- options: UseAsyncDataOptions<T>,
1851
- inComponent: boolean,
1852
- ): Promise<void> | undefined {
1853
- const immediate = options.immediate !== false;
1854
- const onServer = options.server !== false;
1855
- const idle = entry.result.status.value === "idle";
1856
- if (runtime.isServer) {
1857
- if (!immediate || !onServer) return undefined;
1858
- // A render that already answers a redirect throws its HTML away: what it
1859
- // still has to read would reach nobody.
1860
- if (runtime.serverRedirect) return undefined;
1861
- if (idle && options.lazy) entry.schedule();
1862
- else if (idle) void entry.load();
1863
- const settled = entry.settled();
1864
- if (!inComponent) runtime.serverPrefetches.add(settled);
1865
- return settled;
1866
- }
1867
- // The server's render had the data once the setup awaited it, and before
1868
- // the component rendered: handed over at the same points, it gives what
1869
- // the setup derives from it, and so the HTML, the same values.
1870
- const adoption = entry.adoption();
1871
- if (adoption) {
1872
- if (inComponent) onBeforeMount(entry.adoptNow);
1873
- return adoption;
1874
- }
1875
- if (!immediate) return undefined;
1876
- const waitForData = () =>
1877
- !options.lazy && entry.result.data.value === null
1878
- ? entry.settled()
1879
- : undefined;
1880
- if (entry.running()) return waitForData();
1881
- if (!idle) {
1882
- // Loaded before: shown at once and read again in the background, except
1883
- // while the page the server rendered hydrates with the server's data, or
1884
- // when another caller of the page has just read it.
1885
- if (!runtime.hydrating && !entry.justReadForAnother()) void entry.load();
1886
- return waitForData();
1887
- }
1888
- // The server rendered the default in place of this data: the browser's
1889
- // first render must show it too, before the request leaves.
1890
- if (runtime.hydrating && (options.lazy || !onServer)) {
1891
- if (inComponent) onMounted(() => void entry.load());
1892
- else entry.schedule();
1893
- } else if (options.lazy) entry.schedule();
1894
- else void entry.load();
1895
- return !options.lazy && entry.result.pending.value
1896
- ? entry.settled()
1897
- : undefined;
1898
- }
1899
-
1900
- interface AsyncDataConsumer<T> extends AsyncDataBindingHandle {
1901
- entry: AsyncDataEntry<T>;
1902
- /** Whether no other component showed the key when this one came. */
1903
- alone: boolean;
1904
- }
1905
-
1906
- /**
1907
- * Binds a caller to the data of its key. A `null` key has no data to load:
1908
- * the caller holds its default, and nothing runs until it has a key.
1909
- */
1910
- function attachAsyncData<T>(
1911
- runtime: DmsFrontendRuntime,
1912
- key: string | null | undefined,
1913
- handler: DmsAsyncDataHandler<T>,
1914
- options: UseAsyncDataOptions<T>,
1915
- inComponent: boolean,
1916
- ): AsyncDataConsumer<T> {
1917
- const entry = asyncDataEntry(runtime, key ?? undefined, options, inComponent);
1918
- const alone = entry.unused();
1919
- if (key === null) return { entry, alone, unbind() {} };
1920
- return { entry, alone, ...entry.bind({ handler, options }) };
1921
- }
1922
-
1923
- /**
1924
- * Moves a caller to the data of each key its reactive key takes, and returns
1925
- * refs that follow it there. Until the new key's data is in, the previous
1926
- * data stays on screen rather than the default flashing in its place: the
1927
- * caller's refs show it, and `placeholder` says so, while the new key's own
1928
- * data stays its own, so a run that fails or that a switch away aborts never
1929
- * leaves it another key's data. A key loaded before shows its own data at
1930
- * once, and is read again in the background.
1931
- *
1932
- * A new key is read where the first one would have been. A server reads it
1933
- * neither for `server: false` nor for `immediate: false`, and otherwise only
1934
- * while its render still waits for the caller: until the prefetch of the
1935
- * caller's component, or, outside a component, as a call made then. Past
1936
- * that point the server renders the default and the browser reads the key. A
1937
- * browser hydrating the server's HTML reads a key the server did not render
1938
- * once the component is mounted, so that its first render matches the HTML.
1939
- */
1940
- function followAsyncDataKey<T>(
1941
- runtime: DmsFrontendRuntime,
1942
- consumer: ShallowRef<AsyncDataConsumer<T>>,
1943
- keySource: MaybeRefOrGetter<DmsAsyncDataKey>,
1944
- handler: DmsAsyncDataHandler<T>,
1945
- options: UseAsyncDataOptions<T>,
1946
- inComponent: boolean,
1947
- serverWaits: () => boolean,
1948
- ): DmsAsyncData<T> {
1949
- // A read held back until the hydrating component is mounted.
1950
- let held = false;
1951
- let mounted = !(inComponent && runtime.hydrating);
1952
- if (!mounted)
1953
- onMounted(() => {
1954
- mounted = true;
1955
- const { entry } = consumer.value;
1956
- if (held && entry.result.status.value === "idle") void entry.load();
1957
- held = false;
1958
- });
1959
- const startOnServer = (entry: AsyncDataEntry<T>) => {
1960
- if (
1961
- options.server === false ||
1962
- options.immediate === false ||
1963
- !serverWaits()
1964
- )
1965
- return;
1966
- void entry.load();
1967
- if (!inComponent) runtime.serverPrefetches.add(entry.settled());
1968
- };
1969
- const current = () => consumer.value.entry.result;
1970
- // The previous key's data, shown until the current key settles.
1971
- const carried = shallowRef<{ value: T | null } | undefined>();
1972
- const settled = (result: DmsAsyncData<T>) =>
1973
- result.status.value === "success" || result.status.value === "error";
1974
- const shownData = (): T | null => {
1975
- const result = current();
1976
- return carried.value && !settled(result)
1977
- ? carried.value.value
1978
- : result.data.value;
1979
- };
1980
- watch(
1981
- () => settled(current()),
1982
- (done) => {
1983
- if (done) carried.value = undefined;
1984
- },
1985
- { flush: "sync" },
1986
- );
1987
- // Data written to the current key from elsewhere (`useDmsData`, another
1988
- // caller of the key) is its own: it replaces what is carried.
1989
- watch(
1990
- () => [current(), current().data.value] as const,
1991
- ([result], [previous]) => {
1992
- if (result === previous) carried.value = undefined;
1993
- },
1994
- { flush: "sync" },
1995
- );
1996
- watch(
1997
- () => toValue(keySource) ?? null,
1998
- (key) => {
1999
- const previous = current();
2000
- const shown = shownData();
2001
- const wasRunning = previous.pending.value;
2002
- // Past the setup, the server's data for the new key, if it rendered
2003
- // any, is taken over at once.
2004
- const next = attachAsyncData(runtime, key, handler, options, false);
2005
- const { entry } = next;
2006
- const carry =
2007
- next.alone && entry.result.status.value === "idle" && shown !== null;
2008
- // Unbound first: the previous key's run, aborted, settles it, which
2009
- // must not drop what is carried to the next key.
2010
- consumer.value.unbind();
2011
- consumer.value = next;
2012
- carried.value = carry ? { value: shown } : undefined;
2013
- held = false;
2014
- // `watch: false` keeps the caller on the key it names, without a run.
2015
- if (key === null || options.watch === false) return;
2016
- if (runtime.isServer) return startOnServer(entry);
2017
- const wanted =
2018
- options.immediate !== false || shown !== null || wasRunning;
2019
- if (!wanted) return;
2020
- if (!mounted) held = true;
2021
- else if (!inComponent && runtime.hydrating) entry.schedule();
2022
- else void entry.load();
2023
- },
2024
- { flush: "sync" },
2025
- );
2026
- const through = <V>(read: (result: DmsAsyncData<T>) => Ref<V>): Ref<V> =>
2027
- computed({
2028
- get: () => read(current()).value,
2029
- set: (value) => {
2030
- read(current()).value = value;
2031
- },
2032
- });
2033
- return {
2034
- data: computed({
2035
- get: shownData,
2036
- set: (value) => {
2037
- carried.value = undefined;
2038
- current().data.value = value;
2039
- },
2040
- }),
2041
- key: computed(() => current().key.value),
2042
- placeholder: computed(
2043
- () => carried.value !== undefined && !settled(current()),
2044
- ),
2045
- error: through((result) => result.error),
2046
- pending: computed(() => current().pending.value),
2047
- status: through((result) => result.status),
2048
- execute: (refreshOptions) => current().refresh(refreshOptions),
2049
- refresh: (refreshOptions) => current().refresh(refreshOptions),
2050
- clear: () => {
2051
- carried.value = undefined;
2052
- current().clear();
2053
- },
2054
- };
2055
- }
2056
-
2057
- /** What a paged read keeps under its key: the pages loaded, in order. */
2058
- export interface DmsPagedDataValue<TPage, TCursor> {
2059
- pages: TPage[];
2060
- /** The cursor of the page after the last one; `null` once there is none. */
2061
- next: TCursor | null;
2062
- }
2063
- /** What a paged read's handler receives. */
2064
- export interface DmsPagedDataContext<TCursor> extends DmsAsyncDataContext {
2065
- /** The page to read: `initialCursor` for the first one. */
2066
- cursor: TCursor | undefined;
2067
- /** Its position, from `0`. */
2068
- index: number;
2069
- }
2070
- export type DmsPagedDataHandler<TPage, TCursor> = (
2071
- context: DmsPagedDataContext<TCursor>,
2072
- ) => Promise<TPage>;
2073
- export interface UseDmsPagedDataOptions<TPage, TCursor> extends Omit<
2074
- UseAsyncDataOptions<DmsPagedDataValue<TPage, TCursor>>,
2075
- "default" | "transform" | "pick"
2076
- > {
2077
- /** The cursor of the page after `page`, or `null` (or `undefined`) for none. */
2078
- nextCursor(page: TPage, pages: TPage[]): TCursor | null | undefined;
2079
- /** The first page's cursor; none by default. */
2080
- initialCursor?: TCursor;
2081
- }
2082
- export interface DmsPagedData<TPage, TCursor> extends DmsAsyncData<
2083
- DmsPagedDataValue<TPage, TCursor>
2084
- > {
2085
- /** The pages loaded so far, in order; none before the first answer. */
2086
- pages: Readonly<Ref<TPage[]>>;
2087
- /** Whether `loadMore()` has a page to read. */
2088
- hasMore: Readonly<Ref<boolean>>;
2089
- /** `true` while `loadMore()` reads its page. */
2090
- loadingMore: Readonly<Ref<boolean>>;
2091
- /**
2092
- * Reads the next page and appends it under the same key. It waits for a
2093
- * read in flight first, does nothing once there is no next page, and puts
2094
- * a failure in `error`, keeping the pages loaded.
2095
- */
2096
- loadMore(): Promise<void>;
2097
- }
2098
- export type DmsPagedDataResult<TPage, TCursor> = DmsPagedData<TPage, TCursor> &
2099
- Promise<DmsPagedData<TPage, TCursor>>;
2100
-
2101
- type PagedValue<TPage, TCursor> = DmsPagedDataValue<TPage, TCursor>;
2102
-
2103
- /** Reads `count` pages from the first, as long as each has a next one. */
2104
- async function readPages<TPage, TCursor>(
2105
- handler: DmsPagedDataHandler<TPage, TCursor>,
2106
- options: UseDmsPagedDataOptions<TPage, TCursor>,
2107
- count: number,
2108
- signal: AbortSignal,
2109
- ): Promise<PagedValue<TPage, TCursor>> {
2110
- const pages: TPage[] = [];
2111
- let cursor: TCursor | undefined = options.initialCursor;
2112
- for (let index = 0; index < count; index++) {
2113
- const page = await handler({ cursor, index, signal });
2114
- pages.push(page);
2115
- const next = options.nextCursor(page, pages);
2116
- if (next == null) return { pages, next: null };
2117
- cursor = next;
2118
- }
2119
- return { pages, next: cursor ?? null };
2120
- }
2121
-
2122
- /** Resolves once `result` has no run in flight. */
2123
- function untilSettled<T>(result: DmsAsyncData<T>): Promise<void> {
2124
- if (!result.pending.value) return Promise.resolve();
2125
- return new Promise((resolve) => {
2126
- const stop = watch(result.pending, (pending) => {
2127
- if (pending) return;
2128
- stop();
2129
- resolve();
2130
- });
2131
- });
2132
- }
2133
-
2134
- /**
2135
- * A list read page by page under one key: "load more", an infinite list.
2136
- * The first page loads as `useDmsAsyncData` loads its data (a server render
2137
- * waits for it and transfers it), and `loadMore()` appends the next one to
2138
- * the same key, so the pages a component loaded show at once on a return to
2139
- * it. A revalidation (a mount, `refresh()`, `refreshDmsPageData()`, the
2140
- * polling) reads every page loaded so far again, from the first, and
2141
- * replaces them once all are in. Put what filters the list in the key: a new
2142
- * key starts again from its first page.
2143
- *
2144
- * @param key The key, or a ref or getter for it
2145
- * @param handler Reads one page, from the cursor it receives
2146
- * @param options `nextCursor`, `initialCursor` and the async data options
2147
- */
2148
- export function useDmsPagedData<TPage, TCursor = unknown>(
2149
- key: MaybeRefOrGetter<DmsAsyncDataKey>,
2150
- handler: DmsPagedDataHandler<TPage, TCursor>,
2151
- options: UseDmsPagedDataOptions<TPage, TCursor>,
2152
- ): DmsPagedDataResult<TPage, TCursor> {
2153
- const {
2154
- nextCursor: _next,
2155
- initialCursor: _initial,
2156
- ...asyncOptions
2157
- } = options;
2158
- let shown: DmsAsyncData<PagedValue<TPage, TCursor>> | undefined;
2159
- const loadedCount = () =>
2160
- shown && !shown.placeholder.value
2161
- ? Math.max(1, shown.data.value?.pages.length ?? 0)
2162
- : 1;
2163
- const call = useDmsAsyncData<PagedValue<TPage, TCursor>>(
2164
- key,
2165
- ({ signal }) => readPages(handler, options, loadedCount(), signal),
2166
- asyncOptions,
2167
- );
2168
- shown = call;
2169
- const result: DmsPagedData<TPage, TCursor> = Object.assign(
2170
- {},
2171
- call as DmsAsyncData<PagedValue<TPage, TCursor>>,
2172
- pagedControls(call, handler, options),
2173
- );
2174
- return Object.assign(
2175
- call.then(() => result),
2176
- result,
2177
- );
2178
- }
2179
-
2180
- function pagedControls<TPage, TCursor>(
2181
- call: DmsAsyncData<PagedValue<TPage, TCursor>>,
2182
- handler: DmsPagedDataHandler<TPage, TCursor>,
2183
- options: UseDmsPagedDataOptions<TPage, TCursor>,
2184
- ) {
2185
- const loadingMore = ref(false);
2186
- let controller: AbortController | undefined;
2187
- const abort = () => {
2188
- controller?.abort();
2189
- controller = undefined;
2190
- loadingMore.value = false;
2191
- };
2192
- watch(call.key, abort);
2193
- if (getCurrentScope()) onScopeDispose(abort);
2194
- const hasMore = computed(
2195
- () => !call.placeholder.value && (call.data.value?.next ?? null) !== null,
2196
- );
2197
- const loadMore = async (): Promise<void> => {
2198
- if (call.pending.value) await untilSettled(call);
2199
- const before = call.data.value;
2200
- if (!before) return call.execute();
2201
- if (before.next === null || controller) return;
2202
- const current = new AbortController();
2203
- controller = current;
2204
- loadingMore.value = true;
2205
- try {
2206
- const page = await handler({
2207
- cursor: before.next,
2208
- index: before.pages.length,
2209
- signal: current.signal,
2210
- });
2211
- if (controller !== current || call.data.value !== before) return;
2212
- const pages = [...before.pages, page];
2213
- call.data.value = {
2214
- pages,
2215
- next: options.nextCursor(page, pages) ?? null,
2216
- };
2217
- } catch (reason) {
2218
- if (controller === current) call.error.value = reason;
2219
- } finally {
2220
- if (controller === current) abort();
2221
- }
2222
- };
2223
- return {
2224
- pages: computed(() => call.data.value?.pages ?? []),
2225
- hasMore,
2226
- loadingMore: readonly(loadingMore),
2227
- loadMore,
2228
- };
2229
- }
2230
-
2231
- /**
2232
- * The data another caller loads under `key`, as Nuxt's `useNuxtData`: to
2233
- * update it in place without reading it again, from what a write answered
2234
- * (an optimistic update) or what a realtime event says changed. It follows
2235
- * the key in the runtime's scope (`setDmsAsyncDataScope`): `undefined` until
2236
- * a caller loads the key, its data once there, `undefined` again once the
2237
- * browser lets the key go (past the most recent keys it keeps) or the scope
2238
- * changes. It does not load the key, nor keep it.
2239
- */
2240
- export function useDmsData<T>(
2241
- key: MaybeRefOrGetter<DmsAsyncDataKey>,
2242
- ): DmsData<T> {
2243
- const runtime = useDmsRuntime();
2244
- const entry = () => {
2245
- const name = toValue(key);
2246
- const result =
2247
- name == null
2248
- ? undefined
2249
- : runtime.asyncData.get(scopedKey(runtime, String(name)));
2250
- return result && asyncDataEntries.get(result);
2251
- };
2252
- // A component after the caller, in the page or in its subtree, renders the
2253
- // data the browser hydrates it with: the server waits for the caller's run
2254
- // too. One before it has none on either side. Whether the key is there as
2255
- // the setup runs is the same on both sides, and so are the async
2256
- // boundaries `useId()` counts.
2257
- const loaded = entry();
2258
- if (loaded && getCurrentInstance()) onServerPrefetch(() => loaded.settled());
2259
- return {
2260
- data: computed({
2261
- get() {
2262
- // An entry only ever holds its own key's data: a caller showing the
2263
- // previous key's data meanwhile keeps it in its own refs.
2264
- return (entry()?.result.data.value ?? undefined) as T | undefined;
2265
- },
2266
- set(value) {
2267
- const current = entry();
2268
- if (!current) return;
2269
- current.result.data.value = value ?? null;
2270
- },
2271
- }),
2272
- };
2273
- }
2274
-
2275
- /** Reads the data of the keys given, or of every key, again. */
2276
- export async function refreshDmsData(keys?: string | string[]): Promise<void> {
2277
- const runtime = useDmsRuntime();
2278
- const selected = keys
2279
- ? (Array.isArray(keys) ? keys : [keys]).map((key) =>
2280
- scopedKey(runtime, key),
2281
- )
2282
- : [...runtime.asyncData.keys()];
2283
- await Promise.all(
2284
- selected.map((key) => runtime.asyncData.get(key)?.refresh()),
2285
- );
2286
- }
2287
-
2288
- /**
2289
- * Reads again the data the components on screen show, but those that opted
2290
- * out (`refreshOnPageRefresh: false`) and those waiting for `execute()`:
2291
- * what a page calls once something changed what it shows, after a save or
2292
- * on a realtime event, instead of each component refreshing its own.
2293
- */
2294
- export async function refreshDmsPageData(): Promise<void> {
2295
- const runtime = useDmsRuntime();
2296
- await Promise.all(
2297
- [...runtime.boundAsyncData].map((entry) => entry.pageRefresh()),
2298
- );
2299
- }
2300
-
2301
- /**
2302
- * Puts the keys of the async data in a scope, such as a digest of the
2303
- * account and the workspace: the same key in another scope is another
2304
- * entry, never the data of the previous one. Moving to another scope drops
2305
- * the data of every key, aborting its runs; the components on screen then
2306
- * show their default until they load again. Call it on both sides before
2307
- * the page renders, with the same value, so the browser finds the data the
2308
- * server transferred; the scope is written in the page with each key, so a
2309
- * digest rather than an identifier. Call it from a plugin registered with
2310
- * `{ enforce: "pre" }`: a plugin of another module that reads data at setup
2311
- * would otherwise run first, and the new scope abort its read.
2312
- *
2313
- * @param scope The scope, or `undefined` for none
2314
- */
2315
- export function setDmsAsyncDataScope(scope: string | undefined): void {
2316
- const runtime = useDmsRuntime();
2317
- if (runtime.asyncDataScope === scope) return;
2318
- runtime.asyncDataScope = scope;
2319
- for (const result of runtime.asyncData.values()) result.clear();
2320
- runtime.asyncData.clear();
2321
- }
2322
-
2323
- /**
2324
- * Drops the data no component shows any more, but the most recently shown:
2325
- * a return to a key shows its data at once, and the data does not pile up
2326
- * over a session.
2327
- */
2328
- function dropUnusedAsyncData(runtime: DmsFrontendRuntime): void {
2329
- const unused = [...runtime.asyncData]
2330
- .map(([key, result]) => ({ key, entry: asyncDataEntries.get(result) }))
2331
- .filter(({ entry }) => entry?.unused())
2332
- .sort((left, right) => right.entry!.lastShown() - left.entry!.lastShown());
2333
- for (const { key } of unused.slice(MAX_UNUSED_ASYNC_DATA))
2334
- runtime.asyncData.delete(key);
2335
- }
2336
-
2337
- export function serializeDmsAsyncData(
2338
- runtime: DmsFrontendRuntime,
2339
- ): Record<string, unknown> {
2340
- return Object.fromEntries(
2341
- [...runtime.asyncData.entries()]
2342
- .filter(([, entry]) => entry.status.value === "success")
2343
- .map(([key, entry]) => [key, toRaw(entry.data.value)]),
2344
- );
2345
- }
2346
-
2347
- const HTTP_ERROR_STATUS = { min: 400, max: 599 };
2348
- const SERVER_FAILURE_STATUS = 500;
2349
- const FETCH_ERROR_NAME = "FetchError";
2350
-
2351
- /** The fields of a failure, under the names `createDmsError` and ofetch use. */
2352
- interface DmsFailure extends DmsErrorData {
2353
- name?: string;
2354
- status?: number;
2355
- statusText?: string;
2356
- }
2357
-
2358
- /**
2359
- * What of a failure a page may carry, by the rule the error page follows: an
2360
- * HTTP failure keeps its status, status text and message, and its body if it
2361
- * is a 4xx, anything else
2362
- * is a server failure whose cause only reaches the server log. A fetch error
2363
- * names the URL it requested, which on a server is the backend's own address:
2364
- * its status text stands for it.
2365
- */
2366
- function transferredError(key: string, reason: unknown): DmsErrorData {
2367
- const failure = (reason ?? {}) as DmsFailure;
2368
- const statusCode = failure.statusCode ?? failure.status;
2369
- if (
2370
- statusCode === undefined ||
2371
- !Number.isInteger(statusCode) ||
2372
- statusCode < HTTP_ERROR_STATUS.min ||
2373
- statusCode > HTTP_ERROR_STATUS.max
2374
- ) {
2375
- console.error(`DMS async data "${key}" failed`, reason);
2376
- return { statusCode: SERVER_FAILURE_STATUS };
2377
- }
2378
- const statusMessage = failure.statusMessage ?? failure.statusText;
2379
- const transferred: DmsErrorData = {
2380
- statusCode,
2381
- statusMessage,
2382
- message:
2383
- failure.name === FETCH_ERROR_NAME ? statusMessage : failure.message,
2384
- };
2385
- // A 5xx body is the backend's own account of its failure, a stack or a
2386
- // query at times: only a 4xx body, written for the client, is transferred.
2387
- if (statusCode < SERVER_FAILURE_STATUS) transferred.data = failure.data;
2388
- return transferred;
2389
- }
2390
-
2391
- /**
2392
- * What a page's error page shows of an expected failure (a 4xx or 5xx): its
2393
- * status code, status text and message. A fetch error's message names the URL
2394
- * it requested, on a server the backend's own address: its status text
2395
- * stands for it.
2396
- */
2397
- export function pageErrorData(failure: Error & DmsErrorData): DmsErrorData {
2398
- return {
2399
- statusCode: failure.statusCode,
2400
- message:
2401
- failure.name === FETCH_ERROR_NAME
2402
- ? failure.statusMessage
2403
- : failure.message,
2404
- statusMessage: failure.statusMessage,
2405
- };
2406
- }
2407
-
2408
- /**
2409
- * Everything a server render hands the browser: the data and the failures of
2410
- * its keys, and the state keys opted into. Read as the HTML is complete.
2411
- */
2412
- export function serializeDmsPayload(runtime: DmsFrontendRuntime): DmsPayload {
2413
- const errors = [...runtime.asyncData.entries()]
2414
- .filter(([, entry]) => entry.status.value === "error")
2415
- .map(([key, entry]): [string, DmsErrorData] => [
2416
- key,
2417
- transferredError(key, entry.error.value),
2418
- ]);
2419
- const state = [...runtime.transferredState]
2420
- .map((key): [string, unknown] => [
2421
- key,
2422
- toRaw(runtime.sharedState.get(key)?.value),
2423
- ])
2424
- .filter(([, value]) => value !== undefined);
2425
- return {
2426
- data: serializeDmsAsyncData(runtime),
2427
- errors: Object.fromEntries(errors),
2428
- state: Object.fromEntries(state),
2429
- };
2430
- }
2431
-
2432
- /** Starts a browser runtime from what the server rendered. */
2433
- export function restoreDmsPayload(
2434
- runtime: DmsFrontendRuntime,
2435
- payload: Partial<DmsPayload>,
2436
- ): void {
2437
- runtime.hydrating = true;
2438
- for (const [key, value] of Object.entries(payload.data ?? {}))
2439
- runtime.hydratedAsyncData.set(key, value);
2440
- for (const [key, failure] of Object.entries(payload.errors ?? {}))
2441
- runtime.hydratedAsyncErrors.set(key, failure);
2442
- for (const [key, value] of Object.entries(payload.state ?? {}))
2443
- runtime.sharedState.set(key, ref(value));
2444
- }
2445
-
2446
- export function useDmsState<T>(
2447
- key: string,
2448
- init?: () => T,
2449
- options: DmsStateOptions = {},
2450
- ): Ref<T> {
2451
- const runtime = useDmsRuntime();
2452
- if (options.transfer) runtime.transferredState.add(key);
2453
- if (!runtime.sharedState.has(key))
2454
- runtime.sharedState.set(key, ref(init?.()));
2455
- return runtime.sharedState.get(key) as Ref<T>;
2456
- }
2457
- export function useDmsRuntimeConfig(): DmsRuntimeConfig {
2458
- return runtimeConfig.value;
2459
- }
2460
- export function useDmsAppConfig(): DmsAppConfig {
2461
- return appConfig.value as DmsAppConfig;
2462
- }
2463
- export function defineDmsAppConfig<T extends Record<string, unknown>>(
2464
- config: T,
2465
- ): T {
2466
- return config;
2467
- }
2468
- export function defineDmsMiddleware(handler: DmsMiddleware): DmsMiddleware {
2469
- return handler;
2470
- }
2471
- export function defineDmsPageMeta(meta: Record<string, unknown>): void {
2472
- const runtime = useDmsRuntime();
2473
- Object.assign(runtime.route.meta, meta);
2474
- // A middleware that throws fails the page, as in Nuxt: the error page
2475
- // shows in its place with the error's status code, which a server render
2476
- // answers with. A redirect another navigation decided still wins.
2477
- const navigation = runMiddleware(runtime, runtime.route)
2478
- .then(async (result) => {
2479
- if (result !== undefined && result !== false)
2480
- await visit(runtime, result);
2481
- })
2482
- .catch((reason) => failPage(runtime, reason));
2483
- runtime.pendingNavigation = runtime.pendingNavigation
2484
- ? Promise.all([runtime.pendingNavigation, navigation]).then(() => undefined)
2485
- : navigation;
2486
- void runtime.pendingNavigation;
2487
- // A server renders the page's subtree once its middleware is done, so the
2488
- // state a middleware sets is the one the HTML shows and the payload carries.
2489
- // A failure shows the error page in its place (`failPage`). Registered
2490
- // in the browser too, where only a server calls it: both sides must agree
2491
- // on the async boundaries `useId()` counts.
2492
- if (getCurrentInstance())
2493
- onServerPrefetch(() => navigation.then(ignoreSettlement, ignoreSettlement));
2494
- }
2495
- function ignoreSettlement(): void {}
2496
- /**
2497
- * What Vue's development build passes `errorHandler` for an error thrown by a
2498
- * component's `setup`, sync or async (production passes a link instead; it has
2499
- * no warnings to scope either).
2500
- */
2501
- const SETUP_FUNCTION_ERROR = "setup function";
2502
- /**
2503
- * Whether a Vue warning comes from a component whose `setup` failed, or from
2504
- * under it. Vue still renders such a component, against the setup state that
2505
- * never came: in development, where a single-file component's template is
2506
- * compiled apart from its setup, the template reads the missing bindings, and
2507
- * every component it uses becomes `undefined` ("Invalid vnode type when
2508
- * creating vnode: undefined", then "Component <Anonymous> is missing template
2509
- * or render function" for the comment Vue renders in its place). The error
2510
- * page replaces that output (a server renders the page again, the browser
2511
- * swaps it), and the failure itself is already reported.
2512
- *
2513
- * Walks internal instances: a `<script setup>` component exposes itself, so
2514
- * its children's `$parent` is an expose proxy, not the proxy `errorHandler`
2515
- * received.
2516
- */
2517
- function withinFailedSetup(
2518
- instance: ComponentInternalInstance | null,
2519
- failedSetups: WeakSet<ComponentInternalInstance>,
2520
- ): boolean {
2521
- for (let current = instance; current; current = current.parent)
2522
- if (failedSetups.has(current)) return true;
2523
- return false;
2524
- }
2525
- /** Shows the error page in place of the page, keeping the first failure. */
2526
- function failPage(runtime: DmsFrontendRuntime, reason: unknown): void {
2527
- runtime.currentError.value ??=
2528
- reason instanceof Error ? reason : new Error(String(reason));
2529
- }
2530
- export function abortDmsNavigation(): false {
2531
- return false;
2532
- }
2533
- export const useHead = useUnhead;
2534
- export const useSeoMeta = useUnheadSeoMeta;
2535
- export function useI18n(): DmsI18n {
2536
- return useVueI18n() as DmsI18n;
2537
- }
2538
-
2539
- function shouldNavigateDmsLink(event: MouseEvent): boolean {
2540
- const element = event.currentTarget;
2541
- return !(
2542
- event.defaultPrevented ||
2543
- event.button !== 0 ||
2544
- event.altKey ||
2545
- event.ctrlKey ||
2546
- event.metaKey ||
2547
- event.shiftKey ||
2548
- (element instanceof HTMLAnchorElement &&
2549
- Boolean(element.target) &&
2550
- element.target !== "_self")
2551
- );
2552
- }
2553
-
2554
- function dmsLinkNavigationOptions(
2555
- attributes: Record<string, unknown>,
2556
- ): DmsNavigationOptions {
2557
- const options = attributes as DmsNavigationOptions;
2558
- return {
2559
- async: options.async,
2560
- component: options.component,
2561
- data: options.data,
2562
- except: options.except,
2563
- headers: options.headers,
2564
- method: options.method,
2565
- onBefore: options.onBefore,
2566
- onCancel: options.onCancel,
2567
- onCancelToken: options.onCancelToken,
2568
- onError: options.onError,
2569
- onFinish: options.onFinish,
2570
- onProgress: options.onProgress,
2571
- onStart: options.onStart,
2572
- onSuccess: options.onSuccess,
2573
- only: options.only,
2574
- preserveScroll: options.preserveScroll,
2575
- preserveState: options.preserveState,
2576
- preserveUrl: options.preserveUrl,
2577
- queryStringArrayFormat: options.queryStringArrayFormat,
2578
- replace: options.replace,
2579
- viewTransition: options.viewTransition,
2580
- };
2581
- }
2582
-
2583
- export const DmsLink: Component = defineComponent({
2584
- name: "DmsLink",
2585
- inheritAttrs: false,
2586
- props: {
2587
- to: {
2588
- type: [String, Object] as PropType<RouteLocationRaw>,
2589
- required: true,
2590
- },
2591
- },
2592
- setup(props, { attrs, slots }) {
2593
- const runtime = useDmsRuntime();
2594
- const prefetch = attrs.prefetch as DmsLinkPrefetch | undefined;
2595
- const onClick = (event: MouseEvent) => {
2596
- if (typeof attrs.onClick === "function") attrs.onClick(event);
2597
- if (!shouldNavigateDmsLink(event)) return;
2598
- event.preventDefault();
2599
- void visit(runtime, props.to, dmsLinkNavigationOptions(attrs));
2600
- };
2601
- return (): ReturnType<typeof h> =>
2602
- h(
2603
- InertiaLink,
2604
- {
2605
- ...attrs,
2606
- href: locationToUrl(runtime, props.to),
2607
- onClick,
2608
- prefetch: prefetch ?? "hover",
2609
- },
2610
- slots,
2611
- );
2612
- },
2613
- });
2614
-
2615
- /** How a keyboard hint prints its modifiers: macOS symbols, or PC key names. */
2616
- export type DmsKbdPlatform = "mac" | "other";
2617
- /**
2618
- * Where Nuxt UI's key hints read the platform: called in the setup of each
2619
- * component that prints one, it returns the platform that component renders.
2620
- * It must read on the server the platform it renders there, and keep it while
2621
- * its caller hydrates, so the browser's first render matches the server's.
2622
- */
2623
- export type DmsKbdPlatformSource = () => Readonly<Ref<DmsKbdPlatform>>;
2624
-
2625
- const KBD_PLATFORM_KEY: InjectionKey<DmsKbdPlatformSource> =
2626
- Symbol("dms:kbd-platform");
2627
- const MODIFIER_KBD_KEYS: Record<
1
+ export type {
2
+ DmsPageRoute,
3
+ DmsPagePayload,
4
+ DmsSharedPagePayload,
5
+ DmsLayoutDefinition,
6
+ DmsPageLayout,
7
+ DmsPageProps,
8
+ PublicRuntimeConfig,
9
+ RuntimeConfig,
10
+ DmsAppConfig,
11
+ DmsUser,
12
+ DmsSession,
13
+ DmsRuntimeConfig,
14
+ DmsModuleOptions,
15
+ DmsLocaleLoader,
16
+ DmsNavigationOptions,
17
+ RouteLocationObject,
18
+ LocationQueryValue,
19
+ LocationQuery,
20
+ LocationQueryRaw,
21
+ RouteLocationRaw,
22
+ DmsRoute,
23
+ DmsRouter,
24
+ DmsAsyncDataStatus,
25
+ DmsAsyncDataDedupe,
26
+ DmsAsyncDataRefreshOptions,
27
+ DmsAsyncData,
28
+ DmsAsyncDataResult,
29
+ DmsAsyncDataContext,
30
+ DmsAsyncDataHandler,
31
+ DmsAsyncDataKey,
32
+ DmsData,
33
+ DmsAsyncDataTransformContext,
34
+ DmsFetchTransformContext,
35
+ UseAsyncDataOptions,
36
+ UseFetchOptions,
37
+ DmsErrorData,
38
+ DmsPayload,
39
+ DmsStateOptions,
40
+ DmsUserSession,
41
+ DmsCookieOptions,
42
+ DmsAppContext,
43
+ DmsLocale,
44
+ DmsI18n,
45
+ DmsPluginSetup,
46
+ DmsMiddlewareRegistrationOptions,
47
+ DmsPluginRegistrationOptions,
48
+ DmsComponentPreloader,
49
+ DmsComponentRegistrationOptions,
50
+ } from "./frontend-runtime/types";
51
+ export { withPublicOptions } from "./frontend-runtime/module-registration";
52
+ export type {
53
+ DmsFrontendSdk,
54
+ DmsFrontendModule,
55
+ DmsFrontendModuleRegistration,
56
+ DmsLayerAppConfig,
57
+ DmsMiddlewareResult,
58
+ DmsMiddleware,
59
+ DmsRuntimeHook,
60
+ DmsRuntimeHooks,
61
+ DmsFrontendRuntime,
62
+ DmsInertiaPage,
63
+ } from "./frontend-runtime/module-registration";
64
+ export {
65
+ createDmsFrontendRuntime,
66
+ provideDmsFrontendRuntime,
67
+ setDmsServerRuntimeResolver,
68
+ useDmsRuntimeHooks,
69
+ } from "./frontend-runtime/state";
70
+ export {
71
+ useDmsRoute,
72
+ useDmsInertiaPage,
73
+ useDmsRouter,
74
+ navigateDms,
75
+ } from "./frontend-runtime/route";
76
+ export { $fetch, dmsRequestKey, useDmsFetch } from "./frontend-runtime/fetch";
77
+ export type { DmsRequestKeyOptions } from "./frontend-runtime/fetch";
78
+ export { useDmsAsyncData } from "./frontend-runtime/async-data";
79
+ export { useDmsPagedData } from "./frontend-runtime/paged-data";
80
+ export type {
81
+ DmsPagedDataValue,
82
+ DmsPagedDataContext,
83
+ DmsPagedDataHandler,
84
+ UseDmsPagedDataOptions,
85
+ DmsPagedData,
86
+ DmsPagedDataResult,
87
+ } from "./frontend-runtime/paged-data";
88
+ export {
89
+ useDmsData,
90
+ refreshDmsData,
91
+ refreshDmsPageData,
92
+ setDmsAsyncDataScope,
93
+ serializeDmsAsyncData,
94
+ } from "./frontend-runtime/data-access";
95
+ export {
96
+ pageErrorData,
97
+ serializeDmsPayload,
98
+ restoreDmsPayload,
99
+ useDmsState,
100
+ useDmsRuntimeConfig,
101
+ useDmsAppConfig,
102
+ defineDmsAppConfig,
103
+ defineDmsMiddleware,
104
+ defineDmsPageMeta,
105
+ } from "./frontend-runtime/payload";
106
+ export {
107
+ abortDmsNavigation,
108
+ useHead,
109
+ useSeoMeta,
110
+ useI18n,
111
+ DmsLink,
112
+ provideDmsKbdPlatform,
113
+ useDmsKbd,
114
+ DmsClientOnly,
115
+ defineDmsPlugin,
116
+ useDmsApp,
117
+ } from "./frontend-runtime/ui";
118
+ export type {
2628
119
  DmsKbdPlatform,
2629
- Record<"meta" | "ctrl" | "alt", string>
2630
- > = {
2631
- mac: { meta: "⌘", ctrl: "⌃", alt: "⌥" },
2632
- other: { meta: "Ctrl", ctrl: "Ctrl", alt: "Alt" },
2633
- };
2634
- const isModifierKbdKey = (value: string): value is "meta" | "ctrl" | "alt" =>
2635
- value === "meta" || value === "ctrl" || value === "alt";
2636
-
2637
- /**
2638
- * Gives Nuxt UI's key hints (`UKbd`, the `kbds` of its menus, the dashboard
2639
- * search button) the application's keyboard platform, so the server prints
2640
- * `meta` as "⌘" or "Ctrl" from the first render instead of a blank the
2641
- * browser fills in after mounting.
2642
- */
2643
- export function provideDmsKbdPlatform(
2644
- app: App,
2645
- source: DmsKbdPlatformSource,
2646
- ): void {
2647
- app.provide(KBD_PLATFORM_KEY, source);
2648
- }
2649
-
2650
- interface NuxtUiKbd {
2651
- macOS: ComputedRef<unknown>;
2652
- getKbdKey: (value?: string) => string | undefined;
2653
- }
2654
-
2655
- /**
2656
- * Nuxt UI's `useKbd` as the DMS runs it (`ui-kbd.mjs` routes it here): a hint
2657
- * renders the same on the server and in the hydrating browser. Nuxt UI prints
2658
- * `meta`, `ctrl` and `alt` from a map shared by every hint and filled by the
2659
- * first one's `onMounted`, so a hint hydrating after that (in an async
2660
- * component) printed "⌘" over the server's blank. Each hint now keeps the
2661
- * server's value until its own `onMounted`; with a platform provided
2662
- * (`provideDmsKbdPlatform`), that value is the platform's from the start.
2663
- *
2664
- * @internal
2665
- */
2666
- export function useDmsKbd(
2667
- shared: () => NuxtUiKbd,
2668
- keys: Readonly<Record<string, string>>,
2669
- ): NuxtUiKbd {
2670
- const { macOS } = shared();
2671
- const instance = getCurrentInstance();
2672
- const source = instance ? inject(KBD_PLATFORM_KEY, null) : null;
2673
- const platform = source ? source() : null;
2674
- // A hydrating component's vnode is bound to its server node before setup.
2675
- const hydrating = ref(!!instance?.vnode.el);
2676
- if (hydrating.value) onMounted(() => (hydrating.value = false));
2677
- return {
2678
- macOS,
2679
- getKbdKey(value) {
2680
- if (!value) return undefined;
2681
- if (!isModifierKbdKey(value)) return keys[value] || value;
2682
- if (platform) return MODIFIER_KBD_KEYS[platform.value][value];
2683
- if (typeof window === "undefined" || hydrating.value) return " ";
2684
- return MODIFIER_KBD_KEYS[macOS.value ? "mac" : "other"][value];
2685
- },
2686
- };
2687
- }
2688
-
2689
- export const DmsClientOnly = defineComponent({
2690
- name: "DmsClientOnly",
2691
- setup(_, { slots }) {
2692
- const isMounted = ref(false);
2693
- onMounted(() => {
2694
- isMounted.value = true;
2695
- });
2696
- return () =>
2697
- isMounted.value ? slots.default?.() : (slots.fallback?.() ?? null);
2698
- },
2699
- });
2700
-
2701
- export function defineDmsPlugin(setup: DmsPluginSetup): DmsPluginSetup {
2702
- return setup;
2703
- }
2704
- export function useDmsApp(): DmsAppContext {
2705
- const instance = getCurrentInstance();
2706
- const contextual = instance?.appContext.config.globalProperties
2707
- .$dms as DmsAppContext;
2708
- return contextual ?? (useDmsRuntime().appContext as DmsAppContext);
2709
- }
2710
-
2711
- const COOKIE_STATE_PREFIX = "dms-cookie:";
2712
- // A browser tab runs one application, so its cookie refs are shared by every
2713
- // caller; a server shares them within the request it renders instead.
2714
- const browserCookieRefs = new Map<string, Ref<unknown>>();
2715
-
2716
- function readCookie(
2717
- source: string | undefined,
2718
- name: string,
2719
- ): string | undefined {
2720
- const match = source
2721
- ?.split(/;\s*/)
2722
- .find((entry) => entry.startsWith(`${name}=`));
2723
- return match ? decodeURIComponent(match.slice(name.length + 1)) : undefined;
2724
- }
2725
-
2726
- function parseCookieValue<T>(
2727
- stored: string | undefined,
2728
- options: DmsCookieOptions<T>,
2729
- ): T {
2730
- if (stored === undefined) return (options.default?.() ?? null) as T;
2731
- try {
2732
- return JSON.parse(stored) as T;
2733
- } catch {
2734
- return stored as T;
2735
- }
2736
- }
2737
-
2738
- function writeCookie<T>(name: string, next: T, options: DmsCookieOptions<T>) {
2739
- const attributes = [
2740
- `path=${options.path ?? "/"}`,
2741
- options.maxAge === undefined ? "" : `max-age=${options.maxAge}`,
2742
- options.sameSite ? `samesite=${options.sameSite}` : "",
2743
- options.secure ? "secure" : "",
2744
- ].filter(Boolean);
2745
- // biome-ignore lint/suspicious/noDocumentCookie: reactive cookie refs require synchronous writes.
2746
- document.cookie = `${name}=${encodeURIComponent(JSON.stringify(next ?? null))}; ${attributes.join("; ")}`;
2747
- }
2748
-
2749
- function createCookieRef<T>(
2750
- name: string,
2751
- source: string | undefined,
2752
- options: DmsCookieOptions<T>,
2753
- isServer: boolean,
2754
- ): Ref<T> {
2755
- const value = ref(
2756
- parseCookieValue(readCookie(source, name), options),
2757
- ) as Ref<T>;
2758
- return computed({
2759
- get: () => value.value,
2760
- set: (next) => {
2761
- value.value = next;
2762
- if (!isServer) writeCookie(name, next, options);
2763
- },
2764
- });
2765
- }
2766
-
2767
- // The language a visitor picks, kept by the browser so that a reload renders
2768
- // in it. A signed-in user's own language still comes first.
2769
- const LOCALE_COOKIE = "dms_locale";
2770
- const LOCALE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
2771
-
2772
- /**
2773
- * The language this browser picked: read from `document.cookie`, or in a
2774
- * server render from the request. Only one of `supportedLocales` counts.
2775
- */
2776
- export function readDmsLocaleCookie(
2777
- runtime: DmsFrontendRuntime,
2778
- supportedLocales: string[],
2779
- ): string | undefined {
2780
- const source =
2781
- typeof document === "undefined" ? runtime.requestCookies : document.cookie;
2782
- try {
2783
- const locale = parseCookieValue<unknown>(
2784
- readCookie(source, LOCALE_COOKIE),
2785
- {},
2786
- );
2787
- return typeof locale === "string" && supportedLocales.includes(locale)
2788
- ? locale
2789
- : undefined;
2790
- } catch {
2791
- // A value that is not URI-encoded: as if the browser had picked none.
2792
- return undefined;
2793
- }
2794
- }
2795
-
2796
- /**
2797
- * A cookie as a ref, shared by every caller of the same name — so a plugin and
2798
- * a page reading one preference see each other's writes — and read on the
2799
- * server from the request being rendered, so the server HTML matches the client.
2800
- */
2801
- export function useDmsCookie<T = string | null>(
2802
- name: string,
2803
- options: DmsCookieOptions<T> = {},
2804
- ): Ref<T> {
2805
- if (typeof document !== "undefined") {
2806
- if (!browserCookieRefs.has(name))
2807
- browserCookieRefs.set(
2808
- name,
2809
- createCookieRef(name, document.cookie, options, false) as Ref<unknown>,
2810
- );
2811
- return browserCookieRefs.get(name) as Ref<T>;
2812
- }
2813
- const runtime = useDmsRuntime();
2814
- const key = `${COOKIE_STATE_PREFIX}${name}`;
2815
- if (!runtime.sharedState.has(key))
2816
- runtime.sharedState.set(
2817
- key,
2818
- createCookieRef(
2819
- name,
2820
- runtime.requestCookies,
2821
- options,
2822
- true,
2823
- ) as Ref<unknown>,
2824
- );
2825
- return runtime.sharedState.get(key) as Ref<T>;
2826
- }
2827
-
2828
- export function createDmsError(
2829
- input: string | DmsErrorData,
2830
- ): Error & DmsErrorData {
2831
- const details = typeof input === "string" ? { message: input } : input;
2832
- return Object.assign(
2833
- new Error(details.message ?? details.statusMessage),
2834
- details,
2835
- );
2836
- }
2837
- export function showDmsError(
2838
- input: string | DmsErrorData,
2839
- ): Error & DmsErrorData {
2840
- const error = createDmsError(input);
2841
- useDmsRuntime().currentError.value = error;
2842
- return error;
2843
- }
2844
- export function useDmsError(): Ref<(Error & DmsErrorData) | null> {
2845
- return useDmsRuntime().currentError as Ref<(Error & DmsErrorData) | null>;
2846
- }
2847
- export async function clearDmsError(options?: {
2848
- redirect?: string;
2849
- }): Promise<void> {
2850
- useDmsRuntime().currentError.value = null;
2851
- if (options?.redirect) await navigateDms(options.redirect);
2852
- }
2853
-
2854
- function sessionIdentity(session: unknown): string {
2855
- const { accountId, activeTenantId } = (session ?? {}) as Partial<DmsSession>;
2856
- return `${accountId ?? ""}\n${activeTenantId ?? ""}`;
2857
- }
2858
-
2859
- /**
2860
- * Drops the pages Inertia prefetched (a hovered link) once the session is
2861
- * another account's or workspace's, or none: each carries the user, the page
2862
- * and the site layout of the session it was fetched with, and a visit would
2863
- * show them as current.
2864
- */
2865
- function forgetPrefetchedPages(): void {
2866
- if (typeof window !== "undefined") inertiaRouter.flushAll();
2867
- }
2868
-
2869
- /**
2870
- * Warms what a prefetched page renders (its page, layout and block
2871
- * components) once Inertia has its props, so following the link does not wait
2872
- * for those chunks after the props.
2873
- */
2874
- export function preloadPrefetchedDmsPages(): () => void {
2875
- return inertiaRouter.on("prefetched", (event) => {
2876
- const data = event.detail.response.data as unknown;
2877
- let page: { props?: DmsPageProps } | undefined;
2878
- try {
2879
- page = (typeof data === "string" ? JSON.parse(data) : data) as
2880
- | { props?: DmsPageProps }
2881
- | undefined;
2882
- } catch {
2883
- return;
2884
- }
2885
- if (page?.props?.page) void preloadDmsPage(page.props).catch(() => {});
2886
- });
2887
- }
2888
-
2889
- export function useDmsUserSession<
2890
- TUser = DmsUser,
2891
- TSession = DmsSession,
2892
- >(): DmsUserSession<TUser, TSession> {
2893
- const user = useDmsState<TUser | null>("dms-user", () => null);
2894
- const session = useDmsState<TSession | null>("dms-session", () => null);
2895
- return {
2896
- user,
2897
- session,
2898
- loggedIn: computed(() => user.value !== null),
2899
- fetch: async () => {
2900
- const before = sessionIdentity(session.value);
2901
- const value = await ofetch<{
2902
- user?: TUser;
2903
- session?: TSession;
2904
- }>("/api/_auth/session", { method: "POST" });
2905
- user.value = value.user ?? null;
2906
- session.value = value.session ?? null;
2907
- if (sessionIdentity(session.value) !== before) forgetPrefetchedPages();
2908
- },
2909
- clear: async () => {
2910
- await ofetch("/api/_auth/session", { method: "DELETE" });
2911
- user.value = null;
2912
- session.value = null;
2913
- forgetPrefetchedPages();
2914
- },
2915
- };
2916
- }
2917
-
2918
- /**
2919
- * Whether the page loaded by the current navigation is still pending: its
2920
- * chunk, or an async `setup` it awaits, has not resolved yet. A layout reads
2921
- * it to show its own placeholder in place of the page. Always `false` on the
2922
- * server and during hydration, which render the page resolved.
2923
- */
2924
- export function useDmsPageLoading(): Readonly<Ref<boolean>> {
2925
- return readonly(useDmsRuntime().pageLoading);
2926
- }
2927
-
2928
- /**
2929
- * Whether the browser is hydrating the page the server rendered, whose first
2930
- * render must match the server's HTML: from the start of the application to
2931
- * the tick the page's `<Suspense>` resolves. False on the server, in a page
2932
- * the server did not render, and after a client navigation. As Nuxt's
2933
- * `nuxtApp.isHydrating`; read once, it is not reactive.
2934
- */
2935
- export function isDmsHydrating(): boolean {
2936
- return useDmsRuntime().hydrating;
2937
- }
2938
-
2939
- /**
2940
- * The props of the `<Suspense>` around the page that keep `useDmsPageLoading`
2941
- * in step with it. Cleared before the boundary unmounts, so a page left while
2942
- * pending never leaves the state set.
2943
- *
2944
- * They also end the hydration: the page is on screen once its suspense
2945
- * resolves, and the components after it once the tick is over, so the
2946
- * browser has then hydrated what the server rendered. A hydrating suspense
2947
- * never reports itself pending: the server rendered the page resolved.
2948
- */
2949
- export function dmsPageSuspenseProps(): Record<string, () => void> {
2950
- const runtime = useDmsRuntime();
2951
- const loading = runtime.pageLoading;
2952
- const settle = () => {
2953
- loading.value = false;
2954
- };
2955
- return {
2956
- onPending: () => {
2957
- loading.value = true;
2958
- },
2959
- onResolve: () => {
2960
- settle();
2961
- if (runtime.hydrating) void nextTick(() => endHydration(runtime));
2962
- },
2963
- onVnodeBeforeUnmount: settle,
2964
- };
2965
- }
2966
-
2967
- /**
2968
- * The key a component name is registered and resolved under: case, a leading
2969
- * `lazy` and every non-alphanumeric character ignored, so PascalCase,
2970
- * kebab-case and lowercase spellings match. Unlike `normalizeDmsName`, a
2971
- * leading `Dms` is kept: it is the DMS module's prefix, and dropping it would
2972
- * make `DmsBanner` and another module's `Banner` the same component.
2973
- */
2974
- function normalizeDmsComponentName(name: string): string {
2975
- return name
2976
- .replace(/^lazy/i, "")
2977
- .replace(/[^a-z0-9]/gi, "")
2978
- .toLowerCase();
2979
- }
2980
-
2981
- function moduleLabel(module: string | undefined): string {
2982
- return module ? `module "${module}"` : "an unnamed module";
2983
- }
2984
-
2985
- /**
2986
- * A component registered under a name already taken is ignored: the first
2987
- * registration, from the module of higher priority, keeps the name. That is
2988
- * how a module replaces another's component, by registering its full name
2989
- * without a prefix, and only a collision neither side asked for is reported.
2990
- */
2991
- function registerDmsComponentEntry(
2992
- entries: Map<string, DmsComponentRegistration>,
2993
- registration: DmsComponentRegistration,
2994
- ): void {
2995
- const key = normalizeDmsComponentName(registration.name);
2996
- const existing = entries.get(key);
2997
- if (!existing) {
2998
- entries.set(key, registration);
2999
- return;
3000
- }
3001
- if (existing.unprefixed || registration.unprefixed) return;
3002
- console.warn(
3003
- `[dms] Component "${registration.name}" from ${moduleLabel(registration.module)} is ignored: ` +
3004
- `${moduleLabel(existing.module)} already registers "${existing.name}". ` +
3005
- "Give the modules different component prefixes.",
3006
- );
3007
- }
3008
- /** Registers a component unless a higher-priority module already owns its name. */
3009
- export function registerDmsComponent(name: string, component: Component): void {
3010
- registerDmsComponentEntry(registry.components, { name, component });
3011
- }
3012
- /**
3013
- * Resolves a registered component by its full name, prefix included. Case,
3014
- * a leading `lazy` and non-alphanumeric characters are ignored.
3015
- */
3016
- export function resolveDmsComponent(name: string): Component | undefined {
3017
- return registry.components.get(normalizeDmsComponentName(name))?.component;
3018
- }
3019
-
3020
- /**
3021
- * Records, by registered name, every registered async component a server
3022
- * render reaches, so the client can resolve the same ones before hydrating.
3023
- * Inertia re-renders the tree once on boot; an async component still loading
3024
- * at that moment makes Vue drop its server-rendered markup ("Skipping lazy
3025
- * hydration") and leave it empty until its chunk arrives.
3026
- */
3027
- export function trackDmsAsyncComponents(app: App): Set<string> {
3028
- const names = new Map<Component, string>();
3029
- registry.components.forEach(({ component, name }) => {
3030
- if ((component as DmsAsyncComponent).__asyncLoader)
3031
- names.set(component, name);
3032
- });
3033
- const rendered = new Set<string>();
3034
- app.mixin({
3035
- beforeCreate(this: ComponentPublicInstance) {
3036
- const name = names.get(this.$.type as Component);
3037
- if (name) rendered.add(name);
3038
- },
3039
- });
3040
- return rendered;
3041
- }
3042
-
3043
- /**
3044
- * Records the scope of every component a server render creates, and returns
3045
- * the function that stops them once the render is over.
3046
- *
3047
- * A server app is never mounted, so it is never unmounted either: Vue leaves
3048
- * the scope of each component it rendered running, and nothing handed to
3049
- * `onScopeDispose` is ever released. Nuxt UI's `useRuntimeHook` registers in
3050
- * a process-wide hook registry and only unregisters there, so every server
3051
- * render of the dashboard sidebar stayed reachable, its whole component tree
3052
- * and page props with it, until the server ran out of heap.
3053
- *
3054
- * The component's scope is read through `getCurrentScope`: Vue makes it the
3055
- * active scope before it calls `beforeCreate`, and the instance's own `scope`
3056
- * field is internal, absent from the typings a generated workspace checks.
3057
- */
3058
- export function trackDmsServerScopes(app: App): () => void {
3059
- const scopes: EffectScope[] = [];
3060
- app.mixin({
3061
- beforeCreate() {
3062
- const scope = getCurrentScope();
3063
- if (scope) scopes.push(scope);
3064
- },
3065
- });
3066
- return () => {
3067
- for (const scope of scopes.splice(0)) scope.stop();
3068
- };
3069
- }
3070
-
3071
- export async function resolveDmsAsyncComponents(
3072
- names: readonly string[],
3073
- ): Promise<void> {
3074
- await Promise.all(
3075
- names.map((name) =>
3076
- (
3077
- resolveDmsComponent(name) as DmsAsyncComponent | undefined
3078
- )?.__asyncLoader?.(),
3079
- ),
3080
- );
3081
- }
3082
- export async function preloadDmsComponents(names: string[]): Promise<void> {
3083
- const registry = useDmsRuntime().appContext?.vueApp._context.components ?? {};
3084
- await Promise.all(
3085
- names.map((name) =>
3086
- (registry[name] as DmsAsyncComponent | undefined)?.__asyncLoader?.(),
3087
- ),
3088
- );
3089
- }
3090
- export function normalizeDmsName(name: string): string {
3091
- return name
3092
- .replace(/^lazy/i, "")
3093
- .replace(/^dms[-_]?/i, "")
3094
- .replace(/[^a-z0-9]/gi, "")
3095
- .toLowerCase();
3096
- }
3097
-
3098
- function collectRenderedComponentNames(
3099
- value: unknown,
3100
- names: Set<string>,
3101
- ): void {
3102
- if (!value || typeof value !== "object") return;
3103
- if (Array.isArray(value)) {
3104
- value.forEach((entry) => {
3105
- collectRenderedComponentNames(entry, names);
3106
- });
3107
- return;
3108
- }
3109
- const definition = value as Record<string, unknown>;
3110
- for (const key of ["component", "componentName"]) {
3111
- if (typeof definition[key] === "string") names.add(definition[key]);
3112
- }
3113
- // A child places a component definition: `{ id, component: { … } }`.
3114
- if (typeof definition.component === "object")
3115
- collectRenderedComponentNames(definition.component, names);
3116
- collectRenderedComponentNames(definition.children, names);
3117
- }
3118
-
3119
- async function preloadDmsLayoutComponents(
3120
- layout: DmsPageLayout | undefined,
3121
- ): Promise<void> {
3122
- const names = new Set<string>();
3123
- collectRenderedComponentNames(layout?.layout, names);
3124
- Object.values(layout?.components ?? {}).forEach((component) => {
3125
- collectRenderedComponentNames(component, names);
3126
- });
3127
- await Promise.all(
3128
- [...names].map((name) => {
3129
- const component = resolveDmsComponent(name) as
3130
- | DmsAsyncComponent
3131
- | undefined;
3132
- return component?.__asyncLoader?.();
3133
- }),
3134
- );
3135
- }
3136
-
3137
- export function normalizeDmsPageKey(name: string): string {
3138
- return name.split(/[?#]/, 1)[0].replace(/^\/+|\/+$/g, "");
3139
- }
3140
- const CATCH_ALL_PAGE_KEY = "[...slug]";
3141
-
3142
- function findDmsPageEntry(props: DmsPageProps): DmsFrontendEntry | undefined {
3143
- const name = [
3144
- props.page.componentName,
3145
- props.page.route?.fullSlug,
3146
- props.path,
3147
- ].find(
3148
- (candidate) =>
3149
- candidate && registry.pages.has(normalizeDmsPageKey(candidate)),
3150
- );
3151
- return name
3152
- ? registry.pages.get(normalizeDmsPageKey(name))
3153
- : registry.dynamicPages.get(CATCH_ALL_PAGE_KEY);
3154
- }
3155
-
3156
- /** The path a frontend module registered for a typed backend refusal. */
3157
- export function resolveDmsAccessRedirect(code: string): string | undefined {
3158
- return registry.accessRedirects.get(code);
3159
- }
3160
- export function hasDmsPage(name: string): boolean {
3161
- return registry.pages.has(normalizeDmsPageKey(name));
3162
- }
3163
- export function getDmsPage(props: DmsPageProps): Component | undefined {
3164
- if (props.error) return registry.errorPage?.component;
3165
- return findDmsPageEntry(props)?.component;
3166
- }
3167
- export function getDmsDynamicPage(name = "default"): Component | undefined {
3168
- return registry.dynamicPages.get(name)?.component;
3169
- }
3170
- export function getDmsLayout(props: DmsPageProps): Component | undefined {
3171
- const layout = props.page.layout;
3172
- const name = layout?.layout?.componentName ?? layout?.componentName;
3173
- return name
3174
- ? registry.layouts.get(normalizeDmsName(name))?.component
3175
- : undefined;
3176
- }
3177
- export function getDmsLayoutProps(
3178
- props: DmsPageProps,
3179
- ): Record<string, unknown> {
3180
- const definition = props.page.layout?.layout;
3181
- return {
3182
- ...(definition?.options ?? {}),
3183
- icon: props.page.route?.icon,
3184
- title: props.page.route?.displayName,
3185
- description: props.page.route?.description,
3186
- };
3187
- }
3188
-
3189
- /**
3190
- * Whether a page carries an older session of the same account than the one
3191
- * the app holds: Inertia restores a page from history, or serves it from the
3192
- * prefetch cache, with the props it was fetched with, and those must not undo
3193
- * a later `useDmsUserSession().fetch()`.
3194
- */
3195
- function isStaleSession(
3196
- incoming: DmsSession | undefined,
3197
- current: DmsSession | null,
3198
- ): boolean {
3199
- return (
3200
- !!incoming &&
3201
- !!current &&
3202
- incoming.accountId === current.accountId &&
3203
- (incoming.updatedAt ?? 0) < (current.updatedAt ?? 0)
3204
- );
3205
- }
3206
-
3207
- /**
3208
- * Copies the site layout of the page props into the state. A visit carries
3209
- * the layout it was sent over from page to page (a once prop): the same copy
3210
- * is applied once, so the state keeps what a later refresh wrote into it, and
3211
- * `dms-lastRefreshTime` tells when the layout was last received.
3212
- */
3213
- export function hydrateDmsSharedProps(
3214
- shared: DmsSharedPagePayload | undefined,
3215
- ): void {
3216
- const runtime = useDmsRuntime();
3217
- if (!shared || shared === runtime.appliedShared) return;
3218
- // A page from the history carries the layout it was shown with, which the
3219
- // state has replaced since: see `keepSharedOverHistory`.
3220
- if (runtime.restoringHistory) return;
3221
- runtime.appliedShared = shared;
3222
- useDmsState<unknown>("dms-siteLayout", () => undefined).value =
3223
- shared.siteLayout;
3224
- useDmsState<unknown>("dms-pageTree", () => undefined).value =
3225
- shared.siteLayoutTree;
3226
- useDmsState<unknown>("dms-quickActions", () => undefined).value =
3227
- shared.quickActions;
3228
- useDmsState<unknown>("dms-modules", () => undefined).value = shared.modules;
3229
- useDmsState("dms-isOwner", () => false).value = shared.isOwner ?? false;
3230
- useDmsState("dms-lastRefreshTime", () => 0).value = Date.now();
3231
- }
3232
-
3233
- /**
3234
- * Keeps the newest site layout when Back or Forward puts a page back. The
3235
- * page carries the layout it was shown with, which a later reload replaced
3236
- * (a menu change the backend announced); it is not copied into the state, and
3237
- * the page is given the current one, so the next visit carries that over
3238
- * rather than the older one.
3239
- */
3240
- export function keepSharedOverHistory(): () => void {
3241
- const runtime = useDmsRuntime();
3242
- const onPopstate = (event: PopStateEvent) => {
3243
- // Inertia restores only the entries it wrote, which hold a page.
3244
- if ((event.state as { page?: unknown } | null)?.page)
3245
- runtime.restoringHistory = true;
3246
- };
3247
- window.addEventListener("popstate", onPopstate);
3248
- const stopListening = inertiaRouter.on("navigate", (event) => {
3249
- if (!runtime.restoringHistory) return;
3250
- runtime.restoringHistory = false;
3251
- const current = runtime.appliedShared;
3252
- const restored = (event.detail.page.props as Partial<DmsPageProps>).shared;
3253
- if (current && restored !== current)
3254
- inertiaRouter.replaceProp("shared", current);
3255
- });
3256
- return () => {
3257
- window.removeEventListener("popstate", onPopstate);
3258
- stopListening();
3259
- };
3260
- }
3261
-
3262
- export function hydrateDmsPageProps(props: DmsPageProps, url?: string): void {
3263
- const runtime = useDmsRuntime();
3264
- if (url) updateRoute(runtime, url);
3265
- // Pages are keyed by path: another path mounts another page, which declares
3266
- // its own meta, and so does an error page shown in place of the page. A
3267
- // visit that only changes the query keeps the page, and so its meta.
3268
- const pageKey = props.error ? `error:${props.path}` : props.path;
3269
- if (pageKey !== runtime.pageKey) {
3270
- runtime.pageKey = pageKey;
3271
- runtime.route.meta = {};
3272
- }
3273
- dropUnusedAsyncData(runtime);
3274
- runtime.currentError.value = null;
3275
- const session = useDmsState<DmsSession | null>("dms-session", () => null);
3276
- if (!isStaleSession(props.session, session.value)) {
3277
- useDmsState<DmsUser | null>("dms-user", () => null).value =
3278
- props.user ?? null;
3279
- session.value = props.session ?? null;
3280
- }
3281
- hydrateDmsSharedProps(props.shared);
3282
- const layoutUrl = props.page.route?.layoutUrl ?? props.page.layoutUrl;
3283
- const pageLayouts = useDmsState<Record<string, DmsPageLayout>>(
3284
- "dms-pageLayouts",
3285
- () => ({}),
3286
- );
3287
- // Each navigation is a new authorization snapshot. Never retain layouts
3288
- // from an earlier account, tenant, or permission context.
3289
- pageLayouts.value =
3290
- layoutUrl && props.page.layout ? { [layoutUrl]: props.page.layout } : {};
3291
- }
3292
- /**
3293
- * Ends the hydration of the page the server rendered. The server's data no
3294
- * component took over is dropped: a later page must not show it as current.
3295
- */
3296
- function endHydration(runtime: DmsFrontendRuntime): void {
3297
- runtime.hydrating = false;
3298
- runtime.hydratedAsyncData.clear();
3299
- runtime.hydratedAsyncErrors.clear();
3300
- }
3301
-
3302
- export function getDmsErrorPage(): Component | undefined {
3303
- return registry.errorPage?.component;
3304
- }
3305
-
3306
- export async function preloadDmsPage(props: DmsPageProps): Promise<void> {
3307
- const layoutName =
3308
- props.page.layout?.layout?.componentName ??
3309
- props.page.layout?.componentName;
3310
- const page = findDmsPageEntry(props) ?? registry.dynamicPages.get("default");
3311
- const layout = layoutName
3312
- ? registry.layouts.get(normalizeDmsName(layoutName))
3313
- : undefined;
3314
- const entries = props.error ? [registry.errorPage] : [page, layout];
3315
- // `preload` only warms the module; the async wrapper stays unresolved until
3316
- // its own loader runs. Hydrating an unresolved wrapper defers it, and the
3317
- // first parent render (Inertia's initial swap) then makes Vue drop the
3318
- // server-rendered markup and render the subtree from scratch.
3319
- await Promise.all(
3320
- entries.flatMap((entry) => [
3321
- entry?.preload?.(),
3322
- (entry?.component as DmsAsyncComponent | undefined)?.__asyncLoader?.(),
3323
- ]),
3324
- );
3325
- if (!props.error) await preloadDmsLayoutComponents(props.page.layout);
3326
- }
3327
-
3328
- export function useDmsInjection<T>(
3329
- key: string | InjectionKey<T>,
3330
- ): T | undefined {
3331
- return inject(key as InjectionKey<T>, undefined);
3332
- }
3333
-
3334
- function registerDmsFrontendEntry(
3335
- entries: Map<string, DmsFrontendEntry>,
3336
- name: string,
3337
- component: Component,
3338
- preload?: DmsComponentPreloader,
3339
- ): void {
3340
- if (!entries.has(name)) entries.set(name, { component, preload });
3341
- }
3342
-
3343
- /** `name` behind `prefix`, as a PascalCase component name. */
3344
- function prefixedComponentName(prefix: string, name: string): string {
3345
- return prefix ? `${prefix}${capitalize(camelize(name))}` : name;
3346
- }
3347
-
3348
- function createSdk(
3349
- target: DmsFrontendRegistry,
3350
- {
3351
- name: module,
3352
- module: { componentPrefix = "" },
3353
- options,
3354
- }: DmsFrontendModuleRegistration,
3355
- ): DmsFrontendSdk {
3356
- return {
3357
- options,
3358
- registerComponent: (name, component, { prefix = true } = {}) =>
3359
- registerDmsComponentEntry(target.components, {
3360
- name: prefix ? prefixedComponentName(componentPrefix, name) : name,
3361
- component,
3362
- module,
3363
- unprefixed: !prefix,
3364
- }),
3365
- registerPage: (name, component, preload) => {
3366
- const key = normalizeDmsPageKey(name);
3367
- registerDmsFrontendEntry(target.pages, key, component, preload);
3368
- },
3369
- registerDynamicPage: (name, component, preload) =>
3370
- registerDmsFrontendEntry(target.dynamicPages, name, component, preload),
3371
- registerLayout: (name, component, preload) => {
3372
- const key = normalizeDmsName(name);
3373
- registerDmsFrontendEntry(target.layouts, key, component, preload);
3374
- },
3375
- registerErrorPage: (component, preload) => {
3376
- target.errorPage ??= { component, preload };
3377
- },
3378
- registerPlugin: (setup, options = {}) =>
3379
- target.pluginSetups.push({
3380
- setup,
3381
- clientOnly: options.clientOnly ?? false,
3382
- enforce: options.enforce ?? "default",
3383
- }),
3384
- registerMiddleware: (name, handler, options = {}) => {
3385
- if (!target.namedMiddleware.has(name))
3386
- target.namedMiddleware.set(name, handler);
3387
- if (options.global) target.middleware.push(handler);
3388
- if (options.parallel) target.parallelMiddleware.add(handler);
3389
- },
3390
- registerAccessRedirect: (code, path) => {
3391
- if (!target.accessRedirects.has(code))
3392
- target.accessRedirects.set(code, path);
3393
- },
3394
- provide: (key, value) => {
3395
- if (!target.injections.has(key)) target.injections.set(key, value);
3396
- },
3397
- use: (plugin) => target.plugins.push(plugin),
3398
- };
3399
- }
3400
-
3401
- /**
3402
- * Sets the frontend modules up in their generated priority order, into a
3403
- * registry of their own that replaces the previous one once complete.
3404
- *
3405
- * The browser and a production server set the modules up once. A development
3406
- * server sets them up again each time Vite evaluates the server renderer anew
3407
- * after a layer changes, but keeps this module and its state: registering into
3408
- * the previous registry, the new components lost their names to the ones they
3409
- * replace, so the server kept rendering the old ones, and every list gained the
3410
- * same entries once more. A render still in progress keeps reading a complete
3411
- * registry: the new one is only published once every module is set up.
3412
- *
3413
- * `useDmsAppConfig()` holds the layers' app configs merged, highest precedence
3414
- * first, before the first module is set up: a project's layer overrides the
3415
- * DMS's, which fills in the rest. A setup may still read and extend it.
3416
- */
3417
- export async function setupFrontendModules(
3418
- registrations: DmsFrontendModuleRegistration[],
3419
- layerAppConfigs: DmsLayerAppConfig[] = [],
3420
- ): Promise<void> {
3421
- const next = createDmsFrontendRegistry();
3422
- const configs = layerAppConfigs.map(({ config }) => config);
3423
- // Modules read and extend these two while they are set up, so they restart
3424
- // here rather than being published with the registry: the public options
3425
- // empty, the app config from the layers alone.
3426
- runtimeConfig.value.public = {} as PublicRuntimeConfig;
3427
- appConfig.value = mergeLayerAppConfigs(configs);
3428
- for (const registration of registrations) {
3429
- runtimeConfig.value.public = defu(
3430
- runtimeConfig.value.public,
3431
- registration.options.public,
3432
- );
3433
- await registration.module.setup(createSdk(next, registration));
3434
- }
3435
- appConfigSetupChanges = appConfigChanges(
3436
- mergeLayerAppConfigs(configs),
3437
- appConfig.value,
3438
- );
3439
- registry = next;
3440
- }
3441
-
3442
- /**
3443
- * Development only: applies the layers' app configs, edited while the page
3444
- * is open, to `useDmsAppConfig()`, with what the setup of the modules changed
3445
- * in the previous ones still on top. The object is updated in place, records
3446
- * included, since components hold it: the modules are not set up again.
3447
- */
3448
- export function updateDmsAppConfig(layerAppConfigs: DmsLayerAppConfig[]): void {
3449
- const layered = mergeLayerAppConfigs(
3450
- layerAppConfigs.map(({ config }) => config),
3451
- );
3452
- assignInPlace(
3453
- appConfig.value,
3454
- withAppConfigChanges(layered, appConfigSetupChanges),
3455
- );
3456
- }
3457
-
3458
- export async function installDmsPlugins(
3459
- app: App,
3460
- i18n: Composer,
3461
- runtime: DmsFrontendRuntime,
3462
- loadLocaleMessages: DmsLocaleLoader,
3463
- supportedLocales: string[],
3464
- ): Promise<() => Promise<void>> {
3465
- const dmsI18n = i18n as DmsI18n;
3466
- const hooks = new Map<string, Array<() => void | Promise<void>>>();
3467
- const appContext: DmsAppContext = {
3468
- vueApp: app,
3469
- $i18n: dmsI18n,
3470
- provide: (key, value) => {
3471
- app.provide(key, value);
3472
- app.config.globalProperties[`$${key}`] = value;
3473
- },
3474
- runWithContext: (callback) => app.runWithContext(callback),
3475
- hook: (name, callback) =>
3476
- hooks.set(name, [...(hooks.get(name) ?? []), callback]),
3477
- };
3478
- runtime.appContext = appContext;
3479
- if (typeof window !== "undefined" && !runtime.hasNavigationListener) {
3480
- runtime.hasNavigationListener = true;
3481
- const stopNavigationListener = inertiaRouter.on("before", () => {
3482
- runtime.currentError.value = null;
3483
- });
3484
- app.onUnmount(() => {
3485
- stopNavigationListener();
3486
- runtime.hasNavigationListener = false;
3487
- if (browserRuntime === runtime) browserRuntime = undefined;
3488
- });
3489
- }
3490
- const failedSetups = new WeakSet<ComponentInternalInstance>();
3491
- const warnOutsideFailedSetups: AppConfig["warnHandler"] = (
3492
- message,
3493
- instance,
3494
- trace,
3495
- ) => {
3496
- // The comment Vue renders in place of an `undefined` component has no
3497
- // public instance: the template rendering it is the one that failed.
3498
- const source = instance?.$ ?? getCurrentInstance();
3499
- if (!withinFailedSetup(source, failedSetups))
3500
- console.warn(`[Vue warn]: ${message}${trace ? `\n${trace}` : ""}`);
3501
- };
3502
- app.config.errorHandler = (reason, instance, info) => {
3503
- if (instance && info === SETUP_FUNCTION_ERROR) {
3504
- failedSetups.add(instance.$);
3505
- // From the first failure on only: until then Vue keeps its own output,
3506
- // whose component trace shows the props. An app that already has a
3507
- // handler keeps it. A server renders a single page per app; in the
3508
- // browser the app outlives Inertia visits, so once a page has failed
3509
- // the handler stays for the session and later warnings print as plain
3510
- // text (no inspectable objects), still all of them.
3511
- app.config.warnHandler ??= warnOutsideFailedSetups;
3512
- }
3513
- failPage(runtime, reason);
3514
- };
3515
- app.config.globalProperties.$dms = appContext;
3516
- Reflect.set(app, "$dms", appContext);
3517
- Reflect.set(app, "$i18n", i18n);
3518
- Reflect.set(appContext.vueApp, "$dms", appContext);
3519
- Reflect.set(app.config.globalProperties, "$i18n", i18n);
3520
- dmsI18n.setLocale = async (locale: string) => {
3521
- const messages = await loadLocaleMessages(locale);
3522
- i18n.setLocaleMessage(locale, messages);
3523
- i18n.locale.value = locale;
3524
- if (typeof document !== "undefined" && supportedLocales.includes(locale))
3525
- writeCookie(LOCALE_COOKIE, locale, {
3526
- maxAge: LOCALE_COOKIE_MAX_AGE,
3527
- sameSite: "lax",
3528
- secure: globalThis.location?.protocol === "https:",
3529
- });
3530
- };
3531
- dmsI18n.locales = computed(() =>
3532
- supportedLocales.map((code) => ({
3533
- code,
3534
- name: new Intl.DisplayNames([code], { type: "language" }).of(code),
3535
- })),
3536
- );
3537
- app.component("DmsLink", DmsLink);
3538
- app.component("DmsClientOnly", DmsClientOnly);
3539
- registry.components.forEach(({ component, name }) => {
3540
- app.component(name, component);
3541
- });
3542
- registry.injections.forEach((value, key) => {
3543
- app.provide(key, value);
3544
- });
3545
- registry.plugins.forEach((plugin) => {
3546
- app.use(plugin);
3547
- });
3548
- // Stable: within a group, the modules' priority order stays.
3549
- const pluginSetups = [...registry.pluginSetups].sort(
3550
- (left, right) => PLUGIN_ORDER[left.enforce] - PLUGIN_ORDER[right.enforce],
3551
- );
3552
- for (const registration of pluginSetups) {
3553
- if (registration.clientOnly && typeof window === "undefined") continue;
3554
- await runtime.scope.run(() =>
3555
- app.runWithContext(() => registration.setup(appContext)),
3556
- );
3557
- }
3558
- // What plugins start without awaiting it is still part of the page a
3559
- // server renders: its HTML shows it, so the render waits for it.
3560
- if (runtime.isServer) await Promise.all(runtime.serverPrefetches);
3561
- return async () => {
3562
- for (const callback of hooks.get("app:mounted") ?? []) await callback();
3563
- };
3564
- }
120
+ DmsKbdPlatformSource,
121
+ } from "./frontend-runtime/ui";
122
+ export { readDmsLocaleCookie, useDmsCookie } from "./frontend-runtime/cookies";
123
+ export { createDmsError } from "./frontend-runtime/dms-error";
124
+ export {
125
+ showDmsError,
126
+ useDmsError,
127
+ clearDmsError,
128
+ preloadPrefetchedDmsPages,
129
+ useDmsUserSession,
130
+ useDmsPageLoading,
131
+ isDmsHydrating,
132
+ dmsPageSuspenseProps,
133
+ } from "./frontend-runtime/errors";
134
+ export {
135
+ registerDmsComponent,
136
+ resolveDmsComponent,
137
+ trackDmsAsyncComponents,
138
+ trackDmsServerScopes,
139
+ resolveDmsAsyncComponents,
140
+ preloadDmsComponents,
141
+ normalizeDmsName,
142
+ normalizeDmsPageKey,
143
+ resolveDmsAccessRedirect,
144
+ hasDmsPage,
145
+ getDmsPage,
146
+ getDmsDynamicPage,
147
+ getDmsLayout,
148
+ getDmsLayoutProps,
149
+ hydrateDmsSharedProps,
150
+ keepSharedOverHistory,
151
+ hydrateDmsPageProps,
152
+ getDmsErrorPage,
153
+ preloadDmsPage,
154
+ } from "./frontend-runtime/components";
155
+ export {
156
+ useDmsInjection,
157
+ setupFrontendModules,
158
+ updateDmsAppConfig,
159
+ installDmsPlugins,
160
+ } from "./frontend-runtime/sdk";