@webjsdev/server 0.9.0 → 0.10.1
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/README.md +4 -1
- package/index.d.ts +793 -125
- package/index.js +8 -4
- package/package.json +3 -3
- package/src/action-flash.js +185 -0
- package/src/action-params.js +156 -0
- package/src/action-route.js +8 -9
- package/src/action-seed.js +44 -5
- package/src/actions.js +97 -15
- package/src/api.js +67 -10
- package/src/app-layout.js +133 -0
- package/src/asset-hash.js +82 -49
- package/src/auth.js +612 -108
- package/src/browser-entries.js +2 -17
- package/src/build-epoch.js +1 -1
- package/src/cache-tags.js +5 -1
- package/src/check/rules-actions.js +37 -1
- package/src/check/rules-components.js +114 -40
- package/src/check/rules-imports.js +175 -4
- package/src/check/rules-route-modules.js +200 -0
- package/src/check/rules-routes.js +55 -0
- package/src/check/rules-routing.js +143 -95
- package/src/check/rules-typescript.js +24 -2
- package/src/check/rules.js +104 -9
- package/src/check/runner-support.js +53 -171
- package/src/check/runner.js +22 -6
- package/src/client-signals.js +171 -0
- package/src/component-scanner.js +22 -16
- package/src/context.js +152 -16
- package/src/cookie-store.js +269 -0
- package/src/core-exports.js +190 -0
- package/src/csrf.js +21 -3
- package/src/dev/bun-app-source.js +95 -5
- package/src/dev/config.js +7 -51
- package/src/dev/dev-service-worker.js +48 -0
- package/src/dev/esm-hot-hooks.js +373 -0
- package/src/dev/handler.js +502 -155
- package/src/dev/helpers.js +127 -96
- package/src/dev/hot-host.js +33 -6
- package/src/dev/poke-state.js +110 -0
- package/src/dev/serve.js +177 -129
- package/src/dev/server.js +92 -33
- package/src/dev/specifier-versions.js +125 -0
- package/src/dev/watch-ignore.js +49 -4
- package/src/dev/watch-poll.js +78 -0
- package/src/dev/watch-tree.js +59 -22
- package/src/dev-classify.js +59 -26
- package/src/dev-error.js +104 -3
- package/src/dev-hot-classify.js +291 -0
- package/src/dev-hot-client.js +245 -0
- package/src/dev-import.js +22 -5
- package/src/dev-overlay.js +26 -0
- package/src/dev-ready-probe.js +69 -0
- package/src/dev-regenerate.js +2 -2
- package/src/dev-reload-hold.js +49 -20
- package/src/dev-reload-reason.js +61 -0
- package/src/dev-reload-worker.js +62 -13
- package/src/dev-secret.js +73 -0
- package/src/dev-source-locations-hook.js +4 -1
- package/src/dev-source-locations.js +33 -2
- package/src/dev.js +0 -1
- package/src/env-files.js +124 -0
- package/src/file-storage.js +2 -2
- package/src/foreign-imports.js +228 -0
- package/src/form-action-identity.js +52 -25
- package/src/form-dispatch.js +142 -66
- package/src/html-cache.js +35 -4
- package/src/importmap.js +2 -2
- package/src/instrumentation.js +59 -3
- package/src/js-scan.js +5 -4
- package/src/jwe.js +155 -0
- package/src/lazy-components.js +166 -0
- package/src/listener-core.js +4 -2
- package/src/lit-hints.js +172 -0
- package/src/middleware-load.js +248 -0
- package/src/module-graph.js +73 -47
- package/src/module-side-effect.js +284 -0
- package/src/public-files.js +73 -0
- package/src/redirect-location.js +19 -0
- package/src/redirects.js +9 -1
- package/src/route-frontier.js +151 -0
- package/src/route-report.js +130 -0
- package/src/router.js +120 -13
- package/src/session.js +32 -10
- package/src/ssr/client-router-flag.js +1 -1
- package/src/ssr/document.js +37 -13
- package/src/ssr/head.js +78 -23
- package/src/ssr/preloads.js +31 -45
- package/src/ssr/render.js +241 -55
- package/src/ssr/responses.js +51 -4
- package/src/ssr/shipped-modules.js +22 -16
- package/src/testing.d.ts +71 -2
- package/src/testing.js +103 -21
- package/src/thenable-params.js +26 -0
- package/src/vendor/jspm.js +12 -1
- package/src/vendor/pins.js +9 -8
- package/src/vendor/scanner.js +14 -9
- package/src/vendor.js +5 -3
- package/webjs-config.schema.json +1 -6
- package/src/component-elision.js +0 -1828
- package/src/elision-differential.js +0 -101
- package/src/elision-report.js +0 -193
package/src/dev-reload-worker.js
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
213
|
-
|
|
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
|
|
225
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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] === '{') {
|
|
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
package/src/env-files.js
ADDED
|
@@ -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
|
+
}
|
package/src/file-storage.js
CHANGED
|
@@ -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
|
|
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
|
|
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,
|