@webjsdev/server 0.8.85 → 0.8.87
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/package.json +1 -1
- package/src/action-middleware.js +2 -1
- package/src/action-seed-bun.js +2 -2
- package/src/action-seed.js +30 -24
- package/src/action-signal.js +2 -1
- package/src/broadcast.js +2 -1
- package/src/bun-transpiler-cache.js +64 -0
- package/src/cache.js +11 -5
- package/src/context.js +2 -1
- package/src/dev/bun-app-source.js +35 -2
- package/src/dev/handler.js +4 -7
- package/src/dev/serve.js +29 -1
- package/src/html-cache.js +9 -5
- package/src/instrumentation.js +2 -1
- package/src/process-shared.js +34 -0
- package/src/router.js +49 -1
- package/src/session.js +2 -1
- package/src/ssr/head.js +101 -29
- package/src/dev/bun-alias-resolve.js +0 -71
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/server",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.87",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The server for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Provides the file-based router, SSR, server actions, route handlers, middleware, and live reload on Node 24+ or Bun.",
|
|
6
6
|
"main": "index.js",
|
package/src/action-middleware.js
CHANGED
|
@@ -22,9 +22,10 @@
|
|
|
22
22
|
* The framework ships no middleware; it only runs the chain. Server-only.
|
|
23
23
|
*/
|
|
24
24
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
25
|
+
import { shared } from './process-shared.js';
|
|
25
26
|
|
|
26
27
|
/** Per-request accumulated context, readable by the action via actionContext(). */
|
|
27
|
-
const als = new AsyncLocalStorage();
|
|
28
|
+
const als = shared('action-middleware-als', () => new AsyncLocalStorage());
|
|
28
29
|
|
|
29
30
|
/** Shared empty context returned outside an action (a direct server-to-server call). */
|
|
30
31
|
const EMPTY = Object.freeze({});
|
package/src/action-seed-bun.js
CHANGED
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
* serverFileRe: RegExp,
|
|
37
37
|
* }} helpers
|
|
38
38
|
*/
|
|
39
|
-
export function installBunSeedPlugin({ isSeedCandidate, buildSeedFacade, serverFileRe }) {
|
|
39
|
+
export function installBunSeedPlugin({ isSeedCandidate, buildSeedFacade, serverFileRe, rewriteAliases = (s) => s }) {
|
|
40
40
|
Bun.plugin({
|
|
41
41
|
name: 'webjs-action-seed',
|
|
42
42
|
setup(build) {
|
|
@@ -60,7 +60,7 @@ export function installBunSeedPlugin({ isSeedCandidate, buildSeedFacade, serverF
|
|
|
60
60
|
// Fail-open: any faceting error serves the raw source (no seeding for
|
|
61
61
|
// this module), the Bun analog of the Node hook's `nextLoad` fallback.
|
|
62
62
|
}
|
|
63
|
-
return { contents: src, loader };
|
|
63
|
+
return { contents: rewriteAliases(src, absPath), loader };
|
|
64
64
|
});
|
|
65
65
|
},
|
|
66
66
|
});
|
package/src/action-seed.js
CHANGED
|
@@ -69,18 +69,21 @@ import { hashFile } from './actions.js';
|
|
|
69
69
|
import { isStreamable } from './action-stream.js';
|
|
70
70
|
import { serverRuntime } from './listener-core.js';
|
|
71
71
|
import { redactStringsAndTemplates } from './js-scan.js';
|
|
72
|
+
import { shared } from './process-shared.js';
|
|
72
73
|
|
|
73
74
|
/** Ambient per-render seed collector. `Map<key, value>` or undefined. */
|
|
74
|
-
const als = new AsyncLocalStorage();
|
|
75
|
+
const als = shared('action-seed-als', () => new AsyncLocalStorage());
|
|
75
76
|
|
|
76
|
-
/**
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
77
|
+
/**
|
|
78
|
+
* The hook's switches, shared by every copy of this module in the process
|
|
79
|
+
* (#1590): under `bun --hot` an app's `'use server'` facade imports a fresh copy
|
|
80
|
+
* after a reload, and it must see the switches the running server set.
|
|
81
|
+
* seedEnabled: whether seed COLLECTION is on (the `webjs.seed` switch).
|
|
82
|
+
* devMode: dev mode, threaded from `dev.js` at boot; gates the determinism assertion.
|
|
83
|
+
* hookInstalled: whether the load hook is installed at all (so identity is available).
|
|
84
|
+
* registered: idempotency guard, `module.registerHooks` must run at most once.
|
|
85
|
+
*/
|
|
86
|
+
const _flags = shared('action-seed-flags', () => ({ seedEnabled: false, devMode: false, hookInstalled: false, registered: false }));
|
|
84
87
|
|
|
85
88
|
/**
|
|
86
89
|
* `action function -> { file, fnName }` for every export the hook wrapped.
|
|
@@ -103,10 +106,10 @@ let _registered = false;
|
|
|
103
106
|
*
|
|
104
107
|
* @type {WeakMap<Function, { file: string, fnName: string }[]>}
|
|
105
108
|
*/
|
|
106
|
-
const _identity = new WeakMap();
|
|
109
|
+
const _identity = shared('action-identity', () => new WeakMap());
|
|
107
110
|
|
|
108
111
|
/** Memoized `absPath -> hash` so a hot action call does not re-hash per call. */
|
|
109
|
-
const _hashCache = new Map();
|
|
112
|
+
const _hashCache = shared('action-hash-cache', () => new Map());
|
|
110
113
|
|
|
111
114
|
/** This module's own URL, embedded into the generated facade's import. */
|
|
112
115
|
const SELF_URL = import.meta.url;
|
|
@@ -118,7 +121,7 @@ const SELF_URL = import.meta.url;
|
|
|
118
121
|
* @returns {boolean}
|
|
119
122
|
*/
|
|
120
123
|
export function seedingEnabled() {
|
|
121
|
-
return
|
|
124
|
+
return _flags.seedEnabled && _flags.hookInstalled;
|
|
122
125
|
}
|
|
123
126
|
|
|
124
127
|
/**
|
|
@@ -128,7 +131,7 @@ export function seedingEnabled() {
|
|
|
128
131
|
* @returns {boolean}
|
|
129
132
|
*/
|
|
130
133
|
export function identityHookInstalled() {
|
|
131
|
-
return
|
|
134
|
+
return _flags.hookInstalled;
|
|
132
135
|
}
|
|
133
136
|
|
|
134
137
|
/**
|
|
@@ -204,7 +207,7 @@ async function recordSeed(collector, file, fnName, args, value) {
|
|
|
204
207
|
// legitimate second call with different arguments has a different key and
|
|
205
208
|
// cannot false-fire. In its OWN try/catch so a diagnostic failure can never
|
|
206
209
|
// skip `collector.set` and turn observability into a dropped seed.
|
|
207
|
-
if (
|
|
210
|
+
if (_flags.devMode && collector.has(key)) {
|
|
208
211
|
try { await assertDeterministic(collector.get(key), value, hash, fnName); } catch { /* never affect the seed */ }
|
|
209
212
|
}
|
|
210
213
|
collector.set(key, value);
|
|
@@ -214,7 +217,7 @@ async function recordSeed(collector, file, fnName, args, value) {
|
|
|
214
217
|
}
|
|
215
218
|
|
|
216
219
|
/** Warned-once ids, keyed `hash/fn` so the Set is bounded by the action count. */
|
|
217
|
-
const _nonDeterministic = new Set();
|
|
220
|
+
const _nonDeterministic = shared('action-seed-nondeterministic', () => new Set());
|
|
218
221
|
|
|
219
222
|
/**
|
|
220
223
|
* Warn (once per action function) when one render recorded two DIFFERENT results
|
|
@@ -276,7 +279,7 @@ export function __actionWrap(file, fnName, orig) {
|
|
|
276
279
|
const list = _identity.get(orig) || [];
|
|
277
280
|
if (!list.some((e) => e.file === file && e.fnName === fnName)) list.push({ file, fnName });
|
|
278
281
|
_identity.set(orig, list);
|
|
279
|
-
if (!
|
|
282
|
+
if (!_flags.seedEnabled) return orig;
|
|
280
283
|
const wrapped = seedProxy(file, fnName, orig);
|
|
281
284
|
// The wrapper is a fresh object every time, so it shares the TARGET's list
|
|
282
285
|
// rather than starting one of its own.
|
|
@@ -644,10 +647,10 @@ function seedLoadHook(url, context, nextLoad) {
|
|
|
644
647
|
* @returns {Promise<void>}
|
|
645
648
|
*/
|
|
646
649
|
export async function registerActionHooks(opts = {}) {
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
if (
|
|
650
|
-
|
|
650
|
+
_flags.seedEnabled = opts.seed !== false;
|
|
651
|
+
_flags.devMode = opts.dev === true;
|
|
652
|
+
if (_flags.registered) return;
|
|
653
|
+
_flags.registered = true;
|
|
651
654
|
|
|
652
655
|
if (serverRuntime() === 'bun') {
|
|
653
656
|
// Once per PROCESS, not per module instance (#1575): `bun --hot` re-runs
|
|
@@ -656,12 +659,15 @@ export async function registerActionHooks(opts = {}) {
|
|
|
656
659
|
// edit (the first one registered keeps answering anyway).
|
|
657
660
|
const g = /** @type {any} */ (globalThis);
|
|
658
661
|
const installed = Symbol.for('webjs.actionSeed.bunPlugin');
|
|
659
|
-
if (g[installed]) {
|
|
662
|
+
if (g[installed]) { _flags.hookInstalled = true; return; }
|
|
660
663
|
g[installed] = true;
|
|
661
664
|
// Bun has no module.registerHooks; install the same facade via Bun.plugin.
|
|
662
665
|
const { installBunSeedPlugin } = await import('./action-seed-bun.js');
|
|
663
|
-
|
|
664
|
-
|
|
666
|
+
// Dev rewrites the app's `#` imports to absolute paths (#1575); the
|
|
667
|
+
// rewrite reads the app roots dev registered, so it is a no-op in prod.
|
|
668
|
+
const { rewriteAppAliases } = await import('./dev/bun-app-source.js');
|
|
669
|
+
installBunSeedPlugin({ isSeedCandidate, buildSeedFacade, serverFileRe: SERVER_FILE_RE, rewriteAliases: rewriteAppAliases });
|
|
670
|
+
_flags.hookInstalled = true;
|
|
665
671
|
return;
|
|
666
672
|
}
|
|
667
673
|
|
|
@@ -676,7 +682,7 @@ export async function registerActionHooks(opts = {}) {
|
|
|
676
682
|
}
|
|
677
683
|
|
|
678
684
|
nodeModule.registerHooks({ load: seedLoadHook });
|
|
679
|
-
|
|
685
|
+
_flags.hookInstalled = true;
|
|
680
686
|
}
|
|
681
687
|
|
|
682
688
|
/**
|
package/src/action-signal.js
CHANGED
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
* `fetch(url, { signal: actionSignal() })` line is safe everywhere.
|
|
10
10
|
*/
|
|
11
11
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
12
|
+
import { shared } from './process-shared.js';
|
|
12
13
|
|
|
13
|
-
const als = new AsyncLocalStorage();
|
|
14
|
+
const als = shared('action-signal-als', () => new AsyncLocalStorage());
|
|
14
15
|
|
|
15
16
|
/** A shared signal that never aborts, returned outside an action. */
|
|
16
17
|
const NEVER = (() => {
|
package/src/broadcast.js
CHANGED
|
@@ -17,12 +17,13 @@
|
|
|
17
17
|
*
|
|
18
18
|
* @module broadcast
|
|
19
19
|
*/
|
|
20
|
+
import { shared } from './process-shared.js';
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Per-path WebSocket client registry.
|
|
23
24
|
* @type {Map<string, Set<import('ws').WebSocket>>}
|
|
24
25
|
*/
|
|
25
|
-
const pathClients = new Map();
|
|
26
|
+
const pathClients = shared('broadcast-clients', () => new Map());
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* Register a WebSocket client for a path. Called internally by the
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evict Bun transpiler-cache entries written by @webjsdev/server 0.8.85 and
|
|
3
|
+
* 0.8.86 (#1575).
|
|
4
|
+
*
|
|
5
|
+
* Those versions resolved the app's `#` imports through a Bun runtime
|
|
6
|
+
* `onResolve` in dev. Bun caches the transpile of every source over 50KB on
|
|
7
|
+
* disk, keyed by the file's CONTENT, with the resolved import paths baked in,
|
|
8
|
+
* so a large app module kept a broken `file:/...` path (or an absolute path
|
|
9
|
+
* into whichever checkout first ran it) after the fix: same bytes, same key,
|
|
10
|
+
* same poisoned output, a 500 on every request that reaches it. Nothing in a
|
|
11
|
+
* fixed version writes such an entry any more, but the old entries outlive the
|
|
12
|
+
* upgrade.
|
|
13
|
+
*
|
|
14
|
+
* The cache is only a cache, so the cure is to empty it once per machine: the
|
|
15
|
+
* first WebJs server on Bun at or after this version removes the entries and
|
|
16
|
+
* leaves a marker beside them, and every later start sees the marker and does
|
|
17
|
+
* nothing. Bun rebuilds what it needs on the next load.
|
|
18
|
+
*
|
|
19
|
+
* @module bun-transpiler-cache
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { homedir } from 'node:os';
|
|
23
|
+
import { dirname, join } from 'node:path';
|
|
24
|
+
|
|
25
|
+
const MARKER = 'webjs-1575-evicted';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The directory Bun keeps its runtime transpiler cache in, or null when the
|
|
29
|
+
* cache is off (`BUN_RUNTIME_TRANSPILER_CACHE_PATH` set to `0` or empty).
|
|
30
|
+
* @param {NodeJS.ProcessEnv} env
|
|
31
|
+
* @param {string} home
|
|
32
|
+
* @returns {string | null}
|
|
33
|
+
*/
|
|
34
|
+
export function bunTranspilerCacheDir(env = process.env, home = homedir()) {
|
|
35
|
+
const set = env.BUN_RUNTIME_TRANSPILER_CACHE_PATH;
|
|
36
|
+
if (set !== undefined) return set === '0' || set === '' ? null : set;
|
|
37
|
+
return join(env.XDG_CACHE_HOME || join(home, '.cache'), 'bun', '@t@');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Empty the Bun transpiler cache once per machine (see the module comment).
|
|
42
|
+
* Never throws: a cache it cannot read or write is left alone.
|
|
43
|
+
* @param {{ env?: NodeJS.ProcessEnv, home?: string, isBun?: boolean }} [opts]
|
|
44
|
+
* @returns {number} entries removed (0 when there was nothing to do)
|
|
45
|
+
*/
|
|
46
|
+
export function evictPoisonedBunTranspilerCache({ env = process.env, home = homedir(), isBun = !!process.versions.bun } = {}) {
|
|
47
|
+
if (!isBun) return 0;
|
|
48
|
+
const dir = bunTranspilerCacheDir(env, home);
|
|
49
|
+
if (!dir) return 0;
|
|
50
|
+
const marker = join(dirname(dir), MARKER);
|
|
51
|
+
try {
|
|
52
|
+
if (existsSync(marker)) return 0;
|
|
53
|
+
let removed = 0;
|
|
54
|
+
if (existsSync(dir)) {
|
|
55
|
+
for (const name of readdirSync(dir)) {
|
|
56
|
+
try { rmSync(join(dir, name), { recursive: true, force: true }); removed++; } catch { /* in use; leave it */ }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
writeFileSync(marker, `emptied by @webjsdev/server at ${new Date().toISOString()} (#1575)\n`);
|
|
60
|
+
return removed;
|
|
61
|
+
} catch {
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
}
|
package/src/cache.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*
|
|
15
15
|
* @module cache
|
|
16
16
|
*/
|
|
17
|
+
import { shared } from './process-shared.js';
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* @typedef {Object} CacheStore
|
|
@@ -228,8 +229,13 @@ export function redisStore(opts = {}) {
|
|
|
228
229
|
};
|
|
229
230
|
}
|
|
230
231
|
|
|
231
|
-
/**
|
|
232
|
-
|
|
232
|
+
/**
|
|
233
|
+
* The default store lives in process-shared state (#1590), so an app module
|
|
234
|
+
* that imported a fresh copy of this package after a `bun --hot` reload reads
|
|
235
|
+
* and writes the same cache as the server.
|
|
236
|
+
* @type {{ store: CacheStore | null }}
|
|
237
|
+
*/
|
|
238
|
+
const _defaultStore = shared('cache-default-store', () => ({ store: null }));
|
|
233
239
|
|
|
234
240
|
/**
|
|
235
241
|
* Get the default cache store. Memory store unless explicitly set via
|
|
@@ -238,8 +244,8 @@ let _defaultStore = null;
|
|
|
238
244
|
* @returns {CacheStore}
|
|
239
245
|
*/
|
|
240
246
|
export function getStore() {
|
|
241
|
-
if (!_defaultStore) _defaultStore = memoryStore();
|
|
242
|
-
return _defaultStore;
|
|
247
|
+
if (!_defaultStore.store) _defaultStore.store = memoryStore();
|
|
248
|
+
return _defaultStore.store;
|
|
243
249
|
}
|
|
244
250
|
|
|
245
251
|
/**
|
|
@@ -253,5 +259,5 @@ export function getStore() {
|
|
|
253
259
|
* @param {CacheStore} store
|
|
254
260
|
*/
|
|
255
261
|
export function setStore(store) {
|
|
256
|
-
_defaultStore = store;
|
|
262
|
+
_defaultStore.store = store;
|
|
257
263
|
}
|
package/src/context.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
2
|
import { parseCookies } from './csrf.js';
|
|
3
3
|
import { setCspNonceProvider, cspNonce } from '@webjsdev/core';
|
|
4
|
+
import { shared } from './process-shared.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Per-request context backed by AsyncLocalStorage. Lets server-side code
|
|
@@ -39,7 +40,7 @@ import { setCspNonceProvider, cspNonce } from '@webjsdev/core';
|
|
|
39
40
|
*/
|
|
40
41
|
|
|
41
42
|
/** @type {AsyncLocalStorage<Store>} */
|
|
42
|
-
const als = new AsyncLocalStorage();
|
|
43
|
+
const als = shared('request-als', () => new AsyncLocalStorage());
|
|
43
44
|
|
|
44
45
|
/**
|
|
45
46
|
* Run `fn` with the given request bound as the current context.
|
|
@@ -22,7 +22,9 @@
|
|
|
22
22
|
*
|
|
23
23
|
* @module dev/bun-app-source
|
|
24
24
|
*/
|
|
25
|
-
import { readFileSync } from 'node:fs';
|
|
25
|
+
import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
26
|
+
import { resolve as resolvePath } from 'node:path';
|
|
27
|
+
import { expandImportAlias } from '../module-graph.js';
|
|
26
28
|
|
|
27
29
|
const ROOTS = Symbol.for('webjs.dev.bunAppSourceRoots');
|
|
28
30
|
|
|
@@ -59,8 +61,39 @@ export function registerBunAppSource(appDir) {
|
|
|
59
61
|
const abs = args.path.split('?')[0];
|
|
60
62
|
// A read failure (the file is gone) propagates, which Bun reports as
|
|
61
63
|
// the load error it would have raised itself.
|
|
62
|
-
return { contents: readFileSync(abs, 'utf8'), loader: /\.m?ts$/.test(abs) ? 'ts' : 'js' };
|
|
64
|
+
return { contents: rewriteAppAliases(readFileSync(abs, 'utf8'), abs), loader: /\.m?ts$/.test(abs) ? 'ts' : 'js' };
|
|
63
65
|
});
|
|
64
66
|
},
|
|
65
67
|
});
|
|
66
68
|
}
|
|
69
|
+
|
|
70
|
+
/** Static `import`/`export … from '#x'` and `import('#x')` with a literal specifier. */
|
|
71
|
+
const ALIAS_SPEC_RE = /(\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)(['"])(#[^'"\s]+)\2/g;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Rewrite the app's `#` import specifiers in a module's source to the absolute
|
|
75
|
+
* path they name (#1575), when that file exists. Bun's resolver serves a `#`
|
|
76
|
+
* import of a file created after it listed the directory as "Cannot find
|
|
77
|
+
* module" under `bun --hot`, and a runtime `onResolve` cannot stand in for it:
|
|
78
|
+
* its result reaches a dynamic `import()` as a broken `file:/...` path (the
|
|
79
|
+
* 0.8.85 / 0.8.86 regression). An absolute path resolves through neither.
|
|
80
|
+
* Anything that is not an app alias, or names no file yet, is left as written,
|
|
81
|
+
* so Bun reports a missing module exactly as before.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} src
|
|
84
|
+
* @param {string} file absolute path of the module
|
|
85
|
+
* @returns {string}
|
|
86
|
+
*/
|
|
87
|
+
export function rewriteAppAliases(src, file) {
|
|
88
|
+
if (!src.includes('#')) return src;
|
|
89
|
+
const g = /** @type {any} */ (globalThis);
|
|
90
|
+
const roots = g[ROOTS] instanceof Set ? [...g[ROOTS]] : [];
|
|
91
|
+
const appDir = roots.find((r) => file.startsWith(r + '/'));
|
|
92
|
+
if (!appDir || file.includes('/node_modules/')) return src;
|
|
93
|
+
return src.replace(ALIAS_SPEC_RE, (m, lead, q, spec) => {
|
|
94
|
+
const rel = expandImportAlias(spec, appDir);
|
|
95
|
+
if (!rel) return m;
|
|
96
|
+
const abs = resolvePath(appDir, rel);
|
|
97
|
+
try { return existsSync(abs) && statSync(abs).isFile() ? `${lead}${q}${abs}${q}` : m; } catch { return m; }
|
|
98
|
+
});
|
|
99
|
+
}
|
package/src/dev/handler.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
3
|
-
import { registerBunAliasResolver } from './bun-alias-resolve.js';
|
|
4
3
|
import { registerBunAppSource } from './bun-app-source.js';
|
|
4
|
+
import { evictPoisonedBunTranspilerCache } from '../bun-transpiler-cache.js';
|
|
5
5
|
import { serverRuntime } from '../listener-core.js';
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
7
7
|
import { join, relative, resolve, sep } from 'node:path';
|
|
@@ -135,6 +135,9 @@ export async function createRequestHandler(opts) {
|
|
|
135
135
|
// on Bun. Doing it here pays the (one-time) amaro import up front and surfaces
|
|
136
136
|
// a missing-amaro error at boot rather than on the first `.ts` request.
|
|
137
137
|
await ensureStripper();
|
|
138
|
+
// Old (0.8.85 / 0.8.86) Bun transpiler-cache entries carry broken import
|
|
139
|
+
// paths keyed by unchanged file content; empty the cache once (#1575).
|
|
140
|
+
evictPoisonedBunTranspilerCache();
|
|
138
141
|
const appDir = resolve(opts.appDir);
|
|
139
142
|
// Load <appDir>/.env into process.env BEFORE anything else.
|
|
140
143
|
// buildActionIndex below imports server-only files (lib/*.server.ts,
|
|
@@ -300,12 +303,6 @@ export async function createRequestHandler(opts) {
|
|
|
300
303
|
// what a bound `<form action=${action}>` resolves through, so gating the hook
|
|
301
304
|
// on seeding would mean `webjs.seed: false` silently broke every no-JS form.
|
|
302
305
|
await registerActionHooks({ seed: await readSeedEnabled(appDir), dev });
|
|
303
|
-
// Bun's resolver keeps a stale directory listing for a `#` import under
|
|
304
|
-
// `bun --hot` (#1575); resolve the app's aliases ourselves in dev.
|
|
305
|
-
if (dev && serverRuntime() === 'bun') {
|
|
306
|
-
registerBunAliasResolver(appDir);
|
|
307
|
-
try { const real = realpathSync(appDir); if (real !== appDir) registerBunAliasResolver(real); } catch { /* appDir only */ }
|
|
308
|
-
}
|
|
309
306
|
|
|
310
307
|
// Dev source locations (#1499): `WEBJS_SOURCE_LOCATIONS=1` (or
|
|
311
308
|
// `webjs.dev.sourceLocations: true`) under `webjs dev`
|
package/src/dev/serve.js
CHANGED
|
@@ -361,7 +361,15 @@ export async function handleCore(req, ctx) {
|
|
|
361
361
|
|
|
362
362
|
// Metadata routes: /sitemap.xml, /robots.txt, /icon, /opengraph-image, etc.
|
|
363
363
|
if (method === 'GET' && state.routeTable.metadataRoutes) {
|
|
364
|
-
const meta = state.routeTable.metadataRoutes.find((r) => r.urlPath === path)
|
|
364
|
+
const meta = state.routeTable.metadataRoutes.find((r) => r.urlPath === path)
|
|
365
|
+
|| (path === '/favicon.ico' ? faviconFallback(state.routeTable.metadataRoutes) : undefined);
|
|
366
|
+
if (meta && meta.static) {
|
|
367
|
+
// A static metadata file (`app/icon.svg`, `app/manifest.webmanifest`):
|
|
368
|
+
// the file's bytes with the convention's content type.
|
|
369
|
+
const res = await fileResponse(meta.file, { dev, immutable: versioned });
|
|
370
|
+
if (res.ok && meta.contentType) res.headers.set('content-type', meta.contentType);
|
|
371
|
+
return res;
|
|
372
|
+
}
|
|
365
373
|
if (meta) {
|
|
366
374
|
try {
|
|
367
375
|
// The route gets `{ request, url, siteUrl, pages }` and may return a
|
|
@@ -633,6 +641,26 @@ export async function loadMiddleware(appDir, dev, logger) {
|
|
|
633
641
|
* @param {string} abs
|
|
634
642
|
* @param {{ dev: boolean, immutable: boolean }} opts
|
|
635
643
|
*/
|
|
644
|
+
/**
|
|
645
|
+
* What answers `/favicon.ico` when neither `public/favicon.ico` nor
|
|
646
|
+
* `app/favicon.ico` exists: the app's own icon. Browsers and crawlers request
|
|
647
|
+
* `/favicon.ico` without reading the page's <head> (a feed reader, a bookmark
|
|
648
|
+
* import, a page served without HTML), so an app with only `app/icon.svg`
|
|
649
|
+
* would otherwise answer them with a 404. An `.ico` or raster file is
|
|
650
|
+
* preferred over SVG, which some of those clients cannot read; the dynamic
|
|
651
|
+
* `app/icon.ts` route is the last resort.
|
|
652
|
+
*
|
|
653
|
+
* @param {Array<{ stem: string, urlPath: string, static?: boolean, contentType?: string }>} routes
|
|
654
|
+
*/
|
|
655
|
+
export function faviconFallback(routes) {
|
|
656
|
+
const icons = routes.filter((r) => r.stem === 'icon' && (r.static || r.urlPath === '/icon'));
|
|
657
|
+
const rank = (/** @type {typeof icons[number]} */ r) => !r.static ? 9
|
|
658
|
+
: r.contentType === 'image/x-icon' ? 0
|
|
659
|
+
: r.contentType === 'image/png' ? 1
|
|
660
|
+
: r.contentType === 'image/svg+xml' ? 3 : 2;
|
|
661
|
+
return icons.sort((a, b) => rank(a) - rank(b))[0];
|
|
662
|
+
}
|
|
663
|
+
|
|
636
664
|
export async function fileResponse(abs, opts) {
|
|
637
665
|
try {
|
|
638
666
|
let data = await readFile(abs);
|
package/src/html-cache.js
CHANGED
|
@@ -30,6 +30,7 @@ import { createHash } from 'node:crypto';
|
|
|
30
30
|
import { publishedBuildId, basePath } from './importmap.js';
|
|
31
31
|
import { stripBasePath } from './base-path.js';
|
|
32
32
|
import { dynamicAccessed, getRequest } from './context.js';
|
|
33
|
+
import { shared } from './process-shared.js';
|
|
33
34
|
|
|
34
35
|
/** Namespace prefix for every cached-HTML key, so a flush can target it. */
|
|
35
36
|
const KEY_PREFIX = 'webjs:html:';
|
|
@@ -51,9 +52,12 @@ export const HTML_CACHE_MARKER = 'x-webjs-html-cache';
|
|
|
51
52
|
* clear cannot enumerate keys), and the store TTL eventually reclaims the
|
|
52
53
|
* orphaned entries. A single process clears its own memory store this way
|
|
53
54
|
* synchronously; a Redis-backed multi-process deploy bumps per process and
|
|
54
|
-
* leans on the TTL for the rest (a best-effort global flush).
|
|
55
|
+
* leans on the TTL for the rest (a best-effort global flush). Held in
|
|
56
|
+
* process-shared state (#1590), so a `revalidateAll()` from an app module that
|
|
57
|
+
* imported a fresh copy of this package after a `bun --hot` reload still
|
|
58
|
+
* reaches the server's keys.
|
|
55
59
|
*/
|
|
56
|
-
|
|
60
|
+
const _generation = shared('html-cache-generation', () => ({ n: 0 }));
|
|
57
61
|
|
|
58
62
|
/**
|
|
59
63
|
* A fingerprint of the served app source, folded into every cache key (#318).
|
|
@@ -170,7 +174,7 @@ export function htmlCacheKey(url) {
|
|
|
170
174
|
// the importmap-only build id misses. Empty (dev / no fingerprint) collapses
|
|
171
175
|
// to the prior key shape, so an unconfigured app is byte-identical.
|
|
172
176
|
const appfp = _appSourceFp ? `${_appSourceFp}:` : '';
|
|
173
|
-
return `${KEY_PREFIX}${build}:${appfp}${_generation}:${originKey(url)}:${url.pathname}${search}`;
|
|
177
|
+
return `${KEY_PREFIX}${build}:${appfp}${_generation.n}:${originKey(url)}:${url.pathname}${search}`;
|
|
174
178
|
}
|
|
175
179
|
|
|
176
180
|
/**
|
|
@@ -417,10 +421,10 @@ function warnDynamicRevalidateOnce(pathname) {
|
|
|
417
421
|
|
|
418
422
|
/** Evict ALL cached HTML for this process. @returns {void} */
|
|
419
423
|
export function revalidateAll() {
|
|
420
|
-
_generation++;
|
|
424
|
+
_generation.n++;
|
|
421
425
|
}
|
|
422
426
|
|
|
423
427
|
/** Internal: current generation, folded into keys by `htmlCacheKey`. */
|
|
424
428
|
export function htmlCacheGeneration() {
|
|
425
|
-
return _generation;
|
|
429
|
+
return _generation.n;
|
|
426
430
|
}
|
package/src/instrumentation.js
CHANGED
|
@@ -19,6 +19,7 @@ import { join } from 'node:path';
|
|
|
19
19
|
import { stat } from 'node:fs/promises';
|
|
20
20
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
21
21
|
import { devImport } from './dev-import.js';
|
|
22
|
+
import { shared } from './process-shared.js';
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* Per-boot handoff channel for a `register()`-registered error sink. An
|
|
@@ -29,7 +30,7 @@ import { devImport } from './dev-import.js';
|
|
|
29
30
|
* context propagates across the `await register()` awaits.
|
|
30
31
|
* @type {AsyncLocalStorage<{ onError: ((error: unknown, ctx?: any) => void) | null }>}
|
|
31
32
|
*/
|
|
32
|
-
const _als = new AsyncLocalStorage();
|
|
33
|
+
const _als = shared('instrumentation-als', () => new AsyncLocalStorage());
|
|
33
34
|
|
|
34
35
|
/**
|
|
35
36
|
* Register an error sink from inside `instrumentation.register()`. Composes with
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-wide state for the parts of `@webjsdev/server` an app calls into.
|
|
3
|
+
*
|
|
4
|
+
* Under `bun --hot` a dev reload gives the app a fresh module registry while
|
|
5
|
+
* the first run's server keeps running (`dev/hot-host.js`, #1575). An app
|
|
6
|
+
* module that imports `@webjsdev/server` after a reload therefore gets a NEW
|
|
7
|
+
* copy of this package, whose module variables start empty, while the server
|
|
8
|
+
* that handles the request is still the first copy. Anything the two must
|
|
9
|
+
* agree on has to live outside the module: a request bound with the first
|
|
10
|
+
* copy's `AsyncLocalStorage` is invisible to the second copy's `getRequest()`,
|
|
11
|
+
* so `auth()`, `cookies()` and `headers()` inside a page or a `'use server'`
|
|
12
|
+
* function saw no request after the first edit and a signed-in page rendered
|
|
13
|
+
* as signed out (#1590).
|
|
14
|
+
*
|
|
15
|
+
* `shared(name, make)` returns the one value for `name` in this process,
|
|
16
|
+
* creating it on first use, keyed by `Symbol.for` exactly as core keys its own
|
|
17
|
+
* shared state. Use it for request-scoped storage and for any registry or
|
|
18
|
+
* store an app reads or writes through the package's API.
|
|
19
|
+
*
|
|
20
|
+
* @module process-shared
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @template T
|
|
25
|
+
* @param {string} name
|
|
26
|
+
* @param {() => T} make
|
|
27
|
+
* @returns {T}
|
|
28
|
+
*/
|
|
29
|
+
export function shared(name, make) {
|
|
30
|
+
const key = Symbol.for(`webjs.server.${name}`);
|
|
31
|
+
const g = /** @type {any} */ (globalThis);
|
|
32
|
+
if (!(key in g)) g[key] = make();
|
|
33
|
+
return g[key];
|
|
34
|
+
}
|
package/src/router.js
CHANGED
|
@@ -26,7 +26,7 @@ import { findInstrumentationClient } from './instrumentation.js';
|
|
|
26
26
|
* middlewares: string[],
|
|
27
27
|
* }} ApiRoute
|
|
28
28
|
*
|
|
29
|
-
* @typedef {{ stem: string, file: string, urlPath: string }} MetadataRoute
|
|
29
|
+
* @typedef {{ stem: string, file: string, urlPath: string, static?: boolean, contentType?: string }} MetadataRoute
|
|
30
30
|
*
|
|
31
31
|
* @typedef {{
|
|
32
32
|
* pages: PageRoute[],
|
|
@@ -60,6 +60,10 @@ import { findInstrumentationClient } from './instrumentation.js';
|
|
|
60
60
|
* app/sitemap.js → serves /sitemap.xml
|
|
61
61
|
* app/robots.js → serves /robots.txt
|
|
62
62
|
* app/icon.js → serves /icon (dynamic)
|
|
63
|
+
* app/icon.svg | icon.png → serves /icon.svg | /icon.png (static file)
|
|
64
|
+
* app/apple-icon.png → serves /apple-icon.png (static file)
|
|
65
|
+
* app/favicon.ico → serves /favicon.ico (static file)
|
|
66
|
+
* app/manifest.webmanifest → serves /manifest.webmanifest (static file)
|
|
63
67
|
* app/opengraph-image.js → serves /opengraph-image (dynamic)
|
|
64
68
|
*
|
|
65
69
|
* @param {string} appDir
|
|
@@ -114,6 +118,19 @@ export async function buildRouteTable(appDir) {
|
|
|
114
118
|
// Private folders (any segment starting with _) are excluded from routing.
|
|
115
119
|
if (dir !== '.' && dir.split('/').some((s) => s.startsWith('_'))) continue;
|
|
116
120
|
|
|
121
|
+
// Static metadata FILES at the app root (`app/icon.svg`, `app/icon.png`,
|
|
122
|
+
// `app/apple-icon.png`, `app/favicon.ico`, `app/manifest.webmanifest`):
|
|
123
|
+
// served as is at `/<file name>` and auto-linked into <head> like their
|
|
124
|
+
// `.ts` counterparts. Root only: an icon or manifest describes the whole
|
|
125
|
+
// app, and a nested copy has no meaning a browser would act on.
|
|
126
|
+
if (dir === '.') {
|
|
127
|
+
const staticMeta = staticMetadataFile(base);
|
|
128
|
+
if (staticMeta) {
|
|
129
|
+
metadataRoutes.push({ ...staticMeta, file, urlPath: '/' + base, static: true });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
117
134
|
// Match `<name>.<js|mjs|ts|mts>` conventions. Stem is the name without ext.
|
|
118
135
|
const stem = stemOf(base);
|
|
119
136
|
if (!stem) continue;
|
|
@@ -215,6 +232,37 @@ export async function buildRouteTable(appDir) {
|
|
|
215
232
|
* @param {string} base
|
|
216
233
|
* @returns {string | null}
|
|
217
234
|
*/
|
|
235
|
+
/**
|
|
236
|
+
* The static metadata file conventions, keyed by file name pattern. Each maps
|
|
237
|
+
* to the metadata stem it stands for and the content type it is served with.
|
|
238
|
+
* @type {Array<[RegExp, string, Record<string, string>]>}
|
|
239
|
+
*/
|
|
240
|
+
const STATIC_METADATA_FILES = [
|
|
241
|
+
[/^icon\.(svg|png|ico|jpg|jpeg|webp|gif)$/, 'icon', {}],
|
|
242
|
+
[/^apple-icon\.(png|jpg|jpeg)$/, 'apple-icon', {}],
|
|
243
|
+
[/^favicon\.(ico)$/, 'favicon', {}],
|
|
244
|
+
[/^manifest\.(json|webmanifest)$/, 'manifest', { json: 'application/manifest+json', webmanifest: 'application/manifest+json' }],
|
|
245
|
+
];
|
|
246
|
+
|
|
247
|
+
/** @type {Record<string, string>} */
|
|
248
|
+
const IMAGE_TYPES = {
|
|
249
|
+
svg: 'image/svg+xml', png: 'image/png', ico: 'image/x-icon', jpg: 'image/jpeg',
|
|
250
|
+
jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif',
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Classify an app-root file name as a static metadata file, or null.
|
|
255
|
+
* @param {string} base
|
|
256
|
+
* @returns {{ stem: string, contentType: string } | null}
|
|
257
|
+
*/
|
|
258
|
+
export function staticMetadataFile(base) {
|
|
259
|
+
for (const [re, stem, types] of STATIC_METADATA_FILES) {
|
|
260
|
+
const m = re.exec(base);
|
|
261
|
+
if (m) return { stem, contentType: types[m[1]] || IMAGE_TYPES[m[1]] };
|
|
262
|
+
}
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
|
|
218
266
|
function stemOf(base) {
|
|
219
267
|
const m = /^([A-Za-z0-9_.-]+)\.(?:m?[jt]s)$/.exec(base);
|
|
220
268
|
return m ? m[1] : null;
|
package/src/session.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import { getStore } from './cache.js';
|
|
24
24
|
import { markDynamicAccess } from './context.js';
|
|
25
|
+
import { shared } from './process-shared.js';
|
|
25
26
|
|
|
26
27
|
// -- Web Crypto helpers ------------------------------------------------------
|
|
27
28
|
// Same shape as auth.js. We duplicate here rather than share a module
|
|
@@ -293,7 +294,7 @@ function serializeCookie(name, value, opts) {
|
|
|
293
294
|
// ---------------------------------------------------------------------------
|
|
294
295
|
|
|
295
296
|
/** @type {WeakMap<Request, Session>} */
|
|
296
|
-
const sessionMap = new WeakMap();
|
|
297
|
+
const sessionMap = shared('session-map', () => new WeakMap());
|
|
297
298
|
|
|
298
299
|
// ---------------------------------------------------------------------------
|
|
299
300
|
// Session middleware
|
package/src/ssr/head.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { openSync, readSync, closeSync } from 'node:fs';
|
|
1
2
|
import { basePath, buildImportMap, importMapTag, vendorPreconnectOrigins } from '../importmap.js';
|
|
2
3
|
import { withBasePath } from '../base-path.js';
|
|
3
4
|
import { escapeAttr, escapeHtml } from './escape.js';
|
|
@@ -10,62 +11,128 @@ import { embedScriptTag } from '../dev-embed.js';
|
|
|
10
11
|
import { devReloadState } from '../dev-reload-state.js';
|
|
11
12
|
import { openGraphPairs, twitterPairs, setMetadataImageRoutes } from './seo.js';
|
|
12
13
|
|
|
13
|
-
// Which icon metadata
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
14
|
+
// Which icon and manifest metadata the app has: the `app/icon.*`,
|
|
15
|
+
// `app/apple-icon.*` and `app/manifest.*` files (a static file such as
|
|
16
|
+
// `app/icon.svg`, or a route such as `app/icon.ts`). Set at boot and on each
|
|
17
|
+
// route rebuild from the route table, the same shape as setClientRouterEnabled,
|
|
18
|
+
// so no opt has to thread through every render path. Empty by default, which
|
|
19
|
+
// keeps an app that declares its icons (or has none) byte-identical.
|
|
18
20
|
//
|
|
19
21
|
// This sits in head.js rather than in a module of its own the way the
|
|
20
22
|
// client-router flag does: `wrapHead` is the only reader, and module state
|
|
21
23
|
// belongs with the code that uses and writes it. The flag moved out only
|
|
22
24
|
// because it has a second reader in render.js.
|
|
23
|
-
/** @
|
|
24
|
-
|
|
25
|
+
/** @typedef {{ url: string, type?: string, sizes?: string }} AutoIcon */
|
|
26
|
+
/** @type {{ icon: AutoIcon[], apple: AutoIcon[], manifest: string | null }} */
|
|
27
|
+
let _metadataIconRoutes = { icon: [], apple: [], manifest: null };
|
|
25
28
|
|
|
26
29
|
/**
|
|
27
|
-
*
|
|
30
|
+
* The pixel size of a PNG file (`"180x180"`), read from its IHDR header, or
|
|
31
|
+
* undefined when the file is not a readable PNG. Only the first 24 bytes are
|
|
32
|
+
* read, once per route rebuild.
|
|
33
|
+
* @param {string} file
|
|
34
|
+
*/
|
|
35
|
+
function pngSizes(file) {
|
|
36
|
+
let fd;
|
|
37
|
+
try {
|
|
38
|
+
fd = openSync(file, 'r');
|
|
39
|
+
const buf = Buffer.alloc(24);
|
|
40
|
+
if (readSync(fd, buf, 0, 24, 0) < 24) return undefined;
|
|
41
|
+
if (buf.readUInt32BE(0) !== 0x89504e47 || buf.toString('latin1', 12, 16) !== 'IHDR') return undefined;
|
|
42
|
+
return `${buf.readUInt32BE(16)}x${buf.readUInt32BE(20)}`;
|
|
43
|
+
} catch {
|
|
44
|
+
return undefined;
|
|
45
|
+
} finally {
|
|
46
|
+
if (fd !== undefined) closeSync(fd);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Record the icon and manifest metadata the app defines.
|
|
52
|
+
*
|
|
53
|
+
* A static file is linked with its type, plus its pixel size for a PNG and
|
|
54
|
+
* `sizes="any"` for an SVG. A route (`app/icon.ts`) is linked bare: it picks
|
|
55
|
+
* its own content type at request time, which is the reason to use one, so a
|
|
56
|
+
* declared type could contradict the bytes. When an app has both a static
|
|
57
|
+
* icon and an icon route, the static file wins the link: the route then
|
|
58
|
+
* stays reachable at its URL without becoming the favicon.
|
|
59
|
+
*
|
|
60
|
+
* Raster icons are linked before SVG. Google's favicon crawler takes the
|
|
61
|
+
* first usable icon and wants a square raster, and a browser that reads SVG
|
|
62
|
+
* picks it from the list regardless of order.
|
|
28
63
|
*
|
|
29
|
-
* @param {Iterable<{ stem: string }> | null | undefined} metadataRoutes
|
|
64
|
+
* @param {Iterable<{ stem: string, urlPath?: string, file?: string, static?: boolean, contentType?: string }> | null | undefined} metadataRoutes
|
|
30
65
|
* The route table's `metadataRoutes`, or nullish to clear.
|
|
31
66
|
*/
|
|
32
67
|
export function setMetadataIconRoutes(metadataRoutes) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
68
|
+
/** @type {Record<string, Array<{ stem: string, urlPath?: string, file?: string, static?: boolean, contentType?: string }>>} */
|
|
69
|
+
const byStem = { icon: [], 'apple-icon': [], manifest: [] };
|
|
70
|
+
for (const r of metadataRoutes || []) {
|
|
71
|
+
if (!r || !byStem[r.stem]) continue;
|
|
72
|
+
// Only the app-root ones describe the app. A route nested in a segment
|
|
73
|
+
// (`app/blog/icon.ts`) answers at its own URL but is not the favicon.
|
|
74
|
+
const url = r.urlPath || '/' + r.stem;
|
|
75
|
+
if (url.lastIndexOf('/') !== 0) continue;
|
|
76
|
+
byStem[r.stem].push({ ...r, urlPath: url });
|
|
77
|
+
}
|
|
78
|
+
/** @param {typeof byStem.icon} list @returns {AutoIcon[]} */
|
|
79
|
+
const links = (list) => {
|
|
80
|
+
const statics = list.filter((r) => r.static);
|
|
81
|
+
const chosen = statics.length ? statics : list.filter((r) => !r.static);
|
|
82
|
+
const rank = (/** @type {typeof list[number]} */ r) => (r.contentType === 'image/svg+xml' ? 1 : 0);
|
|
83
|
+
return chosen.sort((a, b) => rank(a) - rank(b)).map((r) => {
|
|
84
|
+
/** @type {AutoIcon} */
|
|
85
|
+
const out = { url: /** @type {string} */ (r.urlPath) };
|
|
86
|
+
if (r.static && r.contentType) out.type = r.contentType;
|
|
87
|
+
if (r.contentType === 'image/svg+xml') out.sizes = 'any';
|
|
88
|
+
else if (r.contentType === 'image/png' && r.file) {
|
|
89
|
+
const sizes = pngSizes(r.file);
|
|
90
|
+
if (sizes) out.sizes = sizes;
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
const manifest = byStem.manifest.find((r) => r.static) || byStem.manifest[0];
|
|
96
|
+
_metadataIconRoutes = {
|
|
97
|
+
icon: links(byStem.icon),
|
|
98
|
+
apple: links(byStem['apple-icon']),
|
|
99
|
+
manifest: manifest ? /** @type {string} */ (manifest.urlPath) : null,
|
|
100
|
+
}; // The og/twitter image routes ride the same rebuild hook (seo.js).
|
|
38
101
|
setMetadataImageRoutes(metadataRoutes);
|
|
39
102
|
}
|
|
40
103
|
|
|
41
104
|
/**
|
|
42
|
-
* The implicit `metadata.icons` an app's icon routes stand for, or
|
|
43
|
-
* it has none. Base-path prefixed, because that is where
|
|
105
|
+
* The implicit `metadata.icons` an app's icon files and routes stand for, or
|
|
106
|
+
* null when it has none. Base-path prefixed, because that is where they are
|
|
44
107
|
* SERVED: the listener strips the base path before matching, so under
|
|
45
|
-
* `webjs.basePath` the
|
|
108
|
+
* `webjs.basePath` the icon answers at `<basePath>/icon.svg`. A user-authored
|
|
46
109
|
* `icons` URL is deliberately left alone (it may be cross-origin, and the
|
|
47
110
|
* author writes the path they mean), so only these framework-emitted ones
|
|
48
111
|
* are prefixed.
|
|
49
112
|
*
|
|
50
|
-
*
|
|
51
|
-
* type at request time, which is the reason to use one, so declaring a type
|
|
52
|
-
* here could contradict the bytes; and `sizes` is unknowable without reading
|
|
53
|
-
* the response. Both are optional in HTML, and a browser sniffs the served
|
|
54
|
-
* content type.
|
|
55
|
-
*
|
|
56
|
-
* @returns {{ icon?: string, apple?: string } | null}
|
|
113
|
+
* @returns {{ icon?: AutoIcon[], apple?: AutoIcon[] } | null}
|
|
57
114
|
*/
|
|
58
115
|
function autoMetadataRouteIcons() {
|
|
59
116
|
const { icon, apple } = _metadataIconRoutes;
|
|
60
|
-
if (!icon && !apple) return null;
|
|
117
|
+
if (!icon.length && !apple.length) return null;
|
|
61
118
|
const bp = basePath();
|
|
62
|
-
/** @type {{
|
|
119
|
+
const prefix = (/** @type {AutoIcon} */ i) => ({ ...i, url: withBasePath(i.url, bp) });
|
|
120
|
+
/** @type {{ icon?: AutoIcon[], apple?: AutoIcon[] }} */
|
|
63
121
|
const out = {};
|
|
64
|
-
if (icon) out.icon =
|
|
65
|
-
if (apple) out.apple =
|
|
122
|
+
if (icon.length) out.icon = icon.map(prefix);
|
|
123
|
+
if (apple.length) out.apple = apple.map(prefix);
|
|
66
124
|
return out;
|
|
67
125
|
}
|
|
68
126
|
|
|
127
|
+
/**
|
|
128
|
+
* The app's `app/manifest.*` URL (base-path prefixed), or null.
|
|
129
|
+
* @returns {string | null}
|
|
130
|
+
*/
|
|
131
|
+
function autoManifestUrl() {
|
|
132
|
+
const { manifest } = _metadataIconRoutes;
|
|
133
|
+
return manifest ? withBasePath(manifest, basePath()) : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
69
136
|
/**
|
|
70
137
|
* HTML-safe-escape a JSON string for embedding inside a
|
|
71
138
|
* `<script type="application/ld+json">` element.
|
|
@@ -787,9 +854,14 @@ export function wrapHead(opts) {
|
|
|
787
854
|
}
|
|
788
855
|
}
|
|
789
856
|
|
|
790
|
-
// manifest: a string URL → <link rel="manifest"
|
|
857
|
+
// manifest: a string URL → <link rel="manifest">. With none declared, an
|
|
858
|
+
// `app/manifest.*` file or route is linked automatically, the same way the
|
|
859
|
+
// icons are; `manifest: null` opts out.
|
|
791
860
|
if (typeof m.manifest === 'string') {
|
|
792
861
|
linkTags.push(`<link rel="manifest" href="${escapeAttr(absUrl(m.manifest))}">`);
|
|
862
|
+
} else if (m.manifest === undefined) {
|
|
863
|
+
const auto = autoManifestUrl();
|
|
864
|
+
if (auto) linkTags.push(`<link rel="manifest" href="${escapeAttr(auto)}">`);
|
|
793
865
|
}
|
|
794
866
|
|
|
795
867
|
// alternates: { canonical, languages: { '<hreflang>': url }, media: { '<media>': url } }
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Resolve the app's `#` subpath imports ourselves under `webjs dev` on Bun
|
|
3
|
-
* (#1575).
|
|
4
|
-
*
|
|
5
|
-
* Bun caches a directory's entries when it resolves through it and, under
|
|
6
|
-
* `bun --hot`, refreshes that cache only for a directory it watches. It does
|
|
7
|
-
* not watch a directory that holds only modules a `Bun.plugin` served (an
|
|
8
|
-
* `actions/` folder of `*.server.ts` files), and a `package.json "imports"`
|
|
9
|
-
* specifier (`#modules/x/new.ts`) resolves through that stale listing, while a
|
|
10
|
-
* relative one does not. So a file an agent added next to an action was
|
|
11
|
-
* "Cannot find module" for the rest of the process, through every reload. This
|
|
12
|
-
* `onResolve` expands the alias through the same map the module graph uses
|
|
13
|
-
* (`expandImportAlias`) and hands Bun an absolute path that exists, which Bun
|
|
14
|
-
* then loads normally (the seed plugin's `onLoad` still applies by path). A
|
|
15
|
-
* specifier it cannot place falls through to Bun's own resolver, so an error
|
|
16
|
-
* reads exactly as it would without it.
|
|
17
|
-
*
|
|
18
|
-
* Dev only (production never reloads) and Bun only (Node's resolver reads the
|
|
19
|
-
* file system on every resolve).
|
|
20
|
-
*
|
|
21
|
-
* @module dev/bun-alias-resolve
|
|
22
|
-
*/
|
|
23
|
-
import { existsSync, statSync } from 'node:fs';
|
|
24
|
-
import { resolve, sep } from 'node:path';
|
|
25
|
-
import { expandImportAlias } from '../module-graph.js';
|
|
26
|
-
|
|
27
|
-
const ROOTS = Symbol.for('webjs.dev.bunAliasRoots');
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Resolve `spec` imported by `importer` against the app's `#` alias map, or
|
|
31
|
-
* null when it is not an app alias or the target is not a file.
|
|
32
|
-
*
|
|
33
|
-
* @param {string} spec
|
|
34
|
-
* @param {string} importer absolute path, possibly with a query
|
|
35
|
-
* @param {string} appDir absolute
|
|
36
|
-
* @returns {string | null}
|
|
37
|
-
*/
|
|
38
|
-
export function resolveAppAlias(spec, importer, appDir) {
|
|
39
|
-
if (!spec.startsWith('#')) return null;
|
|
40
|
-
const from = String(importer || '').split('?')[0];
|
|
41
|
-
if (!from.startsWith(appDir + sep) || from.includes(`${sep}node_modules${sep}`)) return null;
|
|
42
|
-
const rel = expandImportAlias(spec, appDir);
|
|
43
|
-
if (!rel) return null;
|
|
44
|
-
const abs = resolve(appDir, rel);
|
|
45
|
-
try { return existsSync(abs) && statSync(abs).isFile() ? abs : null; } catch { return null; }
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Install the resolver for `appDir`, once per process.
|
|
50
|
-
* @param {string} appDir absolute
|
|
51
|
-
*/
|
|
52
|
-
export function registerBunAliasResolver(appDir) {
|
|
53
|
-
const g = /** @type {any} */ (globalThis);
|
|
54
|
-
if (!g.Bun || typeof g.Bun.plugin !== 'function') return;
|
|
55
|
-
if (!(g[ROOTS] instanceof Set)) g[ROOTS] = new Set();
|
|
56
|
-
if (g[ROOTS].has(appDir)) return;
|
|
57
|
-
g[ROOTS].add(appDir);
|
|
58
|
-
g.Bun.plugin({
|
|
59
|
-
name: 'webjs-dev-alias',
|
|
60
|
-
setup(build) {
|
|
61
|
-
build.onResolve({ filter: /^#/ }, (args) => {
|
|
62
|
-
try {
|
|
63
|
-
const path = resolveAppAlias(args.path, args.importer, appDir);
|
|
64
|
-
return path ? { path } : undefined;
|
|
65
|
-
} catch {
|
|
66
|
-
return undefined;
|
|
67
|
-
}
|
|
68
|
-
});
|
|
69
|
-
},
|
|
70
|
-
});
|
|
71
|
-
}
|