@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 ADDED
@@ -0,0 +1,51 @@
1
+ # @webjsdev/server
2
+
3
+ Dev + production server for [webjs](https://github.com/vivek7405/webjs):
4
+ file-based routing, streaming SSR, server actions, WebSocket upgrades, and
5
+ live reload.
6
+
7
+ Rarely installed directly. Use [`@webjsdev/cli`](https://www.npmjs.com/package/@webjsdev/cli)
8
+ to scaffold and run an app, which pulls this package in as a dependency.
9
+
10
+ ## Features
11
+
12
+ - **File-based routing** at parity with NextJs App Router: `page.ts`,
13
+ `layout.ts`, `route.ts`, `error.ts`, `loading.ts`, `not-found.ts`,
14
+ `middleware.ts`, `[param]`, `[...slug]`, `(groups)`, `_private`.
15
+ - **Streaming SSR** with Suspense boundaries.
16
+ - **Server actions**: import a `.server.ts` function from a client component
17
+ and it auto-rewrites into a type-safe RPC stub. webjs's built-in serializer on the wire keeps Date/Map/Set/BigInt/TypedArray/Blob/File/FormData/cycles all surviving.
18
+ - **WebSockets**: export `WS` from `route.ts` and it becomes a WebSocket
19
+ endpoint on the same path.
20
+ - **Live reload** for dev.
21
+ - **Bare-specifier auto-bundling** for npm packages via import maps, backed
22
+ by esbuild (Vite-style `optimizeDeps`).
23
+
24
+ ## Install
25
+
26
+ ```sh
27
+ npm install @webjsdev/server @webjsdev/core
28
+ ```
29
+
30
+ ## Use
31
+
32
+ Normally invoked via the CLI:
33
+
34
+ ```sh
35
+ webjs dev
36
+ webjs start
37
+ ```
38
+
39
+ Or programmatically:
40
+
41
+ ```js
42
+ import { startServer } from '@webjsdev/server';
43
+
44
+ await startServer({ port: 3000, appDir: process.cwd(), dev: true });
45
+ ```
46
+
47
+ See the full framework docs at https://github.com/vivek7405/webjs.
48
+
49
+ ## License
50
+
51
+ MIT
package/index.js ADDED
@@ -0,0 +1,29 @@
1
+ export { startServer, createRequestHandler } from './src/dev.js';
2
+ export { buildRouteTable, matchPage, matchApi } from './src/router.js';
3
+ export { ssrPage, ssrNotFound } from './src/ssr.js';
4
+ export { handleApi } from './src/api.js';
5
+ export {
6
+ buildActionIndex,
7
+ isServerFile,
8
+ hashFile,
9
+ resolveServerModule,
10
+ serveActionStub,
11
+ invokeAction,
12
+ } from './src/actions.js';
13
+ export { buildImportMap, importMapTag, setVendorEntries } from './src/importmap.js';
14
+ export { scanBareImports, extractPackageName, bundlePackage, vendorImportMapEntries, clearVendorCache, serveVendorBundle } from './src/vendor.js';
15
+ export { buildModuleGraph, transitiveDeps } from './src/module-graph.js';
16
+ export { scanComponents, primeComponentRegistry, extractComponents, findOrphanComponents } from './src/component-scanner.js';
17
+ export { headers, cookies, getRequest, withRequest } from './src/context.js';
18
+ export { defaultLogger } from './src/logger.js';
19
+ export { rateLimit, parseWindow } from './src/rate-limit.js';
20
+ export { memoryStore, redisStore, getStore, setStore } from './src/cache.js';
21
+ export { cache } from './src/cache-fn.js';
22
+ export { Session, session, cookieSessionStorage, storeSessionStorage, cookieSession, storeSession, getSession } from './src/session.js';
23
+ export { broadcast } from './src/broadcast.js';
24
+ export { json, readBody } from './src/json.js';
25
+ export { attachWebSocket } from './src/websocket.js';
26
+ export { getSerializer, setSerializer, defaultSerializer } from './src/serializer.js';
27
+
28
+ // Auth (NextAuth-style)
29
+ export { createAuth, Credentials, Google, GitHub } from './src/auth.js';
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@webjsdev/server",
3
+ "version": "0.7.2",
4
+ "type": "module",
5
+ "description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
6
+ "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./check": "./src/check.js"
10
+ },
11
+ "files": [
12
+ "index.js",
13
+ "src",
14
+ "README.md"
15
+ ],
16
+ "dependencies": {
17
+ "@webjsdev/core": "^0.7.1",
18
+ "chokidar": "^3.6.0",
19
+ "esbuild": "^0.28.0",
20
+ "ws": "^8.20.0"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/vivek7405/webjs.git",
28
+ "directory": "packages/server"
29
+ },
30
+ "homepage": "https://github.com/vivek7405/webjs#readme",
31
+ "bugs": "https://github.com/vivek7405/webjs/issues",
32
+ "license": "MIT",
33
+ "keywords": [
34
+ "webjs",
35
+ "ssr",
36
+ "server",
37
+ "router",
38
+ "dev-server"
39
+ ],
40
+ "engines": {
41
+ "node": ">=24.0.0"
42
+ }
43
+ }
package/src/actions.js ADDED
@@ -0,0 +1,478 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { pathToFileURL } from 'node:url';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { join, relative, sep } from 'node:path';
5
+ import { getExposed } from '@webjsdev/core';
6
+ import { walk } from './fs-walk.js';
7
+ import { verify as verifyCsrf, CSRF_COOKIE, CSRF_HEADER } from './csrf.js';
8
+ import { getSerializer } from './serializer.js';
9
+
10
+ /**
11
+ * Internal RPC wire-format content type. Distinguishes webjs action
12
+ * responses from plain `application/json` so the stub can pick the right
13
+ * parser and external JSON consumers aren't confused.
14
+ *
15
+ * Uses the content type from the active serializer (defaults to
16
+ * `application/vnd.webjs+json` with the built-in webjs serializer).
17
+ */
18
+ export const RPC_CONTENT_TYPE = 'application/vnd.webjs+json';
19
+
20
+ /** Build a serialized Response with webjs content-type. */
21
+ async function rpcResponse(payload, init = {}) {
22
+ const s = getSerializer();
23
+ const headers = new Headers(init.headers || {});
24
+ headers.set('content-type', s.contentType);
25
+ return new Response(await s.serialize(payload), { ...init, headers });
26
+ }
27
+
28
+ /**
29
+ * Server-actions subsystem.
30
+ *
31
+ * Two complementary markers describe server-side files:
32
+ *
33
+ * - `.server.{js,ts,mts,mjs}` extension: file is **server-only**. The
34
+ * file router refuses to serve its source to the browser. This is
35
+ * the path-level boundary.
36
+ * - `'use server'` directive at the top: file's exports are
37
+ * **RPC-callable** from client code. This is the semantic opt-in.
38
+ *
39
+ * The two together (`.server.ts` AND `'use server'`) define a server
40
+ * action: source-protected AND RPC-exposed. The extension alone marks
41
+ * a server-only utility (source-protected, NOT RPC-exposed: browser
42
+ * imports get an error stub that throws at load). The directive alone
43
+ * (no extension) does nothing: a `webjs check` lint rule
44
+ * (`use-server-needs-extension`) flags it because the file is served
45
+ * to the browser as plain source and the directive is silently
46
+ * ignored.
47
+ *
48
+ * The server:
49
+ * 1. Scans the app tree on boot, classifying server files into
50
+ * RPC-callable actions vs. server-only utilities.
51
+ * 2. Serves a generated ES-module stub when the browser imports
52
+ * the file URL (an RPC stub for actions, a throw-at-load stub
53
+ * for server-only utilities).
54
+ * 3. Exposes POST endpoints at /__webjs/action/:hash/:fn for
55
+ * RPC-callable actions only.
56
+ * 4. If an exported function was wrapped in `expose('METHOD /path', fn)`,
57
+ * also registers it as a first-class REST endpoint.
58
+ *
59
+ * @typedef {{
60
+ * method: string,
61
+ * pattern: RegExp,
62
+ * paramNames: string[],
63
+ * file: string,
64
+ * fnName: string,
65
+ * validate: ((input: any) => any) | null,
66
+ * cors: { origin: string | string[], credentials: boolean, maxAge: number, headers: string[] | null } | null,
67
+ * }} ExposedRoute
68
+ *
69
+ * @typedef {{
70
+ * hashToFile: Map<string,string>,
71
+ * fileToHash: Map<string,string>,
72
+ * httpRoutes: ExposedRoute[],
73
+ * appDir: string,
74
+ * dev: boolean,
75
+ * }} ActionIndex
76
+ */
77
+
78
+ /**
79
+ * Build the action index by scanning the app directory.
80
+ *
81
+ * @param {string} appDir
82
+ * @param {boolean} dev
83
+ * @returns {Promise<ActionIndex>}
84
+ */
85
+ export async function buildActionIndex(appDir, dev) {
86
+ /** @type {Map<string,string>} */
87
+ const hashToFile = new Map();
88
+ /** @type {Map<string,string>} */
89
+ const fileToHash = new Map();
90
+ /** @type {ExposedRoute[]} */
91
+ const httpRoutes = [];
92
+
93
+ for await (const file of walk(appDir, (p) => /\.m?[jt]s$/.test(p))) {
94
+ // Path-level: only `.server.{ts,js,mts,mjs}` files are server-only.
95
+ // A bare `'use server'` directive without the extension is a lint
96
+ // violation (use-server-needs-extension) and the file is treated as
97
+ // plain browser code: no source protection, no RPC registration.
98
+ if (!isServerFile(file)) continue;
99
+ // Semantic-level: only files that ALSO have `'use server'` are
100
+ // RPC-callable. `.server.ts` without the directive is server-only
101
+ // (still source-protected by the file router) but its exports are
102
+ // NOT registered as RPC endpoints. The browser-side import gets a
103
+ // throw-at-load stub via `serveServerOnlyStub` instead.
104
+ if (!(await hasUseServerDirective(file))) continue;
105
+
106
+ const h = hashFile(file);
107
+ hashToFile.set(h, file);
108
+ fileToHash.set(file, h);
109
+ // Load module once at scan time to pick up any expose() tags.
110
+ try {
111
+ const mod = await loadModule(file, dev);
112
+ for (const [name, fn] of Object.entries(mod)) {
113
+ if (typeof fn !== 'function') continue;
114
+ const http = getExposed(fn);
115
+ if (!http) continue;
116
+ const { pattern, paramNames } = pathToPattern(http.path);
117
+ httpRoutes.push({
118
+ method: http.method,
119
+ pattern,
120
+ paramNames,
121
+ file,
122
+ fnName: name,
123
+ validate: http.validate || null,
124
+ cors: http.cors || null,
125
+ });
126
+ }
127
+ } catch (e) {
128
+ console.error(`[webjs] failed to scan server module ${file}:`, e);
129
+ }
130
+ }
131
+
132
+ return { hashToFile, fileToHash, httpRoutes, appDir, dev };
133
+ }
134
+
135
+ /** @param {string} file */
136
+ export function hashFile(file) {
137
+ return createHash('sha256').update(file).digest('hex').slice(0, 10);
138
+ }
139
+
140
+ /**
141
+ * Predicate: file is server-only (source-protected, never served as
142
+ * source to the browser). True for `.server.{js,ts,mts,mjs}` files.
143
+ * Synchronous, name-only check, the path-level boundary.
144
+ *
145
+ * The `'use server'` directive without the extension does NOT make a
146
+ * file server-only: a `webjs check` lint rule
147
+ * (`use-server-needs-extension`) flags that pattern instead, and the
148
+ * file is treated as plain browser code.
149
+ *
150
+ * @param {string} file
151
+ * @returns {boolean}
152
+ */
153
+ export function isServerFile(file) {
154
+ return /\.server\.m?[jt]s$/.test(file);
155
+ }
156
+
157
+ /**
158
+ * Predicate: file has the `'use server'` directive in its first 5 lines.
159
+ * Semantic-level marker: when paired with `.server.ts`, registers the
160
+ * file's exports as RPC-callable from client code.
161
+ *
162
+ * @param {string} file
163
+ * @returns {Promise<boolean>}
164
+ */
165
+ export async function hasUseServerDirective(file) {
166
+ try {
167
+ const text = await readFile(file, 'utf8');
168
+ const head = text.split('\n').slice(0, 5).join('\n');
169
+ return /^\s*(['"])use server\1\s*;?\s*$/m.test(head);
170
+ } catch {
171
+ return false;
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Predicate: file is a server action (server-only + RPC-callable).
177
+ * True when both markers are present: `.server.{js,ts}` extension AND
178
+ * `'use server'` directive.
179
+ *
180
+ * @param {string} file
181
+ * @returns {Promise<boolean>}
182
+ */
183
+ export async function isServerAction(file) {
184
+ if (!isServerFile(file)) return false;
185
+ return await hasUseServerDirective(file);
186
+ }
187
+
188
+ /**
189
+ * @param {ActionIndex} idx
190
+ * @param {string} urlPath - a browser-visible URL path like `/actions/foo.server.js`
191
+ */
192
+ export function resolveServerModule(idx, urlPath) {
193
+ const abs = join(idx.appDir, urlPath.split('/').join(sep));
194
+ return idx.fileToHash.has(abs) ? abs : null;
195
+ }
196
+
197
+ /**
198
+ * Generate a throw-at-load stub for a server-only file (a `.server.ts`
199
+ * file WITHOUT a `'use server'` directive). When a browser-side module
200
+ * imports this file, the stub throws synchronously at module load time
201
+ * with a clear error pointing at the file, so the developer immediately
202
+ * sees that server-only code can't be reached from the browser.
203
+ *
204
+ * @param {string} relPath path relative to appDir for the error message
205
+ * @returns {string} JavaScript module source
206
+ */
207
+ export function serveServerOnlyStub(relPath) {
208
+ const msg =
209
+ `Cannot import "${relPath}" from browser code. ` +
210
+ `This file is server-only (a .server.{js,ts} file with no 'use server' directive). ` +
211
+ `Either add 'use server' at the top of the file to expose its exports as RPC, ` +
212
+ `or wrap the server-only logic in a separate *.server.{js,ts} action and import that instead.`;
213
+ return `// webjs: server-only module stub for ${relPath} (no 'use server' directive)
214
+ throw new Error(${JSON.stringify(msg)});
215
+ `;
216
+ }
217
+
218
+ /**
219
+ * Serve the generated client stub for a server module.
220
+ * @param {ActionIndex} idx
221
+ * @param {string} absFile
222
+ */
223
+ export async function serveActionStub(idx, absFile) {
224
+ const mod = await loadModule(absFile, idx.dev);
225
+ const hash = idx.fileToHash.get(absFile) || hashFile(absFile);
226
+ const fnNames = Object.keys(mod).filter((k) => typeof mod[k] === 'function');
227
+ if (typeof mod.default === 'function' && !fnNames.includes('default')) {
228
+ fnNames.push('default');
229
+ }
230
+ const body = `// webjs: generated server-action stub for ${relative(idx.appDir, absFile)}\n` +
231
+ `import { stringify as __wjStringify, parse as __wjParse } from '@webjsdev/core';\n` +
232
+ `function __csrf() {\n` +
233
+ ` const m = document.cookie.match(/(?:^|;\\s*)${CSRF_COOKIE}=([^;]+)/);\n` +
234
+ ` return m ? decodeURIComponent(m[1]) : '';\n` +
235
+ `}\n` +
236
+ `async function __rpc(fn, args) {\n` +
237
+ ` const body = await __wjStringify(args);\n` +
238
+ ` const res = await fetch(${JSON.stringify(`/__webjs/action/${hash}/`)} + fn, {\n` +
239
+ ` method: 'POST',\n` +
240
+ ` headers: {\n` +
241
+ ` 'content-type': ${JSON.stringify(RPC_CONTENT_TYPE)},\n` +
242
+ ` ${JSON.stringify(CSRF_HEADER)}: __csrf()\n` +
243
+ ` },\n` +
244
+ ` credentials: 'same-origin',\n` +
245
+ ` body\n` +
246
+ ` });\n` +
247
+ ` const ct = res.headers.get('content-type') || '';\n` +
248
+ ` const text = await res.text();\n` +
249
+ ` const parsed = ct.includes(${JSON.stringify(RPC_CONTENT_TYPE)})\n` +
250
+ ` ? __wjParse(text)\n` +
251
+ ` : (ct.includes('application/json') ? JSON.parse(text) : text);\n` +
252
+ ` if (!res.ok) {\n` +
253
+ ` const msg = (parsed && parsed.error) || ('webjs action ' + fn + ' -> ' + res.status);\n` +
254
+ ` throw new Error(msg);\n` +
255
+ ` }\n` +
256
+ ` return parsed;\n` +
257
+ `}\n` +
258
+ fnNames
259
+ .map((name) =>
260
+ name === 'default'
261
+ ? `export default (...args) => __rpc('default', args);`
262
+ : `export const ${name} = (...args) => __rpc(${JSON.stringify(name)}, args);`
263
+ )
264
+ .join('\n') + '\n';
265
+ return body;
266
+ }
267
+
268
+ /**
269
+ * Invoke a server action via the internal RPC wire format.
270
+ * @param {ActionIndex} idx
271
+ * @param {string} hash
272
+ * @param {string} fnName
273
+ * @param {Request} req
274
+ */
275
+ export async function invokeAction(idx, hash, fnName, req) {
276
+ if (!verifyCsrf(req)) {
277
+ return rpcResponse({ error: 'CSRF validation failed' }, { status: 403 });
278
+ }
279
+ const file = idx.hashToFile.get(hash);
280
+ if (!file) return rpcResponse({ error: 'Unknown action' }, { status: 404 });
281
+ let args = [];
282
+ try {
283
+ const body = await req.text();
284
+ args = body ? getSerializer().deserialize(body) : [];
285
+ if (!Array.isArray(args)) args = [args];
286
+ } catch {
287
+ return rpcResponse({ error: 'Invalid request body' }, { status: 400 });
288
+ }
289
+ const mod = await loadModule(file, idx.dev);
290
+ const fn = fnName === 'default' ? mod.default : mod[fnName];
291
+ if (typeof fn !== 'function') return rpcResponse({ error: `Unknown action ${fnName}` }, { status: 404 });
292
+ try {
293
+ const result = await fn(...args);
294
+ return rpcResponse(result ?? null);
295
+ } catch (e) {
296
+ return actionErrorResponse(e, idx.dev);
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Match an incoming request against an expose()d action route.
302
+ * Returns the single matched route+params for normal methods.
303
+ * @param {ActionIndex} idx
304
+ * @param {string} method
305
+ * @param {string} pathname
306
+ */
307
+ export function matchExposedAction(idx, method, pathname) {
308
+ for (const r of idx.httpRoutes) {
309
+ if (r.method !== method) continue;
310
+ const m = r.pattern.exec(pathname);
311
+ if (!m) continue;
312
+ /** @type {Record<string,string>} */
313
+ const params = {};
314
+ r.paramNames.forEach((n, i) => (params[n] = decodeURIComponent(m[i + 1] || '')));
315
+ return { route: r, params };
316
+ }
317
+ return null;
318
+ }
319
+
320
+ /**
321
+ * Find ALL exposed routes at a given path (any method). Used to build OPTIONS
322
+ * preflight responses and Allow headers.
323
+ * @param {ActionIndex} idx
324
+ * @param {string} pathname
325
+ */
326
+ export function matchAllAtPath(idx, pathname) {
327
+ const out = [];
328
+ for (const r of idx.httpRoutes) {
329
+ if (r.pattern.exec(pathname)) out.push(r);
330
+ }
331
+ return out;
332
+ }
333
+
334
+ /**
335
+ * Build CORS response headers given a route's CORS config + the request.
336
+ * Returns null if CORS isn't configured for this route.
337
+ *
338
+ * @param {ExposedRoute} route
339
+ * @param {Request} req
340
+ */
341
+ export function corsHeadersFor(route, req) {
342
+ if (!route.cors) return null;
343
+ const cfg = route.cors;
344
+ const origin = req.headers.get('origin') || '';
345
+ const allowed = matchOrigin(cfg.origin, origin);
346
+ if (!allowed && origin) return null;
347
+ const h = new Headers();
348
+ h.set('access-control-allow-origin', allowed === true ? '*' : allowed || origin);
349
+ if (cfg.credentials) h.set('access-control-allow-credentials', 'true');
350
+ h.append('vary', 'Origin');
351
+ return h;
352
+ }
353
+
354
+ /** @param {ExposedRoute} route @param {Request} req */
355
+ export function buildPreflightResponse(route, req) {
356
+ const headers = corsHeadersFor(route, req);
357
+ if (!headers) return new Response(null, { status: 403 });
358
+ headers.set('access-control-allow-methods', `${route.method}, OPTIONS`);
359
+ const reqHdrs =
360
+ route.cors?.headers?.join(',') || req.headers.get('access-control-request-headers') || 'content-type';
361
+ headers.set('access-control-allow-headers', reqHdrs);
362
+ headers.set('access-control-max-age', String(route.cors?.maxAge ?? 86400));
363
+ return new Response(null, { status: 204, headers });
364
+ }
365
+
366
+ /** Apply CORS headers (if any) to an existing response. */
367
+ export function withCors(resp, route, req) {
368
+ const h = corsHeadersFor(route, req);
369
+ if (!h) return resp;
370
+ const newHeaders = new Headers(resp.headers);
371
+ h.forEach((v, k) => newHeaders.set(k, v));
372
+ return new Response(resp.body, { status: resp.status, statusText: resp.statusText, headers: newHeaders });
373
+ }
374
+
375
+ /** @param {string|string[]} configured @param {string} origin */
376
+ function matchOrigin(configured, origin) {
377
+ if (configured === '*') return true;
378
+ if (Array.isArray(configured)) return configured.includes(origin) ? origin : null;
379
+ return configured === origin ? origin : null;
380
+ }
381
+
382
+ /**
383
+ * Invoke an exposed action as a REST endpoint.
384
+ * Builds a single object argument from URL params + query + JSON body.
385
+ * @param {ActionIndex} idx
386
+ * @param {ExposedRoute} route
387
+ * @param {Record<string,string>} params
388
+ * @param {Request} req
389
+ */
390
+ export async function invokeExposedAction(idx, route, params, req) {
391
+ const url = new URL(req.url);
392
+ const query = Object.fromEntries(url.searchParams.entries());
393
+ let body = {};
394
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
395
+ const text = await req.text();
396
+ if (text) {
397
+ try {
398
+ const parsed = JSON.parse(text);
399
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) body = parsed;
400
+ else body = { body: parsed };
401
+ } catch {
402
+ return new Response('Invalid JSON body', { status: 400 });
403
+ }
404
+ }
405
+ }
406
+ let arg = { ...query, ...params, ...body };
407
+ if (route.validate) {
408
+ try {
409
+ arg = route.validate(arg);
410
+ } catch (e) {
411
+ const msg = e instanceof Error ? e.message : String(e);
412
+ // Many schema libs (zod, valibot) throw structured errors: pass their
413
+ // `issues` array through when present for easier client-side handling.
414
+ const issues = e && typeof e === 'object' && 'issues' in e
415
+ ? /** @type any */ (e).issues
416
+ : undefined;
417
+ return Response.json({ error: msg, issues }, { status: 400 });
418
+ }
419
+ }
420
+ const mod = await loadModule(route.file, idx.dev);
421
+ const fn = mod[route.fnName];
422
+ if (typeof fn !== 'function') return new Response(`Unknown action ${route.fnName}`, { status: 404 });
423
+ try {
424
+ const result = await fn(arg, { req, params });
425
+ if (result instanceof Response) return result;
426
+ return Response.json(result ?? null);
427
+ } catch (e) {
428
+ return actionErrorResponse(e, idx.dev);
429
+ }
430
+ }
431
+
432
+ /**
433
+ * Return a JSON error response with dev-vs-prod sanitization.
434
+ * In prod we return only the error message (not the stack), and we log the
435
+ * full error server-side. Internal errors with no message become a generic
436
+ * 500.
437
+ *
438
+ * @param {unknown} err
439
+ * @param {boolean} dev
440
+ */
441
+ function actionErrorResponse(err, dev) {
442
+ console.error('[webjs] action threw:', err);
443
+ if (dev) {
444
+ const msg = err instanceof Error ? err.message : String(err);
445
+ const stack = err instanceof Error ? err.stack : undefined;
446
+ return rpcResponse({ error: msg, stack }, { status: 500 });
447
+ }
448
+ // Prod: only expose the thrown message (author-controlled), never the stack.
449
+ const msg =
450
+ err instanceof Error && typeof err.message === 'string' && err.message
451
+ ? err.message
452
+ : 'Internal server error';
453
+ return rpcResponse({ error: msg }, { status: 500 });
454
+ }
455
+
456
+ /**
457
+ * Convert an `expose()` path like `/api/posts/:slug` to a regex + param list.
458
+ * Also accepts NextJs-style `[slug]` brackets for familiarity.
459
+ * @param {string} path
460
+ */
461
+ function pathToPattern(path) {
462
+ const paramNames = [];
463
+ const re = path.replace(/:([A-Za-z_][A-Za-z0-9_]*)|\[([A-Za-z_][A-Za-z0-9_]*)\]/g, (_, a, b) => {
464
+ paramNames.push(a || b);
465
+ return '([^/]+)';
466
+ });
467
+ return { pattern: new RegExp(`^${re}/?$`), paramNames };
468
+ }
469
+
470
+ /**
471
+ * @param {string} file
472
+ * @param {boolean} dev
473
+ */
474
+ async function loadModule(file, dev) {
475
+ const url = pathToFileURL(file).toString();
476
+ const bust = dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : '';
477
+ return import(url + bust);
478
+ }
package/src/api.js ADDED
@@ -0,0 +1,37 @@
1
+ import { pathToFileURL } from 'node:url';
2
+
3
+ /**
4
+ * Dispatch an incoming request to a matched API route.
5
+ * API modules export methods as named async functions: GET, POST, PUT, PATCH, DELETE.
6
+ *
7
+ * Handlers receive a standard `Request` and return a standard `Response`.
8
+ *
9
+ * @param {import('./router.js').ApiRoute} route
10
+ * @param {Record<string,string>} params
11
+ * @param {Request} webRequest
12
+ * @param {boolean} dev
13
+ * @returns {Promise<Response>}
14
+ */
15
+ export async function handleApi(route, params, webRequest, dev) {
16
+ const url = pathToFileURL(route.file).toString();
17
+ const bust = dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : '';
18
+ const mod = await import(url + bust);
19
+ const method = webRequest.method.toUpperCase();
20
+ const handler = mod[method];
21
+ if (!handler) {
22
+ return new Response(`Method ${method} not allowed`, {
23
+ status: 405,
24
+ headers: { allow: allowedMethods(mod).join(', ') },
25
+ });
26
+ }
27
+ /** @type any */ (webRequest).params = params;
28
+ const result = await handler(webRequest, { params });
29
+ if (result instanceof Response) return result;
30
+ // Convenience: allow returning plain objects as JSON.
31
+ return Response.json(result);
32
+ }
33
+
34
+ /** @param {Record<string,unknown>} mod */
35
+ function allowedMethods(mod) {
36
+ return ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].filter((m) => typeof mod[m] === 'function');
37
+ }