@antelopejs/dms-frontend 0.5.6 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +376 -18
- package/dist/app-configs.d.ts +9 -0
- package/dist/app-configs.js +52 -0
- package/dist/asset-roots.d.ts +7 -0
- package/dist/asset-roots.js +34 -0
- package/dist/commands/build-action.js +9 -0
- package/dist/commands/dev-action.js +88 -19
- package/dist/commands/start-action.js +2 -0
- package/dist/common.d.ts +1 -0
- package/dist/common.js +1 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +8 -3
- package/dist/derived-outputs.d.ts +2 -0
- package/dist/derived-outputs.js +38 -31
- package/dist/help.js +10 -3
- package/dist/layer-watch.js +123 -131
- package/dist/layers.js +2 -0
- package/dist/manifest-secrets.js +5 -5
- package/dist/manifest.js +7 -1
- package/dist/materialize.js +21 -1
- package/dist/module-set.d.ts +16 -0
- package/dist/module-set.js +80 -0
- package/dist/server-process.d.ts +1 -0
- package/dist/server-process.js +28 -2
- package/dist/verify-source-runner.js +12 -0
- package/dist/workspace.d.ts +9 -0
- package/package.json +2 -1
- package/templates/vue/app-runtime.ts +58 -16
- package/templates/vue/client-server-guard.mjs +73 -0
- package/templates/vue/email-renderer.ts +17 -4
- package/templates/vue/email-runtime.ts +1 -1
- package/templates/vue/frontend-module.ts +1817 -259
- package/templates/vue/globals.d.ts +1 -1
- package/templates/vue/icon-hydration.ts +106 -1
- package/templates/vue/index.html +0 -1
- package/templates/vue/layer-app-config.ts +92 -0
- package/templates/vue/main.ts +10 -2
- package/templates/vue/package.json +4 -1
- package/templates/vue/server/auth/backend.mjs +13 -19
- package/templates/vue/server/auth/client-ip.mjs +27 -0
- package/templates/vue/server/auth/establish-endpoints.mjs +70 -0
- package/templates/vue/server/auth/session.mjs +6 -97
- package/templates/vue/server/auth-hooks.mjs +84 -0
- package/templates/vue/server/backend-proxies.mjs +110 -0
- package/templates/vue/server/dev-private-files.mjs +99 -0
- package/templates/vue/server/email.mjs +11 -33
- package/templates/vue/server/favicon.mjs +20 -0
- package/templates/vue/server/inertia.mjs +48 -3
- package/templates/vue/server/listen-host.mjs +19 -0
- package/templates/vue/server/module-event.mjs +265 -0
- package/templates/vue/server/module-routes.mjs +513 -0
- package/templates/vue/server/module-server.mjs +225 -0
- package/templates/vue/server/module-testing.mjs +65 -0
- package/templates/vue/server/page-props.mjs +151 -0
- package/templates/vue/server/path-patterns.mjs +38 -0
- package/templates/vue/server/public-endpoints.mjs +69 -11
- package/templates/vue/server/request-routing.mjs +47 -0
- package/templates/vue/server/route-rules.mjs +129 -0
- package/templates/vue/server-module.ts +294 -0
- package/templates/vue/server.mjs +177 -161
- package/templates/vue/ssr-renderer.ts +60 -19
- package/templates/vue/tsconfig.json +2 -1
- package/templates/vue/ui-app-config.ts +18 -6
- package/templates/vue/ui-inertia-page.mjs +37 -0
- package/templates/vue/vite.config.ts +31 -10
- package/templates/vue/vite.email.config.ts +1 -1
- package/templates/vue/vite.server.config.ts +57 -0
- package/templates/vue/server/auth/oauth.mjs +0 -233
- package/templates/vue/server/auth/routes.mjs +0 -444
- package/templates/vue/server/render-token.mjs +0 -81
- package/templates/vue/server/tester.mjs +0 -227
|
@@ -27,18 +27,34 @@ import {
|
|
|
27
27
|
hasInjectionContext,
|
|
28
28
|
type InjectionKey,
|
|
29
29
|
inject,
|
|
30
|
+
isRef,
|
|
31
|
+
type MaybeRefOrGetter,
|
|
32
|
+
nextTick,
|
|
33
|
+
onBeforeMount,
|
|
30
34
|
onMounted,
|
|
31
35
|
onScopeDispose,
|
|
36
|
+
onServerPrefetch,
|
|
32
37
|
type Plugin,
|
|
33
38
|
type PropType,
|
|
34
39
|
type Ref,
|
|
35
40
|
reactive,
|
|
36
41
|
readonly,
|
|
37
42
|
ref,
|
|
43
|
+
type ShallowRef,
|
|
44
|
+
shallowReactive,
|
|
45
|
+
shallowRef,
|
|
46
|
+
toRaw,
|
|
47
|
+
toValue,
|
|
38
48
|
watch,
|
|
39
49
|
} from "vue";
|
|
40
50
|
import type { Composer } from "vue-i18n";
|
|
41
51
|
import { useI18n as useVueI18n } from "vue-i18n";
|
|
52
|
+
import {
|
|
53
|
+
appConfigChanges,
|
|
54
|
+
assignInPlace,
|
|
55
|
+
mergeLayerAppConfigs,
|
|
56
|
+
withAppConfigChanges,
|
|
57
|
+
} from "./layer-app-config";
|
|
42
58
|
|
|
43
59
|
export interface DmsPageRoute {
|
|
44
60
|
displayName?: string;
|
|
@@ -52,7 +68,6 @@ export interface DmsPagePayload {
|
|
|
52
68
|
componentName?: string;
|
|
53
69
|
layoutUrl?: string;
|
|
54
70
|
layout?: DmsPageLayout;
|
|
55
|
-
shared?: DmsSharedPagePayload;
|
|
56
71
|
[key: string]: unknown;
|
|
57
72
|
}
|
|
58
73
|
export interface DmsSharedPagePayload {
|
|
@@ -74,6 +89,11 @@ export interface DmsPageLayout {
|
|
|
74
89
|
export interface DmsPageProps {
|
|
75
90
|
path: string;
|
|
76
91
|
page: DmsPagePayload;
|
|
92
|
+
/**
|
|
93
|
+
* The site layout. An Inertia once prop: a visit carries it over from the
|
|
94
|
+
* previous page until it expires, unless the server sends a newer one.
|
|
95
|
+
*/
|
|
96
|
+
shared?: DmsSharedPagePayload;
|
|
77
97
|
user?: DmsUser;
|
|
78
98
|
session?: DmsSession;
|
|
79
99
|
error?: DmsErrorData;
|
|
@@ -159,30 +179,145 @@ export interface DmsRouter {
|
|
|
159
179
|
replace(to: RouteLocationRaw, options?: DmsNavigationOptions): Promise<void>;
|
|
160
180
|
back(): void;
|
|
161
181
|
}
|
|
182
|
+
export type DmsAsyncDataStatus = "idle" | "pending" | "success" | "error";
|
|
183
|
+
/**
|
|
184
|
+
* What a new run does with the one still in flight: `cancel` aborts it and
|
|
185
|
+
* starts over, `defer` waits for it instead.
|
|
186
|
+
*/
|
|
187
|
+
export type DmsAsyncDataDedupe = "cancel" | "defer";
|
|
188
|
+
export interface DmsAsyncDataRefreshOptions {
|
|
189
|
+
dedupe?: DmsAsyncDataDedupe;
|
|
190
|
+
}
|
|
162
191
|
export interface DmsAsyncData<T> {
|
|
163
192
|
data: Ref<T | null>;
|
|
193
|
+
/**
|
|
194
|
+
* The key the data is loaded under, a reactive key's current one; none for
|
|
195
|
+
* a call without a key.
|
|
196
|
+
*/
|
|
197
|
+
key: Readonly<Ref<string | undefined>>;
|
|
198
|
+
/**
|
|
199
|
+
* `true` while `data` is still the previous key's, shown in place of the
|
|
200
|
+
* default until the current key's first answer (a reactive key only).
|
|
201
|
+
*/
|
|
202
|
+
placeholder: Readonly<Ref<boolean>>;
|
|
203
|
+
/**
|
|
204
|
+
* The last run's failure. It stays while the next run is in flight, and
|
|
205
|
+
* goes back to `null` once a run answers.
|
|
206
|
+
*/
|
|
164
207
|
error: Ref<unknown>;
|
|
165
208
|
pending: Ref<boolean>;
|
|
166
|
-
status: Ref<
|
|
167
|
-
execute(): Promise<void>;
|
|
168
|
-
refresh(): Promise<void>;
|
|
209
|
+
status: Ref<DmsAsyncDataStatus>;
|
|
210
|
+
execute(options?: DmsAsyncDataRefreshOptions): Promise<void>;
|
|
211
|
+
refresh(options?: DmsAsyncDataRefreshOptions): Promise<void>;
|
|
212
|
+
/** Aborts the run in flight and puts the data back to its default. */
|
|
213
|
+
clear(): void;
|
|
214
|
+
}
|
|
215
|
+
/** Usable at once, or awaited until its data is there. */
|
|
216
|
+
export type DmsAsyncDataResult<T> = DmsAsyncData<T> & Promise<DmsAsyncData<T>>;
|
|
217
|
+
export interface DmsAsyncDataContext {
|
|
218
|
+
/**
|
|
219
|
+
* Aborted once the result is no longer wanted: a newer run replaced it, it
|
|
220
|
+
* was cleared, or no component shows its key any more.
|
|
221
|
+
*/
|
|
222
|
+
signal: AbortSignal;
|
|
223
|
+
}
|
|
224
|
+
export type DmsAsyncDataHandler<T> = (
|
|
225
|
+
context: DmsAsyncDataContext,
|
|
226
|
+
) => Promise<T>;
|
|
227
|
+
/** A key, or `null` (or `undefined`) while there is nothing to load. */
|
|
228
|
+
export type DmsAsyncDataKey = string | null | undefined;
|
|
229
|
+
/** What `useDmsData` gives of a key's data. */
|
|
230
|
+
export interface DmsData<T> {
|
|
231
|
+
/**
|
|
232
|
+
* The data loaded under the key, shared with every caller showing it:
|
|
233
|
+
* `undefined` while the key has none of its own (no caller loaded it yet,
|
|
234
|
+
* or it shows the previous key's data meanwhile). Assigning it replaces the
|
|
235
|
+
* key's data; a key nobody loaded keeps none.
|
|
236
|
+
*/
|
|
237
|
+
data: Ref<T | undefined>;
|
|
169
238
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
transform?: (value: unknown) => T | Promise<T>;
|
|
175
|
-
pick?: string[];
|
|
176
|
-
$fetch?: typeof ofetch;
|
|
239
|
+
/** What `transform` knows of the run whose result it transforms. */
|
|
240
|
+
export interface DmsAsyncDataTransformContext {
|
|
241
|
+
/** The key the data is loaded under; none for a call without a key. */
|
|
242
|
+
key: string | undefined;
|
|
177
243
|
}
|
|
244
|
+
/** What `useDmsFetch`'s `transform` knows of the request it answers. */
|
|
245
|
+
export interface DmsFetchTransformContext extends DmsAsyncDataTransformContext {
|
|
246
|
+
/**
|
|
247
|
+
* The URL requested, as given (before the base URL and the query), even
|
|
248
|
+
* when a reactive URL changed since.
|
|
249
|
+
*/
|
|
250
|
+
url: string;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* The request options a ref or a getter may stand for: read again for each
|
|
254
|
+
* request, and part of the key the response is shared under.
|
|
255
|
+
*/
|
|
256
|
+
type DmsFetchRequestSources = "method" | "query" | "params" | "body";
|
|
178
257
|
export interface UseAsyncDataOptions<T> {
|
|
179
258
|
default?: () => T;
|
|
259
|
+
/** `false` waits for `execute()` (a dialog, a drawer) before the first run. */
|
|
180
260
|
immediate?: boolean;
|
|
261
|
+
/**
|
|
262
|
+
* A client navigation does not wait for the data. A server render still
|
|
263
|
+
* does: its HTML shows the data, and the browser takes it over.
|
|
264
|
+
*/
|
|
181
265
|
lazy?: boolean;
|
|
266
|
+
/** `false` leaves the data to the browser: the server renders the default. */
|
|
182
267
|
server?: boolean;
|
|
183
268
|
watch?: false | unknown[];
|
|
184
|
-
transform?: (
|
|
269
|
+
transform?: (
|
|
270
|
+
value: unknown,
|
|
271
|
+
context: DmsAsyncDataTransformContext,
|
|
272
|
+
) => T | Promise<T>;
|
|
185
273
|
pick?: string[];
|
|
274
|
+
/**
|
|
275
|
+
* `true` tracks changes inside the value too. By default only the
|
|
276
|
+
* replacement of the whole value triggers, as in Nuxt 4.
|
|
277
|
+
*/
|
|
278
|
+
deep?: boolean;
|
|
279
|
+
dedupe?: DmsAsyncDataDedupe;
|
|
280
|
+
/**
|
|
281
|
+
* `false` leaves the data out of `refreshDmsPageData()`, which reads again
|
|
282
|
+
* the data of the components on screen once the page changed (a save, a
|
|
283
|
+
* realtime event).
|
|
284
|
+
*/
|
|
285
|
+
refreshOnPageRefresh?: boolean;
|
|
286
|
+
/**
|
|
287
|
+
* Reads again every this many milliseconds, in the browser, while the
|
|
288
|
+
* caller shows the data and the page is visible. A ref or a getter changes
|
|
289
|
+
* the interval as it changes; `0`, `null` or `undefined` stops the polling
|
|
290
|
+
* until it has a value again. A call outside a component (a plugin, a
|
|
291
|
+
* callback) polls for as long as the application runs.
|
|
292
|
+
*/
|
|
293
|
+
refreshInterval?: MaybeRefOrGetter<number | null | undefined>;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* `useDmsFetch`'s options: ofetch's request options, of which the method, the
|
|
297
|
+
* query, the params and the body may be refs or getters, and the async data
|
|
298
|
+
* options.
|
|
299
|
+
*/
|
|
300
|
+
export interface UseFetchOptions<T>
|
|
301
|
+
extends
|
|
302
|
+
Omit<FetchOptions, DmsFetchRequestSources>,
|
|
303
|
+
Omit<UseAsyncDataOptions<T>, "transform"> {
|
|
304
|
+
/**
|
|
305
|
+
* The key the response is shared and transferred under, in place of the
|
|
306
|
+
* one the request gives: its method, base URL, URL, query and body. A ref
|
|
307
|
+
* or a getter moves the caller to the data of each key it takes; `null` or
|
|
308
|
+
* `undefined` requests nothing until it has a value.
|
|
309
|
+
*/
|
|
310
|
+
key?: MaybeRefOrGetter<DmsAsyncDataKey>;
|
|
311
|
+
method?: MaybeRefOrGetter<FetchOptions["method"]>;
|
|
312
|
+
query?: MaybeRefOrGetter<FetchOptions["query"]>;
|
|
313
|
+
params?: MaybeRefOrGetter<FetchOptions["params"]>;
|
|
314
|
+
body?: MaybeRefOrGetter<FetchOptions["body"]>;
|
|
315
|
+
transform?: (
|
|
316
|
+
value: unknown,
|
|
317
|
+
context: DmsFetchTransformContext,
|
|
318
|
+
) => T | Promise<T>;
|
|
319
|
+
/** Replaces the fetch function; it receives the run's signal. */
|
|
320
|
+
$fetch?: typeof ofetch;
|
|
186
321
|
}
|
|
187
322
|
export interface DmsErrorData {
|
|
188
323
|
statusCode?: number;
|
|
@@ -191,6 +326,20 @@ export interface DmsErrorData {
|
|
|
191
326
|
data?: unknown;
|
|
192
327
|
fatal?: boolean;
|
|
193
328
|
}
|
|
329
|
+
/** What a server render hands the browser along with its HTML. */
|
|
330
|
+
export interface DmsPayload {
|
|
331
|
+
data: Record<string, unknown>;
|
|
332
|
+
errors: Record<string, DmsErrorData>;
|
|
333
|
+
state: Record<string, unknown>;
|
|
334
|
+
}
|
|
335
|
+
export interface DmsStateOptions {
|
|
336
|
+
/**
|
|
337
|
+
* Hands the server's value to the browser, which then starts from it
|
|
338
|
+
* instead of calling `init` again. Never for tokens or secrets: the value
|
|
339
|
+
* is written into the page.
|
|
340
|
+
*/
|
|
341
|
+
transfer?: boolean;
|
|
342
|
+
}
|
|
194
343
|
export interface DmsUserSession<TUser = DmsUser, TSession = DmsSession> {
|
|
195
344
|
user: Ref<TUser | null>;
|
|
196
345
|
session: Ref<TSession | null>;
|
|
@@ -225,6 +374,13 @@ export type DmsI18n = Composer & {
|
|
|
225
374
|
export type DmsPluginSetup = (context: DmsAppContext) => void | Promise<void>;
|
|
226
375
|
export interface DmsMiddlewareRegistrationOptions {
|
|
227
376
|
global?: boolean;
|
|
377
|
+
/**
|
|
378
|
+
* Runs at the same time as the parallel middlewares next to it, for one
|
|
379
|
+
* that reads nothing another middleware sets (a check against the backend,
|
|
380
|
+
* say). Their results still apply in registration order: the first that
|
|
381
|
+
* redirects or aborts wins, and the next middlewares wait for all of them.
|
|
382
|
+
*/
|
|
383
|
+
parallel?: boolean;
|
|
228
384
|
}
|
|
229
385
|
export interface DmsPluginRegistrationOptions {
|
|
230
386
|
clientOnly?: boolean;
|
|
@@ -298,6 +454,12 @@ export interface DmsFrontendModuleRegistration {
|
|
|
298
454
|
module: DmsFrontendModule;
|
|
299
455
|
options: DmsModuleOptions;
|
|
300
456
|
}
|
|
457
|
+
/** A layer's `app/app.config.ts`, as `app-configs.generated.ts` lists them. */
|
|
458
|
+
export interface DmsLayerAppConfig {
|
|
459
|
+
/** The id of the frontend module the layer belongs to. */
|
|
460
|
+
module: string;
|
|
461
|
+
config: Record<string, unknown>;
|
|
462
|
+
}
|
|
301
463
|
// Async middleware without a redirect naturally infers Promise<void>.
|
|
302
464
|
// biome-ignore lint/suspicious/noConfusingVoidType: preserve that valid handler signature.
|
|
303
465
|
export type DmsMiddlewareResult = void | false | RouteLocationRaw;
|
|
@@ -348,15 +510,52 @@ interface DmsFrontendRegistry {
|
|
|
348
510
|
pluginSetups: DmsPluginRegistration[];
|
|
349
511
|
middleware: DmsMiddleware[];
|
|
350
512
|
namedMiddleware: Map<string, DmsMiddleware>;
|
|
513
|
+
/** The middlewares registered with `parallel: true`. */
|
|
514
|
+
parallelMiddleware: WeakSet<DmsMiddleware>;
|
|
351
515
|
accessRedirects: Map<string, string>;
|
|
352
516
|
injections: Map<string | symbol, unknown>;
|
|
353
517
|
}
|
|
354
518
|
|
|
355
519
|
export interface DmsFrontendRuntime {
|
|
356
520
|
sharedState: Map<string, Ref<unknown>>;
|
|
521
|
+
/** Reactive: `useDmsData` follows a key as it appears and is dropped. */
|
|
357
522
|
asyncData: Map<string, DmsAsyncData<unknown>>;
|
|
358
|
-
|
|
523
|
+
/** The server's data, which the browser takes over once per key. */
|
|
359
524
|
hydratedAsyncData: Map<string, unknown>;
|
|
525
|
+
/** The server's failures, which the browser takes over once per key. */
|
|
526
|
+
hydratedAsyncErrors: Map<string, DmsErrorData>;
|
|
527
|
+
/** The state keys a server render hands to the browser. */
|
|
528
|
+
transferredState: Set<string>;
|
|
529
|
+
/**
|
|
530
|
+
* Whether the browser is still hydrating the page the server rendered,
|
|
531
|
+
* whose first render must match the server's HTML.
|
|
532
|
+
*/
|
|
533
|
+
hydrating: boolean;
|
|
534
|
+
/**
|
|
535
|
+
* The runs a server render started outside any component, which no
|
|
536
|
+
* `onServerPrefetch` waits for.
|
|
537
|
+
*/
|
|
538
|
+
serverPrefetches: Set<Promise<void>>;
|
|
539
|
+
/** The runs in flight, which a server render aborts once it is over. */
|
|
540
|
+
asyncDataRuns: Set<AbortController>;
|
|
541
|
+
/** The data some component or caller shows: `refreshDmsPageData`'s. */
|
|
542
|
+
boundAsyncData: Set<{ pageRefresh(): Promise<void> | undefined }>;
|
|
543
|
+
/** Orders the data no caller shows, the least recently shown first. */
|
|
544
|
+
asyncDataClock: number;
|
|
545
|
+
/**
|
|
546
|
+
* The account and workspace the keys belong to (`setDmsAsyncDataScope`):
|
|
547
|
+
* the same key of another scope is another entry.
|
|
548
|
+
*/
|
|
549
|
+
asyncDataScope?: string;
|
|
550
|
+
/**
|
|
551
|
+
* Runs an async data handler; a server render marks the requests it makes
|
|
552
|
+
* as transferred (see `inAsyncDataHandler`).
|
|
553
|
+
*/
|
|
554
|
+
runAsyncDataHandler?: <R>(run: () => R) => R;
|
|
555
|
+
/** Whether the code running is an async data handler's. */
|
|
556
|
+
inAsyncDataHandler?: () => boolean;
|
|
557
|
+
/** Whether to warn about what a development render does wrong. */
|
|
558
|
+
devWarnings?: boolean;
|
|
360
559
|
runtimeHooks: Map<string, DmsRuntimeHook[]>;
|
|
361
560
|
serverFetch?: typeof ofetch;
|
|
362
561
|
currentError: Ref<Error | null>;
|
|
@@ -367,9 +566,12 @@ export interface DmsFrontendRuntime {
|
|
|
367
566
|
serverRedirect?: string;
|
|
368
567
|
pendingNavigation?: Promise<void>;
|
|
369
568
|
hasNavigationListener: boolean;
|
|
370
|
-
pageVersion: number;
|
|
371
569
|
/** Whether the page `<Suspense>` waits on the page being navigated to. */
|
|
372
570
|
pageLoading: Ref<boolean>;
|
|
571
|
+
/** The site layout last copied into the state, as the page props hold it. */
|
|
572
|
+
appliedShared?: DmsSharedPagePayload;
|
|
573
|
+
/** Whether Inertia is putting back a page from the browser history. */
|
|
574
|
+
restoringHistory?: boolean;
|
|
373
575
|
/**
|
|
374
576
|
* Identifies the page on screen (its path, or the error page shown at that
|
|
375
577
|
* path), whose meta `route.meta` holds.
|
|
@@ -382,8 +584,13 @@ export interface DmsFrontendRuntime {
|
|
|
382
584
|
* it once done: nothing else would, since a server app is never unmounted.
|
|
383
585
|
*/
|
|
384
586
|
scope: EffectScope;
|
|
587
|
+
/** The Inertia page a server runtime renders: see `useDmsInertiaPage`. */
|
|
588
|
+
inertiaPage?: DmsInertiaPage;
|
|
385
589
|
}
|
|
386
590
|
|
|
591
|
+
/** Inertia's page, as `usePage()` returns it. */
|
|
592
|
+
export type DmsInertiaPage = ReturnType<typeof usePage>;
|
|
593
|
+
|
|
387
594
|
/** Creates the empty registry one setup of the frontend modules fills. */
|
|
388
595
|
function createDmsFrontendRegistry(): DmsFrontendRegistry {
|
|
389
596
|
return {
|
|
@@ -395,6 +602,7 @@ function createDmsFrontendRegistry(): DmsFrontendRegistry {
|
|
|
395
602
|
pluginSetups: [],
|
|
396
603
|
middleware: [],
|
|
397
604
|
namedMiddleware: new Map(),
|
|
605
|
+
parallelMiddleware: new WeakSet(),
|
|
398
606
|
accessRedirects: new Map(),
|
|
399
607
|
injections: new Map(),
|
|
400
608
|
};
|
|
@@ -407,6 +615,9 @@ const runtimeConfig = ref<DmsRuntimeConfig>({
|
|
|
407
615
|
public: {},
|
|
408
616
|
} as DmsRuntimeConfig);
|
|
409
617
|
const appConfig = ref<Record<string, unknown>>({});
|
|
618
|
+
// What the last setup of the modules changed in the layers' app configs, which
|
|
619
|
+
// a development update of those configs keeps: see `updateDmsAppConfig`.
|
|
620
|
+
let appConfigSetupChanges: Record<string, unknown> = {};
|
|
410
621
|
const DMS_RUNTIME_KEY: InjectionKey<DmsFrontendRuntime> = Symbol("dms-runtime");
|
|
411
622
|
type DmsRuntimeContext = DmsAppContext["runWithContext"];
|
|
412
623
|
const runtimeContexts = new WeakMap<DmsFrontendRuntime, DmsRuntimeContext>();
|
|
@@ -416,16 +627,23 @@ let browserRuntime: DmsFrontendRuntime | undefined;
|
|
|
416
627
|
/** Creates mutable runtime state owned by one Vue application. */
|
|
417
628
|
export function createDmsFrontendRuntime(
|
|
418
629
|
serverFetch?: typeof ofetch,
|
|
419
|
-
hydratedAsyncData
|
|
630
|
+
hydratedAsyncData?: Record<string, unknown>,
|
|
420
631
|
isServer = false,
|
|
421
632
|
requestCookies?: string,
|
|
422
633
|
): DmsFrontendRuntime {
|
|
423
|
-
|
|
634
|
+
const runtime: DmsFrontendRuntime = {
|
|
424
635
|
requestCookies,
|
|
425
636
|
sharedState: new Map(),
|
|
426
|
-
asyncData: new Map(),
|
|
427
|
-
|
|
428
|
-
|
|
637
|
+
asyncData: shallowReactive(new Map()),
|
|
638
|
+
hydratedAsyncData: new Map(Object.entries(hydratedAsyncData ?? {})),
|
|
639
|
+
hydratedAsyncErrors: new Map(),
|
|
640
|
+
transferredState: new Set(),
|
|
641
|
+
// A browser handed the server's data, even none, hydrates its HTML.
|
|
642
|
+
hydrating: !isServer && hydratedAsyncData !== undefined,
|
|
643
|
+
serverPrefetches: new Set(),
|
|
644
|
+
asyncDataRuns: new Set(),
|
|
645
|
+
boundAsyncData: new Set(),
|
|
646
|
+
asyncDataClock: 0,
|
|
429
647
|
runtimeHooks: new Map(),
|
|
430
648
|
serverFetch,
|
|
431
649
|
isServer,
|
|
@@ -439,10 +657,17 @@ export function createDmsFrontendRuntime(
|
|
|
439
657
|
matched: [],
|
|
440
658
|
}),
|
|
441
659
|
hasNavigationListener: false,
|
|
442
|
-
pageVersion: 0,
|
|
443
660
|
pageLoading: ref(false),
|
|
444
661
|
scope: effectScope(true),
|
|
445
662
|
};
|
|
663
|
+
// A server render stops its scope once it has answered: whatever it still
|
|
664
|
+
// fetches then would reach nobody.
|
|
665
|
+
runtime.scope.run(() =>
|
|
666
|
+
onScopeDispose(() => {
|
|
667
|
+
for (const run of runtime.asyncDataRuns) run.abort();
|
|
668
|
+
}),
|
|
669
|
+
);
|
|
670
|
+
return runtime;
|
|
446
671
|
}
|
|
447
672
|
|
|
448
673
|
/** Makes a runtime available before application plugins are installed. */
|
|
@@ -623,15 +848,104 @@ async function runMiddleware(
|
|
|
623
848
|
const declaredHandlers = [...declared, ...metadataNames]
|
|
624
849
|
.map((name) => registry.namedMiddleware.get(String(name)))
|
|
625
850
|
.filter((handler): handler is DmsMiddleware => !!handler);
|
|
626
|
-
|
|
851
|
+
const run = (handler: DmsMiddleware) => {
|
|
627
852
|
const runWithContext = runtimeContexts.get(runtime);
|
|
628
|
-
|
|
853
|
+
return runWithContext
|
|
629
854
|
? runWithContext(() => handler(to, runtime.route))
|
|
630
|
-
: handler(to, runtime.route)
|
|
855
|
+
: handler(to, runtime.route);
|
|
856
|
+
};
|
|
857
|
+
for (const group of middlewareGroups([
|
|
858
|
+
...registry.middleware,
|
|
859
|
+
...declaredHandlers,
|
|
860
|
+
])) {
|
|
861
|
+
// A group of parallel middlewares starts together; its results apply in
|
|
862
|
+
// order, as a sequence's would.
|
|
863
|
+
const results = await Promise.all(group.map(run));
|
|
864
|
+
const result = results.find((value) => value !== undefined);
|
|
631
865
|
if (result !== undefined) return result;
|
|
632
866
|
}
|
|
633
867
|
}
|
|
634
868
|
|
|
869
|
+
/** The middlewares in order, each run of parallel ones as one group. */
|
|
870
|
+
function middlewareGroups(handlers: DmsMiddleware[]): DmsMiddleware[][] {
|
|
871
|
+
const groups: DmsMiddleware[][] = [];
|
|
872
|
+
for (const handler of handlers) {
|
|
873
|
+
const last = groups.at(-1);
|
|
874
|
+
const parallel = registry.parallelMiddleware.has(handler);
|
|
875
|
+
if (parallel && last && registry.parallelMiddleware.has(last[0]))
|
|
876
|
+
last.push(handler);
|
|
877
|
+
else groups.push([handler]);
|
|
878
|
+
}
|
|
879
|
+
return groups;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// What only a request to the server can honour: a client-side visit sends
|
|
883
|
+
// none and has none of these events.
|
|
884
|
+
const SERVER_VISIT_OPTIONS = [
|
|
885
|
+
"async",
|
|
886
|
+
"component",
|
|
887
|
+
"data",
|
|
888
|
+
"except",
|
|
889
|
+
"headers",
|
|
890
|
+
"method",
|
|
891
|
+
"onBefore",
|
|
892
|
+
"onCancel",
|
|
893
|
+
"onCancelToken",
|
|
894
|
+
"onProgress",
|
|
895
|
+
"onStart",
|
|
896
|
+
"only",
|
|
897
|
+
"preserveUrl",
|
|
898
|
+
"queryStringArrayFormat",
|
|
899
|
+
] as const satisfies readonly (keyof DmsNavigationOptions)[];
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* Whether a visit to another query or hash of the page on screen can stay in
|
|
903
|
+
* the browser. The page props depend on the path alone: the server would
|
|
904
|
+
* answer with the same page, the site layout included. An error page is left
|
|
905
|
+
* through the server, which renders the page in its place, and so is the
|
|
906
|
+
* same path on another site.
|
|
907
|
+
*/
|
|
908
|
+
function staysOnPage(
|
|
909
|
+
runtime: DmsFrontendRuntime,
|
|
910
|
+
url: string,
|
|
911
|
+
options: DmsNavigationOptions,
|
|
912
|
+
): boolean {
|
|
913
|
+
return (
|
|
914
|
+
new URL(url, currentUrl(runtime)).origin ===
|
|
915
|
+
new URL(currentUrl(runtime)).origin &&
|
|
916
|
+
!runtime.pageKey?.startsWith("error:") &&
|
|
917
|
+
runtime.currentError.value === null &&
|
|
918
|
+
SERVER_VISIT_OPTIONS.every((name) => options[name] === undefined)
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* An Inertia client-side visit: the URL and the history entry change, the
|
|
924
|
+
* page keeps its props, and no request is made.
|
|
925
|
+
*/
|
|
926
|
+
function visitInBrowser(
|
|
927
|
+
url: string,
|
|
928
|
+
options: DmsNavigationOptions,
|
|
929
|
+
): Promise<void> {
|
|
930
|
+
const clientVisit = options.replace
|
|
931
|
+
? inertiaRouter.replace
|
|
932
|
+
: inertiaRouter.push;
|
|
933
|
+
return new Promise<void>((resolve) => {
|
|
934
|
+
clientVisit.call(inertiaRouter, {
|
|
935
|
+
url,
|
|
936
|
+
preserveScroll: options.preserveScroll ?? true,
|
|
937
|
+
preserveState: options.preserveState ?? true,
|
|
938
|
+
viewTransition: options.viewTransition,
|
|
939
|
+
onSuccess: options.onSuccess,
|
|
940
|
+
onError: options.onError,
|
|
941
|
+
onFinish: (visit) => {
|
|
942
|
+
(options.onFinish as ((visit: unknown) => void) | undefined)?.(visit);
|
|
943
|
+
resolve();
|
|
944
|
+
},
|
|
945
|
+
} as Parameters<typeof inertiaRouter.push>[0]);
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
|
|
635
949
|
async function visit(
|
|
636
950
|
runtime: DmsFrontendRuntime,
|
|
637
951
|
to: RouteLocationRaw,
|
|
@@ -651,6 +965,10 @@ async function visit(
|
|
|
651
965
|
// component on every visit and its local state (open tabs, selection,
|
|
652
966
|
// inputs) is lost.
|
|
653
967
|
const samePath = target.path === runtime.route.path;
|
|
968
|
+
if (samePath && staysOnPage(runtime, url, options)) {
|
|
969
|
+
await visitInBrowser(url, options);
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
654
972
|
await new Promise<void>((resolve) =>
|
|
655
973
|
inertiaRouter.visit(url, {
|
|
656
974
|
...options,
|
|
@@ -686,6 +1004,18 @@ export function useDmsRoute(pattern?: string): DmsRoute {
|
|
|
686
1004
|
return runtime.route;
|
|
687
1005
|
}
|
|
688
1006
|
|
|
1007
|
+
/**
|
|
1008
|
+
* Inertia's page, as `usePage()` returns it, for the request being rendered.
|
|
1009
|
+
* Inertia keeps a single page for the whole process, written by each server
|
|
1010
|
+
* render as it starts: read after an await, it may be another request's. Nuxt
|
|
1011
|
+
* UI's Link and route stub read this one instead (see `vite.config.ts`).
|
|
1012
|
+
*/
|
|
1013
|
+
export function useDmsInertiaPage(): DmsInertiaPage {
|
|
1014
|
+
const runtime = useDmsRuntime();
|
|
1015
|
+
if (runtime.isServer && runtime.inertiaPage) return runtime.inertiaPage;
|
|
1016
|
+
return usePage();
|
|
1017
|
+
}
|
|
1018
|
+
|
|
689
1019
|
export function useDmsRouter(): DmsRouter {
|
|
690
1020
|
const runtime = useDmsRuntime();
|
|
691
1021
|
const navigate = (to: RouteLocationRaw, options?: DmsNavigationOptions) => {
|
|
@@ -710,13 +1040,58 @@ export function navigateDms(
|
|
|
710
1040
|
return visit(useDmsRuntime(), to, options);
|
|
711
1041
|
}
|
|
712
1042
|
|
|
1043
|
+
/**
|
|
1044
|
+
* The options of a request a runtime makes. A server render does not retry a
|
|
1045
|
+
* failed read, as ofetch would once: the page waits for it, and a backend
|
|
1046
|
+
* failing once mostly fails twice. A browser keeps ofetch's retries, and a
|
|
1047
|
+
* caller that names its own keeps them everywhere.
|
|
1048
|
+
*/
|
|
1049
|
+
function runtimeFetchOptions(
|
|
1050
|
+
runtime: DmsFrontendRuntime,
|
|
1051
|
+
options: FetchOptions,
|
|
1052
|
+
): FetchOptions {
|
|
1053
|
+
return runtime.isServer && options.retry === undefined
|
|
1054
|
+
? { ...options, retry: 0 }
|
|
1055
|
+
: options;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
const warnedServerReads = new Set<string>();
|
|
1059
|
+
|
|
1060
|
+
/**
|
|
1061
|
+
* Warns, in a development server render, about a backend read no async data
|
|
1062
|
+
* handler makes: an immediate watcher's, a setup's own `await $fetch`. The
|
|
1063
|
+
* server waits for it, but the page carries none of it, so the browser reads
|
|
1064
|
+
* it again and may hydrate with other data.
|
|
1065
|
+
*/
|
|
1066
|
+
function warnUntransferredRead(
|
|
1067
|
+
runtime: DmsFrontendRuntime,
|
|
1068
|
+
request: string,
|
|
1069
|
+
): void {
|
|
1070
|
+
if (
|
|
1071
|
+
!runtime.isServer ||
|
|
1072
|
+
!runtime.devWarnings ||
|
|
1073
|
+
!runtime.inAsyncDataHandler ||
|
|
1074
|
+
runtime.inAsyncDataHandler() ||
|
|
1075
|
+
warnedServerReads.has(request)
|
|
1076
|
+
)
|
|
1077
|
+
return;
|
|
1078
|
+
warnedServerReads.add(request);
|
|
1079
|
+
console.warn(
|
|
1080
|
+
`DMS: ${request} was read during a server render outside useDmsFetch and useDmsAsyncData ` +
|
|
1081
|
+
"(an immediate watcher, a setup's own await): the page does not carry the answer, " +
|
|
1082
|
+
"so the browser reads it again. Load it with useDmsFetch or useDmsAsyncData, " +
|
|
1083
|
+
"or leave it to the browser (server: false, onMounted).",
|
|
1084
|
+
);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
713
1087
|
function createDmsFetch(defaults: FetchOptions = {}): typeof ofetch {
|
|
714
1088
|
const execute = (request: string, options?: FetchOptions) => {
|
|
715
1089
|
const runtime = useDmsRuntime();
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
1090
|
+
warnUntransferredRead(runtime, request);
|
|
1091
|
+
return (runtime.serverFetch ?? ofetch)(
|
|
1092
|
+
request,
|
|
1093
|
+
runtimeFetchOptions(runtime, { ...defaults, ...options }),
|
|
1094
|
+
);
|
|
720
1095
|
};
|
|
721
1096
|
return Object.assign(execute, {
|
|
722
1097
|
create: (options: FetchOptions) =>
|
|
@@ -737,263 +1112,1299 @@ function pickValue<T>(value: unknown, keys?: string[]): T {
|
|
|
737
1112
|
) as T;
|
|
738
1113
|
}
|
|
739
1114
|
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
1115
|
+
/** JSON with sorted keys, or nothing for a value JSON cannot stand for. */
|
|
1116
|
+
function stableJson(value: unknown): string | undefined {
|
|
1117
|
+
if (value === undefined || value === null) return "null";
|
|
1118
|
+
if (value instanceof Date) return JSON.stringify(value);
|
|
1119
|
+
if (typeof value !== "object")
|
|
1120
|
+
return ["string", "number", "boolean"].includes(typeof value)
|
|
1121
|
+
? JSON.stringify(value)
|
|
1122
|
+
: undefined;
|
|
1123
|
+
if (Array.isArray(value)) {
|
|
1124
|
+
const items = value.map(stableJson);
|
|
1125
|
+
return items.includes(undefined) ? undefined : `[${items.join(",")}]`;
|
|
1126
|
+
}
|
|
1127
|
+
const prototype = Object.getPrototypeOf(value) as unknown;
|
|
1128
|
+
if (prototype !== Object.prototype && prototype !== null) return undefined;
|
|
1129
|
+
const record = value as Record<string, unknown>;
|
|
1130
|
+
const members = Object.keys(record)
|
|
1131
|
+
.filter((name) => record[name] !== undefined)
|
|
1132
|
+
.sort()
|
|
1133
|
+
.map((name) => {
|
|
1134
|
+
const member = stableJson(record[name]);
|
|
1135
|
+
return member === undefined
|
|
1136
|
+
? undefined
|
|
1137
|
+
: `${JSON.stringify(name)}:${member}`;
|
|
1138
|
+
});
|
|
1139
|
+
return members.includes(undefined) ? undefined : `{${members.join(",")}}`;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
const FETCH_KEY_PREFIX = "$dmsfetch:";
|
|
1143
|
+
const DEFAULT_FETCH_METHOD = "GET";
|
|
1144
|
+
|
|
1145
|
+
// cyrb53's mixing constants, with its two 32-bit lanes both kept: a 64-bit
|
|
1146
|
+
// digest, as short as Nuxt's keys and as unlikely to collide within a page.
|
|
1147
|
+
const HASH_SEEDS = [0xdeadbeef, 0x41c6ce57];
|
|
1148
|
+
const HASH_MULTIPLIERS = [2654435761, 1597334677];
|
|
1149
|
+
const HASH_FINALIZERS = [2246822507, 3266489909];
|
|
1150
|
+
const HASH_SHIFTS = [16, 13];
|
|
1151
|
+
const HASH_RADIX = 36;
|
|
1152
|
+
const HASH_LANE_LENGTH = 7;
|
|
1153
|
+
|
|
1154
|
+
/**
|
|
1155
|
+
* A short digest of a text, the same in a server and in a browser: pure
|
|
1156
|
+
* JavaScript, so no `node:crypto` reaches a browser bundle.
|
|
1157
|
+
*/
|
|
1158
|
+
function hashText(text: string): string {
|
|
1159
|
+
let [first, second] = HASH_SEEDS;
|
|
1160
|
+
for (let index = 0; index < text.length; index++) {
|
|
1161
|
+
const code = text.charCodeAt(index);
|
|
1162
|
+
first = Math.imul(first ^ code, HASH_MULTIPLIERS[0]);
|
|
1163
|
+
second = Math.imul(second ^ code, HASH_MULTIPLIERS[1]);
|
|
1164
|
+
}
|
|
1165
|
+
first = Math.imul(first ^ (first >>> HASH_SHIFTS[0]), HASH_FINALIZERS[0]);
|
|
1166
|
+
first ^= Math.imul(second ^ (second >>> HASH_SHIFTS[1]), HASH_FINALIZERS[1]);
|
|
1167
|
+
second = Math.imul(second ^ (second >>> HASH_SHIFTS[0]), HASH_FINALIZERS[0]);
|
|
1168
|
+
second ^= Math.imul(first ^ (first >>> HASH_SHIFTS[1]), HASH_FINALIZERS[1]);
|
|
1169
|
+
return [second, first]
|
|
1170
|
+
.map((lane) =>
|
|
1171
|
+
(lane >>> 0).toString(HASH_RADIX).padStart(HASH_LANE_LENGTH, "0"),
|
|
1172
|
+
)
|
|
1173
|
+
.join("");
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* The key a request's response is shared and transferred under: a digest of
|
|
1178
|
+
* its method, base URL, URL, query and params, and body, the headers aside. A
|
|
1179
|
+
* server render writes the key into its HTML, which must not show the URL, a
|
|
1180
|
+
* token in its query or a body. A body only a request can read, such as a
|
|
1181
|
+
* FormData, gives none.
|
|
1182
|
+
*/
|
|
1183
|
+
function requestKey(url: string, options: FetchOptions): string | undefined {
|
|
1184
|
+
// ofetch sends the params and the query together, the query winning.
|
|
1185
|
+
const query =
|
|
1186
|
+
options.query || options.params
|
|
1187
|
+
? { ...options.params, ...options.query }
|
|
1188
|
+
: null;
|
|
1189
|
+
const request = stableJson([
|
|
1190
|
+
(options.method ?? DEFAULT_FETCH_METHOD).toUpperCase(),
|
|
1191
|
+
options.baseURL ?? "",
|
|
1192
|
+
url,
|
|
1193
|
+
query,
|
|
1194
|
+
options.body ?? null,
|
|
1195
|
+
]);
|
|
1196
|
+
return request === undefined
|
|
1197
|
+
? undefined
|
|
1198
|
+
: `${FETCH_KEY_PREFIX}${hashText(request)}`;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/** The parts of a request its key is computed from. */
|
|
1202
|
+
export type DmsRequestKeyOptions = Pick<
|
|
1203
|
+
FetchOptions,
|
|
1204
|
+
"method" | "baseURL" | "query" | "params" | "body"
|
|
1205
|
+
>;
|
|
1206
|
+
|
|
1207
|
+
/**
|
|
1208
|
+
* The key `useDmsFetch` loads a request's response under, without the scope
|
|
1209
|
+
* (`setDmsAsyncDataScope`): what `refreshDmsData(keys)` takes, and what a
|
|
1210
|
+
* test compares with the keys it finds. `undefined` for a body only a
|
|
1211
|
+
* request can read, such as a FormData.
|
|
1212
|
+
*
|
|
1213
|
+
* @param url The URL as given to `useDmsFetch`, before the base URL and the query
|
|
1214
|
+
* @param options The method, base URL, query, params and body of the request
|
|
1215
|
+
*/
|
|
1216
|
+
export function dmsRequestKey(
|
|
1217
|
+
url: string,
|
|
1218
|
+
options: DmsRequestKeyOptions = {},
|
|
1219
|
+
): string | undefined {
|
|
1220
|
+
return requestKey(url, options);
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/** A URL to request, or nothing to request while it is null or undefined. */
|
|
1224
|
+
type DmsFetchRequest = string | null | undefined;
|
|
1225
|
+
|
|
1226
|
+
function isReactiveSource(value: unknown): boolean {
|
|
1227
|
+
return isRef(value) || typeof value === "function";
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/** The options given, without the ones left undefined. */
|
|
1231
|
+
function definedOptions(options: FetchOptions): FetchOptions {
|
|
1232
|
+
return Object.fromEntries(
|
|
1233
|
+
Object.entries(options).filter(([, value]) => value !== undefined),
|
|
1234
|
+
) as FetchOptions;
|
|
751
1235
|
}
|
|
752
1236
|
|
|
1237
|
+
/**
|
|
1238
|
+
* Fetches a URL as `useDmsAsyncData` loads its data, under the key of the
|
|
1239
|
+
* request, so that the callers of one request share it and a server render
|
|
1240
|
+
* transfers it. A ref or a getter for the URL, the method, the query, the
|
|
1241
|
+
* params or the body moves the caller to the key of each request; a URL that
|
|
1242
|
+
* is null or undefined requests nothing.
|
|
1243
|
+
*/
|
|
753
1244
|
export function useDmsFetch<T>(
|
|
754
|
-
request:
|
|
1245
|
+
request: MaybeRefOrGetter<DmsFetchRequest>,
|
|
755
1246
|
options: UseFetchOptions<T> = {},
|
|
756
|
-
):
|
|
1247
|
+
): DmsAsyncDataResult<T> {
|
|
757
1248
|
const runtime = useDmsRuntime();
|
|
758
|
-
const
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
1249
|
+
const {
|
|
1250
|
+
key,
|
|
1251
|
+
immediate,
|
|
1252
|
+
lazy,
|
|
1253
|
+
server,
|
|
1254
|
+
watch: sources,
|
|
1255
|
+
default: fallback,
|
|
1256
|
+
transform,
|
|
1257
|
+
pick,
|
|
1258
|
+
deep,
|
|
1259
|
+
dedupe,
|
|
1260
|
+
refreshOnPageRefresh,
|
|
1261
|
+
refreshInterval,
|
|
1262
|
+
$fetch: fetcher,
|
|
1263
|
+
method,
|
|
1264
|
+
query,
|
|
1265
|
+
params,
|
|
1266
|
+
body,
|
|
1267
|
+
...fixedOptions
|
|
1268
|
+
} = options;
|
|
1269
|
+
const url = () => toValue(request);
|
|
1270
|
+
const requestOptions = (): FetchOptions =>
|
|
1271
|
+
definedOptions({
|
|
1272
|
+
...fixedOptions,
|
|
1273
|
+
method: toValue(method),
|
|
1274
|
+
query: toValue(query),
|
|
1275
|
+
params: toValue(params),
|
|
1276
|
+
body: toValue(body),
|
|
1277
|
+
});
|
|
1278
|
+
// A `$fetch` of the caller's own applies its own retries. The transform
|
|
1279
|
+
// runs here, where the request a response answers is known: a reactive URL
|
|
1280
|
+
// may have changed since it was sent.
|
|
1281
|
+
const handler: DmsAsyncDataHandler<T> = async ({ signal }) => {
|
|
1282
|
+
const target = url() as string;
|
|
1283
|
+
const sent = requestOptions();
|
|
1284
|
+
const runKey =
|
|
1285
|
+
key === undefined
|
|
1286
|
+
? requestKey(target, sent)
|
|
1287
|
+
: (toValue(key) ?? undefined);
|
|
1288
|
+
const value: unknown = await (fetcher
|
|
1289
|
+
? fetcher(target, { ...sent, signal })
|
|
1290
|
+
: (runtime.serverFetch ?? ofetch)(
|
|
1291
|
+
target,
|
|
1292
|
+
runtimeFetchOptions(runtime, { ...sent, signal }),
|
|
1293
|
+
));
|
|
1294
|
+
if (!transform) return value as T;
|
|
1295
|
+
return transform(value, { key: runKey, url: target });
|
|
782
1296
|
};
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
1297
|
+
const asyncOptions: UseAsyncDataOptions<T> = {
|
|
1298
|
+
immediate,
|
|
1299
|
+
lazy,
|
|
1300
|
+
server,
|
|
1301
|
+
watch: sources,
|
|
1302
|
+
default: fallback,
|
|
1303
|
+
pick,
|
|
1304
|
+
deep,
|
|
1305
|
+
dedupe,
|
|
1306
|
+
refreshOnPageRefresh,
|
|
1307
|
+
refreshInterval,
|
|
1308
|
+
};
|
|
1309
|
+
const reactiveSources = [request, method, query, params, body].filter(
|
|
1310
|
+
isReactiveSource,
|
|
1311
|
+
);
|
|
1312
|
+
const isReactive = reactiveSources.length > 0;
|
|
1313
|
+
// The request gives its key, which follows the request as it changes.
|
|
1314
|
+
const derived = (): string | null | undefined => {
|
|
1315
|
+
const target = url();
|
|
1316
|
+
return target == null ? null : requestKey(target, requestOptions());
|
|
1317
|
+
};
|
|
1318
|
+
if (key === undefined && derived() !== undefined)
|
|
1319
|
+
return useDmsAsyncData(
|
|
1320
|
+
isReactive ? derived : derived(),
|
|
1321
|
+
handler,
|
|
1322
|
+
asyncOptions,
|
|
1323
|
+
);
|
|
1324
|
+
// The key the caller names, or none for a body without one, stays put while
|
|
1325
|
+
// the request changes: the request is watched instead.
|
|
1326
|
+
const watched =
|
|
1327
|
+
isReactive && sources !== false
|
|
1328
|
+
? [...reactiveSources, ...(sources ?? [])]
|
|
1329
|
+
: sources;
|
|
1330
|
+
if (key === undefined)
|
|
1331
|
+
return useDmsAsyncData(handler, undefined, {
|
|
1332
|
+
...asyncOptions,
|
|
1333
|
+
watch: watched,
|
|
1334
|
+
});
|
|
1335
|
+
const named = () => (url() == null ? null : toValue(key));
|
|
1336
|
+
const followsKey = isReactiveSource(request) || isReactiveSource(key);
|
|
1337
|
+
return useDmsAsyncData(followsKey ? named : named(), handler, {
|
|
1338
|
+
...asyncOptions,
|
|
1339
|
+
watch: watched,
|
|
1340
|
+
});
|
|
792
1341
|
}
|
|
793
1342
|
|
|
794
1343
|
interface AsyncDataBinding<T> {
|
|
795
|
-
|
|
1344
|
+
handler: DmsAsyncDataHandler<T>;
|
|
796
1345
|
options: UseAsyncDataOptions<T>;
|
|
797
|
-
active: boolean;
|
|
798
|
-
pageVersion: number;
|
|
799
1346
|
}
|
|
800
1347
|
|
|
801
|
-
interface
|
|
802
|
-
|
|
1348
|
+
interface AsyncDataRun {
|
|
1349
|
+
promise: Promise<void>;
|
|
1350
|
+
controller: AbortController;
|
|
803
1351
|
}
|
|
804
1352
|
|
|
805
|
-
interface
|
|
806
|
-
|
|
807
|
-
shouldRefresh: boolean;
|
|
1353
|
+
interface AsyncDataBindingHandle {
|
|
1354
|
+
unbind(): void;
|
|
808
1355
|
}
|
|
809
1356
|
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
1357
|
+
/** What a key holds before any run of the browser's own. */
|
|
1358
|
+
interface AsyncDataInitialState<T> {
|
|
1359
|
+
data: T | null;
|
|
1360
|
+
error: unknown;
|
|
1361
|
+
status: DmsAsyncDataStatus;
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
/** What the components showing one key share: its data and its runs. */
|
|
1365
|
+
interface AsyncDataEntry<T> {
|
|
1366
|
+
result: DmsAsyncData<T>;
|
|
1367
|
+
bind(binding: AsyncDataBinding<T>): AsyncDataBindingHandle;
|
|
1368
|
+
/** A run: the one in flight, or a new one. */
|
|
1369
|
+
load(): Promise<void>;
|
|
1370
|
+
/** The first run, once the current turn is over. */
|
|
1371
|
+
schedule(): void;
|
|
1372
|
+
/** Resolves once no run is in flight, including the runs that replace it. */
|
|
1373
|
+
settled(): Promise<void>;
|
|
1374
|
+
unused(): boolean;
|
|
1375
|
+
/** When a caller last stopped showing the data (`asyncDataClock`). */
|
|
1376
|
+
lastShown(): number;
|
|
1377
|
+
/**
|
|
1378
|
+
* Whether another caller shows the data and its last run answered within
|
|
1379
|
+
* `JOIN_WITHOUT_READ_MS`: a caller joining it then reads nothing again.
|
|
1380
|
+
*/
|
|
1381
|
+
justReadForAnother(): boolean;
|
|
1382
|
+
running(): boolean;
|
|
1383
|
+
/** `refreshDmsPageData`'s run, for data a caller shows and opted into. */
|
|
1384
|
+
pageRefresh(): Promise<void> | undefined;
|
|
1385
|
+
/**
|
|
1386
|
+
* Holds the server's state back, as pending, until the next turn or
|
|
1387
|
+
* `adoptNow`, whichever comes first.
|
|
1388
|
+
*/
|
|
1389
|
+
adoptLater(state: AsyncDataInitialState<T>): void;
|
|
1390
|
+
adoptNow(): void;
|
|
1391
|
+
/** Resolves once the state held back is adopted; nothing when none is. */
|
|
1392
|
+
adoption(): Promise<void> | undefined;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
const DEFAULT_DEDUPE: DmsAsyncDataDedupe = "cancel";
|
|
1396
|
+
/**
|
|
1397
|
+
* How many keys no component shows any more a browser keeps, the most
|
|
1398
|
+
* recently shown, for a return to them to show their data at once.
|
|
1399
|
+
*/
|
|
1400
|
+
const MAX_UNUSED_ASYNC_DATA = 50;
|
|
1401
|
+
/**
|
|
1402
|
+
* How long after a run answered a caller that joins a key another caller
|
|
1403
|
+
* shows takes the data as it is, instead of reading it again: two components
|
|
1404
|
+
* of one page sharing a read mount a moment apart.
|
|
1405
|
+
*/
|
|
1406
|
+
const JOIN_WITHOUT_READ_MS = 2000;
|
|
1407
|
+
|
|
1408
|
+
const asyncDataEntries = new WeakMap<
|
|
1409
|
+
DmsAsyncData<unknown>,
|
|
1410
|
+
AsyncDataEntry<unknown>
|
|
1411
|
+
>();
|
|
1412
|
+
|
|
1413
|
+
function createAsyncDataEntry<T>(
|
|
1414
|
+
runtime: DmsFrontendRuntime,
|
|
1415
|
+
key: string | undefined,
|
|
1416
|
+
initial: AsyncDataInitialState<T>,
|
|
1417
|
+
deep: boolean,
|
|
1418
|
+
): AsyncDataEntry<T> {
|
|
1419
|
+
const data = (
|
|
1420
|
+
deep ? ref(initial.data) : shallowRef(initial.data)
|
|
1421
|
+
) as Ref<T | null>;
|
|
1422
|
+
const error = ref<unknown>(initial.error);
|
|
1423
|
+
const status = ref<DmsAsyncDataStatus>(initial.status);
|
|
1424
|
+
const pending = computed(() => status.value === "pending");
|
|
820
1425
|
const bindings: AsyncDataBinding<T>[] = [];
|
|
821
|
-
let
|
|
822
|
-
let
|
|
823
|
-
let
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
1426
|
+
let run: AsyncDataRun | undefined;
|
|
1427
|
+
let scheduled: Promise<void> | undefined;
|
|
1428
|
+
let heldBack: AsyncDataInitialState<T> | undefined;
|
|
1429
|
+
let adoption: Promise<void> | undefined;
|
|
1430
|
+
let lastShown = 0;
|
|
1431
|
+
// When the last run answered, to spare a caller joining at once a second read.
|
|
1432
|
+
let answeredAt = 0;
|
|
1433
|
+
const adoptNow = () => {
|
|
1434
|
+
if (!heldBack) return;
|
|
1435
|
+
data.value = heldBack.data;
|
|
1436
|
+
error.value = heldBack.error;
|
|
1437
|
+
status.value = heldBack.status;
|
|
1438
|
+
heldBack = undefined;
|
|
1439
|
+
};
|
|
1440
|
+
const handle = (binding: AsyncDataBinding<T>, signal: AbortSignal) => {
|
|
1441
|
+
const call = () => binding.handler({ signal });
|
|
1442
|
+
return runtime.runAsyncDataHandler
|
|
1443
|
+
? runtime.runAsyncDataHandler(call)
|
|
1444
|
+
: call();
|
|
1445
|
+
};
|
|
1446
|
+
const start = (binding: AsyncDataBinding<T>): Promise<void> => {
|
|
1447
|
+
heldBack = undefined;
|
|
1448
|
+
run?.controller.abort();
|
|
1449
|
+
const controller = new AbortController();
|
|
1450
|
+
const current: AsyncDataRun = { controller, promise: Promise.resolve() };
|
|
1451
|
+
run = current;
|
|
1452
|
+
runtime.asyncDataRuns.add(controller);
|
|
1453
|
+
// The last failure stays until this run answers, as in Nuxt.
|
|
830
1454
|
status.value = "pending";
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
1455
|
+
// A run another one replaced, or that nobody waits for any more, settles
|
|
1456
|
+
// nothing, even when its handler ignores the signal.
|
|
1457
|
+
current.promise = (async () => {
|
|
1458
|
+
try {
|
|
1459
|
+
const value = await handle(binding, controller.signal);
|
|
1460
|
+
const transformed = binding.options.transform
|
|
1461
|
+
? await binding.options.transform(value, { key })
|
|
1462
|
+
: value;
|
|
1463
|
+
if (run !== current) return;
|
|
1464
|
+
data.value = pickValue<T>(transformed, binding.options.pick);
|
|
1465
|
+
error.value = null;
|
|
1466
|
+
status.value = "success";
|
|
1467
|
+
answeredAt = Date.now();
|
|
1468
|
+
} catch (reason) {
|
|
1469
|
+
if (run !== current) return;
|
|
1470
|
+
// The data of the last success stays on screen next to the failure.
|
|
1471
|
+
error.value = reason;
|
|
1472
|
+
status.value = "error";
|
|
1473
|
+
} finally {
|
|
1474
|
+
runtime.asyncDataRuns.delete(controller);
|
|
1475
|
+
if (run === current) run = undefined;
|
|
1476
|
+
}
|
|
1477
|
+
})();
|
|
1478
|
+
return current.promise;
|
|
848
1479
|
};
|
|
849
|
-
|
|
1480
|
+
const refresh = (options: DmsAsyncDataRefreshOptions = {}) => {
|
|
1481
|
+
const binding = bindings.at(-1);
|
|
1482
|
+
if (!binding) return Promise.resolve();
|
|
1483
|
+
const dedupe = options.dedupe ?? binding.options.dedupe ?? DEFAULT_DEDUPE;
|
|
1484
|
+
return run && dedupe === "defer" ? run.promise : start(binding);
|
|
1485
|
+
};
|
|
1486
|
+
const load = (): Promise<void> => {
|
|
1487
|
+
if (run) return run.promise;
|
|
1488
|
+
const binding = bindings.at(-1);
|
|
1489
|
+
return binding ? start(binding) : Promise.resolve();
|
|
1490
|
+
};
|
|
1491
|
+
const result: DmsAsyncData<T> = {
|
|
850
1492
|
data,
|
|
1493
|
+
key: computed(() => key),
|
|
1494
|
+
placeholder: computed(() => false),
|
|
851
1495
|
error,
|
|
852
1496
|
pending,
|
|
853
1497
|
status,
|
|
854
1498
|
execute: refresh,
|
|
855
1499
|
refresh,
|
|
1500
|
+
clear() {
|
|
1501
|
+
heldBack = undefined;
|
|
1502
|
+
run?.controller.abort();
|
|
1503
|
+
run = undefined;
|
|
1504
|
+
data.value = bindings.at(-1)?.options.default?.() ?? null;
|
|
1505
|
+
error.value = null;
|
|
1506
|
+
status.value = "idle";
|
|
1507
|
+
},
|
|
1508
|
+
};
|
|
1509
|
+
const entry: AsyncDataEntry<T> = {
|
|
1510
|
+
result,
|
|
856
1511
|
bind(binding) {
|
|
857
|
-
const shouldRefresh =
|
|
858
|
-
bindings.length === 0 &&
|
|
859
|
-
lastBindingPageVersion !== undefined &&
|
|
860
|
-
lastBindingPageVersion !== binding.pageVersion;
|
|
861
|
-
lastBindingPageVersion = binding.pageVersion;
|
|
862
1512
|
bindings.push(binding);
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
1513
|
+
runtime.boundAsyncData.add(entry);
|
|
1514
|
+
const unbind = () => {
|
|
1515
|
+
const index = bindings.indexOf(binding);
|
|
1516
|
+
if (index === -1) return;
|
|
1517
|
+
bindings.splice(index, 1);
|
|
1518
|
+
if (bindings.length > 0) return;
|
|
1519
|
+
lastShown = ++runtime.asyncDataClock;
|
|
1520
|
+
runtime.boundAsyncData.delete(entry);
|
|
1521
|
+
if (!run) return;
|
|
1522
|
+
run.controller.abort();
|
|
1523
|
+
run = undefined;
|
|
1524
|
+
// Back to what the last answer left.
|
|
1525
|
+
status.value =
|
|
1526
|
+
error.value != null
|
|
1527
|
+
? "error"
|
|
1528
|
+
: data.value === null
|
|
1529
|
+
? "idle"
|
|
1530
|
+
: "success";
|
|
1531
|
+
};
|
|
1532
|
+
return { unbind };
|
|
1533
|
+
},
|
|
1534
|
+
load,
|
|
1535
|
+
schedule() {
|
|
1536
|
+
// Started after the caller's turn, an explicit refresh in that turn
|
|
1537
|
+
// replaces this first run instead of cancelling it mid-request.
|
|
1538
|
+
scheduled ??= Promise.resolve().then(() => {
|
|
1539
|
+
scheduled = undefined;
|
|
1540
|
+
return load();
|
|
1541
|
+
});
|
|
1542
|
+
},
|
|
1543
|
+
async settled() {
|
|
1544
|
+
await scheduled;
|
|
1545
|
+
await adoption;
|
|
1546
|
+
while (run) await run.promise;
|
|
1547
|
+
},
|
|
1548
|
+
unused: () => bindings.length === 0,
|
|
1549
|
+
lastShown: () => lastShown,
|
|
1550
|
+
justReadForAnother: () =>
|
|
1551
|
+
bindings.length > 1 && Date.now() - answeredAt < JOIN_WITHOUT_READ_MS,
|
|
1552
|
+
running: () => run !== undefined,
|
|
1553
|
+
pageRefresh() {
|
|
1554
|
+
const wanted = bindings.some(
|
|
1555
|
+
(binding) => binding.options.refreshOnPageRefresh !== false,
|
|
1556
|
+
);
|
|
1557
|
+
// Data a caller waits to `execute()` stays unread.
|
|
1558
|
+
if (!wanted || (status.value === "idle" && !run)) return undefined;
|
|
1559
|
+
return refresh();
|
|
876
1560
|
},
|
|
1561
|
+
adoptLater(state) {
|
|
1562
|
+
heldBack = state;
|
|
1563
|
+
status.value = "pending";
|
|
1564
|
+
adoption = Promise.resolve().then(adoptNow);
|
|
1565
|
+
},
|
|
1566
|
+
adoptNow,
|
|
1567
|
+
adoption: () => (heldBack ? adoption : undefined),
|
|
1568
|
+
};
|
|
1569
|
+
return entry;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
/**
|
|
1573
|
+
* The state the server's render left a key in, if it rendered the key: its
|
|
1574
|
+
* data, or its failure next to the default it rendered.
|
|
1575
|
+
*/
|
|
1576
|
+
function serverAsyncDataState<T>(
|
|
1577
|
+
runtime: DmsFrontendRuntime,
|
|
1578
|
+
key: string,
|
|
1579
|
+
fallback: AsyncDataInitialState<T>,
|
|
1580
|
+
): AsyncDataInitialState<T> | undefined {
|
|
1581
|
+
if (runtime.hydratedAsyncData.has(key))
|
|
1582
|
+
return {
|
|
1583
|
+
data: runtime.hydratedAsyncData.get(key) as T,
|
|
1584
|
+
error: null,
|
|
1585
|
+
status: "success",
|
|
1586
|
+
};
|
|
1587
|
+
const failure = runtime.hydratedAsyncErrors.get(key);
|
|
1588
|
+
if (!failure) return undefined;
|
|
1589
|
+
return {
|
|
1590
|
+
data: fallback.data,
|
|
1591
|
+
error: createDmsError(failure),
|
|
1592
|
+
status: "error",
|
|
877
1593
|
};
|
|
878
1594
|
}
|
|
879
1595
|
|
|
880
|
-
|
|
1596
|
+
/**
|
|
1597
|
+
* The key an entry is stored and transferred under: the caller's key, in
|
|
1598
|
+
* the runtime's scope (`setDmsAsyncDataScope`).
|
|
1599
|
+
*/
|
|
1600
|
+
function scopedKey(runtime: DmsFrontendRuntime, key: string): string {
|
|
1601
|
+
return runtime.asyncDataScope === undefined
|
|
1602
|
+
? key
|
|
1603
|
+
: `${runtime.asyncDataScope}|${key}`;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
function asyncDataEntry<T>(
|
|
881
1607
|
runtime: DmsFrontendRuntime,
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
):
|
|
886
|
-
const
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
const
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
1608
|
+
key: string | undefined,
|
|
1609
|
+
options: UseAsyncDataOptions<T>,
|
|
1610
|
+
inComponent: boolean,
|
|
1611
|
+
): AsyncDataEntry<T> {
|
|
1612
|
+
const stored = key === undefined ? undefined : scopedKey(runtime, key);
|
|
1613
|
+
const shared =
|
|
1614
|
+
stored === undefined ? undefined : runtime.asyncData.get(stored);
|
|
1615
|
+
if (shared) return asyncDataEntries.get(shared) as AsyncDataEntry<T>;
|
|
1616
|
+
const fallback: AsyncDataInitialState<T> = {
|
|
1617
|
+
data: options.default?.() ?? null,
|
|
1618
|
+
error: null,
|
|
1619
|
+
status: "idle",
|
|
1620
|
+
};
|
|
1621
|
+
const server =
|
|
1622
|
+
stored === undefined
|
|
1623
|
+
? undefined
|
|
1624
|
+
: serverAsyncDataState<T>(runtime, stored, fallback);
|
|
1625
|
+
const entry = createAsyncDataEntry<T>(
|
|
1626
|
+
runtime,
|
|
1627
|
+
key,
|
|
1628
|
+
server && !inComponent ? server : fallback,
|
|
1629
|
+
options.deep === true,
|
|
1630
|
+
);
|
|
1631
|
+
// A component's setup saw the server's run in flight, not its result: the
|
|
1632
|
+
// browser holds the result back as long, see `startAsyncData`.
|
|
1633
|
+
if (server && inComponent) entry.adoptLater(server);
|
|
1634
|
+
if (stored === undefined) return entry;
|
|
1635
|
+
runtime.hydratedAsyncData.delete(stored);
|
|
1636
|
+
runtime.hydratedAsyncErrors.delete(stored);
|
|
1637
|
+
const result = entry.result as DmsAsyncData<unknown>;
|
|
1638
|
+
runtime.asyncData.set(stored, result);
|
|
1639
|
+
asyncDataEntries.set(result, entry as AsyncDataEntry<unknown>);
|
|
1640
|
+
return entry;
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
/**
|
|
1644
|
+
* Loads data once for every component that shows the same key, with Nuxt's
|
|
1645
|
+
* semantics: a server render waits for it before rendering, and transfers it,
|
|
1646
|
+
* whether or not the caller awaits it; `lazy` only spares client navigations
|
|
1647
|
+
* the wait; `server: false` leaves it to the browser.
|
|
1648
|
+
*
|
|
1649
|
+
* In the browser, data loaded before is shown at once and read again in the
|
|
1650
|
+
* background on each mount and each return to its key (stale while
|
|
1651
|
+
* revalidate), except while the page the server rendered hydrates: its data
|
|
1652
|
+
* is the server's, just read.
|
|
1653
|
+
*
|
|
1654
|
+
* A call without a key cannot be transferred: the server leaves it to the
|
|
1655
|
+
* browser, as `server: false` does, so the page hydrates with what it shows.
|
|
1656
|
+
*/
|
|
1657
|
+
export function useDmsAsyncData<T>(
|
|
1658
|
+
keyOrHandler: MaybeRefOrGetter<DmsAsyncDataKey> | DmsAsyncDataHandler<T>,
|
|
1659
|
+
handler?: DmsAsyncDataHandler<T>,
|
|
1660
|
+
callerOptions: UseAsyncDataOptions<T> = {},
|
|
1661
|
+
): DmsAsyncDataResult<T> {
|
|
1662
|
+
const runtime = useDmsRuntime();
|
|
1663
|
+
// A call outside any scope (a plugin past its first await, a callback)
|
|
1664
|
+
// belongs to the application: its watchers and its polling live as long.
|
|
1665
|
+
if (getCurrentScope() || !runtime.scope.active)
|
|
1666
|
+
return createAsyncDataCall(runtime, keyOrHandler, handler, callerOptions);
|
|
1667
|
+
return runtime.scope.run(() =>
|
|
1668
|
+
createAsyncDataCall(runtime, keyOrHandler, handler, callerOptions),
|
|
1669
|
+
)!;
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
function createAsyncDataCall<T>(
|
|
1673
|
+
runtime: DmsFrontendRuntime,
|
|
1674
|
+
keyOrHandler: MaybeRefOrGetter<DmsAsyncDataKey> | DmsAsyncDataHandler<T>,
|
|
1675
|
+
handler: DmsAsyncDataHandler<T> | undefined,
|
|
1676
|
+
callerOptions: UseAsyncDataOptions<T>,
|
|
1677
|
+
): DmsAsyncDataResult<T> {
|
|
1678
|
+
// With a handler second, the first argument is the key: a string, or a ref
|
|
1679
|
+
// or getter whose changes move the caller to the data of another key.
|
|
1680
|
+
const keySource = handler
|
|
1681
|
+
? (keyOrHandler as MaybeRefOrGetter<DmsAsyncDataKey>)
|
|
894
1682
|
: undefined;
|
|
895
|
-
const
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
1683
|
+
const execute = handler ?? keyOrHandler;
|
|
1684
|
+
if (typeof execute !== "function")
|
|
1685
|
+
throw new Error("useDmsAsyncData requires a handler");
|
|
1686
|
+
const keyless = keySource === undefined;
|
|
1687
|
+
if (keyless && runtime.isServer && runtime.devWarnings)
|
|
1688
|
+
console.warn(
|
|
1689
|
+
"DMS: useDmsAsyncData without a key, or useDmsFetch with a body it cannot key, " +
|
|
1690
|
+
"is left to the browser: give it a key for the server to render it.",
|
|
900
1691
|
);
|
|
901
|
-
const
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
1692
|
+
const options: UseAsyncDataOptions<T> = keyless
|
|
1693
|
+
? { ...callerOptions, server: false }
|
|
1694
|
+
: callerOptions;
|
|
1695
|
+
const inComponent = getCurrentInstance() !== null;
|
|
1696
|
+
const consumer = shallowRef(
|
|
1697
|
+
attachAsyncData(
|
|
1698
|
+
runtime,
|
|
1699
|
+
keyless ? undefined : (toValue(keySource) ?? null),
|
|
1700
|
+
execute as DmsAsyncDataHandler<T>,
|
|
1701
|
+
options,
|
|
1702
|
+
inComponent,
|
|
1703
|
+
),
|
|
1704
|
+
);
|
|
1705
|
+
if (getCurrentScope()) onScopeDispose(() => consumer.value.unbind());
|
|
1706
|
+
if (options.watch && options.watch.length)
|
|
1707
|
+
refreshOnWatchedChange(consumer, options.watch);
|
|
1708
|
+
// Registered in the browser too, though only a server calls it: Vue makes
|
|
1709
|
+
// a component that prefetches an async boundary, which shifts the ids
|
|
1710
|
+
// `useId()` gives the components after it, and both sides must agree. It
|
|
1711
|
+
// waits for the key the caller is on by then, which its setup may have set.
|
|
1712
|
+
let prefetched = false;
|
|
1713
|
+
if (inComponent && options.immediate !== false && options.server !== false)
|
|
1714
|
+
onServerPrefetch(() => {
|
|
1715
|
+
prefetched = true;
|
|
1716
|
+
return consumer.value.entry.settled();
|
|
1717
|
+
});
|
|
1718
|
+
pollAsyncData(runtime, consumer, options);
|
|
1719
|
+
const { entry } = consumer.value;
|
|
1720
|
+
const waited = startAsyncData(runtime, entry, options, inComponent);
|
|
1721
|
+
const result =
|
|
1722
|
+
keySource == null || typeof keySource === "string"
|
|
1723
|
+
? entry.result
|
|
1724
|
+
: followAsyncDataKey(
|
|
1725
|
+
runtime,
|
|
1726
|
+
consumer,
|
|
1727
|
+
keySource,
|
|
1728
|
+
execute as DmsAsyncDataHandler<T>,
|
|
1729
|
+
options,
|
|
1730
|
+
inComponent,
|
|
1731
|
+
() => !prefetched,
|
|
1732
|
+
);
|
|
1733
|
+
return Object.assign(
|
|
1734
|
+
Promise.resolve(waited).then(() => result),
|
|
1735
|
+
result,
|
|
1736
|
+
);
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
/**
|
|
1740
|
+
* Reads the data again when a watched source changes, but when the caller's
|
|
1741
|
+
* key moved in the same turn: the new key's own read already has the change.
|
|
1742
|
+
*/
|
|
1743
|
+
function refreshOnWatchedChange<T>(
|
|
1744
|
+
consumer: ShallowRef<AsyncDataConsumer<T>>,
|
|
1745
|
+
sources: unknown[],
|
|
1746
|
+
): void {
|
|
1747
|
+
let moved = false;
|
|
1748
|
+
watch(
|
|
1749
|
+
() => consumer.value.entry,
|
|
1750
|
+
() => {
|
|
1751
|
+
moved = true;
|
|
1752
|
+
void nextTick(() => {
|
|
1753
|
+
moved = false;
|
|
1754
|
+
});
|
|
1755
|
+
},
|
|
1756
|
+
{ flush: "sync" },
|
|
1757
|
+
);
|
|
1758
|
+
watch(sources as never[], () => {
|
|
1759
|
+
if (!moved) void consumer.value.entry.result.refresh();
|
|
906
1760
|
});
|
|
907
|
-
if (key) runtime.asyncData.set(key, entry as DmsAsyncData<unknown>);
|
|
908
|
-
return { entry, shouldRefresh };
|
|
909
1761
|
}
|
|
910
1762
|
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
const
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
1763
|
+
/**
|
|
1764
|
+
* Reads the data again every `refreshInterval`, in the browser, following
|
|
1765
|
+
* the interval as it changes: none, `0` or `null` stops it.
|
|
1766
|
+
*/
|
|
1767
|
+
function pollAsyncData<T>(
|
|
1768
|
+
runtime: DmsFrontendRuntime,
|
|
1769
|
+
consumer: ShallowRef<AsyncDataConsumer<T>>,
|
|
1770
|
+
options: UseAsyncDataOptions<T>,
|
|
1771
|
+
): void {
|
|
1772
|
+
const interval = options.refreshInterval;
|
|
1773
|
+
if (runtime.isServer || interval === undefined) return;
|
|
1774
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
1775
|
+
const stop = () => {
|
|
1776
|
+
clearInterval(timer);
|
|
1777
|
+
timer = undefined;
|
|
1778
|
+
};
|
|
1779
|
+
watch(
|
|
1780
|
+
() => toValue(interval),
|
|
1781
|
+
(period) => {
|
|
1782
|
+
stop();
|
|
1783
|
+
if (period && period > 0)
|
|
1784
|
+
timer = setInterval(() => pollOnce(consumer, options), period);
|
|
1785
|
+
},
|
|
1786
|
+
{ immediate: true },
|
|
925
1787
|
);
|
|
926
|
-
if (
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
1788
|
+
if (getCurrentScope()) onScopeDispose(stop);
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
function pollOnce<T>(
|
|
1792
|
+
consumer: ShallowRef<AsyncDataConsumer<T>>,
|
|
1793
|
+
options: UseAsyncDataOptions<T>,
|
|
1794
|
+
): void {
|
|
1795
|
+
if (typeof document !== "undefined" && document.visibilityState === "hidden")
|
|
1796
|
+
return;
|
|
1797
|
+
const result = consumer.value.entry.result;
|
|
1798
|
+
// Data a caller waits to `execute()` is not read before it is.
|
|
1799
|
+
if (result.status.value === "idle" && options.immediate === false) return;
|
|
1800
|
+
void result.refresh({ dedupe: "defer" });
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
/**
|
|
1804
|
+
* Starts the run a caller needs, where its side needs it, and returns what
|
|
1805
|
+
* awaiting the call waits for.
|
|
1806
|
+
*/
|
|
1807
|
+
function startAsyncData<T>(
|
|
1808
|
+
runtime: DmsFrontendRuntime,
|
|
1809
|
+
entry: AsyncDataEntry<T>,
|
|
1810
|
+
options: UseAsyncDataOptions<T>,
|
|
1811
|
+
inComponent: boolean,
|
|
1812
|
+
): Promise<void> | undefined {
|
|
1813
|
+
const immediate = options.immediate !== false;
|
|
1814
|
+
const onServer = options.server !== false;
|
|
1815
|
+
const idle = entry.result.status.value === "idle";
|
|
1816
|
+
if (runtime.isServer) {
|
|
1817
|
+
if (!immediate || !onServer) return undefined;
|
|
1818
|
+
if (idle && options.lazy) entry.schedule();
|
|
1819
|
+
else if (idle) void entry.load();
|
|
1820
|
+
const settled = entry.settled();
|
|
1821
|
+
if (!inComponent) runtime.serverPrefetches.add(settled);
|
|
1822
|
+
return settled;
|
|
941
1823
|
}
|
|
942
|
-
|
|
1824
|
+
// The server's render had the data once the setup awaited it, and before
|
|
1825
|
+
// the component rendered: handed over at the same points, it gives what
|
|
1826
|
+
// the setup derives from it, and so the HTML, the same values.
|
|
1827
|
+
const adoption = entry.adoption();
|
|
1828
|
+
if (adoption) {
|
|
1829
|
+
if (inComponent) onBeforeMount(entry.adoptNow);
|
|
1830
|
+
return adoption;
|
|
1831
|
+
}
|
|
1832
|
+
if (!immediate) return undefined;
|
|
1833
|
+
const waitForData = () =>
|
|
1834
|
+
!options.lazy && entry.result.data.value === null
|
|
1835
|
+
? entry.settled()
|
|
1836
|
+
: undefined;
|
|
1837
|
+
if (entry.running()) return waitForData();
|
|
1838
|
+
if (!idle) {
|
|
1839
|
+
// Loaded before: shown at once and read again in the background, except
|
|
1840
|
+
// while the page the server rendered hydrates with the server's data, or
|
|
1841
|
+
// when another caller of the page has just read it.
|
|
1842
|
+
if (!runtime.hydrating && !entry.justReadForAnother()) void entry.load();
|
|
1843
|
+
return waitForData();
|
|
1844
|
+
}
|
|
1845
|
+
// The server rendered the default in place of this data: the browser's
|
|
1846
|
+
// first render must show it too, before the request leaves.
|
|
1847
|
+
if (runtime.hydrating && (options.lazy || !onServer)) {
|
|
1848
|
+
if (inComponent) onMounted(() => void entry.load());
|
|
1849
|
+
else entry.schedule();
|
|
1850
|
+
} else if (options.lazy) entry.schedule();
|
|
1851
|
+
else void entry.load();
|
|
1852
|
+
return !options.lazy && entry.result.pending.value
|
|
1853
|
+
? entry.settled()
|
|
1854
|
+
: undefined;
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
interface AsyncDataConsumer<T> extends AsyncDataBindingHandle {
|
|
1858
|
+
entry: AsyncDataEntry<T>;
|
|
1859
|
+
/** Whether no other component showed the key when this one came. */
|
|
1860
|
+
alone: boolean;
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
/**
|
|
1864
|
+
* Binds a caller to the data of its key. A `null` key has no data to load:
|
|
1865
|
+
* the caller holds its default, and nothing runs until it has a key.
|
|
1866
|
+
*/
|
|
1867
|
+
function attachAsyncData<T>(
|
|
1868
|
+
runtime: DmsFrontendRuntime,
|
|
1869
|
+
key: string | null | undefined,
|
|
1870
|
+
handler: DmsAsyncDataHandler<T>,
|
|
1871
|
+
options: UseAsyncDataOptions<T>,
|
|
1872
|
+
inComponent: boolean,
|
|
1873
|
+
): AsyncDataConsumer<T> {
|
|
1874
|
+
const entry = asyncDataEntry(runtime, key ?? undefined, options, inComponent);
|
|
1875
|
+
const alone = entry.unused();
|
|
1876
|
+
if (key === null) return { entry, alone, unbind() {} };
|
|
1877
|
+
return { entry, alone, ...entry.bind({ handler, options }) };
|
|
943
1878
|
}
|
|
944
1879
|
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
1880
|
+
/**
|
|
1881
|
+
* Moves a caller to the data of each key its reactive key takes, and returns
|
|
1882
|
+
* refs that follow it there. Until the new key's data is in, the previous
|
|
1883
|
+
* data stays on screen rather than the default flashing in its place: the
|
|
1884
|
+
* caller's refs show it, and `placeholder` says so, while the new key's own
|
|
1885
|
+
* data stays its own, so a run that fails or that a switch away aborts never
|
|
1886
|
+
* leaves it another key's data. A key loaded before shows its own data at
|
|
1887
|
+
* once, and is read again in the background.
|
|
1888
|
+
*
|
|
1889
|
+
* A new key is read where the first one would have been. A server reads it
|
|
1890
|
+
* neither for `server: false` nor for `immediate: false`, and otherwise only
|
|
1891
|
+
* while its render still waits for the caller: until the prefetch of the
|
|
1892
|
+
* caller's component, or, outside a component, as a call made then. Past
|
|
1893
|
+
* that point the server renders the default and the browser reads the key. A
|
|
1894
|
+
* browser hydrating the server's HTML reads a key the server did not render
|
|
1895
|
+
* once the component is mounted, so that its first render matches the HTML.
|
|
1896
|
+
*/
|
|
1897
|
+
function followAsyncDataKey<T>(
|
|
1898
|
+
runtime: DmsFrontendRuntime,
|
|
1899
|
+
consumer: ShallowRef<AsyncDataConsumer<T>>,
|
|
1900
|
+
keySource: MaybeRefOrGetter<DmsAsyncDataKey>,
|
|
1901
|
+
handler: DmsAsyncDataHandler<T>,
|
|
1902
|
+
options: UseAsyncDataOptions<T>,
|
|
1903
|
+
inComponent: boolean,
|
|
1904
|
+
serverWaits: () => boolean,
|
|
949
1905
|
): DmsAsyncData<T> {
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
1906
|
+
// A read held back until the hydrating component is mounted.
|
|
1907
|
+
let held = false;
|
|
1908
|
+
let mounted = !(inComponent && runtime.hydrating);
|
|
1909
|
+
if (!mounted)
|
|
1910
|
+
onMounted(() => {
|
|
1911
|
+
mounted = true;
|
|
1912
|
+
const { entry } = consumer.value;
|
|
1913
|
+
if (held && entry.result.status.value === "idle") void entry.load();
|
|
1914
|
+
held = false;
|
|
1915
|
+
});
|
|
1916
|
+
const startOnServer = (entry: AsyncDataEntry<T>) => {
|
|
1917
|
+
if (
|
|
1918
|
+
options.server === false ||
|
|
1919
|
+
options.immediate === false ||
|
|
1920
|
+
!serverWaits()
|
|
1921
|
+
)
|
|
1922
|
+
return;
|
|
1923
|
+
void entry.load();
|
|
1924
|
+
if (!inComponent) runtime.serverPrefetches.add(entry.settled());
|
|
1925
|
+
};
|
|
1926
|
+
const current = () => consumer.value.entry.result;
|
|
1927
|
+
// The previous key's data, shown until the current key settles.
|
|
1928
|
+
const carried = shallowRef<{ value: T | null } | undefined>();
|
|
1929
|
+
const settled = (result: DmsAsyncData<T>) =>
|
|
1930
|
+
result.status.value === "success" || result.status.value === "error";
|
|
1931
|
+
const shownData = (): T | null => {
|
|
1932
|
+
const result = current();
|
|
1933
|
+
return carried.value && !settled(result)
|
|
1934
|
+
? carried.value.value
|
|
1935
|
+
: result.data.value;
|
|
1936
|
+
};
|
|
1937
|
+
watch(
|
|
1938
|
+
() => settled(current()),
|
|
1939
|
+
(done) => {
|
|
1940
|
+
if (done) carried.value = undefined;
|
|
1941
|
+
},
|
|
1942
|
+
{ flush: "sync" },
|
|
957
1943
|
);
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
(
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
1944
|
+
// Data written to the current key from elsewhere (`useDmsData`, another
|
|
1945
|
+
// caller of the key) is its own: it replaces what is carried.
|
|
1946
|
+
watch(
|
|
1947
|
+
() => [current(), current().data.value] as const,
|
|
1948
|
+
([result], [previous]) => {
|
|
1949
|
+
if (result === previous) carried.value = undefined;
|
|
1950
|
+
},
|
|
1951
|
+
{ flush: "sync" },
|
|
1952
|
+
);
|
|
1953
|
+
watch(
|
|
1954
|
+
() => toValue(keySource) ?? null,
|
|
1955
|
+
(key) => {
|
|
1956
|
+
const previous = current();
|
|
1957
|
+
const shown = shownData();
|
|
1958
|
+
const wasRunning = previous.pending.value;
|
|
1959
|
+
// Past the setup, the server's data for the new key, if it rendered
|
|
1960
|
+
// any, is taken over at once.
|
|
1961
|
+
const next = attachAsyncData(runtime, key, handler, options, false);
|
|
1962
|
+
const { entry } = next;
|
|
1963
|
+
const carry =
|
|
1964
|
+
next.alone && entry.result.status.value === "idle" && shown !== null;
|
|
1965
|
+
// Unbound first: the previous key's run, aborted, settles it, which
|
|
1966
|
+
// must not drop what is carried to the next key.
|
|
1967
|
+
consumer.value.unbind();
|
|
1968
|
+
consumer.value = next;
|
|
1969
|
+
carried.value = carry ? { value: shown } : undefined;
|
|
1970
|
+
held = false;
|
|
1971
|
+
// `watch: false` keeps the caller on the key it names, without a run.
|
|
1972
|
+
if (key === null || options.watch === false) return;
|
|
1973
|
+
if (runtime.isServer) return startOnServer(entry);
|
|
1974
|
+
const wanted =
|
|
1975
|
+
options.immediate !== false || shown !== null || wasRunning;
|
|
1976
|
+
if (!wanted) return;
|
|
1977
|
+
if (!mounted) held = true;
|
|
1978
|
+
else if (!inComponent && runtime.hydrating) entry.schedule();
|
|
1979
|
+
else void entry.load();
|
|
1980
|
+
},
|
|
1981
|
+
{ flush: "sync" },
|
|
1982
|
+
);
|
|
1983
|
+
const through = <V>(read: (result: DmsAsyncData<T>) => Ref<V>): Ref<V> =>
|
|
1984
|
+
computed({
|
|
1985
|
+
get: () => read(current()).value,
|
|
1986
|
+
set: (value) => {
|
|
1987
|
+
read(current()).value = value;
|
|
1988
|
+
},
|
|
969
1989
|
});
|
|
970
|
-
return
|
|
1990
|
+
return {
|
|
1991
|
+
data: computed({
|
|
1992
|
+
get: shownData,
|
|
1993
|
+
set: (value) => {
|
|
1994
|
+
carried.value = undefined;
|
|
1995
|
+
current().data.value = value;
|
|
1996
|
+
},
|
|
1997
|
+
}),
|
|
1998
|
+
key: computed(() => current().key.value),
|
|
1999
|
+
placeholder: computed(
|
|
2000
|
+
() => carried.value !== undefined && !settled(current()),
|
|
2001
|
+
),
|
|
2002
|
+
error: through((result) => result.error),
|
|
2003
|
+
pending: computed(() => current().pending.value),
|
|
2004
|
+
status: through((result) => result.status),
|
|
2005
|
+
execute: (refreshOptions) => current().refresh(refreshOptions),
|
|
2006
|
+
refresh: (refreshOptions) => current().refresh(refreshOptions),
|
|
2007
|
+
clear: () => {
|
|
2008
|
+
carried.value = undefined;
|
|
2009
|
+
current().clear();
|
|
2010
|
+
},
|
|
2011
|
+
};
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
/** What a paged read keeps under its key: the pages loaded, in order. */
|
|
2015
|
+
export interface DmsPagedDataValue<TPage, TCursor> {
|
|
2016
|
+
pages: TPage[];
|
|
2017
|
+
/** The cursor of the page after the last one; `null` once there is none. */
|
|
2018
|
+
next: TCursor | null;
|
|
2019
|
+
}
|
|
2020
|
+
/** What a paged read's handler receives. */
|
|
2021
|
+
export interface DmsPagedDataContext<TCursor> extends DmsAsyncDataContext {
|
|
2022
|
+
/** The page to read: `initialCursor` for the first one. */
|
|
2023
|
+
cursor: TCursor | undefined;
|
|
2024
|
+
/** Its position, from `0`. */
|
|
2025
|
+
index: number;
|
|
2026
|
+
}
|
|
2027
|
+
export type DmsPagedDataHandler<TPage, TCursor> = (
|
|
2028
|
+
context: DmsPagedDataContext<TCursor>,
|
|
2029
|
+
) => Promise<TPage>;
|
|
2030
|
+
export interface UseDmsPagedDataOptions<TPage, TCursor> extends Omit<
|
|
2031
|
+
UseAsyncDataOptions<DmsPagedDataValue<TPage, TCursor>>,
|
|
2032
|
+
"default" | "transform" | "pick"
|
|
2033
|
+
> {
|
|
2034
|
+
/** The cursor of the page after `page`, or `null` (or `undefined`) for none. */
|
|
2035
|
+
nextCursor(page: TPage, pages: TPage[]): TCursor | null | undefined;
|
|
2036
|
+
/** The first page's cursor; none by default. */
|
|
2037
|
+
initialCursor?: TCursor;
|
|
2038
|
+
}
|
|
2039
|
+
export interface DmsPagedData<TPage, TCursor> extends DmsAsyncData<
|
|
2040
|
+
DmsPagedDataValue<TPage, TCursor>
|
|
2041
|
+
> {
|
|
2042
|
+
/** The pages loaded so far, in order; none before the first answer. */
|
|
2043
|
+
pages: Readonly<Ref<TPage[]>>;
|
|
2044
|
+
/** Whether `loadMore()` has a page to read. */
|
|
2045
|
+
hasMore: Readonly<Ref<boolean>>;
|
|
2046
|
+
/** `true` while `loadMore()` reads its page. */
|
|
2047
|
+
loadingMore: Readonly<Ref<boolean>>;
|
|
2048
|
+
/**
|
|
2049
|
+
* Reads the next page and appends it under the same key. It waits for a
|
|
2050
|
+
* read in flight first, does nothing once there is no next page, and puts
|
|
2051
|
+
* a failure in `error`, keeping the pages loaded.
|
|
2052
|
+
*/
|
|
2053
|
+
loadMore(): Promise<void>;
|
|
2054
|
+
}
|
|
2055
|
+
export type DmsPagedDataResult<TPage, TCursor> = DmsPagedData<TPage, TCursor> &
|
|
2056
|
+
Promise<DmsPagedData<TPage, TCursor>>;
|
|
2057
|
+
|
|
2058
|
+
type PagedValue<TPage, TCursor> = DmsPagedDataValue<TPage, TCursor>;
|
|
2059
|
+
|
|
2060
|
+
/** Reads `count` pages from the first, as long as each has a next one. */
|
|
2061
|
+
async function readPages<TPage, TCursor>(
|
|
2062
|
+
handler: DmsPagedDataHandler<TPage, TCursor>,
|
|
2063
|
+
options: UseDmsPagedDataOptions<TPage, TCursor>,
|
|
2064
|
+
count: number,
|
|
2065
|
+
signal: AbortSignal,
|
|
2066
|
+
): Promise<PagedValue<TPage, TCursor>> {
|
|
2067
|
+
const pages: TPage[] = [];
|
|
2068
|
+
let cursor: TCursor | undefined = options.initialCursor;
|
|
2069
|
+
for (let index = 0; index < count; index++) {
|
|
2070
|
+
const page = await handler({ cursor, index, signal });
|
|
2071
|
+
pages.push(page);
|
|
2072
|
+
const next = options.nextCursor(page, pages);
|
|
2073
|
+
if (next == null) return { pages, next: null };
|
|
2074
|
+
cursor = next;
|
|
2075
|
+
}
|
|
2076
|
+
return { pages, next: cursor ?? null };
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
/** Resolves once `result` has no run in flight. */
|
|
2080
|
+
function untilSettled<T>(result: DmsAsyncData<T>): Promise<void> {
|
|
2081
|
+
if (!result.pending.value) return Promise.resolve();
|
|
2082
|
+
return new Promise((resolve) => {
|
|
2083
|
+
const stop = watch(result.pending, (pending) => {
|
|
2084
|
+
if (pending) return;
|
|
2085
|
+
stop();
|
|
2086
|
+
resolve();
|
|
2087
|
+
});
|
|
2088
|
+
});
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
/**
|
|
2092
|
+
* A list read page by page under one key: "load more", an infinite list.
|
|
2093
|
+
* The first page loads as `useDmsAsyncData` loads its data (a server render
|
|
2094
|
+
* waits for it and transfers it), and `loadMore()` appends the next one to
|
|
2095
|
+
* the same key, so the pages a component loaded show at once on a return to
|
|
2096
|
+
* it. A revalidation (a mount, `refresh()`, `refreshDmsPageData()`, the
|
|
2097
|
+
* polling) reads every page loaded so far again, from the first, and
|
|
2098
|
+
* replaces them once all are in. Put what filters the list in the key: a new
|
|
2099
|
+
* key starts again from its first page.
|
|
2100
|
+
*
|
|
2101
|
+
* @param key The key, or a ref or getter for it
|
|
2102
|
+
* @param handler Reads one page, from the cursor it receives
|
|
2103
|
+
* @param options `nextCursor`, `initialCursor` and the async data options
|
|
2104
|
+
*/
|
|
2105
|
+
export function useDmsPagedData<TPage, TCursor = unknown>(
|
|
2106
|
+
key: MaybeRefOrGetter<DmsAsyncDataKey>,
|
|
2107
|
+
handler: DmsPagedDataHandler<TPage, TCursor>,
|
|
2108
|
+
options: UseDmsPagedDataOptions<TPage, TCursor>,
|
|
2109
|
+
): DmsPagedDataResult<TPage, TCursor> {
|
|
2110
|
+
const {
|
|
2111
|
+
nextCursor: _next,
|
|
2112
|
+
initialCursor: _initial,
|
|
2113
|
+
...asyncOptions
|
|
2114
|
+
} = options;
|
|
2115
|
+
let shown: DmsAsyncData<PagedValue<TPage, TCursor>> | undefined;
|
|
2116
|
+
const loadedCount = () =>
|
|
2117
|
+
shown && !shown.placeholder.value
|
|
2118
|
+
? Math.max(1, shown.data.value?.pages.length ?? 0)
|
|
2119
|
+
: 1;
|
|
2120
|
+
const call = useDmsAsyncData<PagedValue<TPage, TCursor>>(
|
|
2121
|
+
key,
|
|
2122
|
+
({ signal }) => readPages(handler, options, loadedCount(), signal),
|
|
2123
|
+
asyncOptions,
|
|
2124
|
+
);
|
|
2125
|
+
shown = call;
|
|
2126
|
+
const result: DmsPagedData<TPage, TCursor> = Object.assign(
|
|
2127
|
+
{},
|
|
2128
|
+
call as DmsAsyncData<PagedValue<TPage, TCursor>>,
|
|
2129
|
+
pagedControls(call, handler, options),
|
|
2130
|
+
);
|
|
2131
|
+
return Object.assign(
|
|
2132
|
+
call.then(() => result),
|
|
2133
|
+
result,
|
|
2134
|
+
);
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
function pagedControls<TPage, TCursor>(
|
|
2138
|
+
call: DmsAsyncData<PagedValue<TPage, TCursor>>,
|
|
2139
|
+
handler: DmsPagedDataHandler<TPage, TCursor>,
|
|
2140
|
+
options: UseDmsPagedDataOptions<TPage, TCursor>,
|
|
2141
|
+
) {
|
|
2142
|
+
const loadingMore = ref(false);
|
|
2143
|
+
let controller: AbortController | undefined;
|
|
2144
|
+
const abort = () => {
|
|
2145
|
+
controller?.abort();
|
|
2146
|
+
controller = undefined;
|
|
2147
|
+
loadingMore.value = false;
|
|
2148
|
+
};
|
|
2149
|
+
watch(call.key, abort);
|
|
2150
|
+
if (getCurrentScope()) onScopeDispose(abort);
|
|
2151
|
+
const hasMore = computed(
|
|
2152
|
+
() => !call.placeholder.value && (call.data.value?.next ?? null) !== null,
|
|
2153
|
+
);
|
|
2154
|
+
const loadMore = async (): Promise<void> => {
|
|
2155
|
+
if (call.pending.value) await untilSettled(call);
|
|
2156
|
+
const before = call.data.value;
|
|
2157
|
+
if (!before) return call.execute();
|
|
2158
|
+
if (before.next === null || controller) return;
|
|
2159
|
+
const current = new AbortController();
|
|
2160
|
+
controller = current;
|
|
2161
|
+
loadingMore.value = true;
|
|
2162
|
+
try {
|
|
2163
|
+
const page = await handler({
|
|
2164
|
+
cursor: before.next,
|
|
2165
|
+
index: before.pages.length,
|
|
2166
|
+
signal: current.signal,
|
|
2167
|
+
});
|
|
2168
|
+
if (controller !== current || call.data.value !== before) return;
|
|
2169
|
+
const pages = [...before.pages, page];
|
|
2170
|
+
call.data.value = {
|
|
2171
|
+
pages,
|
|
2172
|
+
next: options.nextCursor(page, pages) ?? null,
|
|
2173
|
+
};
|
|
2174
|
+
} catch (reason) {
|
|
2175
|
+
if (controller === current) call.error.value = reason;
|
|
2176
|
+
} finally {
|
|
2177
|
+
if (controller === current) abort();
|
|
2178
|
+
}
|
|
2179
|
+
};
|
|
2180
|
+
return {
|
|
2181
|
+
pages: computed(() => call.data.value?.pages ?? []),
|
|
2182
|
+
hasMore,
|
|
2183
|
+
loadingMore: readonly(loadingMore),
|
|
2184
|
+
loadMore,
|
|
2185
|
+
};
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
/**
|
|
2189
|
+
* The data another caller loads under `key`, as Nuxt's `useNuxtData`: to
|
|
2190
|
+
* update it in place without reading it again, from what a write answered
|
|
2191
|
+
* (an optimistic update) or what a realtime event says changed. It follows
|
|
2192
|
+
* the key in the runtime's scope (`setDmsAsyncDataScope`): `undefined` until
|
|
2193
|
+
* a caller loads the key, its data once there, `undefined` again once the
|
|
2194
|
+
* browser lets the key go (past the most recent keys it keeps) or the scope
|
|
2195
|
+
* changes. It does not load the key, nor keep it.
|
|
2196
|
+
*/
|
|
2197
|
+
export function useDmsData<T>(
|
|
2198
|
+
key: MaybeRefOrGetter<DmsAsyncDataKey>,
|
|
2199
|
+
): DmsData<T> {
|
|
2200
|
+
const runtime = useDmsRuntime();
|
|
2201
|
+
const entry = () => {
|
|
2202
|
+
const name = toValue(key);
|
|
2203
|
+
const result =
|
|
2204
|
+
name == null
|
|
2205
|
+
? undefined
|
|
2206
|
+
: runtime.asyncData.get(scopedKey(runtime, String(name)));
|
|
2207
|
+
return result && asyncDataEntries.get(result);
|
|
2208
|
+
};
|
|
2209
|
+
// A component after the caller, in the page or in its subtree, renders the
|
|
2210
|
+
// data the browser hydrates it with: the server waits for the caller's run
|
|
2211
|
+
// too. One before it has none on either side. Whether the key is there as
|
|
2212
|
+
// the setup runs is the same on both sides, and so are the async
|
|
2213
|
+
// boundaries `useId()` counts.
|
|
2214
|
+
const loaded = entry();
|
|
2215
|
+
if (loaded && getCurrentInstance()) onServerPrefetch(() => loaded.settled());
|
|
2216
|
+
return {
|
|
2217
|
+
data: computed({
|
|
2218
|
+
get() {
|
|
2219
|
+
// An entry only ever holds its own key's data: a caller showing the
|
|
2220
|
+
// previous key's data meanwhile keeps it in its own refs.
|
|
2221
|
+
return (entry()?.result.data.value ?? undefined) as T | undefined;
|
|
2222
|
+
},
|
|
2223
|
+
set(value) {
|
|
2224
|
+
const current = entry();
|
|
2225
|
+
if (!current) return;
|
|
2226
|
+
current.result.data.value = value ?? null;
|
|
2227
|
+
},
|
|
2228
|
+
}),
|
|
2229
|
+
};
|
|
971
2230
|
}
|
|
972
2231
|
|
|
2232
|
+
/** Reads the data of the keys given, or of every key, again. */
|
|
973
2233
|
export async function refreshDmsData(keys?: string | string[]): Promise<void> {
|
|
974
2234
|
const runtime = useDmsRuntime();
|
|
975
2235
|
const selected = keys
|
|
976
|
-
? Array.isArray(keys)
|
|
977
|
-
|
|
978
|
-
|
|
2236
|
+
? (Array.isArray(keys) ? keys : [keys]).map((key) =>
|
|
2237
|
+
scopedKey(runtime, key),
|
|
2238
|
+
)
|
|
979
2239
|
: [...runtime.asyncData.keys()];
|
|
980
2240
|
await Promise.all(
|
|
981
2241
|
selected.map((key) => runtime.asyncData.get(key)?.refresh()),
|
|
982
2242
|
);
|
|
983
2243
|
}
|
|
984
2244
|
|
|
2245
|
+
/**
|
|
2246
|
+
* Reads again the data the components on screen show, but those that opted
|
|
2247
|
+
* out (`refreshOnPageRefresh: false`) and those waiting for `execute()`:
|
|
2248
|
+
* what a page calls once something changed what it shows, after a save or
|
|
2249
|
+
* on a realtime event, instead of each component refreshing its own.
|
|
2250
|
+
*/
|
|
2251
|
+
export async function refreshDmsPageData(): Promise<void> {
|
|
2252
|
+
const runtime = useDmsRuntime();
|
|
2253
|
+
await Promise.all(
|
|
2254
|
+
[...runtime.boundAsyncData].map((entry) => entry.pageRefresh()),
|
|
2255
|
+
);
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
/**
|
|
2259
|
+
* Puts the keys of the async data in a scope, such as a digest of the
|
|
2260
|
+
* account and the workspace: the same key in another scope is another
|
|
2261
|
+
* entry, never the data of the previous one. Moving to another scope drops
|
|
2262
|
+
* the data of every key, aborting its runs; the components on screen then
|
|
2263
|
+
* show their default until they load again. Call it on both sides before
|
|
2264
|
+
* the page renders, with the same value, so the browser finds the data the
|
|
2265
|
+
* server transferred; the scope is written in the page with each key, so a
|
|
2266
|
+
* digest rather than an identifier.
|
|
2267
|
+
*
|
|
2268
|
+
* @param scope The scope, or `undefined` for none
|
|
2269
|
+
*/
|
|
2270
|
+
export function setDmsAsyncDataScope(scope: string | undefined): void {
|
|
2271
|
+
const runtime = useDmsRuntime();
|
|
2272
|
+
if (runtime.asyncDataScope === scope) return;
|
|
2273
|
+
runtime.asyncDataScope = scope;
|
|
2274
|
+
for (const result of runtime.asyncData.values()) result.clear();
|
|
2275
|
+
runtime.asyncData.clear();
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
/**
|
|
2279
|
+
* Drops the data no component shows any more, but the most recently shown:
|
|
2280
|
+
* a return to a key shows its data at once, and the data does not pile up
|
|
2281
|
+
* over a session.
|
|
2282
|
+
*/
|
|
2283
|
+
function dropUnusedAsyncData(runtime: DmsFrontendRuntime): void {
|
|
2284
|
+
const unused = [...runtime.asyncData]
|
|
2285
|
+
.map(([key, result]) => ({ key, entry: asyncDataEntries.get(result) }))
|
|
2286
|
+
.filter(({ entry }) => entry?.unused())
|
|
2287
|
+
.sort((left, right) => right.entry!.lastShown() - left.entry!.lastShown());
|
|
2288
|
+
for (const { key } of unused.slice(MAX_UNUSED_ASYNC_DATA))
|
|
2289
|
+
runtime.asyncData.delete(key);
|
|
2290
|
+
}
|
|
2291
|
+
|
|
985
2292
|
export function serializeDmsAsyncData(
|
|
986
2293
|
runtime: DmsFrontendRuntime,
|
|
987
2294
|
): Record<string, unknown> {
|
|
988
2295
|
return Object.fromEntries(
|
|
989
2296
|
[...runtime.asyncData.entries()]
|
|
990
2297
|
.filter(([, entry]) => entry.status.value === "success")
|
|
991
|
-
.map(([key, entry]) => [key, entry.data.value]),
|
|
2298
|
+
.map(([key, entry]) => [key, toRaw(entry.data.value)]),
|
|
992
2299
|
);
|
|
993
2300
|
}
|
|
994
2301
|
|
|
995
|
-
|
|
2302
|
+
const HTTP_ERROR_STATUS = { min: 400, max: 599 };
|
|
2303
|
+
const SERVER_FAILURE_STATUS = 500;
|
|
2304
|
+
const FETCH_ERROR_NAME = "FetchError";
|
|
2305
|
+
|
|
2306
|
+
/** The fields of a failure, under the names `createDmsError` and ofetch use. */
|
|
2307
|
+
interface DmsFailure extends DmsErrorData {
|
|
2308
|
+
name?: string;
|
|
2309
|
+
status?: number;
|
|
2310
|
+
statusText?: string;
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
/**
|
|
2314
|
+
* What of a failure a page may carry, by the rule the error page follows: an
|
|
2315
|
+
* HTTP failure keeps its status, status text and message, and its body if it
|
|
2316
|
+
* is a 4xx, anything else
|
|
2317
|
+
* is a server failure whose cause only reaches the server log. A fetch error
|
|
2318
|
+
* names the URL it requested, which on a server is the backend's own address:
|
|
2319
|
+
* its status text stands for it.
|
|
2320
|
+
*/
|
|
2321
|
+
function transferredError(key: string, reason: unknown): DmsErrorData {
|
|
2322
|
+
const failure = (reason ?? {}) as DmsFailure;
|
|
2323
|
+
const statusCode = failure.statusCode ?? failure.status;
|
|
2324
|
+
if (
|
|
2325
|
+
statusCode === undefined ||
|
|
2326
|
+
!Number.isInteger(statusCode) ||
|
|
2327
|
+
statusCode < HTTP_ERROR_STATUS.min ||
|
|
2328
|
+
statusCode > HTTP_ERROR_STATUS.max
|
|
2329
|
+
) {
|
|
2330
|
+
console.error(`DMS async data "${key}" failed`, reason);
|
|
2331
|
+
return { statusCode: SERVER_FAILURE_STATUS };
|
|
2332
|
+
}
|
|
2333
|
+
const statusMessage = failure.statusMessage ?? failure.statusText;
|
|
2334
|
+
const transferred: DmsErrorData = {
|
|
2335
|
+
statusCode,
|
|
2336
|
+
statusMessage,
|
|
2337
|
+
message:
|
|
2338
|
+
failure.name === FETCH_ERROR_NAME ? statusMessage : failure.message,
|
|
2339
|
+
};
|
|
2340
|
+
// A 5xx body is the backend's own account of its failure, a stack or a
|
|
2341
|
+
// query at times: only a 4xx body, written for the client, is transferred.
|
|
2342
|
+
if (statusCode < SERVER_FAILURE_STATUS) transferred.data = failure.data;
|
|
2343
|
+
return transferred;
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2346
|
+
/**
|
|
2347
|
+
* What a page's error page shows of an expected failure (a 4xx or 5xx): its
|
|
2348
|
+
* status code, status text and message. A fetch error's message names the URL
|
|
2349
|
+
* it requested, on a server the backend's own address: its status text
|
|
2350
|
+
* stands for it.
|
|
2351
|
+
*/
|
|
2352
|
+
export function pageErrorData(failure: Error & DmsErrorData): DmsErrorData {
|
|
2353
|
+
return {
|
|
2354
|
+
statusCode: failure.statusCode,
|
|
2355
|
+
message:
|
|
2356
|
+
failure.name === FETCH_ERROR_NAME
|
|
2357
|
+
? failure.statusMessage
|
|
2358
|
+
: failure.message,
|
|
2359
|
+
statusMessage: failure.statusMessage,
|
|
2360
|
+
};
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
/**
|
|
2364
|
+
* Everything a server render hands the browser: the data and the failures of
|
|
2365
|
+
* its keys, and the state keys opted into. Read as the HTML is complete.
|
|
2366
|
+
*/
|
|
2367
|
+
export function serializeDmsPayload(runtime: DmsFrontendRuntime): DmsPayload {
|
|
2368
|
+
const errors = [...runtime.asyncData.entries()]
|
|
2369
|
+
.filter(([, entry]) => entry.status.value === "error")
|
|
2370
|
+
.map(([key, entry]): [string, DmsErrorData] => [
|
|
2371
|
+
key,
|
|
2372
|
+
transferredError(key, entry.error.value),
|
|
2373
|
+
]);
|
|
2374
|
+
const state = [...runtime.transferredState]
|
|
2375
|
+
.map((key): [string, unknown] => [
|
|
2376
|
+
key,
|
|
2377
|
+
toRaw(runtime.sharedState.get(key)?.value),
|
|
2378
|
+
])
|
|
2379
|
+
.filter(([, value]) => value !== undefined);
|
|
2380
|
+
return {
|
|
2381
|
+
data: serializeDmsAsyncData(runtime),
|
|
2382
|
+
errors: Object.fromEntries(errors),
|
|
2383
|
+
state: Object.fromEntries(state),
|
|
2384
|
+
};
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
/** Starts a browser runtime from what the server rendered. */
|
|
2388
|
+
export function restoreDmsPayload(
|
|
2389
|
+
runtime: DmsFrontendRuntime,
|
|
2390
|
+
payload: Partial<DmsPayload>,
|
|
2391
|
+
): void {
|
|
2392
|
+
runtime.hydrating = true;
|
|
2393
|
+
for (const [key, value] of Object.entries(payload.data ?? {}))
|
|
2394
|
+
runtime.hydratedAsyncData.set(key, value);
|
|
2395
|
+
for (const [key, failure] of Object.entries(payload.errors ?? {}))
|
|
2396
|
+
runtime.hydratedAsyncErrors.set(key, failure);
|
|
2397
|
+
for (const [key, value] of Object.entries(payload.state ?? {}))
|
|
2398
|
+
runtime.sharedState.set(key, ref(value));
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
export function useDmsState<T>(
|
|
2402
|
+
key: string,
|
|
2403
|
+
init?: () => T,
|
|
2404
|
+
options: DmsStateOptions = {},
|
|
2405
|
+
): Ref<T> {
|
|
996
2406
|
const runtime = useDmsRuntime();
|
|
2407
|
+
if (options.transfer) runtime.transferredState.add(key);
|
|
997
2408
|
if (!runtime.sharedState.has(key))
|
|
998
2409
|
runtime.sharedState.set(key, ref(init?.()));
|
|
999
2410
|
return runtime.sharedState.get(key) as Ref<T>;
|
|
@@ -1004,7 +2415,7 @@ export function useDmsRuntimeConfig(): DmsRuntimeConfig {
|
|
|
1004
2415
|
export function useDmsAppConfig(): DmsAppConfig {
|
|
1005
2416
|
return appConfig.value as DmsAppConfig;
|
|
1006
2417
|
}
|
|
1007
|
-
export function
|
|
2418
|
+
export function defineDmsAppConfig<T extends Record<string, unknown>>(
|
|
1008
2419
|
config: T,
|
|
1009
2420
|
): T {
|
|
1010
2421
|
return config;
|
|
@@ -1015,18 +2426,34 @@ export function defineDmsMiddleware(handler: DmsMiddleware): DmsMiddleware {
|
|
|
1015
2426
|
export function defineDmsPageMeta(meta: Record<string, unknown>): void {
|
|
1016
2427
|
const runtime = useDmsRuntime();
|
|
1017
2428
|
Object.assign(runtime.route.meta, meta);
|
|
1018
|
-
|
|
1019
|
-
|
|
2429
|
+
// A middleware that throws fails the page, as in Nuxt: the error page
|
|
2430
|
+
// shows in its place with the error's status code, which a server render
|
|
2431
|
+
// answers with. A redirect another navigation decided still wins.
|
|
2432
|
+
const navigation = runMiddleware(runtime, runtime.route)
|
|
2433
|
+
.then(async (result) => {
|
|
1020
2434
|
if (result !== undefined && result !== false)
|
|
1021
2435
|
await visit(runtime, result);
|
|
1022
|
-
}
|
|
1023
|
-
|
|
2436
|
+
})
|
|
2437
|
+
.catch((reason) => failPage(runtime, reason));
|
|
1024
2438
|
runtime.pendingNavigation = runtime.pendingNavigation
|
|
1025
2439
|
? Promise.all([runtime.pendingNavigation, navigation]).then(() => undefined)
|
|
1026
2440
|
: navigation;
|
|
1027
2441
|
void runtime.pendingNavigation;
|
|
1028
|
-
|
|
1029
|
-
|
|
2442
|
+
// A server renders the page's subtree once its middleware is done, so the
|
|
2443
|
+
// state a middleware sets is the one the HTML shows and the payload carries.
|
|
2444
|
+
// A failure shows the error page in its place (`failPage`). Registered
|
|
2445
|
+
// in the browser too, where only a server calls it: both sides must agree
|
|
2446
|
+
// on the async boundaries `useId()` counts.
|
|
2447
|
+
if (getCurrentInstance())
|
|
2448
|
+
onServerPrefetch(() => navigation.then(ignoreSettlement, ignoreSettlement));
|
|
2449
|
+
}
|
|
2450
|
+
function ignoreSettlement(): void {}
|
|
2451
|
+
/** Shows the error page in place of the page, keeping the first failure. */
|
|
2452
|
+
function failPage(runtime: DmsFrontendRuntime, reason: unknown): void {
|
|
2453
|
+
runtime.currentError.value ??=
|
|
2454
|
+
reason instanceof Error ? reason : new Error(String(reason));
|
|
2455
|
+
}
|
|
2456
|
+
export function abortDmsNavigation(): false {
|
|
1030
2457
|
return false;
|
|
1031
2458
|
}
|
|
1032
2459
|
export const useHead = useUnhead;
|
|
@@ -1250,7 +2677,7 @@ export function useDmsCookie<T = string | null>(
|
|
|
1250
2677
|
return runtime.sharedState.get(key) as Ref<T>;
|
|
1251
2678
|
}
|
|
1252
2679
|
|
|
1253
|
-
export function
|
|
2680
|
+
export function createDmsError(
|
|
1254
2681
|
input: string | DmsErrorData,
|
|
1255
2682
|
): Error & DmsErrorData {
|
|
1256
2683
|
const details = typeof input === "string" ? { message: input } : input;
|
|
@@ -1259,22 +2686,59 @@ export function createError(
|
|
|
1259
2686
|
details,
|
|
1260
2687
|
);
|
|
1261
2688
|
}
|
|
1262
|
-
export function
|
|
1263
|
-
|
|
2689
|
+
export function showDmsError(
|
|
2690
|
+
input: string | DmsErrorData,
|
|
2691
|
+
): Error & DmsErrorData {
|
|
2692
|
+
const error = createDmsError(input);
|
|
1264
2693
|
useDmsRuntime().currentError.value = error;
|
|
1265
2694
|
return error;
|
|
1266
2695
|
}
|
|
1267
|
-
export function
|
|
2696
|
+
export function useDmsError(): Ref<(Error & DmsErrorData) | null> {
|
|
1268
2697
|
return useDmsRuntime().currentError as Ref<(Error & DmsErrorData) | null>;
|
|
1269
2698
|
}
|
|
1270
|
-
export async function
|
|
2699
|
+
export async function clearDmsError(options?: {
|
|
1271
2700
|
redirect?: string;
|
|
1272
2701
|
}): Promise<void> {
|
|
1273
2702
|
useDmsRuntime().currentError.value = null;
|
|
1274
2703
|
if (options?.redirect) await navigateDms(options.redirect);
|
|
1275
2704
|
}
|
|
1276
2705
|
|
|
1277
|
-
|
|
2706
|
+
function sessionIdentity(session: unknown): string {
|
|
2707
|
+
const { accountId, activeTenantId } = (session ?? {}) as Partial<DmsSession>;
|
|
2708
|
+
return `${accountId ?? ""}\n${activeTenantId ?? ""}`;
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
/**
|
|
2712
|
+
* Drops the pages Inertia prefetched (a hovered link) once the session is
|
|
2713
|
+
* another account's or workspace's, or none: each carries the user, the page
|
|
2714
|
+
* and the site layout of the session it was fetched with, and a visit would
|
|
2715
|
+
* show them as current.
|
|
2716
|
+
*/
|
|
2717
|
+
function forgetPrefetchedPages(): void {
|
|
2718
|
+
if (typeof window !== "undefined") inertiaRouter.flushAll();
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
/**
|
|
2722
|
+
* Warms what a prefetched page renders (its page, layout and block
|
|
2723
|
+
* components) once Inertia has its props, so following the link does not wait
|
|
2724
|
+
* for those chunks after the props.
|
|
2725
|
+
*/
|
|
2726
|
+
export function preloadPrefetchedDmsPages(): () => void {
|
|
2727
|
+
return inertiaRouter.on("prefetched", (event) => {
|
|
2728
|
+
const data = event.detail.response.data as unknown;
|
|
2729
|
+
let page: { props?: DmsPageProps } | undefined;
|
|
2730
|
+
try {
|
|
2731
|
+
page = (typeof data === "string" ? JSON.parse(data) : data) as
|
|
2732
|
+
| { props?: DmsPageProps }
|
|
2733
|
+
| undefined;
|
|
2734
|
+
} catch {
|
|
2735
|
+
return;
|
|
2736
|
+
}
|
|
2737
|
+
if (page?.props?.page) void preloadDmsPage(page.props).catch(() => {});
|
|
2738
|
+
});
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
export function useDmsUserSession<
|
|
1278
2742
|
TUser = DmsUser,
|
|
1279
2743
|
TSession = DmsSession,
|
|
1280
2744
|
>(): DmsUserSession<TUser, TSession> {
|
|
@@ -1285,17 +2749,20 @@ export function useUserSession<
|
|
|
1285
2749
|
session,
|
|
1286
2750
|
loggedIn: computed(() => user.value !== null),
|
|
1287
2751
|
fetch: async () => {
|
|
2752
|
+
const before = sessionIdentity(session.value);
|
|
1288
2753
|
const value = await ofetch<{
|
|
1289
2754
|
user?: TUser;
|
|
1290
2755
|
session?: TSession;
|
|
1291
2756
|
}>("/api/_auth/session", { method: "POST" });
|
|
1292
2757
|
user.value = value.user ?? null;
|
|
1293
2758
|
session.value = value.session ?? null;
|
|
2759
|
+
if (sessionIdentity(session.value) !== before) forgetPrefetchedPages();
|
|
1294
2760
|
},
|
|
1295
2761
|
clear: async () => {
|
|
1296
2762
|
await ofetch("/api/_auth/session", { method: "DELETE" });
|
|
1297
2763
|
user.value = null;
|
|
1298
2764
|
session.value = null;
|
|
2765
|
+
forgetPrefetchedPages();
|
|
1299
2766
|
},
|
|
1300
2767
|
};
|
|
1301
2768
|
}
|
|
@@ -1314,9 +2781,15 @@ export function useDmsPageLoading(): Readonly<Ref<boolean>> {
|
|
|
1314
2781
|
* The props of the `<Suspense>` around the page that keep `useDmsPageLoading`
|
|
1315
2782
|
* in step with it. Cleared before the boundary unmounts, so a page left while
|
|
1316
2783
|
* pending never leaves the state set.
|
|
2784
|
+
*
|
|
2785
|
+
* They also end the hydration: the page is on screen once its suspense
|
|
2786
|
+
* resolves, and the components after it once the tick is over, so the
|
|
2787
|
+
* browser has then hydrated what the server rendered. A hydrating suspense
|
|
2788
|
+
* never reports itself pending: the server rendered the page resolved.
|
|
1317
2789
|
*/
|
|
1318
2790
|
export function dmsPageSuspenseProps(): Record<string, () => void> {
|
|
1319
|
-
const
|
|
2791
|
+
const runtime = useDmsRuntime();
|
|
2792
|
+
const loading = runtime.pageLoading;
|
|
1320
2793
|
const settle = () => {
|
|
1321
2794
|
loading.value = false;
|
|
1322
2795
|
};
|
|
@@ -1324,7 +2797,10 @@ export function dmsPageSuspenseProps(): Record<string, () => void> {
|
|
|
1324
2797
|
onPending: () => {
|
|
1325
2798
|
loading.value = true;
|
|
1326
2799
|
},
|
|
1327
|
-
onResolve:
|
|
2800
|
+
onResolve: () => {
|
|
2801
|
+
settle();
|
|
2802
|
+
if (runtime.hydrating) void nextTick(() => endHydration(runtime));
|
|
2803
|
+
},
|
|
1328
2804
|
onVnodeBeforeUnmount: settle,
|
|
1329
2805
|
};
|
|
1330
2806
|
}
|
|
@@ -1381,7 +2857,6 @@ export function registerDmsComponent(name: string, component: Component): void {
|
|
|
1381
2857
|
export function resolveDmsComponent(name: string): Component | undefined {
|
|
1382
2858
|
return registry.components.get(normalizeDmsComponentName(name))?.component;
|
|
1383
2859
|
}
|
|
1384
|
-
export const getDmsComponent = resolveDmsComponent;
|
|
1385
2860
|
|
|
1386
2861
|
/**
|
|
1387
2862
|
* Records, by registered name, every registered async component a server
|
|
@@ -1445,7 +2920,7 @@ export async function resolveDmsAsyncComponents(
|
|
|
1445
2920
|
),
|
|
1446
2921
|
);
|
|
1447
2922
|
}
|
|
1448
|
-
export async function
|
|
2923
|
+
export async function preloadDmsComponents(names: string[]): Promise<void> {
|
|
1449
2924
|
const registry = useDmsRuntime().appContext?.vueApp._context.components ?? {};
|
|
1450
2925
|
await Promise.all(
|
|
1451
2926
|
names.map((name) =>
|
|
@@ -1453,7 +2928,6 @@ export async function preloadComponents(names: string[]): Promise<void> {
|
|
|
1453
2928
|
),
|
|
1454
2929
|
);
|
|
1455
2930
|
}
|
|
1456
|
-
export const prefetchComponents = preloadComponents;
|
|
1457
2931
|
export function normalizeDmsName(name: string): string {
|
|
1458
2932
|
return name
|
|
1459
2933
|
.replace(/^lazy/i, "")
|
|
@@ -1477,6 +2951,9 @@ function collectRenderedComponentNames(
|
|
|
1477
2951
|
for (const key of ["component", "componentName"]) {
|
|
1478
2952
|
if (typeof definition[key] === "string") names.add(definition[key]);
|
|
1479
2953
|
}
|
|
2954
|
+
// A child places a component definition: `{ id, component: { … } }`.
|
|
2955
|
+
if (typeof definition.component === "object")
|
|
2956
|
+
collectRenderedComponentNames(definition.component, names);
|
|
1480
2957
|
collectRenderedComponentNames(definition.children, names);
|
|
1481
2958
|
}
|
|
1482
2959
|
|
|
@@ -1554,7 +3031,7 @@ export function getDmsLayoutProps(
|
|
|
1554
3031
|
* Whether a page carries an older session of the same account than the one
|
|
1555
3032
|
* the app holds: Inertia restores a page from history, or serves it from the
|
|
1556
3033
|
* prefetch cache, with the props it was fetched with, and those must not undo
|
|
1557
|
-
* a later `
|
|
3034
|
+
* a later `useDmsUserSession().fetch()`.
|
|
1558
3035
|
*/
|
|
1559
3036
|
function isStaleSession(
|
|
1560
3037
|
incoming: DmsSession | undefined,
|
|
@@ -1568,6 +3045,61 @@ function isStaleSession(
|
|
|
1568
3045
|
);
|
|
1569
3046
|
}
|
|
1570
3047
|
|
|
3048
|
+
/**
|
|
3049
|
+
* Copies the site layout of the page props into the state. A visit carries
|
|
3050
|
+
* the layout it was sent over from page to page (a once prop): the same copy
|
|
3051
|
+
* is applied once, so the state keeps what a later refresh wrote into it, and
|
|
3052
|
+
* `dms-lastRefreshTime` tells when the layout was last received.
|
|
3053
|
+
*/
|
|
3054
|
+
export function hydrateDmsSharedProps(
|
|
3055
|
+
shared: DmsSharedPagePayload | undefined,
|
|
3056
|
+
): void {
|
|
3057
|
+
const runtime = useDmsRuntime();
|
|
3058
|
+
if (!shared || shared === runtime.appliedShared) return;
|
|
3059
|
+
// A page from the history carries the layout it was shown with, which the
|
|
3060
|
+
// state has replaced since: see `keepSharedOverHistory`.
|
|
3061
|
+
if (runtime.restoringHistory) return;
|
|
3062
|
+
runtime.appliedShared = shared;
|
|
3063
|
+
useDmsState<unknown>("dms-siteLayout", () => undefined).value =
|
|
3064
|
+
shared.siteLayout;
|
|
3065
|
+
useDmsState<unknown>("dms-pageTree", () => undefined).value =
|
|
3066
|
+
shared.siteLayoutTree;
|
|
3067
|
+
useDmsState<unknown>("dms-quickActions", () => undefined).value =
|
|
3068
|
+
shared.quickActions;
|
|
3069
|
+
useDmsState<unknown>("dms-modules", () => undefined).value = shared.modules;
|
|
3070
|
+
useDmsState("dms-isOwner", () => false).value = shared.isOwner ?? false;
|
|
3071
|
+
useDmsState("dms-lastRefreshTime", () => 0).value = Date.now();
|
|
3072
|
+
}
|
|
3073
|
+
|
|
3074
|
+
/**
|
|
3075
|
+
* Keeps the newest site layout when Back or Forward puts a page back. The
|
|
3076
|
+
* page carries the layout it was shown with, which a later reload replaced
|
|
3077
|
+
* (a menu change the backend announced); it is not copied into the state, and
|
|
3078
|
+
* the page is given the current one, so the next visit carries that over
|
|
3079
|
+
* rather than the older one.
|
|
3080
|
+
*/
|
|
3081
|
+
export function keepSharedOverHistory(): () => void {
|
|
3082
|
+
const runtime = useDmsRuntime();
|
|
3083
|
+
const onPopstate = (event: PopStateEvent) => {
|
|
3084
|
+
// Inertia restores only the entries it wrote, which hold a page.
|
|
3085
|
+
if ((event.state as { page?: unknown } | null)?.page)
|
|
3086
|
+
runtime.restoringHistory = true;
|
|
3087
|
+
};
|
|
3088
|
+
window.addEventListener("popstate", onPopstate);
|
|
3089
|
+
const stopListening = inertiaRouter.on("navigate", (event) => {
|
|
3090
|
+
if (!runtime.restoringHistory) return;
|
|
3091
|
+
runtime.restoringHistory = false;
|
|
3092
|
+
const current = runtime.appliedShared;
|
|
3093
|
+
const restored = (event.detail.page.props as Partial<DmsPageProps>).shared;
|
|
3094
|
+
if (current && restored !== current)
|
|
3095
|
+
inertiaRouter.replaceProp("shared", current);
|
|
3096
|
+
});
|
|
3097
|
+
return () => {
|
|
3098
|
+
window.removeEventListener("popstate", onPopstate);
|
|
3099
|
+
stopListening();
|
|
3100
|
+
};
|
|
3101
|
+
}
|
|
3102
|
+
|
|
1571
3103
|
export function hydrateDmsPageProps(props: DmsPageProps, url?: string): void {
|
|
1572
3104
|
const runtime = useDmsRuntime();
|
|
1573
3105
|
if (url) updateRoute(runtime, url);
|
|
@@ -1579,7 +3111,7 @@ export function hydrateDmsPageProps(props: DmsPageProps, url?: string): void {
|
|
|
1579
3111
|
runtime.pageKey = pageKey;
|
|
1580
3112
|
runtime.route.meta = {};
|
|
1581
3113
|
}
|
|
1582
|
-
runtime
|
|
3114
|
+
dropUnusedAsyncData(runtime);
|
|
1583
3115
|
runtime.currentError.value = null;
|
|
1584
3116
|
const session = useDmsState<DmsSession | null>("dms-session", () => null);
|
|
1585
3117
|
if (!isStaleSession(props.session, session.value)) {
|
|
@@ -1587,18 +3119,7 @@ export function hydrateDmsPageProps(props: DmsPageProps, url?: string): void {
|
|
|
1587
3119
|
props.user ?? null;
|
|
1588
3120
|
session.value = props.session ?? null;
|
|
1589
3121
|
}
|
|
1590
|
-
|
|
1591
|
-
if (shared) {
|
|
1592
|
-
useDmsState<unknown>("dms-siteLayout", () => undefined).value =
|
|
1593
|
-
shared.siteLayout;
|
|
1594
|
-
useDmsState<unknown>("dms-pageTree", () => undefined).value =
|
|
1595
|
-
shared.siteLayoutTree;
|
|
1596
|
-
useDmsState<unknown>("dms-quickActions", () => undefined).value =
|
|
1597
|
-
shared.quickActions;
|
|
1598
|
-
useDmsState<unknown>("dms-modules", () => undefined).value = shared.modules;
|
|
1599
|
-
useDmsState("dms-isOwner", () => false).value = shared.isOwner ?? false;
|
|
1600
|
-
useDmsState("dms-lastRefreshTime", () => 0).value = Date.now();
|
|
1601
|
-
}
|
|
3122
|
+
hydrateDmsSharedProps(props.shared);
|
|
1602
3123
|
const layoutUrl = props.page.route?.layoutUrl ?? props.page.layoutUrl;
|
|
1603
3124
|
const pageLayouts = useDmsState<Record<string, DmsPageLayout>>(
|
|
1604
3125
|
"dms-pageLayouts",
|
|
@@ -1609,6 +3130,16 @@ export function hydrateDmsPageProps(props: DmsPageProps, url?: string): void {
|
|
|
1609
3130
|
pageLayouts.value =
|
|
1610
3131
|
layoutUrl && props.page.layout ? { [layoutUrl]: props.page.layout } : {};
|
|
1611
3132
|
}
|
|
3133
|
+
/**
|
|
3134
|
+
* Ends the hydration of the page the server rendered. The server's data no
|
|
3135
|
+
* component took over is dropped: a later page must not show it as current.
|
|
3136
|
+
*/
|
|
3137
|
+
function endHydration(runtime: DmsFrontendRuntime): void {
|
|
3138
|
+
runtime.hydrating = false;
|
|
3139
|
+
runtime.hydratedAsyncData.clear();
|
|
3140
|
+
runtime.hydratedAsyncErrors.clear();
|
|
3141
|
+
}
|
|
3142
|
+
|
|
1612
3143
|
export function getDmsErrorPage(): Component | undefined {
|
|
1613
3144
|
return registry.errorPage?.component;
|
|
1614
3145
|
}
|
|
@@ -1694,6 +3225,7 @@ function createSdk(
|
|
|
1694
3225
|
if (!target.namedMiddleware.has(name))
|
|
1695
3226
|
target.namedMiddleware.set(name, handler);
|
|
1696
3227
|
if (options.global) target.middleware.push(handler);
|
|
3228
|
+
if (options.parallel) target.parallelMiddleware.add(handler);
|
|
1697
3229
|
},
|
|
1698
3230
|
registerAccessRedirect: (code, path) => {
|
|
1699
3231
|
if (!target.accessRedirects.has(code))
|
|
@@ -1717,16 +3249,22 @@ function createSdk(
|
|
|
1717
3249
|
* replace, so the server kept rendering the old ones, and every list gained the
|
|
1718
3250
|
* same entries once more. A render still in progress keeps reading a complete
|
|
1719
3251
|
* registry: the new one is only published once every module is set up.
|
|
3252
|
+
*
|
|
3253
|
+
* `useDmsAppConfig()` holds the layers' app configs merged, highest precedence
|
|
3254
|
+
* first, before the first module is set up: a project's layer overrides the
|
|
3255
|
+
* DMS's, which fills in the rest. A setup may still read and extend it.
|
|
1720
3256
|
*/
|
|
1721
3257
|
export async function setupFrontendModules(
|
|
1722
3258
|
registrations: DmsFrontendModuleRegistration[],
|
|
3259
|
+
layerAppConfigs: DmsLayerAppConfig[] = [],
|
|
1723
3260
|
): Promise<void> {
|
|
1724
3261
|
const next = createDmsFrontendRegistry();
|
|
1725
|
-
|
|
1726
|
-
//
|
|
1727
|
-
// here rather than being published with the registry
|
|
3262
|
+
const configs = layerAppConfigs.map(({ config }) => config);
|
|
3263
|
+
// Modules read and extend these two while they are set up, so they restart
|
|
3264
|
+
// here rather than being published with the registry: the public options
|
|
3265
|
+
// empty, the app config from the layers alone.
|
|
1728
3266
|
runtimeConfig.value.public = {} as PublicRuntimeConfig;
|
|
1729
|
-
appConfig.value =
|
|
3267
|
+
appConfig.value = mergeLayerAppConfigs(configs);
|
|
1730
3268
|
for (const registration of registrations) {
|
|
1731
3269
|
runtimeConfig.value.public = defu(
|
|
1732
3270
|
runtimeConfig.value.public,
|
|
@@ -1734,9 +3272,29 @@ export async function setupFrontendModules(
|
|
|
1734
3272
|
);
|
|
1735
3273
|
await registration.module.setup(createSdk(next, registration));
|
|
1736
3274
|
}
|
|
3275
|
+
appConfigSetupChanges = appConfigChanges(
|
|
3276
|
+
mergeLayerAppConfigs(configs),
|
|
3277
|
+
appConfig.value,
|
|
3278
|
+
);
|
|
1737
3279
|
registry = next;
|
|
1738
3280
|
}
|
|
1739
3281
|
|
|
3282
|
+
/**
|
|
3283
|
+
* Development only: applies the layers' app configs, edited while the page
|
|
3284
|
+
* is open, to `useDmsAppConfig()`, with what the setup of the modules changed
|
|
3285
|
+
* in the previous ones still on top. The object is updated in place, records
|
|
3286
|
+
* included, since components hold it: the modules are not set up again.
|
|
3287
|
+
*/
|
|
3288
|
+
export function updateDmsAppConfig(layerAppConfigs: DmsLayerAppConfig[]): void {
|
|
3289
|
+
const layered = mergeLayerAppConfigs(
|
|
3290
|
+
layerAppConfigs.map(({ config }) => config),
|
|
3291
|
+
);
|
|
3292
|
+
assignInPlace(
|
|
3293
|
+
appConfig.value,
|
|
3294
|
+
withAppConfigChanges(layered, appConfigSetupChanges),
|
|
3295
|
+
);
|
|
3296
|
+
}
|
|
3297
|
+
|
|
1740
3298
|
export async function installDmsPlugins(
|
|
1741
3299
|
app: App,
|
|
1742
3300
|
i18n: Composer,
|
|
@@ -1769,10 +3327,7 @@ export async function installDmsPlugins(
|
|
|
1769
3327
|
if (browserRuntime === runtime) browserRuntime = undefined;
|
|
1770
3328
|
});
|
|
1771
3329
|
}
|
|
1772
|
-
app.config.errorHandler = (reason) =>
|
|
1773
|
-
runtime.currentError.value ??=
|
|
1774
|
-
reason instanceof Error ? reason : new Error(String(reason));
|
|
1775
|
-
};
|
|
3330
|
+
app.config.errorHandler = (reason) => failPage(runtime, reason);
|
|
1776
3331
|
app.config.globalProperties.$dms = appContext;
|
|
1777
3332
|
Reflect.set(app, "$dms", appContext);
|
|
1778
3333
|
Reflect.set(app, "$i18n", i18n);
|
|
@@ -1812,6 +3367,9 @@ export async function installDmsPlugins(
|
|
|
1812
3367
|
app.runWithContext(() => registration.setup(appContext)),
|
|
1813
3368
|
);
|
|
1814
3369
|
}
|
|
3370
|
+
// What plugins start without awaiting it is still part of the page a
|
|
3371
|
+
// server renders: its HTML shows it, so the render waits for it.
|
|
3372
|
+
if (runtime.isServer) await Promise.all(runtime.serverPrefetches);
|
|
1815
3373
|
return async () => {
|
|
1816
3374
|
for (const callback of hooks.get("app:mounted") ?? []) await callback();
|
|
1817
3375
|
};
|