@antelopejs/dms-frontend 0.6.3 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/build-action.d.ts +2 -0
- package/dist/commands/build-action.js +37 -9
- package/dist/commands/build.js +9 -3
- package/dist/commands/snapshot-action.d.ts +6 -0
- package/dist/commands/snapshot-action.js +53 -0
- package/dist/commands/snapshot.d.ts +2 -0
- package/dist/commands/snapshot.js +55 -0
- package/dist/commands/start-action.d.ts +1 -0
- package/dist/commands/start-action.js +95 -5
- package/dist/commands/start.js +7 -2
- package/dist/config.d.ts +3 -1
- package/dist/config.js +14 -3
- package/dist/help.js +16 -3
- package/dist/index.js +2 -0
- package/dist/manifest.d.ts +7 -1
- package/dist/manifest.js +21 -17
- package/dist/materialize.js +11 -1
- package/dist/module-set.d.ts +11 -0
- package/dist/module-set.js +18 -0
- package/dist/private-values.d.ts +9 -0
- package/dist/private-values.js +77 -0
- package/dist/snapshot.d.ts +25 -0
- package/dist/snapshot.js +90 -0
- package/dist/workspace-setup.d.ts +7 -0
- package/dist/workspace-setup.js +55 -23
- package/dist/workspace.d.ts +1 -0
- package/dist/workspace.js +6 -0
- package/package.json +1 -1
- package/templates/vue/frontend-module.ts +9 -6
- package/templates/vue/server/backend-response.mjs +13 -2
- package/templates/vue/server/backend-wake.mjs +129 -0
- package/templates/vue/server/module-server.mjs +67 -5
- package/templates/vue/server.mjs +30 -7
|
@@ -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
|
+
}
|
|
@@ -49,15 +49,33 @@ function readJsonFile(url, fallback) {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
53
|
+
|
|
54
|
+
/** The object holding `path`'s last key, when every key up to it is own. */
|
|
55
|
+
function holderOf(target, path) {
|
|
54
56
|
let node = target;
|
|
55
57
|
for (const key of path.slice(0, -1)) {
|
|
56
|
-
if (
|
|
58
|
+
if (UNSAFE_KEYS.has(key) || !Object.hasOwn(node, key)) return undefined;
|
|
57
59
|
node = node[key];
|
|
60
|
+
if (typeof node !== "object" || node === null) return undefined;
|
|
58
61
|
}
|
|
62
|
+
return UNSAFE_KEYS.has(path.at(-1)) ? undefined : node;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Sets `value` at `path` when the object holding it exists but lacks it. */
|
|
66
|
+
function fillPath(target, path, value) {
|
|
67
|
+
const holder = holderOf(target, path);
|
|
68
|
+
const key = path.at(-1);
|
|
69
|
+
if (holder && (holder[key] === undefined || holder[key] === ""))
|
|
70
|
+
holder[key] = value;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Sets `value` at `path` only where the build left an empty string. */
|
|
74
|
+
function fillEmptied(target, path, value) {
|
|
75
|
+
const holder = holderOf(target, path);
|
|
59
76
|
const key = path.at(-1);
|
|
60
|
-
if (
|
|
77
|
+
if (holder && Object.hasOwn(holder, key) && holder[key] === "")
|
|
78
|
+
holder[key] = value;
|
|
61
79
|
}
|
|
62
80
|
|
|
63
81
|
/**
|
|
@@ -77,12 +95,56 @@ export function privateOptionsWithFallbacks(modules, environment, env) {
|
|
|
77
95
|
return modules;
|
|
78
96
|
}
|
|
79
97
|
|
|
98
|
+
/** Each string leaf of `value`, array items included, with its path. */
|
|
99
|
+
function* stringLeaves(value, path = []) {
|
|
100
|
+
if (typeof value === "string") yield [path, value];
|
|
101
|
+
else if (typeof value === "object" && value !== null)
|
|
102
|
+
for (const [key, child] of Object.entries(value))
|
|
103
|
+
yield* stringLeaves(child, [...path, key]);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Fills the private values a build left empty from `DMS_PRIVATE_OPTIONS`, a
|
|
108
|
+
* JSON object of private options by module id
|
|
109
|
+
* (`{"acme__shop-frontend-vue":{"apiKey":"…"}}`): any module's secrets, where
|
|
110
|
+
* `DMS_HTML_RENDER_SECRET` and `DMS_OAUTH_RELAY_SECRET` name two of them. Only
|
|
111
|
+
* a value the build emptied is filled, array items included: a value the
|
|
112
|
+
* manifest carries wins, and no option or section is added or replaced.
|
|
113
|
+
*
|
|
114
|
+
* @param modules The private options by module id
|
|
115
|
+
* @param json The variable's value; nothing is filled when unset
|
|
116
|
+
* @param warn Reports a value that is not a JSON object, never its content
|
|
117
|
+
*/
|
|
118
|
+
export function privateOptionsFromJson(modules, json, warn) {
|
|
119
|
+
if (!json) return modules;
|
|
120
|
+
let given;
|
|
121
|
+
try {
|
|
122
|
+
given = JSON.parse(json);
|
|
123
|
+
} catch {
|
|
124
|
+
given = undefined;
|
|
125
|
+
}
|
|
126
|
+
if (typeof given !== "object" || given === null || Array.isArray(given)) {
|
|
127
|
+
warn("DMS_PRIVATE_OPTIONS is not a JSON object of options by module id");
|
|
128
|
+
return modules;
|
|
129
|
+
}
|
|
130
|
+
for (const [id, options] of Object.entries(given)) {
|
|
131
|
+
if (UNSAFE_KEYS.has(id) || !Object.hasOwn(modules, id)) continue;
|
|
132
|
+
for (const [path, value] of stringLeaves(options))
|
|
133
|
+
if (path.length > 0) fillEmptied(modules[id], path, value);
|
|
134
|
+
}
|
|
135
|
+
return modules;
|
|
136
|
+
}
|
|
137
|
+
|
|
80
138
|
function readPrivateOptions(env = process.env) {
|
|
81
139
|
const { modules = {}, environment = {} } = readJsonFile(
|
|
82
140
|
SERVER_OPTIONS_FILE,
|
|
83
141
|
{},
|
|
84
142
|
);
|
|
85
|
-
return
|
|
143
|
+
return privateOptionsFromJson(
|
|
144
|
+
privateOptionsWithFallbacks(modules, environment, env),
|
|
145
|
+
env.DMS_PRIVATE_OPTIONS,
|
|
146
|
+
(message) => console.warn(message),
|
|
147
|
+
);
|
|
86
148
|
}
|
|
87
149
|
|
|
88
150
|
function readPublicOptions() {
|
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);
|