@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
|
@@ -1,18 +1,19 @@
|
|
|
1
|
-
// The favicon placeholder of a page whose render declares no icon.
|
|
2
|
-
//
|
|
3
|
-
// A document without any `<link rel="icon">` makes the browser ask for
|
|
4
|
-
// `/favicon.ico`, a request the frontend server would pass on to the backend
|
|
5
|
-
// on every page load. An empty data URL stops it. The template used to carry
|
|
6
|
-
// that placeholder itself, but a module declaring real icons through `useHead`
|
|
7
|
-
// (light and dark, one per `prefers-color-scheme`) then competed with it, and
|
|
8
|
-
// the browser could keep the empty one: it only goes in when the rendered head
|
|
9
|
-
// has no icon of its own.
|
|
10
|
-
|
|
11
1
|
const FAVICON_PLACEHOLDER = '<link rel="icon" href="data:," />';
|
|
12
2
|
/** A `<link>` whose `rel` holds the `icon` token, `shortcut icon` included. */
|
|
13
3
|
const ICON_LINK = /<link\b[^>]*\srel=(["'])(?:[^"']*\s)?icon(?:\s[^"']*)?\1/i;
|
|
14
4
|
|
|
15
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* The rendered head tags, behind the placeholder when they hold no icon.
|
|
7
|
+
*
|
|
8
|
+
* The favicon placeholder of a page whose render declares no icon. A
|
|
9
|
+
* document without any `<link rel="icon">` makes the browser ask for
|
|
10
|
+
* `/favicon.ico`, a request the frontend server would pass on to the backend
|
|
11
|
+
* on every page load; an empty data URL stops it. It only goes in when the
|
|
12
|
+
* rendered head has no icon of its own: a module declaring real icons
|
|
13
|
+
* through `useHead` (light and dark, one per `prefers-color-scheme`)
|
|
14
|
+
* competed with a placeholder carried by the template, and the browser could
|
|
15
|
+
* keep the empty one.
|
|
16
|
+
*/
|
|
16
17
|
export function withFaviconPlaceholder(headTags) {
|
|
17
18
|
return ICON_LINK.test(headTags)
|
|
18
19
|
? headTags
|
|
@@ -1,13 +1,3 @@
|
|
|
1
|
-
// Working out which page a visit to `/` means.
|
|
2
|
-
//
|
|
3
|
-
// The answer lives in the module registry the workspace was materialized with:
|
|
4
|
-
// a module declares `homepage` somewhere in its options, at whatever depth its
|
|
5
|
-
// own configuration shape puts it, and the highest-priority declaration wins —
|
|
6
|
-
// the registry is already sorted. Read once at boot, because the registry is a
|
|
7
|
-
// build artifact and cannot change while the server is up.
|
|
8
|
-
//
|
|
9
|
-
// Split out of server.mjs, whose size the linter caps.
|
|
10
|
-
|
|
11
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
12
2
|
import { join } from "node:path";
|
|
13
3
|
import { fileURLToPath } from "node:url";
|
|
@@ -29,6 +19,14 @@ function findHomepage(options) {
|
|
|
29
19
|
|
|
30
20
|
const OPTIONS = publicOptionsOf(REGISTRY);
|
|
31
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Works out which page a visit to `/` means. The answer lives in the module
|
|
24
|
+
* registry the workspace was materialized with: a module declares `homepage`
|
|
25
|
+
* somewhere in its options, at whatever depth its own configuration shape
|
|
26
|
+
* puts it, and the highest-priority declaration wins, since the registry is
|
|
27
|
+
* already sorted. Read once at boot, because the registry is a build
|
|
28
|
+
* artifact and cannot change while the server is up.
|
|
29
|
+
*/
|
|
32
30
|
export const HOMEPAGE =
|
|
33
31
|
REGISTRY.modules
|
|
34
32
|
.map((module) => findHomepage(OPTIONS[module.id]))
|
|
@@ -9,20 +9,27 @@ import { writeContent } from "./content.mjs";
|
|
|
9
9
|
const WORKSPACE_ROOT = fileURLToPath(new URL("..", import.meta.url));
|
|
10
10
|
const JSON_TYPE = "application/json";
|
|
11
11
|
const READ_METHODS = new Set(["GET", "HEAD"]);
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Iconify's own naming rule, for prefixes and icon names alike. It is also
|
|
14
|
+
* what keeps a request from naming anything but a collection file.
|
|
15
|
+
*/
|
|
14
16
|
const ICON_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
15
17
|
const COLLECTION_FILE = /^([^/]+)\.json$/;
|
|
16
18
|
const ICON_NAME_SEPARATOR = ",";
|
|
17
19
|
const COLLECTION_PACKAGES = join("node_modules", "@iconify-json");
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
/**
|
|
21
|
+
* An icon's data only changes with the installed collection, which a
|
|
22
|
+
* deployment replaces; a week bounds how long an upgraded icon can lag.
|
|
23
|
+
*/
|
|
20
24
|
const ICON_CACHE_CONTROL =
|
|
21
25
|
"public, max-age=604800, stale-while-revalidate=86400";
|
|
22
26
|
const ERROR_CACHE_CONTROL = "no-store";
|
|
23
27
|
const collections = new Map();
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Kept by renderer module: a development server evaluates a new one after a
|
|
30
|
+
* layer changes, and each must find the collections loaded before it
|
|
31
|
+
* renders.
|
|
32
|
+
*/
|
|
26
33
|
const rendererCollections = new WeakMap();
|
|
27
34
|
|
|
28
35
|
function collectionSpecifiers(prefix) {
|
|
@@ -32,14 +39,16 @@ function collectionSpecifiers(prefix) {
|
|
|
32
39
|
];
|
|
33
40
|
}
|
|
34
41
|
|
|
42
|
+
/**
|
|
43
|
+
* The file of an icon collection from the first source installed under its
|
|
44
|
+
* name; a source not installed is skipped and the next one tried.
|
|
45
|
+
*/
|
|
35
46
|
function resolveCollectionFile(root, prefix) {
|
|
36
47
|
const require = createRequire(join(root, "package.json"));
|
|
37
48
|
for (const specifier of collectionSpecifiers(prefix)) {
|
|
38
49
|
try {
|
|
39
50
|
return require.resolve(specifier);
|
|
40
|
-
} catch {
|
|
41
|
-
// Not installed under this name: try the next source.
|
|
42
|
-
}
|
|
51
|
+
} catch {}
|
|
43
52
|
}
|
|
44
53
|
return undefined;
|
|
45
54
|
}
|
|
@@ -1,9 +1,3 @@
|
|
|
1
|
-
// A page that completes its query as it renders, rendered at that query in
|
|
2
|
-
// the same request rather than answered with a redirect.
|
|
3
|
-
//
|
|
4
|
-
// Lives apart from server.mjs so the file stays under the size the linter
|
|
5
|
-
// allows.
|
|
6
|
-
|
|
7
1
|
import { ofetch } from "ofetch";
|
|
8
2
|
|
|
9
3
|
/**
|
|
@@ -34,6 +28,9 @@ function reusableReadKey(path, options, headers) {
|
|
|
34
28
|
* `backendHeaders()` and the read's own. After `reuseCompletedReads()`, a later
|
|
35
29
|
* render of the same request is given a copy of each read a previous render
|
|
36
30
|
* completed instead of reading the backend again.
|
|
31
|
+
*
|
|
32
|
+
* A read returns a copy: the render that read it may still change the object it
|
|
33
|
+
* got. A body that cannot be copied is read again.
|
|
37
34
|
*/
|
|
38
35
|
export function serverReads(backendHeaders) {
|
|
39
36
|
const completed = new Map();
|
|
@@ -52,8 +49,6 @@ export function serverReads(backendHeaders) {
|
|
|
52
49
|
headers,
|
|
53
50
|
});
|
|
54
51
|
if (!key) return reading;
|
|
55
|
-
// A copy: the render that read it may still change the object it got. A
|
|
56
|
-
// body that cannot be copied is read again.
|
|
57
52
|
return reading.then((data) => {
|
|
58
53
|
try {
|
|
59
54
|
completed.set(key, structuredClone(data));
|
|
@@ -89,6 +84,9 @@ function rendersInPlace(request, from, location) {
|
|
|
89
84
|
* the first render made (`fetch`), instead of being answered with a redirect
|
|
90
85
|
* the browser would follow with a whole new request. The page served then
|
|
91
86
|
* names the URL the document shows, which the browser puts in the address bar.
|
|
87
|
+
*
|
|
88
|
+
* A page that completes its query as it renders is rendered at that query in
|
|
89
|
+
* the same request, rather than answered with a redirect.
|
|
92
90
|
*/
|
|
93
91
|
export async function renderInPlace(request, page, fetch, render, propsAt) {
|
|
94
92
|
let rendered = await render(page);
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
// The address the frontend server listens on.
|
|
2
|
-
|
|
3
1
|
const LOOPBACK = "127.0.0.1";
|
|
4
2
|
const EVERY_INTERFACE = "0.0.0.0";
|
|
5
3
|
|
|
@@ -10,6 +8,8 @@ const EVERY_INTERFACE = "0.0.0.0";
|
|
|
10
8
|
* reach. `HOST=0.0.0.0` opens it to them. A production server listens on
|
|
11
9
|
* every interface, as a container expects.
|
|
12
10
|
*
|
|
11
|
+
* The address the frontend server listens on.
|
|
12
|
+
*
|
|
13
13
|
* @param env The server's environment
|
|
14
14
|
*/
|
|
15
15
|
export function listenHost(env = process.env) {
|
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
// What a module route handler receives and how its answer is written: the
|
|
2
|
-
// Web-standard request, the session, the module's sealed cookies, the backend,
|
|
3
|
-
// and the defaults every route gets once (same-origin on unsafe methods, a body
|
|
4
|
-
// limit, `no-store`). See `server-module.ts` for the types a module reads.
|
|
5
|
-
|
|
6
1
|
import { Readable } from "node:stream";
|
|
7
2
|
import { backend, json, RequestBodyError } from "./auth/backend.mjs";
|
|
8
3
|
import {
|
|
@@ -165,6 +160,11 @@ function frozenCopy(value) {
|
|
|
165
160
|
/**
|
|
166
161
|
* The event a handler receives.
|
|
167
162
|
*
|
|
163
|
+
* What a module route handler receives and how its answer is written: the Web-
|
|
164
|
+
* standard request, the session, the module's sealed cookies, the backend, and
|
|
165
|
+
* the defaults every route gets once (same-origin on unsafe methods, a body
|
|
166
|
+
* limit, `no-store`). See `server-module.ts` for the types a module reads.
|
|
167
|
+
*
|
|
168
168
|
* @param context `{ request, response, route, params, module, app, view,
|
|
169
169
|
* authOwner }`, where `module` is `{ id, options, privateOptions }`, `app`
|
|
170
170
|
* the deployment's read-only data, `view` what a session shows a page and
|
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
// The routes frontend modules declare in their `dms.server.ts`: what each
|
|
2
|
-
// declaration is checked against, how conflicts are settled, and which route a
|
|
3
|
-
// request reaches. Pure: the server hands it the declarations it loaded, and
|
|
4
|
-
// `module-event.mjs` runs the handler a request reaches.
|
|
5
|
-
|
|
6
1
|
import { resolveBackendProxies } from "./backend-proxies.mjs";
|
|
7
2
|
import { compilePathPattern } from "./path-patterns.mjs";
|
|
8
3
|
import { matchPublicEndpoint } from "./public-endpoints.mjs";
|
|
@@ -93,6 +88,10 @@ function optionsRejection(options) {
|
|
|
93
88
|
return undefined;
|
|
94
89
|
}
|
|
95
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Two declarations of one path whatever their parameters are named share a
|
|
93
|
+
* shape.
|
|
94
|
+
*/
|
|
96
95
|
function compilePath(path) {
|
|
97
96
|
const normalized = normalizedPath(path);
|
|
98
97
|
const segments = normalized === "/" ? [] : normalized.slice(1).split("/");
|
|
@@ -107,7 +106,6 @@ function compilePath(path) {
|
|
|
107
106
|
.join("");
|
|
108
107
|
return {
|
|
109
108
|
path: normalized,
|
|
110
|
-
// Two declarations of one path whatever their parameters are named.
|
|
111
109
|
shape: segments
|
|
112
110
|
.map((segment) => (PARAM_SEGMENT.test(segment) ? ":" : segment))
|
|
113
111
|
.join("/"),
|
|
@@ -432,6 +430,11 @@ function resolveRules(modules, warn) {
|
|
|
432
430
|
* `proxies`: the backend prefixes the modules and the manifest declare, the
|
|
433
431
|
* most specific first (see `backend-proxies.mjs`).
|
|
434
432
|
*
|
|
433
|
+
* The routes frontend modules declare in their `dms.server.ts`: what each
|
|
434
|
+
* declaration is checked against, how conflicts are settled, and which route a
|
|
435
|
+
* request reaches. Pure: the server hands it the declarations it loaded, and
|
|
436
|
+
* `module-event.mjs` runs the handler a request reaches.
|
|
437
|
+
*
|
|
435
438
|
* @param declarations `{ id, priority, setup }` per module with a dms.server.ts
|
|
436
439
|
* @param warn Reports a declaration problem
|
|
437
440
|
* @param publicEndpoints The deployment's public endpoints (#108), to report a
|
|
@@ -478,13 +481,14 @@ function decodedParams(route, match) {
|
|
|
478
481
|
/**
|
|
479
482
|
* The route a request reaches, with its parameters, or `undefined`.
|
|
480
483
|
*
|
|
484
|
+
* A `HEAD` no route declares is the `GET`'s, without its body.
|
|
485
|
+
*
|
|
481
486
|
* @param routes What `resolveServerDeclarations` returned
|
|
482
487
|
* @param method The request's method
|
|
483
488
|
* @param pathname The request's normalized path, without its query string
|
|
484
489
|
*/
|
|
485
490
|
export function matchModuleRoute(routes, method, pathname) {
|
|
486
491
|
const reached = matchMethod(routes, method, pathname);
|
|
487
|
-
// A `HEAD` no route declares is the `GET`'s, without its body.
|
|
488
492
|
if (reached || method !== "HEAD") return reached;
|
|
489
493
|
return matchMethod(routes, "GET", pathname);
|
|
490
494
|
}
|
|
@@ -1,9 +1,3 @@
|
|
|
1
|
-
// Loads what the frontend modules declare in their `dms.server.ts` and hands
|
|
2
|
-
// a request to the module route it reaches. In development the declarations
|
|
3
|
-
// come from Vite (`ssrLoadModule`), so an edited entry or handler answers on
|
|
4
|
-
// the next request; in production from the bundle `ajs dms build` makes
|
|
5
|
-
// (`dist/server-modules`). A workspace built before the feature has none.
|
|
6
|
-
|
|
7
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
8
2
|
import { fileURLToPath } from "node:url";
|
|
9
3
|
import { allowedEstablishEndpoints } from "./auth/establish-endpoints.mjs";
|
|
@@ -185,6 +179,8 @@ async function currentDeclarations(devServer) {
|
|
|
185
179
|
/**
|
|
186
180
|
* A set of server modules, resolved for a deployment.
|
|
187
181
|
*
|
|
182
|
+
* `authOwner` is the module whose sealed cookies are the `dms_*` ones.
|
|
183
|
+
*
|
|
188
184
|
* @param declarations `{ id, priority, setup }` per module with a dms.server.ts
|
|
189
185
|
* @param deployment `{ publicOptions, privateOptions, authEstablishEndpoints,
|
|
190
186
|
* publicEndpoints, backendProxies, warn }`: each module's options by id,
|
|
@@ -211,7 +207,6 @@ export function createServerModules(declarations, deployment) {
|
|
|
211
207
|
app: {
|
|
212
208
|
authEstablishEndpoints: deployment.authEstablishEndpoints?.() ?? [],
|
|
213
209
|
},
|
|
214
|
-
// The module whose sealed cookies are the `dms_*` ones.
|
|
215
210
|
authOwner: resolution.auth?.id,
|
|
216
211
|
});
|
|
217
212
|
return {
|
|
@@ -244,6 +239,12 @@ function serverModules(declarations) {
|
|
|
244
239
|
/**
|
|
245
240
|
* The server modules of this deployment, resolved.
|
|
246
241
|
*
|
|
242
|
+
* Loads what the frontend modules declare in their `dms.server.ts` and hands a
|
|
243
|
+
* request to the module route it reaches. In development the declarations come
|
|
244
|
+
* from Vite (`ssrLoadModule`), so an edited entry or handler answers on the
|
|
245
|
+
* next request; in production from the bundle `ajs dms build` makes
|
|
246
|
+
* (`dist/server-modules`). A workspace built before the feature has none.
|
|
247
|
+
*
|
|
247
248
|
* @param devServer Vite in development, `undefined` in production
|
|
248
249
|
*/
|
|
249
250
|
export async function loadServerModules(devServer) {
|
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
// Serves a frontend module's `dms.server.ts` routes the way the frontend
|
|
2
|
-
// server does, for the module's own tests: same router, same event, same
|
|
3
|
-
// cookie jar and the same defaults. Imported from
|
|
4
|
-
// `@antelopejs/dms-frontend/templates/vue/server/module-testing.mjs`; the
|
|
5
|
-
// server reads `DMS_API_BASE_URL` and `DMS_SESSION_SECRET` from the
|
|
6
|
-
// environment, as in production.
|
|
7
|
-
|
|
8
1
|
import { createServer } from "node:http";
|
|
9
2
|
import { RequestBodyError, UpstreamError } from "./auth/backend.mjs";
|
|
10
3
|
import { UNEXPECTED_ERROR_BODY } from "./backend-response.mjs";
|
|
@@ -26,6 +19,13 @@ function writeFailure(error, response) {
|
|
|
26
19
|
* Starts an HTTP server on a free local port answering the modules' routes,
|
|
27
20
|
* and `404` to anything else.
|
|
28
21
|
*
|
|
22
|
+
* Serves a frontend module's `dms.server.ts` routes the way the frontend server
|
|
23
|
+
* does, for the module's own tests: same router, same event, same cookie jar
|
|
24
|
+
* and the same defaults. Imported from
|
|
25
|
+
* `@antelopejs/dms-frontend/templates/vue/server/module-testing.mjs`; the server reads
|
|
26
|
+
* `DMS_API_BASE_URL` and `DMS_SESSION_SECRET` from the environment, as in
|
|
27
|
+
* production.
|
|
28
|
+
*
|
|
29
29
|
* @param declarations `{ id, priority?, setup }` per module, `setup` being
|
|
30
30
|
* the default export of its `dms.server.ts`
|
|
31
31
|
* @param deployment `{ publicOptions?, privateOptions?, authEstablishEndpoints?,
|
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
// The props of a DMS page visit, and which of them the browser already has.
|
|
2
|
-
//
|
|
3
|
-
// Lives apart from server.mjs so the file stays under the size the linter
|
|
4
|
-
// allows.
|
|
5
|
-
|
|
6
1
|
import { createHash } from "node:crypto";
|
|
7
2
|
import { readSession } from "./auth/session.mjs";
|
|
8
3
|
import { BackendResponseError } from "./backend-response.mjs";
|
|
@@ -43,8 +38,10 @@ function holdsOnlyThisLayout(held, sharedKey) {
|
|
|
43
38
|
);
|
|
44
39
|
}
|
|
45
40
|
|
|
46
|
-
|
|
47
|
-
|
|
41
|
+
/**
|
|
42
|
+
* The parameters of the page request itself, which a page query of the same
|
|
43
|
+
* name cannot override.
|
|
44
|
+
*/
|
|
48
45
|
const PAGE_REQUEST_PARAMETERS = new Set(["path", SHARED_PROP]);
|
|
49
46
|
|
|
50
47
|
/**
|
|
@@ -118,6 +115,16 @@ export async function propsForQuery(props, url, backend, auth) {
|
|
|
118
115
|
* asks for it, and otherwise asks the backend for the page alone. A partial
|
|
119
116
|
* reload gets the props it names, and the layout too when the browser's copy
|
|
120
117
|
* is not this session's.
|
|
118
|
+
*
|
|
119
|
+
* The sealed session names the account and workspace, whatever the module
|
|
120
|
+
* owning sign-in shows a page of it.
|
|
121
|
+
*
|
|
122
|
+
* A partial reload merges what it gets into the page on screen: a session that
|
|
123
|
+
* ended has to be sent as one, not left out.
|
|
124
|
+
*
|
|
125
|
+
* The shared layout is declared only when the browser ends up with this
|
|
126
|
+
* session's layout, sent now or already held: declaring it otherwise would let
|
|
127
|
+
* a layout of another session pass for this one.
|
|
121
128
|
*/
|
|
122
129
|
export async function loadPageProps(request, backend, auth) {
|
|
123
130
|
const url = new URL(request.url, "http://frontend.local");
|
|
@@ -125,15 +132,11 @@ export async function loadPageProps(request, backend, auth) {
|
|
|
125
132
|
const sealed = readSession(request);
|
|
126
133
|
const { user, session } = auth.view(sealed);
|
|
127
134
|
const { partial, wants } = requestedProps(request);
|
|
128
|
-
// The sealed session names the account and workspace, whatever the module
|
|
129
|
-
// owning sign-in shows a page of it.
|
|
130
135
|
const sharedKey = sharedOnceKey(sealed);
|
|
131
136
|
const holdsShared = holdsOnlyThisLayout(heldOnceProps(request), sharedKey);
|
|
132
137
|
const withShared = (partial && wants(SHARED_PROP)) || !holdsShared;
|
|
133
138
|
const props = {};
|
|
134
139
|
if (wants("path")) props.path = pathname;
|
|
135
|
-
// A partial reload merges what it gets into the page on screen: a session
|
|
136
|
-
// that ended has to be sent as one, not left out.
|
|
137
140
|
if (wants("user") && (user || partial)) props.user = user ?? null;
|
|
138
141
|
if (wants("session") && (session || partial)) props.session = session ?? null;
|
|
139
142
|
let shared;
|
|
@@ -145,9 +148,6 @@ export async function loadPageProps(request, backend, auth) {
|
|
|
145
148
|
shared = await backend.fetchJson(SITE_LAYOUT_PATH);
|
|
146
149
|
}
|
|
147
150
|
if (shared) props[SHARED_PROP] = shared;
|
|
148
|
-
// Declared only when the browser ends up with this session's layout: sent
|
|
149
|
-
// now, or already held. Declaring it otherwise would let a layout of
|
|
150
|
-
// another session pass for this one.
|
|
151
151
|
const onceProps =
|
|
152
152
|
shared || holdsShared
|
|
153
153
|
? {
|
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
// Page path patterns, as the modules' `dms.server.ts` name them: `/x` is the
|
|
2
|
-
// page itself, `/x/*` one segment below it, `/x/**` the page and everything
|
|
3
|
-
// below it. Nothing else: no prefix match inside a segment, no query string.
|
|
4
|
-
|
|
5
1
|
const SEGMENT = /^[A-Za-z0-9._~-]+$/;
|
|
6
2
|
|
|
7
3
|
/**
|
|
@@ -9,6 +5,10 @@ const SEGMENT = /^[A-Za-z0-9._~-]+$/;
|
|
|
9
5
|
*
|
|
10
6
|
* `specificity` orders matching patterns from the least to the most specific:
|
|
11
7
|
* more literal segments first, then a single-segment `*` over `**`.
|
|
8
|
+
*
|
|
9
|
+
* Page path patterns, as the modules' `dms.server.ts` name them: `/x` is the
|
|
10
|
+
* page itself, `/x/*` one segment below it, `/x/**` the page and everything
|
|
11
|
+
* below it. Nothing else: no prefix match inside a segment, no query string.
|
|
12
12
|
*/
|
|
13
13
|
export function compilePathPattern(pattern) {
|
|
14
14
|
if (typeof pattern !== "string" || !pattern.startsWith("/")) return undefined;
|
|
@@ -10,21 +10,26 @@ import { pipeWebBody } from "./web-body.mjs";
|
|
|
10
10
|
*/
|
|
11
11
|
export const PLAIN_ANSWER_HEADERS = { "accept-encoding": "identity" };
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Text the backend sends uncompressed: JSON above all, which the antelope
|
|
15
|
+
* API module never compresses. An event stream is left as it is: its events
|
|
16
|
+
* are small and many, and proxies between here and the browser expect it
|
|
17
|
+
* plain.
|
|
18
|
+
*/
|
|
16
19
|
const COMPRESSIBLE_TYPE =
|
|
17
20
|
/^(?:text\/(?!event-stream)[\w.+-]+|application\/(?:[\w.-]+\+)?(?:json|xml|javascript|ecmascript)|image\/svg\+xml)\s*(?:;|$)/i;
|
|
18
|
-
// Statuses that carry no body to compress.
|
|
19
21
|
const BODILESS_STATUSES = new Set([204, 205, 304]);
|
|
20
22
|
|
|
21
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* Whether the backend answer is one to compress on its way to the client.
|
|
25
|
+
*
|
|
26
|
+
* A response already encoded, or a range of the bytes, is not compressed:
|
|
27
|
+
* compressing would change what the encoding or the range describes.
|
|
28
|
+
*/
|
|
22
29
|
function isCompressible(request, upstream) {
|
|
23
30
|
if (request.method === "HEAD" || !upstream.body) return false;
|
|
24
31
|
if (BODILESS_STATUSES.has(upstream.status)) return false;
|
|
25
32
|
const headers = upstream.headers;
|
|
26
|
-
// Already encoded, or a range of the bytes: compressing would change what
|
|
27
|
-
// the encoding or the range describes.
|
|
28
33
|
const encoding = headers.get("content-encoding");
|
|
29
34
|
if (encoding && encoding.toLowerCase() !== "identity") return false;
|
|
30
35
|
if (upstream.status === 206 || headers.has("content-range")) return false;
|
|
@@ -40,10 +45,11 @@ function isCompressible(request, upstream) {
|
|
|
40
45
|
/**
|
|
41
46
|
* The compressor for a backend answer the proxy relays, in the encoding the
|
|
42
47
|
* client accepts, or `undefined` to relay it as it came.
|
|
48
|
+
*
|
|
49
|
+
* Streaming: an answer of unknown length (chunked JSON, streamed text) reaches
|
|
50
|
+
* the client part by part, as the backend writes it.
|
|
43
51
|
*/
|
|
44
52
|
function proxyEncoder(request, upstream) {
|
|
45
|
-
// Streaming: an answer of unknown length (chunked JSON, streamed text)
|
|
46
|
-
// reaches the client part by part, as the backend writes it.
|
|
47
53
|
return isCompressible(request, upstream)
|
|
48
54
|
? acceptedEncoder(request, { streaming: true })
|
|
49
55
|
: undefined;
|
|
@@ -52,6 +58,12 @@ function proxyEncoder(request, upstream) {
|
|
|
52
58
|
/**
|
|
53
59
|
* Relays a backend answer to the client, compressed when it should be.
|
|
54
60
|
* `isProxiedPath` decides which backend `Location` becomes a frontend path.
|
|
61
|
+
*
|
|
62
|
+
* Only native fetch bodies are decoded. Synthetic or opaque responses may still
|
|
63
|
+
* contain the encoded bytes, and unknown coding chains stay opaque.
|
|
64
|
+
*
|
|
65
|
+
* A decoded partial representation cannot retain the encoded byte offsets, and
|
|
66
|
+
* a complete 200 response is not manufactured from a partial 206 either.
|
|
55
67
|
*/
|
|
56
68
|
export async function relayAnswer(
|
|
57
69
|
request,
|
|
@@ -59,8 +71,6 @@ export async function relayAnswer(
|
|
|
59
71
|
upstream,
|
|
60
72
|
{ fetched = false, isProxiedPath } = {},
|
|
61
73
|
) {
|
|
62
|
-
// Only native fetch bodies are decoded. Synthetic/opaque responses may
|
|
63
|
-
// still contain the encoded bytes, and unknown coding chains stay opaque.
|
|
64
74
|
const codings = (upstream.headers.get("content-encoding") ?? "")
|
|
65
75
|
.toLowerCase()
|
|
66
76
|
.split(",")
|
|
@@ -74,8 +84,6 @@ export async function relayAnswer(
|
|
|
74
84
|
["gzip", "x-gzip", "deflate", "br"].includes(coding),
|
|
75
85
|
)
|
|
76
86
|
) {
|
|
77
|
-
// A decoded partial representation cannot retain the encoded byte offsets.
|
|
78
|
-
// Do not manufacture a complete 200 response from a partial 206 either.
|
|
79
87
|
if (upstream.status === 206 || upstream.headers.has("content-range")) {
|
|
80
88
|
await upstream.body.cancel();
|
|
81
89
|
response.writeHead(502);
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { relayedLocation } from "./proxy-redirects.mjs";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Hop-by-hop headers describe the backend connection, not the client's one.
|
|
5
|
+
* Forwarded, an explicit `connection: keep-alive` overrode the client's own
|
|
6
|
+
* `Connection: close`, so Node kept the socket open until its idle timeout.
|
|
7
|
+
*/
|
|
6
8
|
const HOP_BY_HOP_HEADERS = [
|
|
7
9
|
"connection",
|
|
8
10
|
"keep-alive",
|
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
// A backend redirect is the browser's to follow. Followed server-side, a
|
|
2
|
-
// redirect to another site served that site's page, and set its cookies, on
|
|
3
|
-
// the frontend's origin, and a redirect on the backend lost its Location.
|
|
4
|
-
// The proxy fetches with `redirect: "manual"` and relays the redirect instead.
|
|
5
|
-
|
|
6
1
|
/** The statuses that send the client to their `Location`. */
|
|
7
2
|
export const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
8
3
|
|
|
@@ -32,6 +27,13 @@ export function isBackendUrl(
|
|
|
32
27
|
* declared backend proxy) stays an absolute backend URL, which the browser
|
|
33
28
|
* follows to the backend. Another site's location is kept.
|
|
34
29
|
*
|
|
30
|
+
* A backend redirect is the browser's to follow. Followed server-side, a
|
|
31
|
+
* redirect to another site served that site's page, and set its cookies, on the
|
|
32
|
+
* frontend's origin, and a redirect on the backend lost its Location. The proxy
|
|
33
|
+
* fetches with `redirect: "manual"` and relays the redirect instead.
|
|
34
|
+
*
|
|
35
|
+
* `//host` would name another host, so `/.` keeps the location a path.
|
|
36
|
+
*
|
|
35
37
|
* @param location The backend's `Location`
|
|
36
38
|
* @param fetchedUrl The backend URL the answer came from
|
|
37
39
|
* @param isProxiedPath `(pathname) => boolean`, whether the frontend server
|
|
@@ -51,7 +53,6 @@ export function relayedLocation(
|
|
|
51
53
|
if (target.origin !== originOf(fetchedUrl)) return location;
|
|
52
54
|
if (!isProxiedPath(target.pathname)) return target.href;
|
|
53
55
|
const path = `${target.pathname}${target.search}${target.hash}`;
|
|
54
|
-
// `//host` would name another host: `/.` keeps it a path.
|
|
55
56
|
return path.startsWith("//") ? `/.${path}` : path;
|
|
56
57
|
}
|
|
57
58
|
|
|
@@ -1,17 +1,15 @@
|
|
|
1
|
-
// Large transfers through the proxy, a file upload above all. The request
|
|
2
|
-
// body is streamed to the backend, never buffered, and held to the
|
|
3
|
-
// `maxBodyBytes` of the proxy it reached: a declared Content-Length over the
|
|
4
|
-
// limit is refused before a byte is passed on, a chunked body is cut off as
|
|
5
|
-
// soon as it goes over.
|
|
6
|
-
|
|
7
1
|
const PAYLOAD_TOO_LARGE = 413;
|
|
8
2
|
const TOO_LARGE_BODY = JSON.stringify({ message: "Payload Too Large" });
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
/**
|
|
4
|
+
* The refused body is never passed on: the iteration stops where it went
|
|
5
|
+
* over, and the request stream stays open for the lingering close below.
|
|
6
|
+
*/
|
|
11
7
|
const BODY_ITERATION = { destroyOnReturn: false };
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
8
|
+
/**
|
|
9
|
+
* How long a refused body is read and dropped before the connection closes
|
|
10
|
+
* anyway: long enough for a client to finish sending what was in flight,
|
|
11
|
+
* short enough to bound what a refused client can make the server read.
|
|
12
|
+
*/
|
|
15
13
|
const LINGER_MS = 5_000;
|
|
16
14
|
|
|
17
15
|
const BODILESS_METHODS = new Set(["GET", "HEAD"]);
|
|
@@ -92,6 +90,12 @@ function refuseTooLargeBody(request, response) {
|
|
|
92
90
|
/**
|
|
93
91
|
* Sends a proxied request, its body held to `maxBodyBytes`.
|
|
94
92
|
*
|
|
93
|
+
* Large transfers through the proxy, a file upload above all. The request body
|
|
94
|
+
* is streamed to the backend, never buffered, and held to the `maxBodyBytes` of
|
|
95
|
+
* the proxy it reached: a declared Content-Length over the limit is refused
|
|
96
|
+
* before a byte is passed on, a chunked body is cut off as soon as it goes
|
|
97
|
+
* over.
|
|
98
|
+
*
|
|
95
99
|
* @param send `(body) => Promise<Response>`, the backend request with the
|
|
96
100
|
* body to stream: none for a GET or a HEAD, the request itself, or its
|
|
97
101
|
* bytes cut off past the limit
|