@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.2",
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.13.2",
84
- "@antelopejs/tooling-configs": ">=0.0.7 <1.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.13.2 <2"
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`. The
1074
- * server waits for it, but the page carries none of it, so the browser reads
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 page does not carry the answer, " +
1093
- "so the browser reads it again. Load it with useDmsFetch or useDmsAsyncData, " +
1094
- "or leave it to the browser (server: false, onMounted).",
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
- return new BackendResponseError(response.status, await refusalCode(response));
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
+ }
@@ -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);