@webjsdev/server 0.7.2

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/src/vendor.js ADDED
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Auto-bundle npm dependencies for the browser.
3
+ *
4
+ * When user code imports a bare specifier (e.g. `import dayjs from 'dayjs'`)
5
+ * from a client-side file, the browser can't resolve it natively. This module
6
+ * provides Vite-style `optimizeDeps` behaviour:
7
+ *
8
+ * 1. On startup (and rebuild), scan client-reachable source for bare import
9
+ * specifiers that aren't already in the import map.
10
+ *
11
+ * 2. For each discovered package, bundle it into a single ESM file via
12
+ * esbuild (inlining transitive deps) and cache the result.
13
+ *
14
+ * 3. Serve the bundle at `/__webjs/vendor/<pkg>.js` and add it to the
15
+ * import map automatically.
16
+ *
17
+ * This is intentionally lazy + cached: the first request for a vendor bundle
18
+ * triggers the esbuild build; subsequent requests are served from the in-memory
19
+ * cache. A file watcher rebuild clears the cache so new deps are picked up.
20
+ */
21
+
22
+ import { readFile, readdir, stat } from 'node:fs/promises';
23
+ import { join, extname, sep } from 'node:path';
24
+ import { createRequire } from 'node:module';
25
+
26
+ /**
27
+ * Cache of bundled vendor modules.
28
+ * @type {Map<string, string>}
29
+ */
30
+ const vendorCache = new Map();
31
+ const VENDOR_CACHE_MAX = 100;
32
+
33
+ /**
34
+ * Set of package names known to be built-in / already mapped.
35
+ * These are never auto-bundled.
36
+ */
37
+ const BUILTIN = new Set(['@webjsdev/core', '@webjsdev/core/', '@webjsdev/core/client-router']);
38
+
39
+ /**
40
+ * Scan source files under `dir` for bare import specifiers. Returns a Set of
41
+ * package names (e.g. `'dayjs'`, `'@tanstack/query-core'`).
42
+ *
43
+ * Only scans `.js`, `.ts`, `.mjs`, `.mts` files. Skips `node_modules`,
44
+ * `.webjs`, `public`, and `_private` directories.
45
+ *
46
+ * @param {string} dir
47
+ * @returns {Promise<Set<string>>}
48
+ */
49
+ export async function scanBareImports(dir) {
50
+ /** @type {Set<string>} */
51
+ const found = new Set();
52
+ await walk(dir, found);
53
+ // Remove built-ins
54
+ for (const b of BUILTIN) found.delete(b);
55
+ return found;
56
+ }
57
+
58
+ /**
59
+ * Extract the package name from a bare specifier.
60
+ * `'dayjs'` → `'dayjs'`
61
+ * `'dayjs/locale/en'` → `'dayjs'`
62
+ * `'@tanstack/query'` → `'@tanstack/query'`
63
+ * `'@tanstack/query/x'` → `'@tanstack/query'`
64
+ * `'./foo'`, `'../bar'`, `'/baz'` → `null` (relative/absolute)
65
+ *
66
+ * @param {string} spec
67
+ * @returns {string | null}
68
+ */
69
+ export function extractPackageName(spec) {
70
+ if (!spec || spec.startsWith('.') || spec.startsWith('/') || spec.startsWith('__')) return null;
71
+ // Protocol URLs (http:, data:, blob:, etc.)
72
+ if (/^[a-z]+:/.test(spec)) return null;
73
+ if (spec.startsWith('@')) {
74
+ const parts = spec.split('/');
75
+ return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : null;
76
+ }
77
+ return spec.split('/')[0];
78
+ }
79
+
80
+ /** @type {RegExp} */
81
+ const IMPORT_RE = /\bimport\s+(?:(?:[\w*{}\s,]+)\s+from\s+)?['"]([^'"]+)['"]/g;
82
+ const DYNAMIC_IMPORT_RE = /\bimport\(\s*['"]([^'"]+)['"]\s*\)/g;
83
+
84
+ /**
85
+ * @param {string} dir
86
+ * @param {Set<string>} found
87
+ */
88
+ async function walk(dir, found) {
89
+ let entries;
90
+ try { entries = await readdir(dir, { withFileTypes: true }); }
91
+ catch { return; }
92
+ for (const e of entries) {
93
+ if (e.name === 'node_modules' || e.name === '.webjs' || e.name === 'public' || e.name.startsWith('_')) continue;
94
+ const full = join(dir, e.name);
95
+ if (e.isDirectory()) {
96
+ await walk(full, found);
97
+ } else if (/\.(js|ts|mjs|mts)$/.test(e.name) && !e.name.endsWith('.server.ts') && !e.name.endsWith('.server.js')) {
98
+ try {
99
+ const src = await readFile(full, 'utf8');
100
+ // Skip files with 'use server' pragma
101
+ if (src.trimStart().startsWith("'use server'") || src.trimStart().startsWith('"use server"')) continue;
102
+ for (const m of src.matchAll(IMPORT_RE)) {
103
+ const pkg = extractPackageName(m[1]);
104
+ if (pkg) found.add(pkg);
105
+ }
106
+ for (const m of src.matchAll(DYNAMIC_IMPORT_RE)) {
107
+ const pkg = extractPackageName(m[1]);
108
+ if (pkg) found.add(pkg);
109
+ }
110
+ } catch { /* unreadable file */ }
111
+ }
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Bundle an npm package into a single ESM file for the browser.
117
+ *
118
+ * @param {string} pkgName e.g. `'dayjs'`
119
+ * @param {string} appDir app root for resolving node_modules
120
+ * @param {boolean} dev
121
+ * @returns {Promise<string | null>} bundled JS source, or null if not found
122
+ */
123
+ export async function bundlePackage(pkgName, appDir, dev) {
124
+ const cached = vendorCache.get(pkgName);
125
+ if (cached) return cached;
126
+
127
+ let build;
128
+ try { ({ build } = await import('esbuild')); }
129
+ catch { return null; }
130
+
131
+ // Locate the package entry via Node resolution
132
+ const require = createRequire(join(appDir, 'package.json'));
133
+ let entryPoint;
134
+ try {
135
+ entryPoint = require.resolve(pkgName);
136
+ } catch {
137
+ return null;
138
+ }
139
+
140
+ try {
141
+ const result = await build({
142
+ entryPoints: [entryPoint],
143
+ bundle: true,
144
+ format: 'esm',
145
+ target: 'es2022',
146
+ platform: 'browser',
147
+ write: false,
148
+ minify: !dev,
149
+ // External: don't bundle packages already in the import map
150
+ external: [...BUILTIN],
151
+ });
152
+ const code = result.outputFiles[0].text;
153
+ if (vendorCache.size >= VENDOR_CACHE_MAX) {
154
+ const oldest = vendorCache.keys().next().value;
155
+ vendorCache.delete(oldest);
156
+ }
157
+ vendorCache.set(pkgName, code);
158
+ return code;
159
+ } catch (e) {
160
+ // Build failed (native module, server-only dep, etc.): skip silently
161
+ return null;
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Build extra import map entries for discovered bare imports.
167
+ *
168
+ * @param {Set<string>} bareImports from scanBareImports()
169
+ * @returns {Record<string, string>}
170
+ */
171
+ export function vendorImportMapEntries(bareImports) {
172
+ /** @type {Record<string, string>} */
173
+ const entries = {};
174
+ for (const pkg of bareImports) {
175
+ if (BUILTIN.has(pkg)) continue;
176
+ entries[pkg] = `/__webjs/vendor/${encodeURIComponent(pkg)}.js`;
177
+ }
178
+ return entries;
179
+ }
180
+
181
+ /**
182
+ * Clear the vendor cache (called on file-watcher rebuild so newly added
183
+ * deps are picked up on next request).
184
+ */
185
+ export function clearVendorCache() {
186
+ vendorCache.clear();
187
+ }
188
+
189
+ /**
190
+ * Serve a vendor bundle for the given package name.
191
+ *
192
+ * @param {string} pkgName
193
+ * @param {string} appDir
194
+ * @param {boolean} dev
195
+ * @returns {Promise<Response>}
196
+ */
197
+ export async function serveVendorBundle(pkgName, appDir, dev) {
198
+ const code = await bundlePackage(pkgName, appDir, dev);
199
+ if (code == null) {
200
+ return new Response(`/* vendor bundle failed for ${pkgName} */`, {
201
+ status: 404,
202
+ headers: { 'content-type': 'application/javascript; charset=utf-8' },
203
+ });
204
+ }
205
+ return new Response(code, {
206
+ headers: {
207
+ 'content-type': 'application/javascript; charset=utf-8',
208
+ 'cache-control': dev ? 'no-cache' : 'public, max-age=31536000, immutable',
209
+ },
210
+ });
211
+ }
@@ -0,0 +1,119 @@
1
+ import { WebSocketServer } from 'ws';
2
+ import { pathToFileURL } from 'node:url';
3
+ import { matchApi } from './router.js';
4
+ import { urlFromRequest } from './forwarded.js';
5
+
6
+ /**
7
+ * WebSocket support.
8
+ *
9
+ * A `route.js` file that exports a `WS` function becomes a WebSocket endpoint
10
+ * at that URL. Example:
11
+ *
12
+ * // app/api/chat/route.js
13
+ * const clients = new Set();
14
+ * export function WS(ws, req, { params }) {
15
+ * clients.add(ws);
16
+ * ws.on('message', (data) => {
17
+ * for (const c of clients) if (c.readyState === 1) c.send(data.toString());
18
+ * });
19
+ * ws.on('close', () => clients.delete(ws));
20
+ * }
21
+ *
22
+ * The second arg is the original `Request` (so you can read cookies, headers,
23
+ * query params, session). The third is the usual `{ params }` shape from
24
+ * dynamic route segments.
25
+ *
26
+ * Protocol choices:
27
+ * - HTTP/1.1 Upgrade only in v1. WebSockets-over-HTTP/2 (RFC 8441) has
28
+ * patchy server/browser support; h1.1 upgrade is the universal path
29
+ * and works alongside an h2-TLS server for page loads.
30
+ * - Uses the `ws` library (node's built-in WebSocketServer is not yet a
31
+ * stable API; `ws` is the standard and zero-dep itself).
32
+ *
33
+ * @param {import('node:http').Server | import('node:http2').Http2SecureServer} server
34
+ * @param {() => import('./router.js').RouteTable} getRouteTable
35
+ * @param {{ dev: boolean, logger: import('./logger.js').Logger }} opts
36
+ */
37
+ export function attachWebSocket(server, getRouteTable, opts) {
38
+ const wss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
39
+
40
+ server.on('upgrade', async (req, socket, head) => {
41
+ try {
42
+ const url = urlFromRequest(req);
43
+ const table = getRouteTable();
44
+ const match = matchApi(table, url.pathname);
45
+
46
+ if (!match) {
47
+ return reject(socket, 404, 'Not Found');
48
+ }
49
+
50
+ const mod = await loadModule(match.route.file, opts.dev);
51
+ if (typeof mod.WS !== 'function') {
52
+ return reject(socket, 426, 'Upgrade not supported at this route');
53
+ }
54
+
55
+ wss.handleUpgrade(req, socket, head, (ws) => {
56
+ try {
57
+ const webReq = buildRequestFromUpgrade(req, url);
58
+ mod.WS(ws, webReq, { params: match.params });
59
+ } catch (e) {
60
+ opts.logger.error('WebSocket handler threw', {
61
+ err: e instanceof Error ? e.stack || e.message : String(e),
62
+ });
63
+ try { ws.close(1011, 'Internal error'); } catch {}
64
+ }
65
+ });
66
+ } catch (e) {
67
+ opts.logger.error('WebSocket upgrade failed', {
68
+ err: e instanceof Error ? e.stack || e.message : String(e),
69
+ });
70
+ try { reject(socket, 500, 'Upgrade failed'); } catch {}
71
+ }
72
+ });
73
+
74
+ return wss;
75
+ }
76
+
77
+ /**
78
+ * Write an HTTP error on the raw TCP socket and destroy it: used to refuse
79
+ * an upgrade cleanly.
80
+ * @param {import('node:net').Socket} socket
81
+ * @param {number} status
82
+ * @param {string} message
83
+ */
84
+ function reject(socket, status, message) {
85
+ socket.write(
86
+ `HTTP/1.1 ${status} ${message}\r\n` +
87
+ `Content-Type: text/plain\r\n` +
88
+ `Content-Length: ${Buffer.byteLength(message)}\r\n` +
89
+ `Connection: close\r\n\r\n` +
90
+ message
91
+ );
92
+ socket.destroy();
93
+ }
94
+
95
+ /**
96
+ * Best-effort `Request` for the upgrade attempt: headers + method + URL.
97
+ * No body (it's a WS handshake). Handy for reading cookies/auth in the handler.
98
+ * @param {import('node:http').IncomingMessage} req
99
+ * @param {URL} url
100
+ */
101
+ function buildRequestFromUpgrade(req, url) {
102
+ /** @type {Record<string,string>} */
103
+ const headers = {};
104
+ for (const [k, v] of Object.entries(req.headers)) {
105
+ if (k.startsWith(':')) continue;
106
+ headers[k] = Array.isArray(v) ? v.join(',') : String(v ?? '');
107
+ }
108
+ return new Request(url, { method: 'GET', headers });
109
+ }
110
+
111
+ /**
112
+ * @param {string} file
113
+ * @param {boolean} dev
114
+ */
115
+ async function loadModule(file, dev) {
116
+ const url = pathToFileURL(file).toString();
117
+ const bust = dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : '';
118
+ return import(url + bust);
119
+ }