@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.
@@ -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
- /** Sets `value` at `path` when the object holding it exists but lacks it. */
53
- function fillPath(target, path, value) {
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 (typeof node[key] !== "object" || node[key] === null) return;
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 (typeof node[key] !== "string" || node[key] === "") node[key] = value;
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 privateOptionsWithFallbacks(modules, environment, env);
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() {
@@ -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 fetch(new URL(path, process.env.DMS_API_BASE_URL), {
158
- headers: backendHeaders(request),
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 fetch(backendUrl(request.url), {
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(request, response, status, htmlHeaders(), html);
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);