@antelopejs/dms-frontend 0.6.2 → 0.6.4
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.
|
@@ -11,6 +11,19 @@ const server_process_1 = require("../server-process");
|
|
|
11
11
|
const update_check_1 = require("../update-check");
|
|
12
12
|
const module_set_1 = require("../module-set");
|
|
13
13
|
const BUILD_TIME_MANIFEST = "the build-time manifest";
|
|
14
|
+
const PRODUCTION = "production";
|
|
15
|
+
function serverNodeEnv() {
|
|
16
|
+
const nodeEnv = process.env.NODE_ENV || PRODUCTION;
|
|
17
|
+
if (nodeEnv !== PRODUCTION) {
|
|
18
|
+
const ui = (0, cli_1.getProcessUi)();
|
|
19
|
+
ui.message("warn", `NODE_ENV is ${nodeEnv}: the server renders with the development builds of Vue and its libraries`, {
|
|
20
|
+
details: [
|
|
21
|
+
`${ui.symbols.levels.hint} Unset it for start, or set it to ${PRODUCTION}`,
|
|
22
|
+
],
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
return nodeEnv;
|
|
26
|
+
}
|
|
14
27
|
async function runStart(options) {
|
|
15
28
|
const startedAt = Date.now();
|
|
16
29
|
const backendUrl = (0, common_1.requireBackendUrl)(options.backendUrl);
|
|
@@ -46,6 +59,7 @@ async function runStart(options) {
|
|
|
46
59
|
const secrets = (0, common_1.resolveManifestSecrets)((0, common_1.collectManifestSecrets)((0, common_1.readCachedManifest)(workspaceDir)?.manifest.modules ?? []));
|
|
47
60
|
(0, common_1.reportManifestSecrets)(secrets, (count) => `Set ${count === 1 ? "it" : "them"}, or rebuild with DMS_BOOTSTRAP_SECRET so the manifest carries ${count === 1 ? "it" : "them"}`);
|
|
48
61
|
const secretSources = (0, common_1.describeSecretSources)(secrets.sources, BUILD_TIME_MANIFEST);
|
|
62
|
+
const nodeEnv = serverNodeEnv();
|
|
49
63
|
await reserved.release();
|
|
50
64
|
const code = await (0, server_process_1.runServer)({
|
|
51
65
|
name: "production server",
|
|
@@ -53,6 +67,7 @@ async function runStart(options) {
|
|
|
53
67
|
cwd: workspaceDir,
|
|
54
68
|
env: {
|
|
55
69
|
...process.env,
|
|
70
|
+
NODE_ENV: nodeEnv,
|
|
56
71
|
PORT: String(port),
|
|
57
72
|
DMS_API_BASE_URL: backendUrl,
|
|
58
73
|
DMS_SESSION_SECRET: sessionSecret,
|
package/dist/help.js
CHANGED
|
@@ -24,6 +24,10 @@ exports.ENVIRONMENT_VARIABLES = [
|
|
|
24
24
|
"DMS_COOKIE_SECURE",
|
|
25
25
|
"Secure cookies unless false (default: false in dev, true in start)",
|
|
26
26
|
],
|
|
27
|
+
[
|
|
28
|
+
"NODE_ENV",
|
|
29
|
+
"Build of Vue and its libraries the server loads (default: development in dev, production in start)",
|
|
30
|
+
],
|
|
27
31
|
[
|
|
28
32
|
"DMS_TRUSTED_PROXY_HOPS",
|
|
29
33
|
"Trusted reverse-proxy hops, counted from the right (default: 0)",
|
|
@@ -52,6 +56,10 @@ exports.ENVIRONMENT_VARIABLES = [
|
|
|
52
56
|
"DMS_BACKEND_PROXIES",
|
|
53
57
|
"More backend prefixes the server proxies, as a JSON array of { path, auth? }",
|
|
54
58
|
],
|
|
59
|
+
[
|
|
60
|
+
"DMS_BACKEND_WAKE_BUDGET_MS",
|
|
61
|
+
"How long a GET or HEAD waits out a backend's cold-start 503, in ms (default: 30000; 0: never)",
|
|
62
|
+
],
|
|
55
63
|
["DMS_CLIENT_BASE_URL", "Public frontend URL used in links and e-mails"],
|
|
56
64
|
["NO_COLOR", "Same as --no-color"],
|
|
57
65
|
["ANTELOPEJS_VERBOSE", "Same as --verbose"],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@antelopejs/dms-frontend",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.4",
|
|
4
4
|
"description": "Frontend-agnostic loader for AntelopeJS DMS, shipping the Vue 3 renderer (Vite, Inertia, SSR)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"antelope",
|
|
@@ -80,8 +80,8 @@
|
|
|
80
80
|
"unzipper": "^0.12.3"
|
|
81
81
|
},
|
|
82
82
|
"devDependencies": {
|
|
83
|
-
"@antelopejs/core": "^1.
|
|
84
|
-
"@antelopejs/tooling-configs": ">=0.0.
|
|
83
|
+
"@antelopejs/core": "^1.16.0",
|
|
84
|
+
"@antelopejs/tooling-configs": ">=0.0.9 <1.0.0",
|
|
85
85
|
"@iconify/utils": "^3.1.0",
|
|
86
86
|
"@iconify/vue": "^5.0.1",
|
|
87
87
|
"@inertiajs/vue3": "^3.7.1",
|
|
@@ -109,7 +109,7 @@
|
|
|
109
109
|
"vue-i18n": "^11.2.7"
|
|
110
110
|
},
|
|
111
111
|
"peerDependencies": {
|
|
112
|
-
"@antelopejs/core": ">=1.
|
|
112
|
+
"@antelopejs/core": ">=1.16.0 <2"
|
|
113
113
|
},
|
|
114
114
|
"optionalDependencies": {
|
|
115
115
|
"@oxc-parser/binding-darwin-arm64": "^0.95.0",
|
|
@@ -1070,9 +1070,11 @@ const warnedServerReads = new Set<string>();
|
|
|
1070
1070
|
|
|
1071
1071
|
/**
|
|
1072
1072
|
* Warns, in a development server render, about a backend read no async data
|
|
1073
|
-
* handler makes: an immediate watcher's, a setup's own `await $fetch
|
|
1074
|
-
* server waits for it, but the page carries none of it, so
|
|
1075
|
-
* it again and may hydrate with other data.
|
|
1073
|
+
* handler makes: an immediate watcher's, a setup's own `await $fetch`, a
|
|
1074
|
+
* middleware's. The server waits for it, but the page carries none of it, so
|
|
1075
|
+
* the browser reads it again and may hydrate with other data. Code that hands
|
|
1076
|
+
* the answer over itself (`useDmsState` with `transfer: true`) looks the same
|
|
1077
|
+
* from here, so the message names that exception rather than guess.
|
|
1076
1078
|
*/
|
|
1077
1079
|
function warnUntransferredRead(
|
|
1078
1080
|
runtime: DmsFrontendRuntime,
|
|
@@ -1089,9 +1091,10 @@ function warnUntransferredRead(
|
|
|
1089
1091
|
warnedServerReads.add(request);
|
|
1090
1092
|
console.warn(
|
|
1091
1093
|
`DMS: ${request} was read during a server render outside useDmsFetch and useDmsAsyncData ` +
|
|
1092
|
-
"(an immediate watcher, a setup's own await): the
|
|
1093
|
-
"
|
|
1094
|
-
"
|
|
1094
|
+
"(an immediate watcher, a setup's own await, a middleware): unless the code hands the " +
|
|
1095
|
+
"answer over (useDmsState with transfer: true), the page does not carry it and the " +
|
|
1096
|
+
"browser reads it again. Load it with useDmsFetch or useDmsAsyncData, in a middleware " +
|
|
1097
|
+
"or a plugin too, or leave it to the browser (server: false, onMounted).",
|
|
1095
1098
|
);
|
|
1096
1099
|
}
|
|
1097
1100
|
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// allows.
|
|
5
5
|
|
|
6
6
|
import { GENERIC_ERROR_MESSAGE } from "./auth/backend.mjs";
|
|
7
|
+
import { isColdStart } from "./backend-wake.mjs";
|
|
7
8
|
import { isFrontendVisit, redirectFrontendVisit } from "./inertia.mjs";
|
|
8
9
|
|
|
9
10
|
// What a JSON caller gets when the backend fails: the generic i18n key of the
|
|
@@ -19,10 +20,13 @@ const FORBIDDEN = 403;
|
|
|
19
20
|
const FRONTEND_ORIGIN = "http://frontend.local";
|
|
20
21
|
|
|
21
22
|
export class BackendResponseError extends Error {
|
|
22
|
-
constructor(status, code) {
|
|
23
|
+
constructor(status, code, retryAfter) {
|
|
23
24
|
super(`DMS backend returned ${status}`);
|
|
24
25
|
this.status = status;
|
|
25
26
|
this.code = code;
|
|
27
|
+
// Set only for a cold-start answer, so the visitor is told when to try
|
|
28
|
+
// again instead of getting an opaque failure.
|
|
29
|
+
this.retryAfter = retryAfter;
|
|
26
30
|
}
|
|
27
31
|
}
|
|
28
32
|
|
|
@@ -43,7 +47,14 @@ async function refusalCode(response) {
|
|
|
43
47
|
|
|
44
48
|
/** The error for a failed backend response, with its typed code if any. */
|
|
45
49
|
export async function backendResponseError(response) {
|
|
46
|
-
|
|
50
|
+
const retryAfter = isColdStart(response)
|
|
51
|
+
? (response.headers.get("retry-after") ?? undefined)
|
|
52
|
+
: undefined;
|
|
53
|
+
return new BackendResponseError(
|
|
54
|
+
response.status,
|
|
55
|
+
await refusalCode(response),
|
|
56
|
+
retryAfter,
|
|
57
|
+
);
|
|
47
58
|
}
|
|
48
59
|
|
|
49
60
|
/**
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Waiting out a backend the platform is waking from sleep.
|
|
2
|
+
//
|
|
3
|
+
// A platform that scales the backend to zero answers the first requests with
|
|
4
|
+
// a 503 while it starts a replica, marked with the cold-start header and a
|
|
5
|
+
// Retry-After in whole seconds: all three make the signal, and a 503 missing
|
|
6
|
+
// one of them goes out as it came. The header is a reserved resume
|
|
7
|
+
// convention, not a proof of where the answer came from: a backend may set it
|
|
8
|
+
// too, and then gets the same bounded wait. Only a GET or a HEAD is repeated;
|
|
9
|
+
// a POST, PATCH or DELETE is sent once.
|
|
10
|
+
//
|
|
11
|
+
// The wait starts with the first cold-start answer and lasts at most the
|
|
12
|
+
// budget, the repeated requests included: one still waiting for its answer
|
|
13
|
+
// when the budget runs out is aborted, and no request starts after it. It
|
|
14
|
+
// ends at once when the client leaves.
|
|
15
|
+
//
|
|
16
|
+
// Lives apart from server.mjs so the file stays under the size the linter
|
|
17
|
+
// allows.
|
|
18
|
+
|
|
19
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
20
|
+
|
|
21
|
+
/** Header marking a 503 as a cold start to wait out. */
|
|
22
|
+
export const COLD_START_HEADER = "x-antelope-cold-start";
|
|
23
|
+
const COLD_START_VALUE = "1";
|
|
24
|
+
const UNAVAILABLE = 503;
|
|
25
|
+
const RETRYABLE_METHODS = new Set(["GET", "HEAD"]);
|
|
26
|
+
const DEFAULT_BUDGET_MS = 30_000;
|
|
27
|
+
const MIN_DELAY_MS = 1_000;
|
|
28
|
+
// Repeats after the first request, so a GET is sent 16 times at most. They
|
|
29
|
+
// are capped as well as the time, so a marked answer with `Retry-After: 0`
|
|
30
|
+
// cannot multiply a GET.
|
|
31
|
+
const MAX_REPEATS = 15;
|
|
32
|
+
const MAX_DELAY_MS = 5_000;
|
|
33
|
+
const MS_PER_SECOND = 1_000;
|
|
34
|
+
const WHOLE_SECONDS = /^\d+$/;
|
|
35
|
+
const WHOLE_MILLISECONDS = /^\d+$/;
|
|
36
|
+
|
|
37
|
+
/** Whether `response` is a 503 marked as a cold start, with its Retry-After. */
|
|
38
|
+
export function isColdStart(response) {
|
|
39
|
+
return (
|
|
40
|
+
response.status === UNAVAILABLE &&
|
|
41
|
+
response.headers.get(COLD_START_HEADER) === COLD_START_VALUE &&
|
|
42
|
+
WHOLE_SECONDS.test(retryAfter(response))
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function retryAfter(response) {
|
|
47
|
+
return (response.headers.get("retry-after") ?? "").trim();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* How long a request may wait for a waking backend, from
|
|
52
|
+
* DMS_BACKEND_WAKE_BUDGET_MS. 0 turns the wait off; anything but a whole
|
|
53
|
+
* number of milliseconds keeps the default.
|
|
54
|
+
*/
|
|
55
|
+
export function wakeBudgetMs(env = process.env) {
|
|
56
|
+
const raw = (env.DMS_BACKEND_WAKE_BUDGET_MS ?? "").trim();
|
|
57
|
+
return WHOLE_MILLISECONDS.test(raw) ? Number(raw) : DEFAULT_BUDGET_MS;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The pause a cold-start answer's Retry-After asks for, kept between bounds. */
|
|
61
|
+
function retryDelayMs(response) {
|
|
62
|
+
return Math.min(
|
|
63
|
+
MAX_DELAY_MS,
|
|
64
|
+
Math.max(MIN_DELAY_MS, Number(retryAfter(response)) * MS_PER_SECOND),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* What the client gets once the budget is spent: the last cold-start answer's
|
|
70
|
+
* status and headers, without the body its pause already discarded.
|
|
71
|
+
*/
|
|
72
|
+
function expired(cold) {
|
|
73
|
+
return new Response(null, {
|
|
74
|
+
status: UNAVAILABLE,
|
|
75
|
+
headers: {
|
|
76
|
+
[COLD_START_HEADER]: COLD_START_VALUE,
|
|
77
|
+
"retry-after": retryAfter(cold),
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Sends one repeat, aborted if no answer has come by `deadline`. Once the
|
|
84
|
+
* answer has come the deadline no longer applies, so a ready backend's body
|
|
85
|
+
* streams for as long as it needs; only the client leaving ends it.
|
|
86
|
+
* Resolves to undefined when the deadline aborted the request.
|
|
87
|
+
*/
|
|
88
|
+
async function repeat(url, init, deadline, now) {
|
|
89
|
+
const late = new AbortController();
|
|
90
|
+
const timer = setTimeout(() => late.abort(), Math.max(0, deadline - now()));
|
|
91
|
+
const signal = init.signal
|
|
92
|
+
? AbortSignal.any([init.signal, late.signal])
|
|
93
|
+
: late.signal;
|
|
94
|
+
try {
|
|
95
|
+
return await fetch(url, { ...init, signal });
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (late.signal.aborted && !init.signal?.aborted) return undefined;
|
|
98
|
+
throw error;
|
|
99
|
+
} finally {
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* `fetch`, waiting out cold-start answers to a GET or a HEAD until `budgetMs`
|
|
106
|
+
* from the first of them is spent or MAX_REPEATS repeats are sent. Then the
|
|
107
|
+
* caller gets a cold-start 503 with its `Retry-After` to relay. The client
|
|
108
|
+
* leaving, through `init.signal`, ends the wait with its AbortError.
|
|
109
|
+
*/
|
|
110
|
+
export async function fetchWaking(
|
|
111
|
+
url,
|
|
112
|
+
init = {},
|
|
113
|
+
{ budgetMs = wakeBudgetMs(), now = Date.now } = {},
|
|
114
|
+
) {
|
|
115
|
+
const method = (init.method ?? "GET").toUpperCase();
|
|
116
|
+
let response = await fetch(url, init);
|
|
117
|
+
if (!RETRYABLE_METHODS.has(method)) return response;
|
|
118
|
+
const deadline = now() + budgetMs;
|
|
119
|
+
for (let repeats = 0; isColdStart(response); repeats += 1) {
|
|
120
|
+
const cold = response;
|
|
121
|
+
const delay = retryDelayMs(cold);
|
|
122
|
+
if (repeats >= MAX_REPEATS || now() + delay > deadline) return cold;
|
|
123
|
+
cold.body?.cancel().catch(() => {});
|
|
124
|
+
await sleep(delay, undefined, { signal: init.signal });
|
|
125
|
+
if (now() >= deadline) return expired(cold);
|
|
126
|
+
response = (await repeat(url, init, deadline, now)) ?? expired(cold);
|
|
127
|
+
}
|
|
128
|
+
return response;
|
|
129
|
+
}
|
package/templates/vue/server.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
redirectAccessRefusal,
|
|
18
18
|
UNEXPECTED_ERROR_BODY,
|
|
19
19
|
} from "./server/backend-response.mjs";
|
|
20
|
+
import { fetchWaking } from "./server/backend-wake.mjs";
|
|
20
21
|
import { handleClientError } from "./server/client-error.mjs";
|
|
21
22
|
import {
|
|
22
23
|
reportListenError,
|
|
@@ -154,9 +155,10 @@ function serverComponentFetch(request) {
|
|
|
154
155
|
}
|
|
155
156
|
|
|
156
157
|
async function backendJson(path, request) {
|
|
157
|
-
const response = await
|
|
158
|
-
|
|
159
|
-
|
|
158
|
+
const response = await fetchWaking(
|
|
159
|
+
new URL(path, process.env.DMS_API_BASE_URL),
|
|
160
|
+
{ headers: backendHeaders(request), signal: request.dmsAbandoned },
|
|
161
|
+
);
|
|
160
162
|
if (!response.ok) {
|
|
161
163
|
throw await backendResponseError(response);
|
|
162
164
|
}
|
|
@@ -211,7 +213,7 @@ async function proxy(request, response, backendProxy) {
|
|
|
211
213
|
// answers: its stream, an SSE feed above all, then stayed open for good.
|
|
212
214
|
const abandoned = new AbortController();
|
|
213
215
|
response.once("close", () => abandoned.abort());
|
|
214
|
-
const upstream = await
|
|
216
|
+
const upstream = await fetchWaking(backendUrl(request.url), {
|
|
215
217
|
signal: abandoned.signal,
|
|
216
218
|
method: request.method,
|
|
217
219
|
headers: { ...backendHeaders(request, access), ...PLAIN_ANSWER_HEADERS },
|
|
@@ -330,6 +332,9 @@ async function writeBackendError(error, request, response) {
|
|
|
330
332
|
if (await redirectAccessRefusal(error, request, response, loadRenderer))
|
|
331
333
|
return;
|
|
332
334
|
console.error("DMS backend request failed", error);
|
|
335
|
+
const retryHeaders = error?.retryAfter
|
|
336
|
+
? { "retry-after": error.retryAfter }
|
|
337
|
+
: {};
|
|
333
338
|
if (isFrontendVisit(request)) {
|
|
334
339
|
const props = {
|
|
335
340
|
path: pathname,
|
|
@@ -343,18 +348,24 @@ async function writeBackendError(error, request, response) {
|
|
|
343
348
|
request,
|
|
344
349
|
response,
|
|
345
350
|
status,
|
|
346
|
-
inertiaHeaders(page.version),
|
|
351
|
+
{ ...inertiaHeaders(page.version), ...retryHeaders },
|
|
347
352
|
JSON.stringify(page),
|
|
348
353
|
);
|
|
349
354
|
}
|
|
350
355
|
const { html } = await renderRequestHtml(page, request);
|
|
351
|
-
return writeContent(
|
|
356
|
+
return writeContent(
|
|
357
|
+
request,
|
|
358
|
+
response,
|
|
359
|
+
status,
|
|
360
|
+
{ ...htmlHeaders(), ...retryHeaders },
|
|
361
|
+
html,
|
|
362
|
+
);
|
|
352
363
|
}
|
|
353
364
|
const payload =
|
|
354
365
|
error instanceof UpstreamError || error instanceof RequestBodyError
|
|
355
366
|
? { message: error.message }
|
|
356
367
|
: UNEXPECTED_ERROR_BODY;
|
|
357
|
-
response.writeHead(status, { "content-type": JSON_TYPE });
|
|
368
|
+
response.writeHead(status, { "content-type": JSON_TYPE, ...retryHeaders });
|
|
358
369
|
response.end(JSON.stringify(payload));
|
|
359
370
|
}
|
|
360
371
|
|
|
@@ -476,7 +487,19 @@ async function writeRequestFailure(error, request, response) {
|
|
|
476
487
|
await writeBackendError(error, request, response);
|
|
477
488
|
}
|
|
478
489
|
|
|
490
|
+
/**
|
|
491
|
+
* Aborted once the client is gone, ending a wait for a waking backend. An
|
|
492
|
+
* embedder's response that emits no events never aborts it.
|
|
493
|
+
*/
|
|
494
|
+
function abandonedSignal(response) {
|
|
495
|
+
const abandoned = new AbortController();
|
|
496
|
+
if (typeof response.once === "function")
|
|
497
|
+
response.once("close", () => abandoned.abort());
|
|
498
|
+
return abandoned.signal;
|
|
499
|
+
}
|
|
500
|
+
|
|
479
501
|
export async function handleRequestSafely(request, response) {
|
|
502
|
+
request.dmsAbandoned = abandonedSignal(response);
|
|
480
503
|
try {
|
|
481
504
|
if (refuseForeignTarget(request, response)) return;
|
|
482
505
|
await handleRequest(request, response);
|