@antelopejs/dms-frontend 0.9.1 → 0.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -1
- package/dist/cancellation.js +2 -1
- package/dist/config.js +11 -7
- package/dist/derived-outputs.js +6 -113
- package/dist/dev-modules.js +3 -2
- package/dist/discovery.js +19 -12
- package/dist/fs-sync.js +1 -2
- package/dist/internal/guards.d.ts +5 -0
- package/dist/internal/guards.js +22 -0
- package/dist/internal/locale-catalogs.d.ts +6 -0
- package/dist/internal/locale-catalogs.js +115 -0
- package/dist/layer-aliases.js +2 -1
- package/dist/layers.js +2 -1
- package/dist/locale-files.js +9 -4
- package/dist/manifest-secrets.js +11 -4
- package/dist/manifest.js +11 -5
- package/dist/materialize.js +6 -5
- package/dist/module-set.js +2 -4
- package/dist/ports.js +8 -1
- package/dist/private-values.js +26 -17
- package/dist/project-modules.js +17 -11
- package/dist/renderer-range.js +2 -1
- package/dist/server-process.js +17 -16
- package/dist/update-check.js +23 -16
- package/dist/verify-source-runner.js +2 -2
- package/dist/workspace-setup.js +1 -2
- package/dist/workspace.js +11 -10
- package/package.json +7 -4
- package/skills/dms-frontend/SKILL.md +1 -1
- package/skills/dms-frontend/cases.json +77 -0
- package/templates/vue/app-runtime.ts +88 -7
- package/templates/vue/client-chunking.mjs +0 -5
- package/templates/vue/client-server-guard.mjs +10 -13
- package/templates/vue/entry-weight.mjs +12 -14
- package/templates/vue/frontend-module.ts +159 -3563
- package/templates/vue/frontend-runtime/async-data-entry.ts +320 -0
- package/templates/vue/frontend-runtime/async-data.ts +404 -0
- package/templates/vue/frontend-runtime/components.ts +386 -0
- package/templates/vue/frontend-runtime/cookies.ts +125 -0
- package/templates/vue/frontend-runtime/data-access.ts +132 -0
- package/templates/vue/frontend-runtime/dms-error.ts +11 -0
- package/templates/vue/frontend-runtime/errors.ts +145 -0
- package/templates/vue/frontend-runtime/fetch.ts +306 -0
- package/templates/vue/frontend-runtime/module-registration.ts +247 -0
- package/templates/vue/frontend-runtime/paged-data.ts +191 -0
- package/templates/vue/frontend-runtime/payload.ts +172 -0
- package/templates/vue/frontend-runtime/route.ts +328 -0
- package/templates/vue/frontend-runtime/sdk.ts +300 -0
- package/templates/vue/frontend-runtime/state.ts +167 -0
- package/templates/vue/frontend-runtime/types.ts +361 -0
- package/templates/vue/frontend-runtime/ui.ts +247 -0
- package/templates/vue/globals.d.ts +5 -0
- package/templates/vue/head-order.mjs +11 -13
- package/templates/vue/icon-bundle.ts +31 -0
- package/templates/vue/message-subset.ts +65 -0
- package/templates/vue/network-error.ts +4 -0
- package/templates/vue/prepare-types.mjs +0 -8
- package/templates/vue/server/auth/backend.mjs +4 -2
- package/templates/vue/server/auth/establish-endpoints.mjs +14 -9
- package/templates/vue/server/auth/session.mjs +4 -3
- package/templates/vue/server/auth-hooks.mjs +6 -6
- package/templates/vue/server/backend-proxies.mjs +6 -6
- package/templates/vue/server/backend-response.mjs +15 -14
- package/templates/vue/server/built-files.mjs +10 -8
- package/templates/vue/server/cli-report.mjs +7 -5
- package/templates/vue/server/client-error.mjs +11 -8
- package/templates/vue/server/client-manifest.mjs +9 -8
- package/templates/vue/server/content.mjs +2 -1
- package/templates/vue/server/dev-private-files.mjs +20 -15
- package/templates/vue/server/dev-styles.mjs +22 -25
- package/templates/vue/server/email-bundle-watch.mjs +0 -12
- package/templates/vue/server/email.mjs +20 -15
- package/templates/vue/server/favicon.mjs +12 -11
- package/templates/vue/server/homepage.mjs +8 -10
- package/templates/vue/server/icons.mjs +18 -9
- package/templates/vue/server/in-place-render.mjs +6 -8
- package/templates/vue/server/listen-host.mjs +2 -2
- package/templates/vue/server/module-event.mjs +5 -5
- package/templates/vue/server/module-routes.mjs +11 -7
- package/templates/vue/server/module-server.mjs +8 -7
- package/templates/vue/server/module-testing.mjs +7 -7
- package/templates/vue/server/page-props.mjs +14 -14
- package/templates/vue/server/path-patterns.mjs +4 -4
- package/templates/vue/server/proxy-compression.mjs +21 -13
- package/templates/vue/server/proxy-headers.mjs +5 -3
- package/templates/vue/server/proxy-redirects.mjs +7 -6
- package/templates/vue/server/proxy-transfers.mjs +15 -11
- package/templates/vue/server/public-endpoints.mjs +27 -19
- package/templates/vue/server/public-options.mjs +9 -7
- package/templates/vue/server/request-failure.mjs +4 -6
- package/templates/vue/server/request-routing.mjs +8 -11
- package/templates/vue/server/request-target.mjs +6 -6
- package/templates/vue/server/route-rules.mjs +7 -5
- package/templates/vue/server/static-files.mjs +19 -10
- package/templates/vue/server/web-body.mjs +3 -4
- package/templates/vue/server.mjs +32 -19
- package/templates/vue/sfc-global-types.mjs +27 -24
- package/templates/vue/ssr-renderer.ts +10 -1
- package/templates/vue/ui-inertia-page.mjs +7 -7
- package/templates/vue/ui-kbd.mjs +10 -10
- package/templates/vue/ui-lazy-icons.mjs +44 -0
- package/templates/vue/ui-request-state.mjs +9 -9
- package/templates/vue/vite.config.ts +2 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import {
|
|
2
|
+
computed,
|
|
3
|
+
getCurrentScope,
|
|
4
|
+
type MaybeRefOrGetter,
|
|
5
|
+
onScopeDispose,
|
|
6
|
+
type Ref,
|
|
7
|
+
readonly,
|
|
8
|
+
ref,
|
|
9
|
+
watch,
|
|
10
|
+
} from "vue";
|
|
11
|
+
import {
|
|
12
|
+
type DmsAsyncData,
|
|
13
|
+
type DmsAsyncDataContext,
|
|
14
|
+
type DmsAsyncDataKey,
|
|
15
|
+
type UseAsyncDataOptions,
|
|
16
|
+
} from "./types";
|
|
17
|
+
import { useDmsAsyncData } from "./async-data";
|
|
18
|
+
|
|
19
|
+
/** What a paged read keeps under its key: the pages loaded, in order. */
|
|
20
|
+
export interface DmsPagedDataValue<TPage, TCursor> {
|
|
21
|
+
pages: TPage[];
|
|
22
|
+
/** The cursor of the page after the last one; `null` once there is none. */
|
|
23
|
+
next: TCursor | null;
|
|
24
|
+
}
|
|
25
|
+
/** What a paged read's handler receives. */
|
|
26
|
+
export interface DmsPagedDataContext<TCursor> extends DmsAsyncDataContext {
|
|
27
|
+
/** The page to read: `initialCursor` for the first one. */
|
|
28
|
+
cursor: TCursor | undefined;
|
|
29
|
+
/** Its position, from `0`. */
|
|
30
|
+
index: number;
|
|
31
|
+
}
|
|
32
|
+
export type DmsPagedDataHandler<TPage, TCursor> = (
|
|
33
|
+
context: DmsPagedDataContext<TCursor>,
|
|
34
|
+
) => Promise<TPage>;
|
|
35
|
+
export interface UseDmsPagedDataOptions<TPage, TCursor> extends Omit<
|
|
36
|
+
UseAsyncDataOptions<DmsPagedDataValue<TPage, TCursor>>,
|
|
37
|
+
"default" | "transform" | "pick"
|
|
38
|
+
> {
|
|
39
|
+
/** The cursor of the page after `page`, or `null` (or `undefined`) for none. */
|
|
40
|
+
nextCursor(page: TPage, pages: TPage[]): TCursor | null | undefined;
|
|
41
|
+
/** The first page's cursor; none by default. */
|
|
42
|
+
initialCursor?: TCursor;
|
|
43
|
+
}
|
|
44
|
+
export interface DmsPagedData<TPage, TCursor> extends DmsAsyncData<
|
|
45
|
+
DmsPagedDataValue<TPage, TCursor>
|
|
46
|
+
> {
|
|
47
|
+
/** The pages loaded so far, in order; none before the first answer. */
|
|
48
|
+
pages: Readonly<Ref<TPage[]>>;
|
|
49
|
+
/** Whether `loadMore()` has a page to read. */
|
|
50
|
+
hasMore: Readonly<Ref<boolean>>;
|
|
51
|
+
/** `true` while `loadMore()` reads its page. */
|
|
52
|
+
loadingMore: Readonly<Ref<boolean>>;
|
|
53
|
+
/**
|
|
54
|
+
* Reads the next page and appends it under the same key. It waits for a
|
|
55
|
+
* read in flight first, does nothing once there is no next page, and puts
|
|
56
|
+
* a failure in `error`, keeping the pages loaded.
|
|
57
|
+
*/
|
|
58
|
+
loadMore(): Promise<void>;
|
|
59
|
+
}
|
|
60
|
+
export type DmsPagedDataResult<TPage, TCursor> = DmsPagedData<TPage, TCursor> &
|
|
61
|
+
Promise<DmsPagedData<TPage, TCursor>>;
|
|
62
|
+
|
|
63
|
+
type PagedValue<TPage, TCursor> = DmsPagedDataValue<TPage, TCursor>;
|
|
64
|
+
|
|
65
|
+
/** Reads `count` pages from the first, as long as each has a next one. */
|
|
66
|
+
async function readPages<TPage, TCursor>(
|
|
67
|
+
handler: DmsPagedDataHandler<TPage, TCursor>,
|
|
68
|
+
options: UseDmsPagedDataOptions<TPage, TCursor>,
|
|
69
|
+
count: number,
|
|
70
|
+
signal: AbortSignal,
|
|
71
|
+
): Promise<PagedValue<TPage, TCursor>> {
|
|
72
|
+
const pages: TPage[] = [];
|
|
73
|
+
let cursor: TCursor | undefined = options.initialCursor;
|
|
74
|
+
for (let index = 0; index < count; index++) {
|
|
75
|
+
const page = await handler({ cursor, index, signal });
|
|
76
|
+
pages.push(page);
|
|
77
|
+
const next = options.nextCursor(page, pages);
|
|
78
|
+
if (next == null) return { pages, next: null };
|
|
79
|
+
cursor = next;
|
|
80
|
+
}
|
|
81
|
+
return { pages, next: cursor ?? null };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Resolves once `result` has no run in flight. */
|
|
85
|
+
function untilSettled<T>(result: DmsAsyncData<T>): Promise<void> {
|
|
86
|
+
if (!result.pending.value) return Promise.resolve();
|
|
87
|
+
return new Promise((resolve) => {
|
|
88
|
+
const stop = watch(result.pending, (pending) => {
|
|
89
|
+
if (pending) return;
|
|
90
|
+
stop();
|
|
91
|
+
resolve();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A list read page by page under one key: "load more", an infinite list.
|
|
98
|
+
* The first page loads as `useDmsAsyncData` loads its data (a server render
|
|
99
|
+
* waits for it and transfers it), and `loadMore()` appends the next one to
|
|
100
|
+
* the same key, so the pages a component loaded show at once on a return to
|
|
101
|
+
* it. A revalidation (a mount, `refresh()`, `refreshDmsPageData()`, the
|
|
102
|
+
* polling) reads every page loaded so far again, from the first, and
|
|
103
|
+
* replaces them once all are in. Put what filters the list in the key: a new
|
|
104
|
+
* key starts again from its first page.
|
|
105
|
+
*
|
|
106
|
+
* @param key The key, or a ref or getter for it
|
|
107
|
+
* @param handler Reads one page, from the cursor it receives
|
|
108
|
+
* @param options `nextCursor`, `initialCursor` and the async data options
|
|
109
|
+
*/
|
|
110
|
+
export function useDmsPagedData<TPage, TCursor = unknown>(
|
|
111
|
+
key: MaybeRefOrGetter<DmsAsyncDataKey>,
|
|
112
|
+
handler: DmsPagedDataHandler<TPage, TCursor>,
|
|
113
|
+
options: UseDmsPagedDataOptions<TPage, TCursor>,
|
|
114
|
+
): DmsPagedDataResult<TPage, TCursor> {
|
|
115
|
+
const {
|
|
116
|
+
nextCursor: _next,
|
|
117
|
+
initialCursor: _initial,
|
|
118
|
+
...asyncOptions
|
|
119
|
+
} = options;
|
|
120
|
+
let shown: DmsAsyncData<PagedValue<TPage, TCursor>> | undefined;
|
|
121
|
+
const loadedCount = () =>
|
|
122
|
+
shown && !shown.placeholder.value
|
|
123
|
+
? Math.max(1, shown.data.value?.pages.length ?? 0)
|
|
124
|
+
: 1;
|
|
125
|
+
const call = useDmsAsyncData<PagedValue<TPage, TCursor>>(
|
|
126
|
+
key,
|
|
127
|
+
({ signal }) => readPages(handler, options, loadedCount(), signal),
|
|
128
|
+
asyncOptions,
|
|
129
|
+
);
|
|
130
|
+
shown = call;
|
|
131
|
+
const result: DmsPagedData<TPage, TCursor> = Object.assign(
|
|
132
|
+
{},
|
|
133
|
+
call as DmsAsyncData<PagedValue<TPage, TCursor>>,
|
|
134
|
+
pagedControls(call, handler, options),
|
|
135
|
+
);
|
|
136
|
+
return Object.assign(
|
|
137
|
+
call.then(() => result),
|
|
138
|
+
result,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function pagedControls<TPage, TCursor>(
|
|
143
|
+
call: DmsAsyncData<PagedValue<TPage, TCursor>>,
|
|
144
|
+
handler: DmsPagedDataHandler<TPage, TCursor>,
|
|
145
|
+
options: UseDmsPagedDataOptions<TPage, TCursor>,
|
|
146
|
+
) {
|
|
147
|
+
const loadingMore = ref(false);
|
|
148
|
+
let controller: AbortController | undefined;
|
|
149
|
+
const abort = () => {
|
|
150
|
+
controller?.abort();
|
|
151
|
+
controller = undefined;
|
|
152
|
+
loadingMore.value = false;
|
|
153
|
+
};
|
|
154
|
+
watch(call.key, abort);
|
|
155
|
+
if (getCurrentScope()) onScopeDispose(abort);
|
|
156
|
+
const hasMore = computed(
|
|
157
|
+
() => !call.placeholder.value && (call.data.value?.next ?? null) !== null,
|
|
158
|
+
);
|
|
159
|
+
const loadMore = async (): Promise<void> => {
|
|
160
|
+
if (call.pending.value) await untilSettled(call);
|
|
161
|
+
const before = call.data.value;
|
|
162
|
+
if (!before) return call.execute();
|
|
163
|
+
if (before.next === null || controller) return;
|
|
164
|
+
const current = new AbortController();
|
|
165
|
+
controller = current;
|
|
166
|
+
loadingMore.value = true;
|
|
167
|
+
try {
|
|
168
|
+
const page = await handler({
|
|
169
|
+
cursor: before.next,
|
|
170
|
+
index: before.pages.length,
|
|
171
|
+
signal: current.signal,
|
|
172
|
+
});
|
|
173
|
+
if (controller !== current || call.data.value !== before) return;
|
|
174
|
+
const pages = [...before.pages, page];
|
|
175
|
+
call.data.value = {
|
|
176
|
+
pages,
|
|
177
|
+
next: options.nextCursor(page, pages) ?? null,
|
|
178
|
+
};
|
|
179
|
+
} catch (reason) {
|
|
180
|
+
if (controller === current) call.error.value = reason;
|
|
181
|
+
} finally {
|
|
182
|
+
if (controller === current) abort();
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
return {
|
|
186
|
+
pages: computed(() => call.data.value?.pages ?? []),
|
|
187
|
+
hasMore,
|
|
188
|
+
loadingMore: readonly(loadingMore),
|
|
189
|
+
loadMore,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getCurrentInstance,
|
|
3
|
+
onServerPrefetch,
|
|
4
|
+
type Ref,
|
|
5
|
+
ref,
|
|
6
|
+
toRaw,
|
|
7
|
+
} from "vue";
|
|
8
|
+
import {
|
|
9
|
+
type DmsAppConfig,
|
|
10
|
+
type DmsErrorData,
|
|
11
|
+
type DmsPayload,
|
|
12
|
+
type DmsRuntimeConfig,
|
|
13
|
+
type DmsStateOptions,
|
|
14
|
+
} from "./types";
|
|
15
|
+
import {
|
|
16
|
+
type DmsFrontendRuntime,
|
|
17
|
+
type DmsMiddleware,
|
|
18
|
+
} from "./module-registration";
|
|
19
|
+
import { serializeDmsAsyncData } from "./data-access";
|
|
20
|
+
import { appConfig, runtimeConfig, useDmsRuntime } from "./state";
|
|
21
|
+
import { runMiddleware, visit } from "./route";
|
|
22
|
+
import { failPage } from "./ui";
|
|
23
|
+
|
|
24
|
+
const HTTP_ERROR_STATUS = { min: 400, max: 599 };
|
|
25
|
+
const SERVER_FAILURE_STATUS = 500;
|
|
26
|
+
const FETCH_ERROR_NAME = "FetchError";
|
|
27
|
+
|
|
28
|
+
/** The fields of a failure, under the names `createDmsError` and ofetch use. */
|
|
29
|
+
interface DmsFailure extends DmsErrorData {
|
|
30
|
+
name?: string;
|
|
31
|
+
status?: number;
|
|
32
|
+
statusText?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* What of a failure a page may carry, by the rule the error page follows: an
|
|
37
|
+
* HTTP failure keeps its status, status text and message, and its body if it
|
|
38
|
+
* is a 4xx, anything else
|
|
39
|
+
* is a server failure whose cause only reaches the server log. A fetch error
|
|
40
|
+
* names the URL it requested, which on a server is the backend's own address:
|
|
41
|
+
* its status text stands for it.
|
|
42
|
+
*/
|
|
43
|
+
function transferredError(key: string, reason: unknown): DmsErrorData {
|
|
44
|
+
const failure = (reason ?? {}) as DmsFailure;
|
|
45
|
+
const statusCode = failure.statusCode ?? failure.status;
|
|
46
|
+
if (
|
|
47
|
+
statusCode === undefined ||
|
|
48
|
+
!Number.isInteger(statusCode) ||
|
|
49
|
+
statusCode < HTTP_ERROR_STATUS.min ||
|
|
50
|
+
statusCode > HTTP_ERROR_STATUS.max
|
|
51
|
+
) {
|
|
52
|
+
console.error(`DMS async data "${key}" failed`, reason);
|
|
53
|
+
return { statusCode: SERVER_FAILURE_STATUS };
|
|
54
|
+
}
|
|
55
|
+
const statusMessage = failure.statusMessage ?? failure.statusText;
|
|
56
|
+
const transferred: DmsErrorData = {
|
|
57
|
+
statusCode,
|
|
58
|
+
statusMessage,
|
|
59
|
+
message:
|
|
60
|
+
failure.name === FETCH_ERROR_NAME ? statusMessage : failure.message,
|
|
61
|
+
};
|
|
62
|
+
// A 5xx body is the backend's own account of its failure, a stack or a
|
|
63
|
+
// query at times: only a 4xx body, written for the client, is transferred.
|
|
64
|
+
if (statusCode < SERVER_FAILURE_STATUS) transferred.data = failure.data;
|
|
65
|
+
return transferred;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* What a page's error page shows of an expected failure (a 4xx or 5xx): its
|
|
70
|
+
* status code, status text and message. A fetch error's message names the URL
|
|
71
|
+
* it requested, on a server the backend's own address: its status text
|
|
72
|
+
* stands for it.
|
|
73
|
+
*/
|
|
74
|
+
export function pageErrorData(failure: Error & DmsErrorData): DmsErrorData {
|
|
75
|
+
return {
|
|
76
|
+
statusCode: failure.statusCode,
|
|
77
|
+
message:
|
|
78
|
+
failure.name === FETCH_ERROR_NAME
|
|
79
|
+
? failure.statusMessage
|
|
80
|
+
: failure.message,
|
|
81
|
+
statusMessage: failure.statusMessage,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Everything a server render hands the browser: the data and the failures of
|
|
87
|
+
* its keys, and the state keys opted into. Read as the HTML is complete.
|
|
88
|
+
*/
|
|
89
|
+
export function serializeDmsPayload(runtime: DmsFrontendRuntime): DmsPayload {
|
|
90
|
+
const errors = [...runtime.asyncData.entries()]
|
|
91
|
+
.filter(([, entry]) => entry.status.value === "error")
|
|
92
|
+
.map(([key, entry]): [string, DmsErrorData] => [
|
|
93
|
+
key,
|
|
94
|
+
transferredError(key, entry.error.value),
|
|
95
|
+
]);
|
|
96
|
+
const state = [...runtime.transferredState]
|
|
97
|
+
.map((key): [string, unknown] => [
|
|
98
|
+
key,
|
|
99
|
+
toRaw(runtime.sharedState.get(key)?.value),
|
|
100
|
+
])
|
|
101
|
+
.filter(([, value]) => value !== undefined);
|
|
102
|
+
return {
|
|
103
|
+
data: serializeDmsAsyncData(runtime),
|
|
104
|
+
errors: Object.fromEntries(errors),
|
|
105
|
+
state: Object.fromEntries(state),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Starts a browser runtime from what the server rendered. */
|
|
110
|
+
export function restoreDmsPayload(
|
|
111
|
+
runtime: DmsFrontendRuntime,
|
|
112
|
+
payload: Partial<DmsPayload>,
|
|
113
|
+
): void {
|
|
114
|
+
runtime.hydrating = true;
|
|
115
|
+
for (const [key, value] of Object.entries(payload.data ?? {}))
|
|
116
|
+
runtime.hydratedAsyncData.set(key, value);
|
|
117
|
+
for (const [key, failure] of Object.entries(payload.errors ?? {}))
|
|
118
|
+
runtime.hydratedAsyncErrors.set(key, failure);
|
|
119
|
+
for (const [key, value] of Object.entries(payload.state ?? {}))
|
|
120
|
+
runtime.sharedState.set(key, ref(value));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function useDmsState<T>(
|
|
124
|
+
key: string,
|
|
125
|
+
init?: () => T,
|
|
126
|
+
options: DmsStateOptions = {},
|
|
127
|
+
): Ref<T> {
|
|
128
|
+
const runtime = useDmsRuntime();
|
|
129
|
+
if (options.transfer) runtime.transferredState.add(key);
|
|
130
|
+
if (!runtime.sharedState.has(key))
|
|
131
|
+
runtime.sharedState.set(key, ref(init?.()));
|
|
132
|
+
return runtime.sharedState.get(key) as Ref<T>;
|
|
133
|
+
}
|
|
134
|
+
export function useDmsRuntimeConfig(): DmsRuntimeConfig {
|
|
135
|
+
return runtimeConfig.value;
|
|
136
|
+
}
|
|
137
|
+
export function useDmsAppConfig(): DmsAppConfig {
|
|
138
|
+
return appConfig.value as DmsAppConfig;
|
|
139
|
+
}
|
|
140
|
+
export function defineDmsAppConfig<T extends Record<string, unknown>>(
|
|
141
|
+
config: T,
|
|
142
|
+
): T {
|
|
143
|
+
return config;
|
|
144
|
+
}
|
|
145
|
+
export function defineDmsMiddleware(handler: DmsMiddleware): DmsMiddleware {
|
|
146
|
+
return handler;
|
|
147
|
+
}
|
|
148
|
+
export function defineDmsPageMeta(meta: Record<string, unknown>): void {
|
|
149
|
+
const runtime = useDmsRuntime();
|
|
150
|
+
Object.assign(runtime.route.meta, meta);
|
|
151
|
+
// A middleware that throws fails the page, as in Nuxt: the error page
|
|
152
|
+
// shows in its place with the error's status code, which a server render
|
|
153
|
+
// answers with. A redirect another navigation decided still wins.
|
|
154
|
+
const navigation = runMiddleware(runtime, runtime.route)
|
|
155
|
+
.then(async (result) => {
|
|
156
|
+
if (result !== undefined && result !== false)
|
|
157
|
+
await visit(runtime, result);
|
|
158
|
+
})
|
|
159
|
+
.catch((reason) => failPage(runtime, reason));
|
|
160
|
+
runtime.pendingNavigation = runtime.pendingNavigation
|
|
161
|
+
? Promise.all([runtime.pendingNavigation, navigation]).then(() => undefined)
|
|
162
|
+
: navigation;
|
|
163
|
+
void runtime.pendingNavigation;
|
|
164
|
+
// A server renders the page's subtree once its middleware is done, so the
|
|
165
|
+
// state a middleware sets is the one the HTML shows and the payload carries.
|
|
166
|
+
// A failure shows the error page in its place (`failPage`). Registered
|
|
167
|
+
// in the browser too, where only a server calls it: both sides must agree
|
|
168
|
+
// on the async boundaries `useId()` counts.
|
|
169
|
+
if (getCurrentInstance())
|
|
170
|
+
onServerPrefetch(() => navigation.then(ignoreSettlement, ignoreSettlement));
|
|
171
|
+
}
|
|
172
|
+
function ignoreSettlement(): void {}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { router as inertiaRouter, usePage } from "@inertiajs/vue3";
|
|
2
|
+
import { computed, watch } from "vue";
|
|
3
|
+
import {
|
|
4
|
+
type DmsFrontendRuntime,
|
|
5
|
+
type DmsInertiaPage,
|
|
6
|
+
type DmsMiddleware,
|
|
7
|
+
type DmsMiddlewareResult,
|
|
8
|
+
} from "./module-registration";
|
|
9
|
+
import {
|
|
10
|
+
type DmsNavigationOptions,
|
|
11
|
+
type DmsRoute,
|
|
12
|
+
type DmsRouter,
|
|
13
|
+
type RouteLocationRaw,
|
|
14
|
+
} from "./types";
|
|
15
|
+
import { registry, runtimeContexts, useDmsRuntime } from "./state";
|
|
16
|
+
|
|
17
|
+
function extractRouteParams(
|
|
18
|
+
pattern: string | undefined,
|
|
19
|
+
path: string,
|
|
20
|
+
): Record<string, string> {
|
|
21
|
+
if (!pattern) return {};
|
|
22
|
+
const patternSegments = pattern.split("/").filter(Boolean);
|
|
23
|
+
const pathSegments = path.split("/").filter(Boolean);
|
|
24
|
+
if (patternSegments.length !== pathSegments.length) return {};
|
|
25
|
+
const params: Record<string, string> = {};
|
|
26
|
+
const matches = patternSegments.every((segment, index) => {
|
|
27
|
+
if (!segment.startsWith(":")) return segment === pathSegments[index];
|
|
28
|
+
params[segment.slice(1)] = decodeURIComponent(pathSegments[index] ?? "");
|
|
29
|
+
return true;
|
|
30
|
+
});
|
|
31
|
+
return matches ? params : {};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseRoute(runtime: DmsFrontendRuntime, url: string): DmsRoute {
|
|
35
|
+
const parsed = new URL(
|
|
36
|
+
url,
|
|
37
|
+
typeof window === "undefined"
|
|
38
|
+
? "http://frontend.local"
|
|
39
|
+
: window.location.origin,
|
|
40
|
+
);
|
|
41
|
+
return {
|
|
42
|
+
name: parsed.pathname,
|
|
43
|
+
fullPath: `${parsed.pathname}${parsed.search}${parsed.hash}`,
|
|
44
|
+
path: parsed.pathname,
|
|
45
|
+
query: Object.fromEntries(parsed.searchParams),
|
|
46
|
+
params: extractRouteParams(runtime.currentRoutePattern, parsed.pathname),
|
|
47
|
+
meta: {},
|
|
48
|
+
matched: [{}],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function hasSameEntries(
|
|
53
|
+
left: Record<string, string>,
|
|
54
|
+
right: Record<string, string>,
|
|
55
|
+
): boolean {
|
|
56
|
+
const keys = Object.keys(left);
|
|
57
|
+
return (
|
|
58
|
+
keys.length === Object.keys(right).length &&
|
|
59
|
+
keys.every((key) => right[key] === left[key])
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Every parse builds a new query, params and matched: assigning them as they
|
|
64
|
+
// come would re-run each watcher of the route on every call of useDmsRoute,
|
|
65
|
+
// though the URL did not change. They are only replaced when their content did.
|
|
66
|
+
export function updateRoute(runtime: DmsFrontendRuntime, url: string): void {
|
|
67
|
+
// The meta is the page's, not the URL's: a page declares it, then reads its
|
|
68
|
+
// route, and the middleware the meta selected must still find it.
|
|
69
|
+
const {
|
|
70
|
+
meta: _pageMeta,
|
|
71
|
+
query,
|
|
72
|
+
params,
|
|
73
|
+
matched,
|
|
74
|
+
...location
|
|
75
|
+
} = parseRoute(runtime, url);
|
|
76
|
+
const route = runtime.route;
|
|
77
|
+
if (!hasSameEntries(query, route.query)) route.query = query;
|
|
78
|
+
if (!hasSameEntries(params, route.params)) route.params = params;
|
|
79
|
+
if (matched.length !== route.matched.length) route.matched = matched;
|
|
80
|
+
Object.assign(route, location);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const URL_BASE = "http://frontend.local";
|
|
84
|
+
|
|
85
|
+
// The URL the browser shows: it may be ahead of the route, which follows
|
|
86
|
+
// Inertia's page, when a component wrote it in place (history.replaceState).
|
|
87
|
+
function currentUrl(runtime: DmsFrontendRuntime): string {
|
|
88
|
+
if (runtime.isServer || typeof window === "undefined")
|
|
89
|
+
return new URL(runtime.route.fullPath, URL_BASE).href;
|
|
90
|
+
return window.location.href;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// One spelling per URL: `a b`, `a+b` and `a%20b` are the same query, as are
|
|
94
|
+
// `:` and `%3A`. The origin is kept, so a URL of another site never matches
|
|
95
|
+
// the current one, whatever its path.
|
|
96
|
+
function normalizeUrl(url: string, base: string): string {
|
|
97
|
+
const parsed = new URL(url, base);
|
|
98
|
+
const search = parsed.searchParams.toString();
|
|
99
|
+
return `${parsed.origin}${parsed.pathname}${search ? `?${search}` : ""}${parsed.hash}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function locationToUrl(
|
|
103
|
+
runtime: DmsFrontendRuntime,
|
|
104
|
+
to: RouteLocationRaw,
|
|
105
|
+
): string {
|
|
106
|
+
if (typeof to === "string") return to;
|
|
107
|
+
const origin =
|
|
108
|
+
typeof window === "undefined"
|
|
109
|
+
? "http://frontend.local"
|
|
110
|
+
: window.location.origin;
|
|
111
|
+
const parsed = new URL(to.path ?? runtime.route.path, origin);
|
|
112
|
+
if (to.query !== undefined) {
|
|
113
|
+
parsed.search = Object.entries(to.query)
|
|
114
|
+
.flatMap(([key, value]) =>
|
|
115
|
+
(Array.isArray(value) ? value : [value])
|
|
116
|
+
.filter((item) => item !== undefined)
|
|
117
|
+
.map((item) =>
|
|
118
|
+
item === null
|
|
119
|
+
? encodeURIComponent(key)
|
|
120
|
+
: `${encodeURIComponent(key)}=${encodeURIComponent(String(item))}`,
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
.join("&");
|
|
124
|
+
}
|
|
125
|
+
parsed.hash = to.hash ?? "";
|
|
126
|
+
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function runMiddleware(
|
|
130
|
+
runtime: DmsFrontendRuntime,
|
|
131
|
+
to: DmsRoute,
|
|
132
|
+
): Promise<DmsMiddlewareResult> {
|
|
133
|
+
const declared = Array.isArray(to.meta.middleware) ? to.meta.middleware : [];
|
|
134
|
+
const metadataNames = Object.keys(to.meta).filter((name) => to.meta[name]);
|
|
135
|
+
const declaredHandlers = [...declared, ...metadataNames]
|
|
136
|
+
.map((name) => registry.namedMiddleware.get(String(name)))
|
|
137
|
+
.filter((handler): handler is DmsMiddleware => !!handler);
|
|
138
|
+
const run = (handler: DmsMiddleware) => {
|
|
139
|
+
const runWithContext = runtimeContexts.get(runtime);
|
|
140
|
+
return runWithContext
|
|
141
|
+
? runWithContext(() => handler(to, runtime.route))
|
|
142
|
+
: handler(to, runtime.route);
|
|
143
|
+
};
|
|
144
|
+
for (const group of middlewareGroups([
|
|
145
|
+
...registry.middleware,
|
|
146
|
+
...declaredHandlers,
|
|
147
|
+
])) {
|
|
148
|
+
// A group of parallel middlewares starts together; its results apply in
|
|
149
|
+
// order, as a sequence's would.
|
|
150
|
+
const results = await Promise.all(group.map(run));
|
|
151
|
+
const result = results.find((value) => value !== undefined);
|
|
152
|
+
if (result !== undefined) return result;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The middlewares in order, each run of parallel ones as one group. */
|
|
157
|
+
function middlewareGroups(handlers: DmsMiddleware[]): DmsMiddleware[][] {
|
|
158
|
+
const groups: DmsMiddleware[][] = [];
|
|
159
|
+
for (const handler of handlers) {
|
|
160
|
+
const last = groups.at(-1);
|
|
161
|
+
const parallel = registry.parallelMiddleware.has(handler);
|
|
162
|
+
if (parallel && last && registry.parallelMiddleware.has(last[0]))
|
|
163
|
+
last.push(handler);
|
|
164
|
+
else groups.push([handler]);
|
|
165
|
+
}
|
|
166
|
+
return groups;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// What only a request to the server can honour: a client-side visit sends
|
|
170
|
+
// none and has none of these events.
|
|
171
|
+
const SERVER_VISIT_OPTIONS = [
|
|
172
|
+
"async",
|
|
173
|
+
"component",
|
|
174
|
+
"data",
|
|
175
|
+
"except",
|
|
176
|
+
"headers",
|
|
177
|
+
"method",
|
|
178
|
+
"onBefore",
|
|
179
|
+
"onCancel",
|
|
180
|
+
"onCancelToken",
|
|
181
|
+
"onProgress",
|
|
182
|
+
"onStart",
|
|
183
|
+
"only",
|
|
184
|
+
"preserveUrl",
|
|
185
|
+
"queryStringArrayFormat",
|
|
186
|
+
] as const satisfies readonly (keyof DmsNavigationOptions)[];
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Whether a visit to another query or hash of the page on screen can stay in
|
|
190
|
+
* the browser. The page props depend on the path alone: the server would
|
|
191
|
+
* answer with the same page, the site layout included. An error page is left
|
|
192
|
+
* through the server, which renders the page in its place, and so is the
|
|
193
|
+
* same path on another site.
|
|
194
|
+
*/
|
|
195
|
+
function staysOnPage(
|
|
196
|
+
runtime: DmsFrontendRuntime,
|
|
197
|
+
url: string,
|
|
198
|
+
options: DmsNavigationOptions,
|
|
199
|
+
): boolean {
|
|
200
|
+
return (
|
|
201
|
+
new URL(url, currentUrl(runtime)).origin ===
|
|
202
|
+
new URL(currentUrl(runtime)).origin &&
|
|
203
|
+
!runtime.pageKey?.startsWith("error:") &&
|
|
204
|
+
runtime.currentError.value === null &&
|
|
205
|
+
SERVER_VISIT_OPTIONS.every((name) => options[name] === undefined)
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* An Inertia client-side visit: the URL and the history entry change, the
|
|
211
|
+
* page keeps its props, and no request is made.
|
|
212
|
+
*/
|
|
213
|
+
function visitInBrowser(
|
|
214
|
+
url: string,
|
|
215
|
+
options: DmsNavigationOptions,
|
|
216
|
+
): Promise<void> {
|
|
217
|
+
const clientVisit = options.replace
|
|
218
|
+
? inertiaRouter.replace
|
|
219
|
+
: inertiaRouter.push;
|
|
220
|
+
return new Promise<void>((resolve) => {
|
|
221
|
+
clientVisit.call(inertiaRouter, {
|
|
222
|
+
url,
|
|
223
|
+
preserveScroll: options.preserveScroll ?? true,
|
|
224
|
+
preserveState: options.preserveState ?? true,
|
|
225
|
+
viewTransition: options.viewTransition,
|
|
226
|
+
onSuccess: options.onSuccess,
|
|
227
|
+
onError: options.onError,
|
|
228
|
+
onFinish: (visit) => {
|
|
229
|
+
(options.onFinish as ((visit: unknown) => void) | undefined)?.(visit);
|
|
230
|
+
resolve();
|
|
231
|
+
},
|
|
232
|
+
} as Parameters<typeof inertiaRouter.push>[0]);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function visit(
|
|
237
|
+
runtime: DmsFrontendRuntime,
|
|
238
|
+
to: RouteLocationRaw,
|
|
239
|
+
options: DmsNavigationOptions = {},
|
|
240
|
+
): Promise<void> {
|
|
241
|
+
const url = locationToUrl(runtime, to);
|
|
242
|
+
const target = parseRoute(runtime, url);
|
|
243
|
+
const result = await runMiddleware(runtime, target);
|
|
244
|
+
if (result === false) return;
|
|
245
|
+
if (result !== undefined) return visit(runtime, result, options);
|
|
246
|
+
if (runtime.isServer) {
|
|
247
|
+
runtime.serverRedirect = url;
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
// Like Vue Router, a navigation that only changes the query or hash keeps
|
|
251
|
+
// the page mounted: without preserveState, Inertia remounts the page
|
|
252
|
+
// component on every visit and its local state (open tabs, selection,
|
|
253
|
+
// inputs) is lost.
|
|
254
|
+
const samePath = target.path === runtime.route.path;
|
|
255
|
+
if (samePath && staysOnPage(runtime, url, options)) {
|
|
256
|
+
await visitInBrowser(url, options);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
await new Promise<void>((resolve) =>
|
|
260
|
+
inertiaRouter.visit(url, {
|
|
261
|
+
...options,
|
|
262
|
+
preserveState: options.preserveState ?? samePath,
|
|
263
|
+
preserveScroll: options.preserveScroll ?? samePath,
|
|
264
|
+
onFinish: (completedVisit) => {
|
|
265
|
+
options.onFinish?.(completedVisit);
|
|
266
|
+
resolve();
|
|
267
|
+
},
|
|
268
|
+
}),
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function useDmsRoute(pattern?: string): DmsRoute {
|
|
273
|
+
const runtime = useDmsRuntime();
|
|
274
|
+
runtime.currentRoutePattern = pattern;
|
|
275
|
+
// A server render answers one request, whose URL the runtime was given with
|
|
276
|
+
// its page, and never changes it. Inertia's page is one ref for the whole
|
|
277
|
+
// process, written by each render as it starts: after an await it may hold
|
|
278
|
+
// another request's URL, and while the plugins set up, the previous one's.
|
|
279
|
+
if (runtime.isServer) {
|
|
280
|
+
updateRoute(runtime, runtime.route.fullPath);
|
|
281
|
+
return runtime.route;
|
|
282
|
+
}
|
|
283
|
+
const page = usePage();
|
|
284
|
+
// The browser's plugins set up before Inertia has a page: until then the
|
|
285
|
+
// runtime's route is the initial page's.
|
|
286
|
+
if (page.url !== undefined) updateRoute(runtime, page.url);
|
|
287
|
+
watch(
|
|
288
|
+
() => page.url,
|
|
289
|
+
(url) => updateRoute(runtime, url),
|
|
290
|
+
);
|
|
291
|
+
return runtime.route;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Inertia's page, as `usePage()` returns it, for the request being rendered.
|
|
296
|
+
* Inertia keeps a single page for the whole process, written by each server
|
|
297
|
+
* render as it starts: read after an await, it may be another request's. Nuxt
|
|
298
|
+
* UI's Link and route stub read this one instead (see `vite.config.ts`).
|
|
299
|
+
*/
|
|
300
|
+
export function useDmsInertiaPage(): DmsInertiaPage {
|
|
301
|
+
const runtime = useDmsRuntime();
|
|
302
|
+
if (runtime.isServer && runtime.inertiaPage) return runtime.inertiaPage;
|
|
303
|
+
return usePage();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function useDmsRouter(): DmsRouter {
|
|
307
|
+
const runtime = useDmsRuntime();
|
|
308
|
+
const navigate = (to: RouteLocationRaw, options?: DmsNavigationOptions) => {
|
|
309
|
+
const current = currentUrl(runtime);
|
|
310
|
+
return normalizeUrl(locationToUrl(runtime, to), current) ===
|
|
311
|
+
normalizeUrl(current, current)
|
|
312
|
+
? Promise.resolve()
|
|
313
|
+
: visit(runtime, to, options);
|
|
314
|
+
};
|
|
315
|
+
return {
|
|
316
|
+
currentRoute: computed(() => runtime.route),
|
|
317
|
+
push: navigate,
|
|
318
|
+
replace: (to, options) => navigate(to, { ...options, replace: true }),
|
|
319
|
+
back: () => window.history.back(),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function navigateDms(
|
|
324
|
+
to: RouteLocationRaw,
|
|
325
|
+
options?: DmsNavigationOptions,
|
|
326
|
+
): Promise<void> {
|
|
327
|
+
return visit(useDmsRuntime(), to, options);
|
|
328
|
+
}
|