@antelopejs/dms-frontend 0.5.6 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +376 -18
  2. package/dist/app-configs.d.ts +9 -0
  3. package/dist/app-configs.js +52 -0
  4. package/dist/asset-roots.d.ts +7 -0
  5. package/dist/asset-roots.js +34 -0
  6. package/dist/commands/build-action.js +9 -0
  7. package/dist/commands/dev-action.js +88 -19
  8. package/dist/commands/start-action.js +2 -0
  9. package/dist/common.d.ts +1 -0
  10. package/dist/common.js +1 -0
  11. package/dist/config.d.ts +2 -0
  12. package/dist/config.js +8 -3
  13. package/dist/derived-outputs.d.ts +2 -0
  14. package/dist/derived-outputs.js +38 -31
  15. package/dist/help.js +10 -3
  16. package/dist/layer-watch.js +123 -131
  17. package/dist/layers.js +2 -0
  18. package/dist/manifest-secrets.js +5 -5
  19. package/dist/manifest.js +7 -1
  20. package/dist/materialize.js +21 -1
  21. package/dist/module-set.d.ts +16 -0
  22. package/dist/module-set.js +84 -0
  23. package/dist/server-process.d.ts +1 -0
  24. package/dist/server-process.js +28 -2
  25. package/dist/verify-source-runner.js +12 -0
  26. package/dist/workspace.d.ts +9 -0
  27. package/package.json +2 -1
  28. package/templates/vue/app-runtime.ts +58 -16
  29. package/templates/vue/client-server-guard.mjs +73 -0
  30. package/templates/vue/email-renderer.ts +17 -4
  31. package/templates/vue/email-runtime.ts +1 -1
  32. package/templates/vue/frontend-module.ts +1817 -259
  33. package/templates/vue/globals.d.ts +1 -1
  34. package/templates/vue/icon-hydration.ts +106 -1
  35. package/templates/vue/index.html +0 -1
  36. package/templates/vue/layer-app-config.ts +92 -0
  37. package/templates/vue/main.ts +10 -2
  38. package/templates/vue/package.json +4 -1
  39. package/templates/vue/server/auth/backend.mjs +13 -19
  40. package/templates/vue/server/auth/client-ip.mjs +27 -0
  41. package/templates/vue/server/auth/establish-endpoints.mjs +70 -0
  42. package/templates/vue/server/auth/session.mjs +6 -97
  43. package/templates/vue/server/auth-hooks.mjs +84 -0
  44. package/templates/vue/server/backend-proxies.mjs +110 -0
  45. package/templates/vue/server/dev-private-files.mjs +99 -0
  46. package/templates/vue/server/email.mjs +11 -33
  47. package/templates/vue/server/favicon.mjs +20 -0
  48. package/templates/vue/server/inertia.mjs +48 -3
  49. package/templates/vue/server/listen-host.mjs +19 -0
  50. package/templates/vue/server/module-event.mjs +265 -0
  51. package/templates/vue/server/module-routes.mjs +513 -0
  52. package/templates/vue/server/module-server.mjs +225 -0
  53. package/templates/vue/server/module-testing.mjs +65 -0
  54. package/templates/vue/server/page-props.mjs +151 -0
  55. package/templates/vue/server/path-patterns.mjs +38 -0
  56. package/templates/vue/server/public-endpoints.mjs +69 -11
  57. package/templates/vue/server/request-routing.mjs +47 -0
  58. package/templates/vue/server/route-rules.mjs +129 -0
  59. package/templates/vue/server-module.ts +294 -0
  60. package/templates/vue/server.mjs +177 -161
  61. package/templates/vue/ssr-renderer.ts +60 -19
  62. package/templates/vue/tsconfig.json +2 -1
  63. package/templates/vue/ui-app-config.ts +18 -6
  64. package/templates/vue/ui-inertia-page.mjs +37 -0
  65. package/templates/vue/vite.config.ts +31 -10
  66. package/templates/vue/vite.email.config.ts +1 -1
  67. package/templates/vue/vite.server.config.ts +57 -0
  68. package/templates/vue/server/auth/oauth.mjs +0 -233
  69. package/templates/vue/server/auth/routes.mjs +0 -444
  70. package/templates/vue/server/render-token.mjs +0 -81
  71. package/templates/vue/server/tester.mjs +0 -227
@@ -0,0 +1,129 @@
1
+ // Route rules the modules declare in their `dms.server.ts`: how a page is
2
+ // rendered and which headers its document and Inertia answers carry. They
3
+ // change rendering and caching, never who may see a page.
4
+
5
+ import { compilePathPattern } from "./path-patterns.mjs";
6
+
7
+ const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9a-z-]+$/;
8
+ /** Whether a header value holds a control character other than a tab: a CR
9
+ * or an LF would split the answer. */
10
+ function hasControlCharacter(value) {
11
+ return [...value].some((character) => {
12
+ const code = character.charCodeAt(0);
13
+ return (code < 0x20 && code !== 0x09) || code === 0x7f;
14
+ });
15
+ }
16
+ /** Set by the server itself: a rule's `cache` decides `cache-control`. */
17
+ const RESERVED_HEADERS = new Set([
18
+ "cache-control",
19
+ "set-cookie",
20
+ "vary",
21
+ "content-type",
22
+ "content-length",
23
+ "x-inertia",
24
+ "x-inertia-version",
25
+ ]);
26
+
27
+ function isSeconds(value) {
28
+ return Number.isSafeInteger(value) && value >= 0;
29
+ }
30
+
31
+ function headersRejection(headers) {
32
+ if (headers === undefined) return undefined;
33
+ if (typeof headers !== "object" || headers === null || Array.isArray(headers))
34
+ return "headers is not an object";
35
+ for (const [name, value] of Object.entries(headers)) {
36
+ const lower = name.toLowerCase();
37
+ if (
38
+ !HEADER_NAME.test(lower) ||
39
+ typeof value !== "string" ||
40
+ hasControlCharacter(value)
41
+ )
42
+ return `"${name}" is not a header with a text value`;
43
+ if (RESERVED_HEADERS.has(lower))
44
+ return `the server sets "${name}" itself (use cache for cache-control)`;
45
+ }
46
+ return undefined;
47
+ }
48
+
49
+ function cacheRejection(cache) {
50
+ if (cache === undefined || cache === false) return undefined;
51
+ if (
52
+ typeof cache !== "object" ||
53
+ cache === null ||
54
+ cache.public !== true ||
55
+ !isSeconds(cache.maxAge) ||
56
+ (cache.staleWhileRevalidate !== undefined &&
57
+ !isSeconds(cache.staleWhileRevalidate))
58
+ )
59
+ return "cache is neither false nor { public: true, maxAge, staleWhileRevalidate? } in seconds";
60
+ return undefined;
61
+ }
62
+
63
+ /** Why a rule is ignored, or `undefined`. */
64
+ export function ruleRejection(pattern, rule) {
65
+ if (!compilePathPattern(pattern))
66
+ return "the pattern is not /x, /x/* or /x/**";
67
+ if (typeof rule !== "object" || rule === null) return "it is not an object";
68
+ if (rule.ssr !== undefined && rule.ssr !== false)
69
+ return "ssr is either left out or false";
70
+ return headersRejection(rule.headers) ?? cacheRejection(rule.cache);
71
+ }
72
+
73
+ /**
74
+ * The rules that apply to `pathname`, merged from the least to the most
75
+ * specific pattern: a more specific rule overrides `ssr`, `cache` and each
76
+ * header it sets.
77
+ *
78
+ * @param rules The resolved rules, `{ pattern, rule }` each
79
+ * @param pathname The page's path
80
+ */
81
+ export function routeRuleFor(rules, pathname) {
82
+ const merged = { headers: {} };
83
+ const matching = rules
84
+ .filter(({ pattern }) => pattern.test(pathname))
85
+ .sort(
86
+ (left, right) => left.pattern.specificity - right.pattern.specificity,
87
+ );
88
+ for (const { rule } of matching) {
89
+ if (rule.ssr !== undefined) merged.ssr = rule.ssr;
90
+ if (rule.cache !== undefined) merged.cache = rule.cache;
91
+ Object.assign(merged.headers, rule.headers);
92
+ }
93
+ return merged;
94
+ }
95
+
96
+ function publicCacheControl(cache) {
97
+ const stale =
98
+ cache.staleWhileRevalidate === undefined
99
+ ? ""
100
+ : `, stale-while-revalidate=${cache.staleWhileRevalidate}`;
101
+ return `public, max-age=${cache.maxAge}${stale}`;
102
+ }
103
+
104
+ /**
105
+ * The headers a page answer adds for its rule. A public `cache` only applies
106
+ * to a page the backend serves to anyone, asked without any cookie or
107
+ * content language: the answer then holds nobody's data, and `Vary: Cookie`
108
+ * keeps a shared cache from serving it to a visitor who has some.
109
+ *
110
+ * @param rule What `routeRuleFor` returned
111
+ * @param request The page request
112
+ * @param route The page's route, as the backend described it
113
+ * @param status The answer's status: only a 200 is cached
114
+ */
115
+ export function ruleHeaders(rule, request, route, status) {
116
+ const headers = { ...rule.headers };
117
+ if (
118
+ rule.cache &&
119
+ status === 200 &&
120
+ request.method === "GET" &&
121
+ !request.headers.cookie &&
122
+ !request.headers["x-content-language"] &&
123
+ route?.publicAccess === true
124
+ ) {
125
+ headers["cache-control"] = publicCacheControl(rule.cache);
126
+ headers.vary = "X-Inertia, Cookie";
127
+ }
128
+ return headers;
129
+ }
@@ -0,0 +1,294 @@
1
+ /// <reference types="node" />
2
+ /**
3
+ * What a frontend module's `dms.server.ts` declares: the routes it serves from
4
+ * the frontend server, its auth hooks, its route rules and the backend paths
5
+ * the server proxies. Imported as `#dms/server`.
6
+ *
7
+ * The entry runs in the frontend server's Node.js process, next to the
8
+ * session secret: it is the module's own trusted code, as its backend half
9
+ * is, not a sandbox. It may import `#dms/server`, Node.js built-ins, npm
10
+ * dependencies and the module's own server files, never the application's
11
+ * code (`.vue` files, `dms.frontend.ts`). Nothing it imports reaches the
12
+ * browser bundle.
13
+ */
14
+
15
+ /** The methods a route can answer. */
16
+ export type DmsServerMethod =
17
+ | "GET"
18
+ | "HEAD"
19
+ | "POST"
20
+ | "PUT"
21
+ | "PATCH"
22
+ | "DELETE"
23
+ | "OPTIONS";
24
+
25
+ /** The session the frontend server keeps in its sealed session cookie. */
26
+ export interface DmsSession {
27
+ user?: Record<string, unknown> & { language?: string };
28
+ accessToken?: string;
29
+ refreshToken?: string;
30
+ accountId?: string;
31
+ activeTenantId?: string;
32
+ /** False when the session ends with the browser. */
33
+ persistent?: boolean;
34
+ /** Stamped by the frontend server on every write. */
35
+ updatedAt?: number;
36
+ [key: string]: unknown;
37
+ }
38
+
39
+ export interface DmsSealedCookieOptions {
40
+ /** Seconds. Without one, the cookie ends with the browser session. */
41
+ maxAge?: number;
42
+ /** `/` by default. A flow cookie keeps to the paths of its flow. */
43
+ path?: string;
44
+ }
45
+
46
+ export interface DmsBackendRequest {
47
+ method?: string;
48
+ /** Sent as JSON. */
49
+ body?: unknown;
50
+ /** Sent as `Authorization: Bearer <token>`. */
51
+ token?: string;
52
+ /**
53
+ * Extra request headers. The client's address (`x-forwarded-for`) and the
54
+ * headers the frontend server relays to every backend request (the
55
+ * client's country, the user agent, the content language) are its own.
56
+ */
57
+ headers?: Record<string, string>;
58
+ }
59
+
60
+ /** What a session shows the browser: the `user` and `session` page props. */
61
+ export interface DmsPublicSession {
62
+ user?: unknown;
63
+ session?: Record<string, unknown>;
64
+ }
65
+
66
+ /** What a route handler receives. */
67
+ export interface DmsServerEvent {
68
+ /**
69
+ * The request, Web-standard. Its body stream stops at the route's body
70
+ * limit (64 KiB by default) with a 413.
71
+ */
72
+ request: Request;
73
+ url: URL;
74
+ /** The values of the route's `:name` segments. */
75
+ params: Readonly<Record<string, string>>;
76
+ /** The body as JSON, `{}` when empty; a 400 when it is not JSON. */
77
+ readJson<T = Record<string, unknown>>(): Promise<T>;
78
+ session: {
79
+ read(): DmsSession | undefined;
80
+ /**
81
+ * What the session shows a page (`user`, `session`), as the module that
82
+ * owns sign-in renders it: the page props, and what
83
+ * `useDmsUserSession().fetch()` reads.
84
+ */
85
+ view(): DmsPublicSession;
86
+ /**
87
+ * Writes the session cookie, stamped with `updatedAt`; it ends with the
88
+ * browser unless `persistent` is true or unset.
89
+ */
90
+ write(session: DmsSession): void;
91
+ clear(): void;
92
+ /** The session the rest of this request runs with (page props, retries). */
93
+ useForRequest(session: DmsSession | undefined): void;
94
+ };
95
+ /**
96
+ * Sealed (encrypted, authenticated) cookies, by logical name: the frontend
97
+ * server scopes the name to its backend and sets `HttpOnly`, `SameSite=Lax`
98
+ * and `Secure`. The session cookie is not reachable here. The `dms_*`
99
+ * names are the module that owns sign-in's (`registerAuth`): any other
100
+ * module's cookie is stored as `<module id>.<name>`, so no module reads or
101
+ * overwrites another's, and naming a `dms_*` cookie throws.
102
+ */
103
+ cookies: {
104
+ readSealed<T = unknown>(name: string): T | undefined;
105
+ writeSealed(
106
+ name: string,
107
+ value: unknown,
108
+ options?: DmsSealedCookieOptions,
109
+ ): void;
110
+ clear(name: string, options?: { path?: string }): void;
111
+ };
112
+ /**
113
+ * Calls the backend, JSON in and out. A refusal throws an error carrying
114
+ * the status and the backend's public `error.*` message, which the server
115
+ * answers as is when the handler lets it through.
116
+ */
117
+ backend<T = unknown>(path: string, init?: DmsBackendRequest): Promise<T>;
118
+ /**
119
+ * The backend URL of an absolute path, for a call `backend` cannot make (a
120
+ * raw response, no relayed header): always on the backend's origin,
121
+ * whatever the path holds.
122
+ */
123
+ backendUrl(path: string): URL;
124
+ /** The client's address, behind `DMS_TRUSTED_PROXY_HOPS` proxies. */
125
+ clientIp(): string;
126
+ /** This module, and only this one. */
127
+ module: {
128
+ id: string;
129
+ options: Readonly<Record<string, unknown>>;
130
+ privateOptions: Readonly<Record<string, unknown>>;
131
+ };
132
+ /** Renders an email template of the application (`dms.email.ts`). */
133
+ renderEmail(
134
+ template: string,
135
+ props: Record<string, unknown>,
136
+ options?: { locale?: string },
137
+ ): Promise<string>;
138
+ /** Deployment data from the manifest, read-only. */
139
+ app: {
140
+ /** Backend endpoints declared as session-opening (`authEstablishEndpoints`). */
141
+ authEstablishEndpoints: readonly string[];
142
+ };
143
+ }
144
+
145
+ /**
146
+ * A route handler. A `Response` is sent as it is; any other value is sent as
147
+ * JSON with a 200, `null` or nothing as an empty 204. Cookies written through
148
+ * the event are added to the response either way.
149
+ */
150
+ export type DmsServerHandler = (
151
+ event: DmsServerEvent,
152
+ ) => Response | unknown | Promise<Response | unknown>;
153
+
154
+ export interface DmsServerRouteOptions {
155
+ /**
156
+ * Whether a request with a state-changing method must carry the frontend's
157
+ * own `Origin`. `true` by default; only a route authenticated otherwise (a
158
+ * service token) turns it off.
159
+ */
160
+ sameOrigin?: boolean;
161
+ /** Request body limit in bytes, 64 KiB by default. */
162
+ bodyLimit?: number;
163
+ /**
164
+ * The path is under `/api/` or `/dms/`, the backend's namespace, outside an
165
+ * underscore prefix (`/api/_<module>/`): the route shadows whatever the
166
+ * backend serves there. Say so to silence the warning.
167
+ */
168
+ overridesBackend?: boolean;
169
+ }
170
+
171
+ /**
172
+ * The session logic the frontend server runs outside any route, which the
173
+ * module owning sign-in provides. Without `publicSession`, pages carry no
174
+ * `user` and `session`; without `refreshSession`, a session the backend
175
+ * refuses is not renewed; `signInPath` is `/auth` by default.
176
+ */
177
+ export interface DmsServerAuth {
178
+ /** Where a signed-out visit to a private page goes, with `?redirect=`. */
179
+ signInPath?: string;
180
+ /**
181
+ * Pages a signed-out visit is never sent to sign in from when the backend
182
+ * refuses it: `/x`, `/x/*` (one segment below) or `/x/**` (the page and
183
+ * everything below). Left out, `<signInPath>/**` and `/onboarding`.
184
+ */
185
+ signInExempt?: readonly string[];
186
+ /**
187
+ * Renews a session the backend refused (a 401 to a signed-in page visit,
188
+ * which is then retried once): writes it with `event.session.write` and
189
+ * returns it, or clears it and returns `undefined`.
190
+ */
191
+ refreshSession?(
192
+ event: DmsServerEvent,
193
+ session: DmsSession,
194
+ ): Promise<DmsSession | undefined>;
195
+ /**
196
+ * The page props `user` and `session` of a signed-in session. The server
197
+ * adds `session.updatedAt` itself.
198
+ */
199
+ publicSession?(session: DmsSession): DmsPublicSession;
200
+ }
201
+
202
+ /**
203
+ * How a page is rendered, and what its document and Inertia answers carry.
204
+ * A rule changes rendering and caching, never who may see the page.
205
+ */
206
+ export interface DmsRouteRule {
207
+ /**
208
+ * `false`: a document request gets the client-rendered shell instead of a
209
+ * server render. Inertia visits are unchanged.
210
+ */
211
+ ssr?: false;
212
+ /** Headers added to the page's answers (not `cache-control`: see `cache`). */
213
+ headers?: Record<string, string>;
214
+ /**
215
+ * Public caching, for a page the backend serves to anyone, asked without
216
+ * any cookie or content language; any other request keeps the private
217
+ * default. `false` keeps the default under a broader rule.
218
+ */
219
+ cache?:
220
+ | { public: true; maxAge: number; staleWhileRevalidate?: number }
221
+ | false;
222
+ }
223
+
224
+ export interface DmsServer {
225
+ /**
226
+ * Serves `path` for `method` from the frontend server, before the backend
227
+ * proxy. A path is literal segments and `:name` parameters
228
+ * (`/auth/oauth/:provider/start`); a trailing slash is ignored.
229
+ */
230
+ registerRoute(
231
+ method: DmsServerMethod | readonly DmsServerMethod[],
232
+ path: string,
233
+ handler: DmsServerHandler,
234
+ options?: DmsServerRouteOptions,
235
+ ): void;
236
+ /**
237
+ * Provides the session logic the server runs outside routes. The
238
+ * highest-priority module that registers it provides it; another one is
239
+ * reported and ignored.
240
+ */
241
+ registerAuth(auth: DmsServerAuth): void;
242
+ /**
243
+ * Route rules by page pattern: `/x`, `/x/*` (one segment below) or `/x/**`
244
+ * (the page and everything below). The rules matching a page merge from the
245
+ * least to the most specific pattern; for one pattern, the
246
+ * highest-priority module's rule wins.
247
+ */
248
+ registerRouteRules(rules: Readonly<Record<string, DmsRouteRule>>): void;
249
+ /**
250
+ * Proxies the paths `pattern` covers to the backend (`/x`, `/x/*` one
251
+ * segment below, `/x/**` the path and everything below; the first segment
252
+ * literal). The frontend server sends nothing else to the backend: a path no
253
+ * proxy covers is a page, or a `404` for a method other than `GET` and
254
+ * `HEAD`. Of two patterns covering a path, the more specific wins; for one
255
+ * pattern, the highest-priority module's.
256
+ *
257
+ * ```ts
258
+ * server.registerBackendProxy("/api/**");
259
+ * server.registerBackendProxy("/media/**", { auth: "none" });
260
+ * ```
261
+ */
262
+ registerBackendProxy(pattern: string, options?: DmsBackendProxyOptions): void;
263
+ }
264
+
265
+ /** How the frontend server authenticates a proxied request to the backend. */
266
+ export interface DmsBackendProxyOptions {
267
+ /**
268
+ * `session` (the default): the server adds the session's access token
269
+ * (`Authorization: Bearer`), which never reaches the browser, and a request
270
+ * with a state-changing method needs the frontend's own `Origin`, except a
271
+ * public endpoint a backend module declares (`publicEndpoints`), sent
272
+ * without the session. `none`: never sends the session, so no `Origin` is
273
+ * needed.
274
+ */
275
+ auth?: "session" | "none";
276
+ }
277
+
278
+ export type DmsServerSetup = (server: DmsServer) => void;
279
+
280
+ /**
281
+ * Declares what a frontend module serves from the frontend server, as the
282
+ * default export of its `dms.server.ts`:
283
+ *
284
+ * ```ts
285
+ * import { defineDmsServer } from "#dms/server";
286
+ *
287
+ * export default defineDmsServer((server) => {
288
+ * server.registerRoute("GET", "/api/_acme/ping", () => ({ ok: true }));
289
+ * });
290
+ * ```
291
+ */
292
+ export function defineDmsServer(setup: DmsServerSetup): DmsServerSetup {
293
+ return setup;
294
+ }