@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/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
|
+
}
|