@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.
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Build a full URL from a Node IncomingMessage, respecting standard
3
+ * reverse-proxy headers (`X-Forwarded-Proto`, `X-Forwarded-Host`).
4
+ *
5
+ * Why: webjs apps are almost always deployed behind a reverse proxy
6
+ * (Railway, Fly, Render, Vercel, Cloudflare, nginx, Caddy, Traefik -
7
+ * see the no-build architecture docs). The proxy terminates TLS and
8
+ * speaks plain HTTP/1.1 to the container, so `req.url` inside the
9
+ * container reflects the internal "http" view. Without honoring the
10
+ * forwarded headers, `ctx.url.origin` returns `http://container-host`
11
+ * even though the browser is on `https://your-domain.com`: which
12
+ * breaks OG / og:image tags, OAuth callback URLs, and any user code
13
+ * that builds absolute URLs.
14
+ *
15
+ * Threat model: in webjs's typical deployment topology, the
16
+ * container's HTTP port is only reachable through the trusted edge
17
+ * proxy. There's no path for an attacker to inject these headers
18
+ * without going through that proxy. For self-hosted bare-VM deploys
19
+ * where the container is somehow directly exposed, set
20
+ * `WEBJS_NO_TRUST_PROXY=1` to fall back to the raw `Host` header and
21
+ * `http://` default.
22
+ *
23
+ * Header semantics:
24
+ * - `X-Forwarded-Host` / `X-Forwarded-Proto` can be a comma-separated
25
+ * chain if multiple proxies are in front (e.g. CDN -> load balancer
26
+ * -> container). The first entry is the value closest to the
27
+ * original client: that's what we want.
28
+ * - Node sometimes returns headers as an array (when the same header
29
+ * appears multiple times); handle both string and array shapes.
30
+ *
31
+ * @param {{ url?: string, headers: Record<string, string | string[] | undefined> }} req
32
+ * @returns {URL}
33
+ */
34
+ export function urlFromRequest(req) {
35
+ const trust = process.env.WEBJS_NO_TRUST_PROXY !== '1';
36
+ let host = null;
37
+ let proto = null;
38
+ if (trust) {
39
+ host = firstHeaderValue(req.headers['x-forwarded-host']);
40
+ proto = firstHeaderValue(req.headers['x-forwarded-proto']);
41
+ }
42
+ const finalHost = host || /** @type {string|undefined} */ (req.headers.host) || 'localhost';
43
+ const finalProto = proto || 'http';
44
+ return new URL(req.url || '/', `${finalProto}://${finalHost}`);
45
+ }
46
+
47
+ /**
48
+ * Pick the first comma-separated value from a header that may be a
49
+ * string, an array of strings, or undefined.
50
+ *
51
+ * @param {string | string[] | undefined} h
52
+ * @returns {string | null}
53
+ */
54
+ function firstHeaderValue(h) {
55
+ const v = Array.isArray(h) ? h[0] : h;
56
+ if (!v) return null;
57
+ const first = v.split(',')[0].trim();
58
+ return first || null;
59
+ }
package/src/fs-walk.js ADDED
@@ -0,0 +1,28 @@
1
+ import { readdir, stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * Recursively walk a directory, yielding absolute file paths.
6
+ *
7
+ * @param {string} dir
8
+ * @param {(path: string) => boolean} [filter]
9
+ * @returns {AsyncGenerator<string>}
10
+ */
11
+ export async function* walk(dir, filter) {
12
+ let entries;
13
+ try {
14
+ entries = await readdir(dir, { withFileTypes: true });
15
+ } catch {
16
+ return;
17
+ }
18
+ for (const entry of entries) {
19
+ if (entry.name.startsWith('.')) continue;
20
+ if (entry.name === 'node_modules') continue;
21
+ const full = join(dir, entry.name);
22
+ if (entry.isDirectory()) {
23
+ yield* walk(full, filter);
24
+ } else if (entry.isFile()) {
25
+ if (!filter || filter(full)) yield full;
26
+ }
27
+ }
28
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Build the import map JSON injected into every SSR HTML document.
3
+ *
4
+ * Additional vendor entries are added automatically when the bare-import
5
+ * scanner discovers npm packages used by client code (Vite-style
6
+ * optimizeDeps).
7
+ */
8
+
9
+ /** @type {Record<string, string>} */
10
+ let _extraEntries = {};
11
+
12
+ /**
13
+ * Merge additional vendor entries into the import map.
14
+ * Called by the server after scanning for bare imports.
15
+ * @param {Record<string, string>} entries
16
+ */
17
+ export function setVendorEntries(entries) {
18
+ _extraEntries = entries;
19
+ }
20
+
21
+ export function buildImportMap() {
22
+ return {
23
+ imports: {
24
+ '@webjsdev/core': '/__webjs/core/index.js',
25
+ '@webjsdev/core/': '/__webjs/core/src/',
26
+ '@webjsdev/core/client-router': '/__webjs/core/src/router-client.js',
27
+ '@webjsdev/core/lazy-loader': '/__webjs/core/src/lazy-loader.js',
28
+ '@webjsdev/core/directives': '/__webjs/core/src/directives.js',
29
+ '@webjsdev/core/context': '/__webjs/core/src/context.js',
30
+ '@webjsdev/core/testing': '/__webjs/core/src/testing.js',
31
+ '@webjsdev/core/task': '/__webjs/core/src/task.js',
32
+ ..._extraEntries,
33
+ },
34
+ };
35
+ }
36
+
37
+ /** Serialise the import map to an HTML script tag string. */
38
+ export function importMapTag() {
39
+ return `<script type="importmap">${JSON.stringify(buildImportMap())}</script>`;
40
+ }
package/src/json.js ADDED
@@ -0,0 +1,64 @@
1
+ import { stringify as wjStringify, parse as wjParse } from '@webjsdev/core';
2
+ import { getRequest } from './context.js';
3
+ import { RPC_CONTENT_TYPE } from './actions.js';
4
+
5
+ /**
6
+ * Content-negotiated JSON helper for API routes (`route.js` handlers).
7
+ *
8
+ * // GET /api/posts
9
+ * import { json } from '@webjsdev/server';
10
+ * export async function GET() {
11
+ * return json(await listPosts()); // plain Prisma rows with Date columns
12
+ * }
13
+ *
14
+ * The helper reads the in-flight Request from the AsyncLocalStorage
15
+ * request context. If the caller sent `Accept: application/vnd.webjs+json`
16
+ * (e.g. via the `richFetch` client helper), the response body is
17
+ * encoded with the webjs serializer so rich types (Date, Map, Set,
18
+ * BigInt, TypedArrays, Blob, File, FormData, cycles) survive. Otherwise
19
+ * the response is plain `application/json`, unchanged behaviour for
20
+ * curl / external consumers.
21
+ *
22
+ * Passing an options object with `{ status, headers }` mirrors
23
+ * `Response.json(data, init)`.
24
+ *
25
+ * @template T
26
+ * @param {T} data
27
+ * @param {ResponseInit} [init]
28
+ * @returns {Promise<Response>} async because the rich path may need to
29
+ * read bytes from Blob/File/FormData; plain-JSON path resolves
30
+ * immediately.
31
+ */
32
+ export async function json(data, init = {}) {
33
+ const req = getRequest();
34
+ const accept = req?.headers.get('accept') || '';
35
+ const wantsRich = accept.includes(RPC_CONTENT_TYPE);
36
+
37
+ const headers = new Headers(init.headers || {});
38
+ if (wantsRich) {
39
+ headers.set('content-type', RPC_CONTENT_TYPE);
40
+ headers.append('vary', 'Accept');
41
+ return new Response(await wjStringify(data), { ...init, headers });
42
+ }
43
+ headers.set('content-type', 'application/json; charset=utf-8');
44
+ headers.append('vary', 'Accept');
45
+ return new Response(JSON.stringify(data), { ...init, headers });
46
+ }
47
+
48
+ /**
49
+ * Parse a request body using the webjs serializer when the client sent
50
+ * our content type, otherwise as plain JSON. Handy for route handlers
51
+ * that accept rich bodies from the `richFetch` helper but plain JSON
52
+ * from everyone else.
53
+ *
54
+ * @param {Request} req
55
+ */
56
+ export async function readBody(req) {
57
+ const ct = req.headers.get('content-type') || '';
58
+ const text = await req.text();
59
+ if (!text) return null;
60
+ if (ct.includes(RPC_CONTENT_TYPE)) return wjParse(text);
61
+ return JSON.parse(text);
62
+ }
63
+
64
+ export { RPC_CONTENT_TYPE };
package/src/logger.js ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Minimal pluggable logger.
3
+ *
4
+ * webjs doesn't pick pino/winston/bunyan for you. The default in prod emits
5
+ * one JSON object per line to stdout: trivially ingestable by any log
6
+ * aggregator. In dev, lines are plain text for readability.
7
+ *
8
+ * Apps can pass their own logger to `createRequestHandler({ logger })`.
9
+ * Any object that implements `{ info, warn, error }` works.
10
+ *
11
+ * @typedef {{
12
+ * info: (msg: string, meta?: Record<string, unknown>) => void,
13
+ * warn: (msg: string, meta?: Record<string, unknown>) => void,
14
+ * error: (msg: string, meta?: Record<string, unknown>) => void,
15
+ * }} Logger
16
+ */
17
+
18
+ /**
19
+ * @param {{ dev?: boolean }} [opts]
20
+ * @returns {Logger}
21
+ */
22
+ export function defaultLogger(opts = {}) {
23
+ if (opts.dev) {
24
+ return {
25
+ info: (msg, meta) => console.log(meta ? `[webjs] ${msg}` : `[webjs] ${msg}`, meta ?? ''),
26
+ warn: (msg, meta) => console.warn(`[webjs] ${msg}`, meta ?? ''),
27
+ error: (msg, meta) => console.error(`[webjs] ${msg}`, meta ?? ''),
28
+ };
29
+ }
30
+ const emit = (level, stream) => (msg, meta) => {
31
+ const line = JSON.stringify({ level, msg, time: new Date().toISOString(), ...(meta || {}) });
32
+ stream.write(line + '\n');
33
+ };
34
+ return {
35
+ info: emit('info', process.stdout),
36
+ warn: emit('warn', process.stderr),
37
+ error: emit('error', process.stderr),
38
+ };
39
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Lightweight module dependency graph.
3
+ *
4
+ * At startup, scans the app directory and builds an in-memory map of
5
+ * `file → Set<imported files>`. The SSR pipeline queries this graph to
6
+ * emit *complete* modulepreload hints: including transitive dependencies
7
+ * of components: so the browser can fetch the entire tree in parallel
8
+ * rather than discovering imports one waterfall at a time.
9
+ *
10
+ * The graph is file-path-based (absolute paths). URLs are derived when
11
+ * emitting preload hints.
12
+ */
13
+
14
+ import { readFile, readdir, stat } from 'node:fs/promises';
15
+ import { join, resolve, dirname, extname } from 'node:path';
16
+
17
+ /** @type {RegExp} match static `import … from '…'` and `import '…'` */
18
+ const IMPORT_RE = /\bimport\s+(?:(?:[\w*{}\s,]+)\s+from\s+)?['"]([^'"]+)['"]/g;
19
+
20
+ /**
21
+ * @typedef {Map<string, Set<string>>} ModuleGraph
22
+ * A map of absolute file path → Set of absolute file paths it imports.
23
+ */
24
+
25
+ /**
26
+ * Build the module graph for all source files under `appDir`.
27
+ *
28
+ * @param {string} appDir
29
+ * @returns {Promise<ModuleGraph>}
30
+ */
31
+ export async function buildModuleGraph(appDir) {
32
+ /** @type {ModuleGraph} */
33
+ const graph = new Map();
34
+ await walk(appDir, appDir, graph);
35
+ return graph;
36
+ }
37
+
38
+ /**
39
+ * Given a set of entry files, return all transitive dependencies (deduplicated).
40
+ * Entry files themselves are NOT included (they're already preloaded by the
41
+ * boot script).
42
+ *
43
+ * @param {ModuleGraph} graph
44
+ * @param {string[]} entryFiles absolute paths
45
+ * @param {string} appDir
46
+ * @returns {string[]} absolute paths of transitive deps
47
+ */
48
+ export function transitiveDeps(graph, entryFiles, appDir) {
49
+ /** @type {Set<string>} */
50
+ const visited = new Set(entryFiles);
51
+ /** @type {string[]} */
52
+ const result = [];
53
+ /** @type {string[]} */
54
+ const queue = [...entryFiles];
55
+
56
+ while (queue.length) {
57
+ const file = /** @type {string} */ (queue.shift());
58
+ const deps = graph.get(file);
59
+ if (!deps) continue;
60
+ for (const dep of deps) {
61
+ if (visited.has(dep)) continue;
62
+ visited.add(dep);
63
+ // Only include files within the app dir (skip node_modules, core, etc.)
64
+ if (dep.startsWith(appDir)) {
65
+ result.push(dep);
66
+ }
67
+ queue.push(dep);
68
+ }
69
+ }
70
+ return result;
71
+ }
72
+
73
+ /**
74
+ * Recursively walk a directory, parse imports, and populate the graph.
75
+ * @param {string} dir
76
+ * @param {string} appDir
77
+ * @param {ModuleGraph} graph
78
+ */
79
+ async function walk(dir, appDir, graph) {
80
+ let entries;
81
+ try { entries = await readdir(dir, { withFileTypes: true }); }
82
+ catch { return; }
83
+ for (const e of entries) {
84
+ if (e.name === 'node_modules' || e.name === '.webjs' || e.name === 'public' || e.name.startsWith('_')) continue;
85
+ const full = join(dir, e.name);
86
+ if (e.isDirectory()) {
87
+ await walk(full, appDir, graph);
88
+ } else if (/\.(js|ts|mjs|mts)$/.test(e.name)) {
89
+ await parseFile(full, appDir, graph);
90
+ }
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Parse a single file's imports and add them to the graph.
96
+ * Only resolves relative imports (bare specifiers are npm deps, not in the graph).
97
+ *
98
+ * @param {string} file
99
+ * @param {string} appDir
100
+ * @param {ModuleGraph} graph
101
+ */
102
+ async function parseFile(file, appDir, graph) {
103
+ let src;
104
+ try { src = await readFile(file, 'utf8'); }
105
+ catch { return; }
106
+
107
+ const deps = new Set();
108
+ for (const m of src.matchAll(IMPORT_RE)) {
109
+ const spec = m[1];
110
+ // Only resolve relative imports within the project
111
+ if (!spec.startsWith('.') && !spec.startsWith('/')) continue;
112
+ const resolved = resolveImport(spec, file, appDir);
113
+ if (resolved) deps.add(resolved);
114
+ }
115
+ if (deps.size) graph.set(file, deps);
116
+ }
117
+
118
+ /**
119
+ * Resolve a relative import specifier to an absolute file path.
120
+ * Handles: exact match, .ts/.js extension fallback, /index.ts fallback.
121
+ *
122
+ * @param {string} spec e.g. `'../components/theme-toggle.ts'`
123
+ * @param {string} fromFile absolute path of the importing file
124
+ * @param {string} appDir
125
+ * @returns {string | null}
126
+ */
127
+ function resolveImport(spec, fromFile, appDir) {
128
+ const base = dirname(fromFile);
129
+ let target;
130
+ if (spec.startsWith('/')) {
131
+ // Absolute from app root (how browser sees it)
132
+ target = join(appDir, spec);
133
+ } else {
134
+ target = resolve(base, spec);
135
+ }
136
+ // Exact match check: we can't use async `stat` in a sync resolver, so we
137
+ // store the resolved path optimistically. The graph is advisory (for preload
138
+ // hints), not load-bearing, so a wrong entry is harmless: the browser will
139
+ // just get a redundant preload that 404s and is ignored.
140
+ return target;
141
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Fixed-window rate limiter backed by the pluggable cache store.
3
+ *
4
+ * Uses the global cache store (`getStore()`) by default, which is
5
+ * in-memory unless the app calls `setStore(redisStore(...))` at
6
+ * startup. Passing `opts.store` lets a single middleware target a
7
+ * different store than the default.
8
+ *
9
+ * ```js
10
+ * import { rateLimit } from '@webjsdev/server';
11
+ * export default rateLimit({ window: '1m', max: 60 });
12
+ * ```
13
+ *
14
+ * For horizontal scaling across multiple instances, switch the global
15
+ * store to Redis once at app startup:
16
+ *
17
+ * ```js
18
+ * import { setStore, redisStore } from '@webjsdev/server';
19
+ * setStore(redisStore({ url: process.env.REDIS_URL }));
20
+ * ```
21
+ *
22
+ * @module rate-limit
23
+ */
24
+
25
+ import { getStore } from './cache.js';
26
+
27
+ /**
28
+ * @param {{
29
+ * window?: number | string,
30
+ * max?: number,
31
+ * key?: string | ((req: Request) => string | Promise<string>),
32
+ * message?: string,
33
+ * store?: import('./cache.js').CacheStore,
34
+ * }} opts
35
+ * @returns {(req: Request, next: () => Promise<Response>) => Promise<Response>}
36
+ */
37
+ export function rateLimit(opts = {}) {
38
+ const windowMs = parseWindow(opts.window ?? '1m');
39
+ const max = opts.max ?? 60;
40
+ const keyFn = typeof opts.key === 'function' ? opts.key : defaultKey;
41
+ const keyPrefix = typeof opts.key === 'string' ? opts.key : '';
42
+ const message = opts.message ?? 'Too Many Requests';
43
+ // Use the provided store, or fall back to the global cache store -
44
+ // whatever was set via `setStore()` at app startup (in-memory by default).
45
+ const store = opts.store || null;
46
+
47
+ return async function rateLimitMiddleware(req, next) {
48
+ const s = store || getStore();
49
+ const raw = typeof opts.key === 'function' ? await keyFn(req) : defaultKey(req);
50
+ const key = `rl:${keyPrefix}${raw}`;
51
+
52
+ const count = await s.increment(key, windowMs);
53
+ const resetAt = Date.now() + windowMs;
54
+
55
+ if (count > max) {
56
+ return new Response(JSON.stringify({ error: message }), {
57
+ status: 429,
58
+ headers: {
59
+ 'content-type': 'application/json; charset=utf-8',
60
+ 'retry-after': String(Math.ceil(windowMs / 1000)),
61
+ 'x-ratelimit-limit': String(max),
62
+ 'x-ratelimit-remaining': '0',
63
+ 'x-ratelimit-reset': String(Math.floor(resetAt / 1000)),
64
+ },
65
+ });
66
+ }
67
+
68
+ const resp = await next();
69
+ try {
70
+ resp.headers.set('x-ratelimit-limit', String(max));
71
+ resp.headers.set('x-ratelimit-remaining', String(Math.max(0, max - count)));
72
+ resp.headers.set('x-ratelimit-reset', String(Math.floor(resetAt / 1000)));
73
+ } catch {
74
+ // Headers may be immutable on some synthetic Responses.
75
+ }
76
+ return resp;
77
+ };
78
+ }
79
+
80
+ /** @param {Request} req */
81
+ function defaultKey(req) {
82
+ return (
83
+ req.headers.get('x-forwarded-for')?.split(',')[0].trim() ||
84
+ req.headers.get('cf-connecting-ip') ||
85
+ req.headers.get('x-real-ip') ||
86
+ '_anon_'
87
+ );
88
+ }
89
+
90
+ /** @param {number | string} w @returns {number} milliseconds */
91
+ export function parseWindow(w) {
92
+ if (typeof w === 'number') return w;
93
+ const m = /^(\d+)\s*(ms|s|m|h)?$/.exec(String(w));
94
+ if (!m) return 60_000;
95
+ const n = Number(m[1]);
96
+ const unit = m[2] || 'ms';
97
+ const mult = { ms: 1, s: 1000, m: 60_000, h: 3_600_000 }[unit];
98
+ return n * (mult || 1);
99
+ }
100
+
101
+ /** Testing hook: reset the default store (for unit tests). */
102
+ export function _resetRateLimits() {
103
+ // With the cache store, there's nothing to reset here: the store
104
+ // handles its own state. This function exists for API compatibility.
105
+ }