@webjsdev/server 0.8.27 → 0.8.29

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.
@@ -89,6 +89,15 @@ export function requiredNodeMajor() {
89
89
  * @returns {void}
90
90
  */
91
91
  export function assertNodeVersion(opts = {}) {
92
+ // Bun (#508) satisfies webjs's requirements through a different mechanism: the
93
+ // TS strip comes from `amaro` (resolved by ts-strip.js), and `fs.watch` /
94
+ // `node:crypto` are provided by its node-compat layer, even though Bun reports
95
+ // a Node version string. The Node-major gate is a proxy for "the Node built-ins
96
+ // exist", which does not hold on Bun, so skip it there. Only when no explicit
97
+ // `current` is passed (real runtime detection, not a unit test override).
98
+ if (opts.current === undefined && typeof process !== 'undefined' && process.versions && process.versions.bun) {
99
+ return;
100
+ }
92
101
  const current = opts.current ?? process.versions.node;
93
102
  const requiredMajor = opts.requiredMajor ?? requiredNodeMajor();
94
103
  const onFail = opts.onFail ?? 'throw';
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Pluggable TypeScript stripper (#508), the one seam that lets webjs run on both
3
+ * Node and Bun.
4
+ *
5
+ * webjs serves `.ts` / `.mts` source to the browser as JavaScript by ERASING the
6
+ * type syntax in place (position-preserving whitespace replacement, so no
7
+ * sourcemap is shipped and stack traces stay byte-exact). On Node 24+ that is the
8
+ * built-in `module.stripTypeScriptTypes`. Bun has no such built-in, so this
9
+ * module picks the backend at boot:
10
+ *
11
+ * - **Node 24+**: the built-in `module.stripTypeScriptTypes` (zero deps, the
12
+ * default; nothing about the Node path changes).
13
+ * - **Bun (or any runtime lacking the built-in)**: `amaro`, lazily imported.
14
+ * Node's built-in is itself a thin wrapper over `amaro`'s `strip-only` mode,
15
+ * so the output is BYTE-IDENTICAL and equally position-preserving. `amaro` is
16
+ * an `optionalDependency` of `@webjsdev/server`: a Node-only install that
17
+ * prunes optionals still runs (it never loads amaro), while a Bun install
18
+ * gets it.
19
+ *
20
+ * `WEBJS_TS_STRIPPER=builtin|amaro` forces a backend (used by the tests to
21
+ * exercise the amaro path under Node, and an escape hatch).
22
+ *
23
+ * Erasable TypeScript only, either way: non-erasable syntax (`enum`, value
24
+ * `namespace`, parameter properties, legacy decorators, `import = require`)
25
+ * throws at strip time, which the `erasable-typescript-only` /
26
+ * `no-non-erasable-typescript` lint rules catch at edit time.
27
+ */
28
+ // Namespace import, NOT `import { stripTypeScriptTypes }`. On Node < 22.13 the
29
+ // named export does not exist, and a NAMED import of a missing builtin export is
30
+ // a LINK-TIME SyntaxError that fires before any module body runs, which would
31
+ // defeat the Node-version preflight by crashing the import of @webjsdev/server
32
+ // itself. A namespace import links on every runtime (the property is just
33
+ // `undefined` where absent, e.g. Bun) and we branch on it at runtime instead.
34
+ import * as nodeModule from 'node:module';
35
+
36
+ // Suppress the one-shot ExperimentalWarning Node prints the first time
37
+ // `stripTypeScriptTypes` is called (the API is committed per Node 24's release
38
+ // notes; the warning is a holdover). Installed here, co-located with the strip
39
+ // call, so it covers every consumer of this module (dev.js, the CLI, tests),
40
+ // not just the dev server. Every other warning passes through untouched.
41
+ const _origEmitWarning = process.emitWarning.bind(process);
42
+ process.emitWarning = function (warning, type, code, ctor) {
43
+ const msg = warning && warning.message ? warning.message : String(warning);
44
+ if (
45
+ (type === 'ExperimentalWarning' || (warning && warning.name === 'ExperimentalWarning')) &&
46
+ msg.includes('stripTypeScriptTypes')
47
+ ) {
48
+ return;
49
+ }
50
+ return _origEmitWarning(warning, type, code, ctor);
51
+ };
52
+
53
+ /** @typedef {{ fn: (source: string) => string, name: 'builtin' | 'amaro' }} Stripper */
54
+
55
+ /** @type {Stripper | null} */
56
+ let _strip = null;
57
+ /** @type {Promise<Stripper> | null} */
58
+ let _resolving = null;
59
+
60
+ /** Forced backend from the env, or '' for auto-detect. */
61
+ function forcedBackend() {
62
+ const v = String(process.env.WEBJS_TS_STRIPPER || '').toLowerCase().trim();
63
+ return v === 'builtin' || v === 'amaro' ? v : '';
64
+ }
65
+
66
+ /**
67
+ * Resolve the active stripper backend once. Auto-detects (built-in when present,
68
+ * else amaro); honors a `WEBJS_TS_STRIPPER` override.
69
+ * @returns {Promise<Stripper>}
70
+ */
71
+ async function resolve() {
72
+ const forced = forcedBackend();
73
+ const hasBuiltin = typeof nodeModule.stripTypeScriptTypes === 'function';
74
+ if (forced !== 'amaro' && (forced === 'builtin' || hasBuiltin)) {
75
+ if (forced === 'builtin' && !hasBuiltin) {
76
+ throw new Error('WEBJS_TS_STRIPPER=builtin but module.stripTypeScriptTypes is unavailable on this runtime.');
77
+ }
78
+ return { fn: (source) => nodeModule.stripTypeScriptTypes(source), name: 'builtin' };
79
+ }
80
+ // amaro backend (Bun, a Node without the built-in, or forced).
81
+ let amaro;
82
+ try {
83
+ amaro = await import('amaro');
84
+ } catch (e) {
85
+ const why = e && e.message ? e.message : String(e);
86
+ throw new Error(
87
+ "webjs needs a TypeScript stripper: this runtime has no built-in " +
88
+ "`module.stripTypeScriptTypes` (e.g. Bun) and the `amaro` fallback failed " +
89
+ "to load. Install `amaro` (it is an optionalDependency of @webjsdev/server) " +
90
+ "to run webjs here. Underlying: " + why,
91
+ );
92
+ }
93
+ const tx = amaro.transformSync || (amaro.default && amaro.default.transformSync);
94
+ if (typeof tx !== 'function') {
95
+ throw new Error('webjs: the resolved `amaro` module has no `transformSync` export.');
96
+ }
97
+ return {
98
+ fn: (source) => {
99
+ try {
100
+ return tx(source, { mode: 'strip-only' }).code;
101
+ } catch (e) {
102
+ // Normalize amaro's non-erasable-syntax error code to Node's, so a
103
+ // single downstream check (`err.code === 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX'`
104
+ // in dev.js's tsResponse) classifies the failure identically on both
105
+ // runtimes (amaro reports `code: 'UnsupportedSyntax'` for an enum /
106
+ // namespace-with-values / parameter-property / legacy-decorator). The
107
+ // strip-error contract is part of this seam, like the strip itself.
108
+ if (e && e.code === 'UnsupportedSyntax') {
109
+ try { e.code = 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX'; } catch {}
110
+ }
111
+ throw e;
112
+ }
113
+ },
114
+ name: 'amaro',
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Resolve (once, memoized) and return the active stripper. Call at boot so the
120
+ * backend is ready and a missing-amaro error surfaces early rather than on the
121
+ * first `.ts` request.
122
+ * @returns {Promise<Stripper>}
123
+ */
124
+ export async function ensureStripper() {
125
+ if (_strip) return _strip;
126
+ // Clear `_resolving` on BOTH settle paths so a failed resolve (e.g. a missing
127
+ // amaro on a runtime lacking the built-in) re-detects on the next call instead
128
+ // of caching the rejection forever (self-healing, matching `__resetStripper`).
129
+ if (!_resolving) {
130
+ _resolving = resolve().then(
131
+ (s) => { _strip = s; _resolving = null; return s; },
132
+ (e) => { _resolving = null; throw e; },
133
+ );
134
+ }
135
+ return _resolving;
136
+ }
137
+
138
+ /**
139
+ * Strip TypeScript types from `source`, resolving the backend on first use.
140
+ * @param {string} source
141
+ * @returns {Promise<string>}
142
+ */
143
+ export async function stripTypeScript(source) {
144
+ const s = _strip || await ensureStripper();
145
+ return s.fn(source);
146
+ }
147
+
148
+ /** The active backend name, or null before resolution (diagnostics / tests). */
149
+ export function stripperName() {
150
+ return _strip ? _strip.name : null;
151
+ }
152
+
153
+ /** Test seam: forget the resolved backend so the next call re-detects. */
154
+ export function __resetStripper() {
155
+ _strip = null;
156
+ _resolving = null;
157
+ }
package/src/websocket.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { WebSocketServer } from 'ws';
2
- import { pathToFileURL } from 'node:url';
3
2
  import { matchApi } from './router.js';
4
3
  import { urlFromRequest } from './forwarded.js';
5
4
  import { registerClient } from './broadcast.js';
5
+ import { loadWsModule } from './listener-core.js';
6
6
 
7
7
  /**
8
8
  * WebSocket support.
@@ -48,7 +48,7 @@ export function attachWebSocket(server, getRouteTable, opts) {
48
48
  return reject(socket, 404, 'Not Found');
49
49
  }
50
50
 
51
- const mod = await loadModule(match.route.file, opts.dev);
51
+ const mod = await loadWsModule(match.route.file, opts.dev);
52
52
  if (typeof mod.WS !== 'function') {
53
53
  return reject(socket, 426, 'Upgrade not supported at this route');
54
54
  }
@@ -109,13 +109,3 @@ function buildRequestFromUpgrade(req, url) {
109
109
  }
110
110
  return new Request(url, { method: 'GET', headers });
111
111
  }
112
-
113
- /**
114
- * @param {string} file
115
- * @param {boolean} dev
116
- */
117
- async function loadModule(file, dev) {
118
- const url = pathToFileURL(file).toString();
119
- const bust = dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : '';
120
- return import(url + bust);
121
- }