@webjsdev/server 0.9.0 → 0.10.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.
Files changed (101) hide show
  1. package/README.md +4 -1
  2. package/index.d.ts +427 -109
  3. package/index.js +8 -4
  4. package/package.json +3 -3
  5. package/src/action-flash.js +185 -0
  6. package/src/action-params.js +156 -0
  7. package/src/action-route.js +8 -9
  8. package/src/action-seed.js +44 -5
  9. package/src/actions.js +96 -14
  10. package/src/api.js +67 -10
  11. package/src/app-layout.js +133 -0
  12. package/src/asset-hash.js +82 -49
  13. package/src/auth.js +612 -108
  14. package/src/browser-entries.js +2 -17
  15. package/src/build-epoch.js +1 -1
  16. package/src/cache-tags.js +5 -1
  17. package/src/check/rules-actions.js +37 -1
  18. package/src/check/rules-components.js +114 -40
  19. package/src/check/rules-imports.js +175 -4
  20. package/src/check/rules-route-modules.js +200 -0
  21. package/src/check/rules-routes.js +55 -0
  22. package/src/check/rules-routing.js +143 -95
  23. package/src/check/rules-typescript.js +24 -2
  24. package/src/check/rules.js +104 -9
  25. package/src/check/runner-support.js +53 -171
  26. package/src/check/runner.js +22 -6
  27. package/src/client-signals.js +171 -0
  28. package/src/component-scanner.js +22 -16
  29. package/src/context.js +152 -16
  30. package/src/cookie-store.js +262 -0
  31. package/src/core-exports.js +190 -0
  32. package/src/dev/bun-app-source.js +95 -5
  33. package/src/dev/config.js +7 -51
  34. package/src/dev/dev-service-worker.js +48 -0
  35. package/src/dev/esm-hot-hooks.js +373 -0
  36. package/src/dev/handler.js +502 -155
  37. package/src/dev/helpers.js +127 -96
  38. package/src/dev/hot-host.js +33 -6
  39. package/src/dev/poke-state.js +110 -0
  40. package/src/dev/serve.js +177 -129
  41. package/src/dev/server.js +92 -33
  42. package/src/dev/specifier-versions.js +125 -0
  43. package/src/dev/watch-ignore.js +49 -4
  44. package/src/dev/watch-poll.js +78 -0
  45. package/src/dev/watch-tree.js +59 -22
  46. package/src/dev-classify.js +59 -26
  47. package/src/dev-error.js +104 -3
  48. package/src/dev-hot-classify.js +291 -0
  49. package/src/dev-hot-client.js +245 -0
  50. package/src/dev-import.js +22 -5
  51. package/src/dev-overlay.js +26 -0
  52. package/src/dev-ready-probe.js +69 -0
  53. package/src/dev-regenerate.js +2 -2
  54. package/src/dev-reload-hold.js +49 -20
  55. package/src/dev-reload-reason.js +61 -0
  56. package/src/dev-reload-worker.js +62 -13
  57. package/src/dev-secret.js +73 -0
  58. package/src/dev-source-locations-hook.js +4 -1
  59. package/src/dev-source-locations.js +33 -2
  60. package/src/dev.js +0 -1
  61. package/src/env-files.js +124 -0
  62. package/src/file-storage.js +2 -2
  63. package/src/foreign-imports.js +228 -0
  64. package/src/form-action-identity.js +52 -25
  65. package/src/form-dispatch.js +140 -65
  66. package/src/html-cache.js +35 -4
  67. package/src/importmap.js +2 -2
  68. package/src/instrumentation.js +59 -3
  69. package/src/js-scan.js +5 -4
  70. package/src/jwe.js +155 -0
  71. package/src/lazy-components.js +166 -0
  72. package/src/listener-core.js +4 -2
  73. package/src/lit-hints.js +172 -0
  74. package/src/middleware-load.js +248 -0
  75. package/src/module-graph.js +73 -47
  76. package/src/module-side-effect.js +284 -0
  77. package/src/public-files.js +73 -0
  78. package/src/redirect-location.js +19 -0
  79. package/src/redirects.js +9 -1
  80. package/src/route-frontier.js +151 -0
  81. package/src/route-report.js +130 -0
  82. package/src/router.js +120 -13
  83. package/src/session.js +32 -10
  84. package/src/ssr/client-router-flag.js +1 -1
  85. package/src/ssr/document.js +37 -13
  86. package/src/ssr/head.js +78 -23
  87. package/src/ssr/preloads.js +31 -45
  88. package/src/ssr/render.js +241 -55
  89. package/src/ssr/responses.js +51 -4
  90. package/src/ssr/shipped-modules.js +22 -16
  91. package/src/testing.d.ts +31 -2
  92. package/src/testing.js +80 -17
  93. package/src/thenable-params.js +26 -0
  94. package/src/vendor/jspm.js +12 -1
  95. package/src/vendor/pins.js +9 -8
  96. package/src/vendor/scanner.js +14 -9
  97. package/src/vendor.js +5 -3
  98. package/webjs-config.schema.json +1 -6
  99. package/src/component-elision.js +0 -1828
  100. package/src/elision-differential.js +0 -101
  101. package/src/elision-report.js +0 -193
@@ -67,13 +67,13 @@ export const RELOAD_MAX_HOLD_MS = 5000;
67
67
  * component edit is a component edit, and morphing it would leave the old
68
68
  * component class running against fresh markup.
69
69
  */
70
- export const VERDICT_STRENGTH = ['reload', 'shell', 'page'];
70
+ export const VERDICT_STRENGTH = ['reload', 'shell', 'page', 'component', 'css'];
71
71
 
72
72
  /**
73
73
  * Resolve an SSE `reload` frame's `data` to a verdict name.
74
74
  *
75
75
  * ANY failure resolves to `reload`: an absent payload, malformed JSON, a
76
- * non-object, a `v` outside the three literals, or the legacy bare `data: now`.
76
+ * non-object, a `v` outside the known names, or the legacy bare `data: now`.
77
77
  * That is what makes the wire-format change safe in both directions between a
78
78
  * long-running browser tab and a restarted server, since the worst a skew can
79
79
  * produce is the full reload that was the behaviour before this existed.
@@ -89,6 +89,32 @@ export function parseVerdict(data) {
89
89
  return 'reload';
90
90
  }
91
91
 
92
+ /**
93
+ * Resolve an SSE `reload` frame's `data` to the whole verdict the relay posts
94
+ * to a tab (#1689): `{ v, by, why, modules, css }`, every field always present
95
+ * so a tab never has to guess. The same fail-safe as `parseVerdict`: anything
96
+ * it cannot read is `{ v: 'reload', why: 'unreadable-frame' }`, and a field of
97
+ * the wrong type falls back to its empty value rather than failing the frame.
98
+ *
99
+ * @param {string} data
100
+ * @returns {{ v: string, by: string, why: string, modules: string[], css: boolean }}
101
+ */
102
+ export function parseReloadFrame(data) {
103
+ try {
104
+ const o = JSON.parse(data);
105
+ if (o && typeof o === 'object' && VERDICT_STRENGTH.indexOf(o.v) !== -1) {
106
+ return {
107
+ v: o.v,
108
+ by: typeof o.by === 'string' ? o.by : '',
109
+ why: typeof o.why === 'string' ? o.why : '',
110
+ modules: Array.isArray(o.modules) ? o.modules.filter((m) => typeof m === 'string') : [],
111
+ css: o.css === true,
112
+ };
113
+ }
114
+ } catch (_) { /* fall through to the fail-safe */ }
115
+ return { v: 'reload', by: '', why: 'unreadable-frame', modules: [], css: false };
116
+ }
117
+
92
118
  /** First reconnect wait after the stream drops (#1507), doubled per failure. */
93
119
  export const RECONNECT_BASE_MS = 300;
94
120
  /** The longest wait between reconnect attempts (#1507). */
@@ -209,8 +235,13 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
209
235
  /** @type {any} */ let quietTimer = null;
210
236
  /** @type {any} */ let capTimer = null;
211
237
 
212
- /** @type {string} the strongest verdict seen in the CURRENT batch (#1398) */
213
- let batchVerdict = 'page';
238
+ /**
239
+ * The CURRENT batch's merged verdict (#1398, #1689): the strongest `v` with
240
+ * its `by` / `why`, the union of every `modules` list and whether any frame
241
+ * set `css`. Null between batches.
242
+ * @type {{ v: string, by: string, why: string, modules: Set<string>, css: boolean } | null}
243
+ */
244
+ let batch = null;
214
245
  /** Whether the batch reloads every tab, or only the stale ones in `batchPorts` (#1516). */
215
246
  let batchAll = false;
216
247
  /** @type {Set<any>} tabs whose page fell behind, reloaded on their own */
@@ -221,9 +252,9 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
221
252
  function emitReload() {
222
253
  if (quietTimer !== null) { timers.clearTimeout(quietTimer); quietTimer = null; }
223
254
  if (capTimer !== null) { timers.clearTimeout(capTimer); capTimer = null; }
224
- const v = batchVerdict;
225
- batchVerdict = 'page'; // a fresh batch starts at the weakest verdict
226
- const msg = { type: 'reload', verdict: v };
255
+ const b = batch || { v: 'reload', by: '', why: 'no-verdict', modules: new Set(), css: false };
256
+ batch = null; // a fresh batch starts from nothing
257
+ const msg = { type: 'reload', verdict: { v: b.v, by: b.by, why: b.why, modules: Array.from(b.modules).sort(), css: b.css } };
227
258
  const targets = batchAll ? Array.from(ports.keys()) : Array.from(batchPorts).filter((p) => ports.has(p));
228
259
  batchAll = false;
229
260
  batchPorts.clear();
@@ -236,6 +267,9 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
236
267
  }
237
268
  }
238
269
 
270
+ /** The reload a tab gets when its page is behind the server (#1516). */
271
+ const PAGE_BEHIND = { v: 'reload', by: '', why: 'page-behind' };
272
+
239
273
  /** Strength rank; an unrecognised name ranks strongest, so it can only ever
240
274
  * over-reload. @param {string} v */
241
275
  function rank(v) {
@@ -244,12 +278,27 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
244
278
  }
245
279
 
246
280
  /**
247
- * @param {string} verdict
281
+ * Fold one signal into the batch: the strongest `v` wins with its `by` /
282
+ * `why` (the earlier one on a tie), `modules` unions and `css` ORs, the same
283
+ * rule as `mergeVerdicts` on the server.
284
+ * @param {{ v: string, by?: string, why?: string, modules?: string[], css?: boolean }} verdict
285
+ */
286
+ function mergeIntoBatch(verdict) {
287
+ const v = VERDICT_STRENGTH[rank(verdict.v)];
288
+ if (batch === null) batch = { v, by: verdict.by || '', why: verdict.why || '', modules: new Set(), css: false };
289
+ else if (rank(v) < rank(batch.v)) { batch.v = v; batch.by = verdict.by || ''; batch.why = verdict.why || ''; }
290
+ if (verdict.modules) for (const m of verdict.modules) batch.modules.add(m);
291
+ if (verdict.css) batch.css = true;
292
+ }
293
+
294
+ /**
295
+ * @param {{ v: string, by?: string, why?: string, modules?: string[], css?: boolean }} verdict
248
296
  * @param {any} [port] reload only this tab (its page fell behind); omitted, every tab
297
+ * @param {boolean} [inPlace] the signal came from a server that reloads in place
249
298
  */
250
299
  function requestReload(verdict, port, inPlace) {
251
300
  if (port === undefined) batchAll = true; else batchPorts.add(port);
252
- if (rank(verdict) < rank(batchVerdict)) batchVerdict = VERDICT_STRENGTH[rank(verdict)];
301
+ mergeIntoBatch(verdict);
253
302
  // One restart signal anywhere in the batch keeps the long window (#1575).
254
303
  if (!inPlace) batchRestart = true;
255
304
  if (quietTimer !== null) timers.clearTimeout(quietTimer);
@@ -278,7 +327,7 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
278
327
  // Reload every tab whose page is behind what the server reports now (#1516).
279
328
  function checkPages() {
280
329
  for (const [p, page] of pages) {
281
- if (pageIsStale(page, lastBoot, lastSeq)) requestReload('reload', p);
330
+ if (pageIsStale(page, lastBoot, lastSeq)) requestReload(PAGE_BEHIND, p);
282
331
  }
283
332
  }
284
333
 
@@ -297,7 +346,7 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
297
346
  touch();
298
347
  const h = parseHello(e.data);
299
348
  if (lastBoot !== null && (h.boot !== lastBoot || (h.seq !== null && lastSeq !== null && h.seq !== lastSeq))) {
300
- requestReload('reload');
349
+ requestReload({ v: 'reload', by: '', why: h.boot !== lastBoot ? 'server-restarted' : 'missed-update' });
301
350
  }
302
351
  lastBoot = h.boot;
303
352
  lastSeq = h.seq;
@@ -308,7 +357,7 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
308
357
  lastError = null;
309
358
  const n = reloadSeq(e.data);
310
359
  if (n !== null) lastSeq = n;
311
- requestReload(parseVerdict(e.data), undefined, frameInPlace(e.data));
360
+ requestReload(parseReloadFrame(e.data), undefined, frameInPlace(e.data));
312
361
  });
313
362
  es.addEventListener('webjs-error', (e) => { lastError = e.data; fanout({ type: 'webjs-error', data: e.data }); });
314
363
  // The stream dropped (a restarting server, a network blip, a host that
@@ -348,7 +397,7 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
348
397
  // out reload catches up, and again on every hello.
349
398
  if (typeof d.boot === 'string') {
350
399
  pages.set(port, { boot: d.boot, seq: typeof d.seq === 'number' ? d.seq : null });
351
- if (pageIsStale(pages.get(port), lastBoot, lastSeq)) requestReload('reload', port);
400
+ if (pageIsStale(pages.get(port), lastBoot, lastSeq)) requestReload(PAGE_BEHIND, port);
352
401
  }
353
402
  return;
354
403
  }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * A signing secret for development and tests when the app sets none (#1686),
3
+ * the way Rails keeps `tmp/local_secret.txt` and NextAuth v4 fell back to a
4
+ * generated secret outside production. `.env` is gitignored, so a fresh clone
5
+ * or a git worktree had no `AUTH_SECRET` / `SESSION_SECRET` and every page
6
+ * behind `session()` or `createAuth()` answered 500 until someone wrote one.
7
+ *
8
+ * The secret is generated once and kept in `.webjs/dev-secret` under the
9
+ * current directory (the app root for `webjs dev` and `webjs test`), which the
10
+ * scaffold's `.gitignore` already excludes, so sessions survive a restart.
11
+ *
12
+ * It is OPT-IN, and `NODE_ENV` alone decides ({@link devSecretApplies}): only
13
+ * `NODE_ENV=development` or `NODE_ENV=test` gets one (`webjs dev` and
14
+ * `webjs test` set them when unset). A process a test spawns inherits
15
+ * `NODE_ENV=test`, as under Jest, so it gets one too unless the test sets
16
+ * `NODE_ENV=production` on it.
17
+ * Anything else, a production `NODE_ENV` or none at all (an embedded host, a
18
+ * container running `node server.js`), gets undefined and the caller throws,
19
+ * so a production cookie is never signed with a key from disk because an env
20
+ * var was missing. It is read per call, never latched for the process, so a
21
+ * development handler created earlier in the same process opts nothing in.
22
+ *
23
+ * @module
24
+ */
25
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
26
+ import { randomBytes } from 'node:crypto';
27
+ import { join } from 'node:path';
28
+
29
+ const warned = new Set();
30
+
31
+ /**
32
+ * Whether this process may use the generated development secret, decided
33
+ * from `NODE_ENV` alone on every call: `development` or `test`.
34
+ * @param {Record<string, string | undefined>} [env]
35
+ * @returns {boolean}
36
+ */
37
+ export function devSecretApplies(env = process.env) {
38
+ return env.NODE_ENV === 'development' || env.NODE_ENV === 'test';
39
+ }
40
+
41
+ /**
42
+ * The development secret, created on first use, or undefined unless this is a
43
+ * development or test process.
44
+ * @param {string} name the variable the app left unset, named in the warning
45
+ * @param {{ dir?: string, env?: Record<string, string | undefined>, warn?: (msg: string) => void }} [opts]
46
+ * @returns {string | undefined}
47
+ */
48
+ export function devSecret(name, opts = {}) {
49
+ const env = opts.env || process.env;
50
+ if (!devSecretApplies(env)) return undefined;
51
+ const file = join(opts.dir || process.cwd(), '.webjs', 'dev-secret');
52
+ let secret = '';
53
+ try {
54
+ if (existsSync(file)) secret = readFileSync(file, 'utf8').trim();
55
+ } catch {}
56
+ if (!secret) {
57
+ secret = randomBytes(32).toString('hex');
58
+ try {
59
+ mkdirSync(join(file, '..'), { recursive: true });
60
+ writeFileSync(file, secret + '\n', { mode: 0o600 });
61
+ } catch {
62
+ // A read-only directory still gets a secret, just one per process.
63
+ }
64
+ }
65
+ if (!warned.has(name)) {
66
+ warned.add(name);
67
+ (opts.warn || ((m) => console.warn(m)))(
68
+ `[webjs] ${name} is not set, so a generated development secret in .webjs/dev-secret signs the cookie. `
69
+ + `Set ${name} in .env for production, where it is required.`,
70
+ );
71
+ }
72
+ return secret;
73
+ }
@@ -28,6 +28,7 @@ import { readFileSync } from 'node:fs';
28
28
  import { fileURLToPath } from 'node:url';
29
29
  import { relative, isAbsolute } from 'node:path';
30
30
  import { serverRuntime } from './listener-core.js';
31
+ import { rewriteAppSource } from './dev/bun-app-source.js';
31
32
  import {
32
33
  annotateSourceLocations,
33
34
  isSourceLocationCandidate,
@@ -128,7 +129,9 @@ export function registerSourceLocationHook(appDir) {
128
129
  try {
129
130
  contents = transformForSsr(abs, src) ?? src;
130
131
  } catch { /* fail open: the raw module */ }
131
- return { contents, loader };
132
+ // The same alias rewrite and module versions the app-source plugin
133
+ // applies (#1689), which this plugin wins over while it is on.
134
+ return { contents: rewriteAppSource(contents, abs), loader };
132
135
  });
133
136
  },
134
137
  });
@@ -141,14 +141,33 @@ export function annotateSourceLocations(source, file) {
141
141
  }
142
142
  }
143
143
 
144
+ /**
145
+ * The static strings of every `html` template in `src`, with the 1-based line
146
+ * each template starts on, for a check rule that inspects template markup
147
+ * (`no-jsx-attributes`). The strings are the raw source text between holes.
148
+ * Returns [] when the source cannot be lexed.
149
+ *
150
+ * @param {string} src
151
+ * @returns {{ strings: string[], line: number }[]}
152
+ */
153
+ export function htmlTemplateStrings(src) {
154
+ if (typeof src !== 'string' || !src.includes('html')) return [];
155
+ /** @type {{ strings: string[], line: number }[]} */
156
+ const templates = [];
157
+ try { collectInserts(src, '', templates); } catch { return []; }
158
+ return templates;
159
+ }
160
+
144
161
  /**
145
162
  * Lex `src` and return `[offset, text]` insertions, in ascending order.
146
163
  *
147
164
  * @param {string} src
148
165
  * @param {string} file
166
+ * @param {{ strings: string[], line: number }[]} [templates] when given,
167
+ * each `html` template's static strings are collected here
149
168
  * @returns {Array<[number, string]>}
150
169
  */
151
- function collectInserts(src, file) {
170
+ function collectInserts(src, file, templates) {
152
171
  /** @type {Array<[number, string]>} */
153
172
  const inserts = [];
154
173
  const n = src.length;
@@ -184,13 +203,19 @@ function collectInserts(src, file) {
184
203
  const c = src[i];
185
204
  if (c === '\\') {
186
205
  // An escape is static text the html machine sees as an opaque char.
206
+ if (html && html.record) html.record.strings[html.record.strings.length - 1] += src.slice(i, i + 2);
187
207
  if (html) html.feed(src, i, line);
188
208
  if (src[i + 1] === '\n') line++;
189
209
  i += 2;
190
210
  continue;
191
211
  }
192
212
  if (c === '`') { i++; return 'end'; }
193
- if (c === '$' && src[i + 1] === '{') { i += 2; return 'hole'; }
213
+ if (c === '$' && src[i + 1] === '{') {
214
+ i += 2;
215
+ if (html && html.record) html.record.strings.push('');
216
+ return 'hole';
217
+ }
218
+ if (html && html.record) html.record.strings[html.record.strings.length - 1] += c;
194
219
  if (html) {
195
220
  const ins = html.feed(src, i, line);
196
221
  if (ins !== -1) inserts.push([ins, ` data-webjs-src="${file}:${line}"`]);
@@ -208,6 +233,10 @@ function collectInserts(src, file) {
208
233
  const enterTemplate = (isHtml) => {
209
234
  i++; // the backtick
210
235
  const html = isHtml ? new HtmlState() : null;
236
+ if (html && templates) {
237
+ html.record = { strings: [''], line };
238
+ templates.push(html.record);
239
+ }
211
240
  const r = scanTemplateText(html);
212
241
  if (r === 'hole') { stack.push({ kind: 'tpl', html }); prev = 'punct'; }
213
242
  else prev = 'str';
@@ -320,6 +349,8 @@ function collectInserts(src, file) {
320
349
  */
321
350
  class HtmlState {
322
351
  constructor() {
352
+ /** @type {{ strings: string[], line: number } | null} the template's static strings, when collected */
353
+ this.record = null;
323
354
  /** @type {'text' | 'tag' | 'dq' | 'sq' | 'comment' | 'raw'} */
324
355
  this.mode = 'text';
325
356
  /** Tag name of the tag being read (lowercased), '' for a close / bang tag. */
package/src/dev.js CHANGED
@@ -4,7 +4,6 @@
4
4
  */
5
5
 
6
6
  export {
7
- readElideEnabled,
8
7
  readSeedEnabled,
9
8
  readClientRouterEnabled,
10
9
  readHeaderRules,
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Load an app's `.env` files the way Next.js does, so a Next project's
3
+ * `.env.local` / `.env.development` / `.env.production` keep working.
4
+ *
5
+ * Order, highest precedence first (Next's `@next/env`):
6
+ * 1. the real environment (`process.env`, never overwritten)
7
+ * 2. `.env.$mode.local`
8
+ * 3. `.env.local` (skipped when mode is `test`, so tests are reproducible)
9
+ * 4. `.env.$mode`
10
+ * 5. `.env`
11
+ * where `$mode` is `development` under `webjs dev`, `production` under
12
+ * `webjs start`, and `test` when `NODE_ENV=test`.
13
+ *
14
+ * Files are read with `readFileSync` + `util.parseEnv`, so loading behaves the
15
+ * same on Node and Bun (`process.loadEnvFile`, which this replaces, does not
16
+ * exist on Bun, so a Bun app never saw its `.env`). A key is assigned only
17
+ * when still unset, which yields the precedence above.
18
+ *
19
+ * Values are expanded the way `@next/env` does with dotenv-expand: `$NAME`,
20
+ * `${NAME}` and `${NAME:-default}` (also `${NAME-default}`) resolve against the
21
+ * real environment and the files already loaded, then the same file's keys,
22
+ * else empty (or the default); `\$` is a literal dollar, and a single-quoted
23
+ * value is left as written.
24
+ *
25
+ * The CLI keeps a byte-identical copy (`packages/cli/lib/env-files.js`) because
26
+ * it must load env before the server package is imported (the port, the
27
+ * `webjs.*` tasks); `test/cli/env-files-parity.test.mjs` keeps them in step.
28
+ *
29
+ * @module env-files
30
+ */
31
+ import { readFileSync } from 'node:fs';
32
+ import { join } from 'node:path';
33
+ import { parseEnv } from 'node:util';
34
+
35
+ /**
36
+ * @param {string} mode `development` | `production` | `test`
37
+ * @returns {string[]} file names, highest precedence first
38
+ */
39
+ export function envFileOrder(mode) {
40
+ return [`.env.${mode}.local`, ...(mode === 'test' ? [] : ['.env.local']), `.env.${mode}`, '.env'];
41
+ }
42
+
43
+ /**
44
+ * The env mode for a server run: `test` when NODE_ENV says so, else the run's
45
+ * own mode.
46
+ * @param {boolean} dev
47
+ * @returns {'development' | 'production' | 'test'}
48
+ */
49
+ export function envMode(dev) {
50
+ if (process.env.NODE_ENV === 'test') return 'test';
51
+ return dev ? 'development' : 'production';
52
+ }
53
+
54
+ /**
55
+ * Load the app's env files into `process.env` (unset keys only).
56
+ * @param {string} appDir
57
+ * @param {string} mode
58
+ * @param {NodeJS.ProcessEnv} [env]
59
+ * @returns {string[]} the files that were found and read
60
+ */
61
+ export function loadEnvFiles(appDir, mode, env = process.env) {
62
+ /** @type {string[]} */
63
+ const loaded = [];
64
+ for (const name of envFileOrder(mode)) {
65
+ let text;
66
+ try { text = readFileSync(join(appDir, name), 'utf8'); } catch { continue; }
67
+ let parsed;
68
+ try { parsed = parseEnv(text); } catch { continue; }
69
+ const literal = singleQuotedKeys(text);
70
+ /** @type {Record<string, string>} */
71
+ const done = {};
72
+ /** @type {Set<string>} */
73
+ const active = new Set();
74
+ /** @param {string} key @returns {string | undefined} undefined when unset */
75
+ const resolve = (key) => {
76
+ // Own keys only: ${toString} or $constructor names a variable, never an
77
+ // Object.prototype member, which would be a function here.
78
+ if (Object.hasOwn(env, key) && env[key] !== undefined) return env[key];
79
+ if (!Object.hasOwn(parsed, key)) return undefined;
80
+ if (Object.hasOwn(done, key)) return done[key];
81
+ if (active.has(key)) return ''; // a cycle expands to empty rather than looping
82
+ active.add(key);
83
+ const out = literal.has(key) ? parsed[key] : expandValue(parsed[key], resolve);
84
+ active.delete(key);
85
+ return (done[key] = out);
86
+ };
87
+ for (const k of Object.keys(parsed)) {
88
+ if (env[k] === undefined) env[k] = literal.has(k) ? parsed[k] : expandValue(parsed[k], resolve);
89
+ }
90
+ loaded.push(name);
91
+ }
92
+ return loaded;
93
+ }
94
+
95
+ /**
96
+ * Expand `$NAME`, `${NAME}`, `${NAME:-default}` and `${NAME-default}` in one
97
+ * value, with `\$` as a literal dollar (dotenv-expand's syntax). `:-` uses the
98
+ * default when NAME is unset or empty, `-` only when it is unset.
99
+ * @param {string} value
100
+ * @param {(key: string) => string | undefined} lookup undefined when unset
101
+ * @returns {string}
102
+ */
103
+ export function expandValue(value, lookup) {
104
+ return value.replace(/\\\$|\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:?-)([^}]*))?\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (m, braced, op, fallback, bare) => {
105
+ if (m === '\\$') return '$';
106
+ const v = lookup(braced || bare);
107
+ if (op === ':-') return v === undefined || v === '' ? fallback : v;
108
+ if (op === '-') return v === undefined ? fallback : v;
109
+ return v ?? '';
110
+ });
111
+ }
112
+
113
+ /**
114
+ * The keys whose value is single-quoted in the file text, which dotenv-expand
115
+ * leaves unexpanded (`util.parseEnv` strips the quotes, so read them here).
116
+ * @param {string} text
117
+ * @returns {Set<string>}
118
+ */
119
+ function singleQuotedKeys(text) {
120
+ /** @type {Set<string>} */
121
+ const keys = new Set();
122
+ for (const m of text.matchAll(/^[ \t]*(?:export[ \t]+)?([A-Za-z_][A-Za-z0-9_.-]*)[ \t]*=[ \t]*'/gm)) keys.add(m[1]);
123
+ return keys;
124
+ }
@@ -28,7 +28,7 @@
28
28
  * a limit; it only stays streaming.
29
29
  * - **Traversal-safe keys.** Every key is resolved to an absolute path under
30
30
  * `dir` and rejected if it escapes, using the same `resolve` +
31
- * `startsWith(dir + sep)` containment guard the `/public/*` serve path uses
31
+ * `startsWith(dir + sep)` containment guard the `public/` serve path uses
32
32
  * in dev.js. A key with `..`, an absolute path, a leading slash, a NUL, or a
33
33
  * backslash is rejected BEFORE any fs operation.
34
34
  * - **S3-pluggability.** The interface operates on web-standard objects only
@@ -117,7 +117,7 @@ const SAFE_EXTENSIONS = new Set([
117
117
  /**
118
118
  * Validate a storage key and reject anything that could escape the root or is
119
119
  * otherwise unsafe. Throws on a bad key BEFORE any filesystem operation; this is
120
- * the security-critical seam. Mirrors the `/public/*` containment guard
120
+ * the security-critical seam. Mirrors the `public/` containment guard
121
121
  * (`resolve` + `startsWith(root + sep)`) in dev.js.
122
122
  *
123
123
  * Rejected: an empty / non-string key, a key with a NUL byte, an absolute path,