@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/README.md +51 -0
- package/index.js +29 -0
- package/package.json +43 -0
- package/src/actions.js +478 -0
- package/src/api.js +37 -0
- package/src/auth.js +431 -0
- package/src/broadcast.js +69 -0
- package/src/cache-fn.js +85 -0
- package/src/cache.js +187 -0
- package/src/check.js +878 -0
- package/src/component-scanner.js +164 -0
- package/src/context.js +62 -0
- package/src/csrf.js +95 -0
- package/src/dev.js +952 -0
- package/src/forwarded.js +59 -0
- package/src/fs-walk.js +28 -0
- package/src/importmap.js +40 -0
- package/src/json.js +64 -0
- package/src/logger.js +39 -0
- package/src/module-graph.js +141 -0
- package/src/rate-limit.js +105 -0
- package/src/router.js +280 -0
- package/src/serializer.js +86 -0
- package/src/session.js +336 -0
- package/src/ssr.js +1258 -0
- package/src/vendor.js +211 -0
- package/src/websocket.js +119 -0
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
|
+
}
|
package/src/websocket.js
ADDED
|
@@ -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
|
+
}
|