@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/auth.js
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NextAuth/Auth.js-style authentication for webjs.
|
|
3
|
+
*
|
|
4
|
+
* JWT or database sessions, Credentials + OAuth (Google, GitHub) providers.
|
|
5
|
+
* Uses Web Crypto HMAC-SHA256: no external dependencies.
|
|
6
|
+
*
|
|
7
|
+
* @module auth
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { getStore } from './cache.js';
|
|
11
|
+
import { getRequest } from './context.js';
|
|
12
|
+
|
|
13
|
+
const enc = new TextEncoder();
|
|
14
|
+
const dec = new TextDecoder();
|
|
15
|
+
const AUTH_COOKIE = 'webjs.auth';
|
|
16
|
+
const STATE_COOKIE = 'webjs.auth.state';
|
|
17
|
+
const DEFAULT_MAX_AGE = 30 * 24 * 60 * 60; // 30 days (seconds)
|
|
18
|
+
|
|
19
|
+
// -- Web Crypto helpers -----------------------------------------------------
|
|
20
|
+
|
|
21
|
+
/** @param {string} secret @returns {Promise<CryptoKey>} */
|
|
22
|
+
async function hmacKey(secret) {
|
|
23
|
+
return crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** @param {ArrayBuffer} buf @returns {string} */
|
|
27
|
+
function b64url(buf) {
|
|
28
|
+
let s = '';
|
|
29
|
+
for (const b of new Uint8Array(buf)) s += String.fromCharCode(b);
|
|
30
|
+
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** @param {string} str @returns {Uint8Array} */
|
|
34
|
+
function unb64url(str) {
|
|
35
|
+
const bin = atob(str.replace(/-/g, '+').replace(/_/g, '/'));
|
|
36
|
+
const out = new Uint8Array(bin.length);
|
|
37
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Sign `value` → `value.sig` */
|
|
42
|
+
async function sign(value, secret) {
|
|
43
|
+
const sig = await crypto.subtle.sign('HMAC', await hmacKey(secret), enc.encode(value));
|
|
44
|
+
return `${value}.${b64url(sig)}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Verify and unsign. Returns original value or null. */
|
|
48
|
+
async function unsign(input, secret) {
|
|
49
|
+
const idx = input.lastIndexOf('.');
|
|
50
|
+
if (idx < 1) return null;
|
|
51
|
+
const value = input.slice(0, idx);
|
|
52
|
+
const ok = await crypto.subtle.verify('HMAC', await hmacKey(secret), unb64url(input.slice(idx + 1)), enc.encode(value));
|
|
53
|
+
return ok ? value : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function randomId() {
|
|
57
|
+
const bytes = new Uint8Array(24);
|
|
58
|
+
crypto.getRandomValues(bytes);
|
|
59
|
+
return b64url(bytes.buffer);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// -- Cookie helpers ---------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
/** @param {string} header @returns {Record<string,string>} */
|
|
65
|
+
function parseCookies(header) {
|
|
66
|
+
const out = {};
|
|
67
|
+
if (!header) return out;
|
|
68
|
+
for (const pair of header.split(';')) {
|
|
69
|
+
const eq = pair.indexOf('=');
|
|
70
|
+
if (eq < 0) continue;
|
|
71
|
+
out[pair.slice(0, eq).trim()] = decodeURIComponent(pair.slice(eq + 1).trim());
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function setCookie(name, value, maxAge, secure) {
|
|
77
|
+
let s = `${name}=${encodeURIComponent(value)}; Max-Age=${Math.floor(maxAge / 1000)}; Path=/; HttpOnly; SameSite=Lax`;
|
|
78
|
+
if (secure) s += '; Secure';
|
|
79
|
+
return s;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function clearCookie(name) {
|
|
83
|
+
return `${name}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// -- JWT --------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
/** @param {Record<string,unknown>} payload @param {string} secret */
|
|
89
|
+
async function encodeJwt(payload, secret) {
|
|
90
|
+
const h = b64url(enc.encode(JSON.stringify({ alg: 'HS256', typ: 'JWT' })));
|
|
91
|
+
const p = b64url(enc.encode(JSON.stringify(payload)));
|
|
92
|
+
const unsigned = `${h}.${p}`;
|
|
93
|
+
const sig = await crypto.subtle.sign('HMAC', await hmacKey(secret), enc.encode(unsigned));
|
|
94
|
+
return `${unsigned}.${b64url(sig)}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** @param {string} token @param {string} secret @returns {Promise<Record<string,unknown>|null>} */
|
|
98
|
+
async function decodeJwt(token, secret) {
|
|
99
|
+
const parts = token.split('.');
|
|
100
|
+
if (parts.length !== 3) return null;
|
|
101
|
+
// `unb64url` → `atob` throws InvalidCharacterError on non-base64 input.
|
|
102
|
+
// Any failure here (bad base64, bad HMAC verify) means the cookie is
|
|
103
|
+
// garbage; treat it as "no session" rather than crashing the request.
|
|
104
|
+
try {
|
|
105
|
+
const ok = await crypto.subtle.verify('HMAC', await hmacKey(secret), unb64url(parts[2]), enc.encode(`${parts[0]}.${parts[1]}`));
|
|
106
|
+
if (!ok) return null;
|
|
107
|
+
const payload = JSON.parse(dec.decode(unb64url(parts[1])));
|
|
108
|
+
if (payload.exp && Date.now() / 1000 > payload.exp) return null;
|
|
109
|
+
return payload;
|
|
110
|
+
} catch { return null; }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// -- Providers --------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* @typedef {Object} ProviderConfig
|
|
117
|
+
* @property {string} id
|
|
118
|
+
* @property {string} type
|
|
119
|
+
* @property {string} [name]
|
|
120
|
+
* @property {string} [authorizationUrl]
|
|
121
|
+
* @property {string} [tokenUrl]
|
|
122
|
+
* @property {string} [userinfoUrl]
|
|
123
|
+
* @property {string} [clientId]
|
|
124
|
+
* @property {string} [clientSecret]
|
|
125
|
+
* @property {string[]} [scope]
|
|
126
|
+
* @property {((creds: Record<string,unknown>) => Promise<Record<string,unknown>|null>)} [authorize]
|
|
127
|
+
* @property {((profile: Record<string,unknown>) => Record<string,unknown>)} [profile]
|
|
128
|
+
*/
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Credentials provider: email/password or custom logic.
|
|
132
|
+
* @param {{ authorize: (creds: Record<string,unknown>) => Promise<Record<string,unknown>|null> }} opts
|
|
133
|
+
* @returns {ProviderConfig}
|
|
134
|
+
*/
|
|
135
|
+
export function Credentials(opts) {
|
|
136
|
+
return { id: 'credentials', type: 'credentials', name: 'Credentials', authorize: opts.authorize };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Google OAuth 2.0 provider.
|
|
141
|
+
* @param {{ clientId?: string, clientSecret?: string }} [opts]
|
|
142
|
+
* @returns {ProviderConfig}
|
|
143
|
+
*/
|
|
144
|
+
export function Google(opts = {}) {
|
|
145
|
+
return {
|
|
146
|
+
id: 'google', type: 'oauth', name: 'Google',
|
|
147
|
+
authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
|
|
148
|
+
tokenUrl: 'https://oauth2.googleapis.com/token',
|
|
149
|
+
userinfoUrl: 'https://www.googleapis.com/oauth2/v3/userinfo',
|
|
150
|
+
clientId: opts.clientId || process.env.AUTH_GOOGLE_ID,
|
|
151
|
+
clientSecret: opts.clientSecret || process.env.AUTH_GOOGLE_SECRET,
|
|
152
|
+
scope: ['openid', 'email', 'profile'],
|
|
153
|
+
profile: (p) => ({ id: String(p.sub), name: p.name, email: p.email, image: p.picture }),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* GitHub OAuth 2.0 provider.
|
|
159
|
+
* @param {{ clientId?: string, clientSecret?: string }} [opts]
|
|
160
|
+
* @returns {ProviderConfig}
|
|
161
|
+
*/
|
|
162
|
+
export function GitHub(opts = {}) {
|
|
163
|
+
return {
|
|
164
|
+
id: 'github', type: 'oauth', name: 'GitHub',
|
|
165
|
+
authorizationUrl: 'https://github.com/login/oauth/authorize',
|
|
166
|
+
tokenUrl: 'https://github.com/login/oauth/access_token',
|
|
167
|
+
userinfoUrl: 'https://api.github.com/user',
|
|
168
|
+
clientId: opts.clientId || process.env.AUTH_GITHUB_ID,
|
|
169
|
+
clientSecret: opts.clientSecret || process.env.AUTH_GITHUB_SECRET,
|
|
170
|
+
scope: ['read:user', 'user:email'],
|
|
171
|
+
profile: (p) => ({ id: String(p.id), name: p.name || p.login, email: p.email, image: p.avatar_url }),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// -- createAuth -------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* @typedef {Object} AuthConfig
|
|
179
|
+
* @property {ProviderConfig[]} providers
|
|
180
|
+
* @property {{ strategy?: 'jwt'|'database', maxAge?: number }} [session]
|
|
181
|
+
* @property {string} secret
|
|
182
|
+
* @property {{ session?: Function, jwt?: Function, signIn?: Function, redirect?: Function }} [callbacks]
|
|
183
|
+
* @property {{ load?: Function, save?: Function, destroy?: Function }} [adapter]
|
|
184
|
+
* @property {{ signIn?: string, signOut?: string, error?: string }} [pages]
|
|
185
|
+
*/
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Create the auth system.
|
|
189
|
+
* @param {AuthConfig} config
|
|
190
|
+
* @returns {{
|
|
191
|
+
* auth: (req?: Request) => Promise<{ user: Record<string,unknown> }|null>,
|
|
192
|
+
* signIn: (provider: string, data?: Record<string,unknown>, opts?: { redirectTo?: string, req?: Request }) => Promise<Response>,
|
|
193
|
+
* signOut: (opts?: { redirectTo?: string, req?: Request }) => Promise<Response>,
|
|
194
|
+
* handlers: { GET: (req: Request) => Promise<Response>, POST: (req: Request) => Promise<Response> },
|
|
195
|
+
* }}
|
|
196
|
+
*/
|
|
197
|
+
export function createAuth(config) {
|
|
198
|
+
const { secret } = config;
|
|
199
|
+
if (!secret) throw new Error('createAuth() requires a `secret` (set AUTH_SECRET or SESSION_SECRET)');
|
|
200
|
+
|
|
201
|
+
const strategy = config.session?.strategy || 'jwt';
|
|
202
|
+
const maxAge = config.session?.maxAge || DEFAULT_MAX_AGE;
|
|
203
|
+
const maxAgeMs = maxAge * 1000;
|
|
204
|
+
const cb = config.callbacks || {};
|
|
205
|
+
const pages = config.pages || {};
|
|
206
|
+
const secure = () => process.env.NODE_ENV === 'production';
|
|
207
|
+
|
|
208
|
+
/** @type {Map<string, ProviderConfig>} */
|
|
209
|
+
const providers = new Map();
|
|
210
|
+
for (const p of config.providers) providers.set(p.id, p);
|
|
211
|
+
|
|
212
|
+
const dbStore = strategy === 'database' ? (config.adapter || defaultAdapter()) : null;
|
|
213
|
+
|
|
214
|
+
// -- Session read/write ---------------------------------------------------
|
|
215
|
+
|
|
216
|
+
async function readSession(req) {
|
|
217
|
+
const cookies = parseCookies(req.headers.get('cookie') || '');
|
|
218
|
+
const raw = cookies[AUTH_COOKIE];
|
|
219
|
+
if (!raw) return null;
|
|
220
|
+
|
|
221
|
+
if (strategy === 'jwt') {
|
|
222
|
+
const payload = await decodeJwt(raw, secret);
|
|
223
|
+
if (!payload) return null;
|
|
224
|
+
let token = cb.jwt ? await cb.jwt({ token: payload, user: undefined }) : payload;
|
|
225
|
+
let session = { user: { id: token.sub, name: token.name, email: token.email, image: token.image, ...token } };
|
|
226
|
+
delete session.user.iat; delete session.user.exp; delete session.user.sub;
|
|
227
|
+
return cb.session ? cb.session({ session, token, user: undefined }) : session;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const sid = await unsign(raw, secret);
|
|
231
|
+
if (!sid || !dbStore) return null;
|
|
232
|
+
const data = await dbStore.load(sid);
|
|
233
|
+
if (!data) return null;
|
|
234
|
+
let session = { user: data };
|
|
235
|
+
return cb.session ? cb.session({ session, token: undefined, user: data }) : session;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function writeSession(user) {
|
|
239
|
+
if (strategy === 'jwt') {
|
|
240
|
+
let token = { sub: user.id, name: user.name, email: user.email, image: user.image, role: user.role, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + maxAge };
|
|
241
|
+
if (cb.jwt) token = await cb.jwt({ token, user });
|
|
242
|
+
return setCookie(AUTH_COOKIE, await encodeJwt(token, secret), maxAgeMs, secure());
|
|
243
|
+
}
|
|
244
|
+
const sid = randomId();
|
|
245
|
+
await dbStore.save(sid, user, maxAgeMs);
|
|
246
|
+
return setCookie(AUTH_COOKIE, await sign(sid, secret), maxAgeMs, secure());
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// -- auth() ---------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
async function auth(req) {
|
|
252
|
+
const r = req || getRequest();
|
|
253
|
+
return r ? readSession(r) : null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// -- signIn() -------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
async function signInFn(providerId, data, opts = {}) {
|
|
259
|
+
const provider = providers.get(providerId);
|
|
260
|
+
if (!provider) return new Response('Unknown provider', { status: 400 });
|
|
261
|
+
|
|
262
|
+
if (provider.type === 'credentials') {
|
|
263
|
+
if (!provider.authorize) return new Response('No authorize function', { status: 500 });
|
|
264
|
+
const user = await provider.authorize(data || {});
|
|
265
|
+
if (!user) {
|
|
266
|
+
return new Response(null, { status: 302, headers: { location: `${pages.error || pages.signIn || '/'}?error=CredentialsSignin` } });
|
|
267
|
+
}
|
|
268
|
+
if (cb.signIn) {
|
|
269
|
+
const ok = await cb.signIn({ user, account: { provider: providerId } });
|
|
270
|
+
if (ok === false) return new Response(null, { status: 302, headers: { location: pages.error || '/?error=AccessDenied' } });
|
|
271
|
+
}
|
|
272
|
+
const cookie = await writeSession(user);
|
|
273
|
+
const redirectTo = opts.redirectTo || (data && data.redirectTo) || '/';
|
|
274
|
+
return new Response(null, { status: 302, headers: { location: /** @type {string} */ (redirectTo), 'set-cookie': cookie } });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (provider.type === 'oauth') return oauthRedirect(provider, opts);
|
|
278
|
+
return new Response('Unsupported provider type', { status: 400 });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// -- signOut() ------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
async function signOutFn(opts = {}) {
|
|
284
|
+
const hdrs = new Headers();
|
|
285
|
+
hdrs.set('location', opts.redirectTo || pages.signOut || '/');
|
|
286
|
+
hdrs.append('set-cookie', clearCookie(AUTH_COOKIE));
|
|
287
|
+
|
|
288
|
+
if (strategy === 'database' && dbStore) {
|
|
289
|
+
const r = opts.req || getRequest();
|
|
290
|
+
if (r) {
|
|
291
|
+
const raw = parseCookies(r.headers.get('cookie') || '')[AUTH_COOKIE];
|
|
292
|
+
if (raw) { const sid = await unsign(raw, secret); if (sid) await dbStore.destroy(sid); }
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return new Response(null, { status: 302, headers: hdrs });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// -- OAuth helpers --------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
async function oauthRedirect(provider, opts) {
|
|
301
|
+
const req = opts.req || getRequest();
|
|
302
|
+
const origin = req ? new URL(req.url).origin : 'http://localhost:3000';
|
|
303
|
+
const state = randomId();
|
|
304
|
+
|
|
305
|
+
const url = new URL(provider.authorizationUrl);
|
|
306
|
+
url.searchParams.set('client_id', provider.clientId);
|
|
307
|
+
url.searchParams.set('redirect_uri', `${origin}/api/auth/callback/${provider.id}`);
|
|
308
|
+
url.searchParams.set('response_type', 'code');
|
|
309
|
+
url.searchParams.set('scope', (provider.scope || []).join(' '));
|
|
310
|
+
url.searchParams.set('state', state);
|
|
311
|
+
|
|
312
|
+
const hdrs = new Headers();
|
|
313
|
+
hdrs.set('location', url.toString());
|
|
314
|
+
hdrs.append('set-cookie', setCookie(STATE_COOKIE, await sign(state, secret), 600_000, secure()));
|
|
315
|
+
return new Response(null, { status: 302, headers: hdrs });
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function oauthCallback(req, provider) {
|
|
319
|
+
const url = new URL(req.url);
|
|
320
|
+
const code = url.searchParams.get('code');
|
|
321
|
+
const state = url.searchParams.get('state');
|
|
322
|
+
if (!code || !state) return new Response('Missing code or state', { status: 400 });
|
|
323
|
+
|
|
324
|
+
const rawState = parseCookies(req.headers.get('cookie') || '')[STATE_COOKIE];
|
|
325
|
+
if (!rawState) return new Response('Missing state cookie', { status: 403 });
|
|
326
|
+
const verified = await unsign(rawState, secret);
|
|
327
|
+
if (!verified || verified !== state) return new Response('Invalid state', { status: 403 });
|
|
328
|
+
|
|
329
|
+
// Exchange code for token
|
|
330
|
+
const callbackUrl = `${url.origin}/api/auth/callback/${provider.id}`;
|
|
331
|
+
const tokenRes = await fetch(provider.tokenUrl, {
|
|
332
|
+
method: 'POST',
|
|
333
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
|
334
|
+
body: new URLSearchParams({ client_id: provider.clientId, client_secret: provider.clientSecret, code, redirect_uri: callbackUrl, grant_type: 'authorization_code' }),
|
|
335
|
+
});
|
|
336
|
+
if (!tokenRes.ok) return new Response('Token exchange failed', { status: 502 });
|
|
337
|
+
const { access_token: accessToken } = await tokenRes.json();
|
|
338
|
+
if (!accessToken) return new Response('No access token', { status: 502 });
|
|
339
|
+
|
|
340
|
+
// Fetch profile
|
|
341
|
+
const profileRes = await fetch(provider.userinfoUrl, {
|
|
342
|
+
headers: { authorization: `Bearer ${accessToken}`, accept: 'application/json' },
|
|
343
|
+
});
|
|
344
|
+
if (!profileRes.ok) return new Response('Profile fetch failed', { status: 502 });
|
|
345
|
+
const rawProfile = await profileRes.json();
|
|
346
|
+
|
|
347
|
+
// GitHub private email fallback
|
|
348
|
+
if (provider.id === 'github' && !rawProfile.email) {
|
|
349
|
+
try {
|
|
350
|
+
const r = await fetch('https://api.github.com/user/emails', { headers: { authorization: `Bearer ${accessToken}`, accept: 'application/json' } });
|
|
351
|
+
if (r.ok) { const emails = await r.json(); const p = emails.find(/** @param {any} e */ e => e.primary && e.verified); if (p) rawProfile.email = p.email; }
|
|
352
|
+
} catch { /* non-critical */ }
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const user = provider.profile ? provider.profile(rawProfile) : rawProfile;
|
|
356
|
+
|
|
357
|
+
if (cb.signIn) {
|
|
358
|
+
const ok = await cb.signIn({ user, account: { provider: provider.id, accessToken } });
|
|
359
|
+
if (ok === false) return new Response(null, { status: 302, headers: { location: pages.error || '/?error=AccessDenied' } });
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const hdrs = new Headers();
|
|
363
|
+
hdrs.set('location', '/');
|
|
364
|
+
hdrs.append('set-cookie', await writeSession(user));
|
|
365
|
+
hdrs.append('set-cookie', clearCookie(STATE_COOKIE));
|
|
366
|
+
return new Response(null, { status: 302, headers: hdrs });
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// -- Route handlers (mount at app/api/auth/[...path]/route.js) ------------
|
|
370
|
+
|
|
371
|
+
function parseSegments(req) {
|
|
372
|
+
return new URL(req.url).pathname.replace(/^\/api\/auth\/?/, '').split('/').filter(Boolean);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function GET(req) {
|
|
376
|
+
const seg = parseSegments(req);
|
|
377
|
+
|
|
378
|
+
if (seg[0] === 'session') {
|
|
379
|
+
return new Response(JSON.stringify(await readSession(req)), { headers: { 'content-type': 'application/json' } });
|
|
380
|
+
}
|
|
381
|
+
if (seg[0] === 'signin' && seg[1]) {
|
|
382
|
+
const p = providers.get(seg[1]);
|
|
383
|
+
if (!p || p.type !== 'oauth') return new Response('Unknown OAuth provider', { status: 404 });
|
|
384
|
+
return oauthRedirect(p, { req });
|
|
385
|
+
}
|
|
386
|
+
if (seg[0] === 'callback' && seg[1]) {
|
|
387
|
+
const p = providers.get(seg[1]);
|
|
388
|
+
if (!p || p.type !== 'oauth') return new Response('Unknown OAuth provider', { status: 404 });
|
|
389
|
+
return oauthCallback(req, p);
|
|
390
|
+
}
|
|
391
|
+
if (seg[0] === 'signout') return signOutFn({ req });
|
|
392
|
+
if (seg[0] === 'providers') {
|
|
393
|
+
const list = {};
|
|
394
|
+
for (const [id, p] of providers) list[id] = { id: p.id, name: p.name, type: p.type, signinUrl: `/api/auth/signin/${id}` };
|
|
395
|
+
return new Response(JSON.stringify(list), { headers: { 'content-type': 'application/json' } });
|
|
396
|
+
}
|
|
397
|
+
return new Response('Not found', { status: 404 });
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function POST(req) {
|
|
401
|
+
const seg = parseSegments(req);
|
|
402
|
+
|
|
403
|
+
if (seg[0] === 'signin' && seg[1]) {
|
|
404
|
+
const provider = providers.get(seg[1]);
|
|
405
|
+
if (!provider) return new Response('Unknown provider', { status: 404 });
|
|
406
|
+
if (provider.type === 'credentials') {
|
|
407
|
+
let body = {};
|
|
408
|
+
const ct = req.headers.get('content-type') || '';
|
|
409
|
+
if (ct.includes('json')) body = await req.json();
|
|
410
|
+
else if (ct.includes('form')) { const fd = await req.formData(); for (const [k, v] of fd.entries()) body[k] = v; }
|
|
411
|
+
return signInFn('credentials', body, { req });
|
|
412
|
+
}
|
|
413
|
+
if (provider.type === 'oauth') return oauthRedirect(provider, { req });
|
|
414
|
+
}
|
|
415
|
+
if (seg[0] === 'signout') return signOutFn({ req });
|
|
416
|
+
return new Response('Not found', { status: 404 });
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return { auth, signIn: signInFn, signOut: signOutFn, handlers: { GET, POST } };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// -- Default database adapter (cache store) ---------------------------------
|
|
423
|
+
|
|
424
|
+
function defaultAdapter() {
|
|
425
|
+
const store = getStore();
|
|
426
|
+
return {
|
|
427
|
+
async load(id) { const r = await store.get(`auth:session:${id}`); if (!r) return null; try { return JSON.parse(r); } catch { return null; } },
|
|
428
|
+
async save(id, data, maxAgeMs) { await store.set(`auth:session:${id}`, JSON.stringify(data), maxAgeMs); },
|
|
429
|
+
async destroy(id) { await store.delete(`auth:session:${id}`); },
|
|
430
|
+
};
|
|
431
|
+
}
|
package/src/broadcast.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket broadcast: send data to all connected clients on a route.
|
|
3
|
+
*
|
|
4
|
+
* ```js
|
|
5
|
+
* // app/api/chat/route.ts
|
|
6
|
+
* import { broadcast } from '@webjsdev/server';
|
|
7
|
+
*
|
|
8
|
+
* export function WS(ws, req) {
|
|
9
|
+
* ws.on('message', (data) => {
|
|
10
|
+
* broadcast('/api/chat', data);
|
|
11
|
+
* });
|
|
12
|
+
* }
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* Single-instance by default. For multi-instance scaling, the user
|
|
16
|
+
* wires Redis themselves: explicit, not magic.
|
|
17
|
+
*
|
|
18
|
+
* @module broadcast
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Per-path WebSocket client registry.
|
|
23
|
+
* @type {Map<string, Set<import('ws').WebSocket>>}
|
|
24
|
+
*/
|
|
25
|
+
const pathClients = new Map();
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Register a WebSocket client for a path. Called internally by the
|
|
29
|
+
* WebSocket handler when a connection is established.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} path
|
|
32
|
+
* @param {import('ws').WebSocket} ws
|
|
33
|
+
*/
|
|
34
|
+
export function registerClient(path, ws) {
|
|
35
|
+
let clients = pathClients.get(path);
|
|
36
|
+
if (!clients) { clients = new Set(); pathClients.set(path, clients); }
|
|
37
|
+
clients.add(ws);
|
|
38
|
+
ws.on('close', () => {
|
|
39
|
+
clients.delete(ws);
|
|
40
|
+
if (clients.size === 0) pathClients.delete(path);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Broadcast data to all WebSocket clients connected to a route path.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} path Route path (e.g., '/api/chat')
|
|
48
|
+
* @param {string | Buffer} data Data to send
|
|
49
|
+
* @param {{ except?: import('ws').WebSocket }} [opts]
|
|
50
|
+
* - `except`: exclude this client (e.g., the sender)
|
|
51
|
+
*/
|
|
52
|
+
export function broadcast(path, data, opts) {
|
|
53
|
+
const clients = pathClients.get(path);
|
|
54
|
+
if (!clients) return;
|
|
55
|
+
const msg = typeof data === 'string' ? data : data.toString();
|
|
56
|
+
for (const ws of clients) {
|
|
57
|
+
if (opts?.except && ws === opts.except) continue;
|
|
58
|
+
if (ws.readyState === 1) ws.send(msg);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get the number of connected WebSocket clients on a path.
|
|
64
|
+
* @param {string} path
|
|
65
|
+
* @returns {number}
|
|
66
|
+
*/
|
|
67
|
+
export function clientCount(path) {
|
|
68
|
+
return pathClients.get(path)?.size || 0;
|
|
69
|
+
}
|
package/src/cache-fn.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side function caching for RPC queries.
|
|
3
|
+
*
|
|
4
|
+
* Wraps an async function with a cache layer backed by the cache store.
|
|
5
|
+
* Same function + same arguments = cached result until TTL expires.
|
|
6
|
+
*
|
|
7
|
+
* ```js
|
|
8
|
+
* import { cache } from '@webjsdev/server';
|
|
9
|
+
*
|
|
10
|
+
* export const listPosts = cache(
|
|
11
|
+
* async () => prisma.post.findMany({ orderBy: { createdAt: 'desc' } }),
|
|
12
|
+
* { key: 'posts', ttl: 60 }
|
|
13
|
+
* );
|
|
14
|
+
*
|
|
15
|
+
* // Call it normally: first call hits DB, subsequent calls serve cache
|
|
16
|
+
* const posts = await listPosts();
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* For page-level HTTP caching, use `metadata.cacheControl` instead -
|
|
20
|
+
* that sets standard Cache-Control headers for browsers and CDNs.
|
|
21
|
+
* This `cache()` is for server-side query result caching.
|
|
22
|
+
*
|
|
23
|
+
* @module cache-fn
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { getStore } from './cache.js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Wrap an async function with server-side caching.
|
|
30
|
+
*
|
|
31
|
+
* @template {(...args: any[]) => Promise<any>} T
|
|
32
|
+
* @param {T} fn The async function to cache.
|
|
33
|
+
* @param {{
|
|
34
|
+
* key: string,
|
|
35
|
+
* ttl?: number,
|
|
36
|
+
* }} opts
|
|
37
|
+
* - `key`: cache key prefix. Combined with serialized args to form the full key.
|
|
38
|
+
* - `ttl`: time-to-live in seconds. Default: 60.
|
|
39
|
+
* @returns {T & { invalidate: () => Promise<void> }}
|
|
40
|
+
* The cached function with the same signature, plus an `invalidate()`
|
|
41
|
+
* method to manually clear the cache.
|
|
42
|
+
*/
|
|
43
|
+
export function cache(fn, opts) {
|
|
44
|
+
const prefix = opts.key;
|
|
45
|
+
const ttlMs = (opts.ttl ?? 60) * 1000;
|
|
46
|
+
|
|
47
|
+
const wrapped = /** @type {T & { invalidate: () => Promise<void> }} */ (
|
|
48
|
+
async function (...args) {
|
|
49
|
+
const store = getStore();
|
|
50
|
+
const cacheKey = args.length
|
|
51
|
+
? `cache:${prefix}:${JSON.stringify(args)}`
|
|
52
|
+
: `cache:${prefix}`;
|
|
53
|
+
|
|
54
|
+
const hit = await store.get(cacheKey);
|
|
55
|
+
if (hit !== null) {
|
|
56
|
+
try { return JSON.parse(hit); } catch { /* corrupted: recompute */ }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const result = await fn(...args);
|
|
60
|
+
await store.set(cacheKey, JSON.stringify(result), ttlMs);
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Manually invalidate this cache. Call after mutations:
|
|
67
|
+
*
|
|
68
|
+
* ```js
|
|
69
|
+
* export async function createPost(input) {
|
|
70
|
+
* await prisma.post.create({ data: input });
|
|
71
|
+
* await listPosts.invalidate();
|
|
72
|
+
* }
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
wrapped.invalidate = async function () {
|
|
76
|
+
const store = getStore();
|
|
77
|
+
// Delete the base key (no-args call)
|
|
78
|
+
await store.delete(`cache:${prefix}`);
|
|
79
|
+
// Note: arg-specific keys are not tracked. If the cached function
|
|
80
|
+
// is called with different arguments, those entries expire via TTL.
|
|
81
|
+
// For full invalidation of arg-specific keys, use a short TTL.
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
return wrapped;
|
|
85
|
+
}
|