@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
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side scanner that walks the app tree and records the
|
|
3
|
+
* browser-visible URL for every webjs component module.
|
|
4
|
+
*
|
|
5
|
+
* Called once at server boot. Results are used to prime the core
|
|
6
|
+
* registry (`primeModuleUrl`) BEFORE any SSR render: so when a page
|
|
7
|
+
* renders a component tag, `lookupModuleUrl(tag)` already has the URL
|
|
8
|
+
* ready for `<link rel="modulepreload">` hints.
|
|
9
|
+
*
|
|
10
|
+
* The convention webjs uses is the web-standard one:
|
|
11
|
+
*
|
|
12
|
+
* class Counter extends WebComponent { … }
|
|
13
|
+
* customElements.define('my-counter', Counter);
|
|
14
|
+
*
|
|
15
|
+
* The scanner looks for `customElements.define('<tag>', <ClassName>)`
|
|
16
|
+
* calls: static text patterns that are cheap to regex-match without
|
|
17
|
+
* a full TS parse. A full parse would be ~50× slower for no payoff;
|
|
18
|
+
* we only need `{ tag, className, moduleUrl }` tuples.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readFile } from 'node:fs/promises';
|
|
22
|
+
import { sep } from 'node:path';
|
|
23
|
+
import { walk } from './fs-walk.js';
|
|
24
|
+
import { primeModuleUrl } from '@webjsdev/core';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Recognise either registration pattern:
|
|
28
|
+
*
|
|
29
|
+
* Counter.register('my-counter') // idiomatic webjs
|
|
30
|
+
* customElements.define('my-counter', Counter) // native DOM API
|
|
31
|
+
*
|
|
32
|
+
* Both single and double quotes; whitespace is flexible.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} src
|
|
35
|
+
* @returns {Array<{ className: string, tag: string }>}
|
|
36
|
+
*/
|
|
37
|
+
export function extractComponents(src) {
|
|
38
|
+
/** @type {Array<{ className: string, tag: string }>} */
|
|
39
|
+
const results = [];
|
|
40
|
+
// Pattern A: Class.register('tag')
|
|
41
|
+
const registerRe = /\b([A-Z][A-Za-z0-9_$]*)\.register\s*\(\s*['"]([^'"\n]+)['"]\s*\)/g;
|
|
42
|
+
let m;
|
|
43
|
+
while ((m = registerRe.exec(src)) !== null) {
|
|
44
|
+
const className = m[1];
|
|
45
|
+
const tag = m[2];
|
|
46
|
+
if (tag.includes('-')) {
|
|
47
|
+
results.push({ className, tag });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// Pattern B: customElements.define('tag', Class)
|
|
51
|
+
const defineRe = /\bcustomElements\.define\s*\(\s*['"]([^'"\n]+)['"]\s*,\s*([A-Z][A-Za-z0-9_$]*)\b/g;
|
|
52
|
+
while ((m = defineRe.exec(src)) !== null) {
|
|
53
|
+
const tag = m[1];
|
|
54
|
+
const className = m[2];
|
|
55
|
+
if (tag.includes('-')) {
|
|
56
|
+
results.push({ className, tag });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return results;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Walk an app directory, return every discovered component with its
|
|
64
|
+
* browser-visible URL (rooted at `/`, matching how the dev server
|
|
65
|
+
* serves module files).
|
|
66
|
+
*
|
|
67
|
+
* @param {string} appDir
|
|
68
|
+
* @returns {Promise<Array<{ tag: string, className: string, moduleUrl: string, file: string }>>}
|
|
69
|
+
*/
|
|
70
|
+
export async function scanComponents(appDir) {
|
|
71
|
+
/** @type {Array<{ tag: string, className: string, moduleUrl: string, file: string }>} */
|
|
72
|
+
const components = [];
|
|
73
|
+
const filter = (p) =>
|
|
74
|
+
/\.m?[jt]sx?$/.test(p) &&
|
|
75
|
+
!/\.(test|spec)\.m?[jt]sx?$/.test(p) &&
|
|
76
|
+
!/\.server\.m?[jt]s$/.test(p);
|
|
77
|
+
|
|
78
|
+
for await (const file of walk(appDir, filter)) {
|
|
79
|
+
let src;
|
|
80
|
+
try { src = await readFile(file, 'utf8'); } catch { continue; }
|
|
81
|
+
const comps = extractComponents(src);
|
|
82
|
+
if (!comps.length) continue;
|
|
83
|
+
const moduleUrl = toUrlPath(file, appDir);
|
|
84
|
+
for (const c of comps) {
|
|
85
|
+
components.push({ ...c, moduleUrl, file });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return components;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Scan the app tree and push every component's (tag, moduleUrl) pair
|
|
93
|
+
* into the core registry via `primeModuleUrl`. Idempotent: if called
|
|
94
|
+
* again (e.g. on dev-server rebuild after a file add), new discoveries
|
|
95
|
+
* are added and existing tags are updated.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} appDir
|
|
98
|
+
* @returns {Promise<{ count: number }>}
|
|
99
|
+
*/
|
|
100
|
+
export async function primeComponentRegistry(appDir) {
|
|
101
|
+
const components = await scanComponents(appDir);
|
|
102
|
+
for (const { tag, moduleUrl } of components) {
|
|
103
|
+
primeModuleUrl(tag, moduleUrl);
|
|
104
|
+
}
|
|
105
|
+
return { count: components.length };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Find `class X extends WebComponent` (or its subclasses) declarations
|
|
110
|
+
* that are NOT accompanied by a `customElements.define(tag, X)` call in
|
|
111
|
+
* the same file. Lets the dev server warn authors early when they
|
|
112
|
+
* forget the registration step.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} appDir
|
|
115
|
+
* @returns {Promise<Array<{ className: string, file: string }>>}
|
|
116
|
+
*/
|
|
117
|
+
export async function findOrphanComponents(appDir) {
|
|
118
|
+
/** @type {Array<{ className: string, file: string }>} */
|
|
119
|
+
const orphans = [];
|
|
120
|
+
const filter = (p) =>
|
|
121
|
+
/\.m?[jt]sx?$/.test(p) &&
|
|
122
|
+
!/\.(test|spec)\.m?[jt]sx?$/.test(p) &&
|
|
123
|
+
!/\.server\.m?[jt]s$/.test(p);
|
|
124
|
+
|
|
125
|
+
for await (const file of walk(appDir, filter)) {
|
|
126
|
+
let src;
|
|
127
|
+
try { src = await readFile(file, 'utf8'); } catch { continue; }
|
|
128
|
+
// Find every class that extends WebComponent (exact name: we trust
|
|
129
|
+
// the framework convention).
|
|
130
|
+
const classRe = /\b(?:export\s+)?(?:default\s+)?class\s+([A-Z][A-Za-z0-9_$]*)\s+extends\s+WebComponent\b/g;
|
|
131
|
+
// A class counts as "registered" if either Class.register('tag') or
|
|
132
|
+
// customElements.define('tag', Class) appears in the file.
|
|
133
|
+
const registerRe = /\b([A-Z][A-Za-z0-9_$]*)\.register\s*\(\s*['"][^'"]+['"]\s*\)/g;
|
|
134
|
+
const defineRe = /\bcustomElements\.define\s*\(\s*['"][^'"]+['"]\s*,\s*([A-Z][A-Za-z0-9_$]*)\b/g;
|
|
135
|
+
|
|
136
|
+
const declared = new Set();
|
|
137
|
+
let m;
|
|
138
|
+
while ((m = classRe.exec(src)) !== null) declared.add(m[1]);
|
|
139
|
+
if (declared.size === 0) continue;
|
|
140
|
+
|
|
141
|
+
const registered = new Set();
|
|
142
|
+
while ((m = registerRe.exec(src)) !== null) registered.add(m[1]);
|
|
143
|
+
while ((m = defineRe.exec(src)) !== null) registered.add(m[1]);
|
|
144
|
+
|
|
145
|
+
for (const cls of declared) {
|
|
146
|
+
if (!registered.has(cls)) {
|
|
147
|
+
orphans.push({ className: cls, file });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return orphans;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* @param {string} abs
|
|
156
|
+
* @param {string} appDir
|
|
157
|
+
* @returns {string}
|
|
158
|
+
*/
|
|
159
|
+
function toUrlPath(abs, appDir) {
|
|
160
|
+
let rel = abs.startsWith(appDir) ? abs.slice(appDir.length) : abs;
|
|
161
|
+
rel = rel.split(sep).join('/');
|
|
162
|
+
if (!rel.startsWith('/')) rel = '/' + rel;
|
|
163
|
+
return rel;
|
|
164
|
+
}
|
package/src/context.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { parseCookies } from './csrf.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Per-request context backed by AsyncLocalStorage. Lets server-side code
|
|
6
|
+
* (pages, layouts, server actions, exposed actions) read the current
|
|
7
|
+
* Request's headers and cookies without explicit threading: the same
|
|
8
|
+
* ergonomics as NextJs's `headers()` / `cookies()` from `next/headers`.
|
|
9
|
+
*
|
|
10
|
+
* Strictly server-side: importing this module on the client is a bug.
|
|
11
|
+
*
|
|
12
|
+
* @typedef {{ req: Request }} Store
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** @type {AsyncLocalStorage<Store>} */
|
|
16
|
+
const als = new AsyncLocalStorage();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Run `fn` with the given request bound as the current context.
|
|
20
|
+
* @template T
|
|
21
|
+
* @param {Request} req
|
|
22
|
+
* @param {() => T} fn
|
|
23
|
+
* @returns {T}
|
|
24
|
+
*/
|
|
25
|
+
export function withRequest(req, fn) {
|
|
26
|
+
return als.run({ req }, fn);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** @returns {Request | null} */
|
|
30
|
+
export function getRequest() {
|
|
31
|
+
return als.getStore()?.req ?? null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Read-only headers for the in-flight request. Throws outside a request
|
|
36
|
+
* (e.g. at module top-level).
|
|
37
|
+
* @returns {Headers}
|
|
38
|
+
*/
|
|
39
|
+
export function headers() {
|
|
40
|
+
const req = getRequest();
|
|
41
|
+
if (!req) throw new Error('headers(): called outside a request scope');
|
|
42
|
+
return req.headers;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Read-only cookie jar for the in-flight request. Returns an object with
|
|
47
|
+
* `.get(name)`, `.has(name)`, `.entries()`. To SET a cookie, return a
|
|
48
|
+
* `Response` whose headers include `Set-Cookie` (route handlers and
|
|
49
|
+
* exposed actions can do this directly).
|
|
50
|
+
*
|
|
51
|
+
* @returns {{ get: (name: string) => string | undefined, has: (name: string) => boolean, entries: () => [string, string][] }}
|
|
52
|
+
*/
|
|
53
|
+
export function cookies() {
|
|
54
|
+
const req = getRequest();
|
|
55
|
+
if (!req) throw new Error('cookies(): called outside a request scope');
|
|
56
|
+
const map = parseCookies(req);
|
|
57
|
+
return {
|
|
58
|
+
get: (name) => map[name],
|
|
59
|
+
has: (name) => Object.prototype.hasOwnProperty.call(map, name),
|
|
60
|
+
entries: () => Object.entries(map),
|
|
61
|
+
};
|
|
62
|
+
}
|
package/src/csrf.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Use Web Crypto (globalThis.crypto) for random + hash: works on Node >=20,
|
|
2
|
+
// Deno, Bun, Cloudflare Workers. Avoids the node:crypto import and keeps
|
|
3
|
+
// CSRF portable across runtimes.
|
|
4
|
+
const webCrypto = /** @type {Crypto} */ (globalThis.crypto);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Double-submit cookie CSRF protection for `/__webjs/action/*` RPC endpoints.
|
|
8
|
+
*
|
|
9
|
+
* Flow:
|
|
10
|
+
* 1. Every SSR response that lacks the cookie issues a fresh `webjs_csrf`
|
|
11
|
+
* cookie with `SameSite=Lax; Path=/` (and `Secure` when over HTTPS).
|
|
12
|
+
* The cookie is readable by JS (not HttpOnly) so the auto-generated
|
|
13
|
+
* action stub can echo it.
|
|
14
|
+
* 2. The action-stub `fetch` sends the token back in `x-webjs-csrf`.
|
|
15
|
+
* 3. `invokeAction` compares the header to the cookie with constant-time
|
|
16
|
+
* equality; mismatch → 403.
|
|
17
|
+
*
|
|
18
|
+
* This protects against classic CSRF (a malicious site triggering a POST
|
|
19
|
+
* from a victim browser): cross-origin requests cannot read the cookie, so
|
|
20
|
+
* they cannot set the header to the matching value.
|
|
21
|
+
*
|
|
22
|
+
* Notes on scope:
|
|
23
|
+
* - Applies to internal RPC only. `expose()`d REST endpoints are *not*
|
|
24
|
+
* CSRF-protected by default because they're intended for external
|
|
25
|
+
* consumers; those endpoints should carry their own auth (bearer token,
|
|
26
|
+
* signed request, API key) which the app provides via middleware.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export const CSRF_COOKIE = 'webjs_csrf';
|
|
30
|
+
export const CSRF_HEADER = 'x-webjs-csrf';
|
|
31
|
+
const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; // 7 days
|
|
32
|
+
|
|
33
|
+
/** @returns {string} a 128-bit hex token */
|
|
34
|
+
export function newToken() {
|
|
35
|
+
const bytes = new Uint8Array(16);
|
|
36
|
+
webCrypto.getRandomValues(bytes);
|
|
37
|
+
let s = '';
|
|
38
|
+
for (const b of bytes) s += b.toString(16).padStart(2, '0');
|
|
39
|
+
return s;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parse cookies off a standard Request.
|
|
44
|
+
* @param {Request} req
|
|
45
|
+
* @returns {Record<string,string>}
|
|
46
|
+
*/
|
|
47
|
+
export function parseCookies(req) {
|
|
48
|
+
const header = req.headers.get('cookie') || '';
|
|
49
|
+
/** @type {Record<string,string>} */
|
|
50
|
+
const out = {};
|
|
51
|
+
for (const part of header.split(/;\s*/)) {
|
|
52
|
+
if (!part) continue;
|
|
53
|
+
const eq = part.indexOf('=');
|
|
54
|
+
if (eq <= 0) continue;
|
|
55
|
+
const k = part.slice(0, eq).trim();
|
|
56
|
+
const v = part.slice(eq + 1).trim();
|
|
57
|
+
try { out[k] = decodeURIComponent(v); } catch { out[k] = v; }
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @param {Request} req */
|
|
63
|
+
export function readToken(req) {
|
|
64
|
+
return parseCookies(req)[CSRF_COOKIE] || null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Serialise a Set-Cookie value for the CSRF token.
|
|
69
|
+
* @param {string} token
|
|
70
|
+
* @param {{ secure?: boolean }} [opts]
|
|
71
|
+
*/
|
|
72
|
+
export function cookieHeader(token, opts = {}) {
|
|
73
|
+
const parts = [
|
|
74
|
+
`${CSRF_COOKIE}=${encodeURIComponent(token)}`,
|
|
75
|
+
'Path=/',
|
|
76
|
+
'SameSite=Lax',
|
|
77
|
+
`Max-Age=${COOKIE_MAX_AGE_SECONDS}`,
|
|
78
|
+
];
|
|
79
|
+
if (opts.secure) parts.push('Secure');
|
|
80
|
+
return parts.join('; ');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Constant-time verification of the double-submit.
|
|
85
|
+
* @param {Request} req
|
|
86
|
+
*/
|
|
87
|
+
export function verify(req) {
|
|
88
|
+
const cookie = readToken(req);
|
|
89
|
+
const header = req.headers.get(CSRF_HEADER);
|
|
90
|
+
if (!cookie || !header) return false;
|
|
91
|
+
if (cookie.length !== header.length) return false;
|
|
92
|
+
let diff = 0;
|
|
93
|
+
for (let i = 0; i < cookie.length; i++) diff |= cookie.charCodeAt(i) ^ header.charCodeAt(i);
|
|
94
|
+
return diff === 0;
|
|
95
|
+
}
|