@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/router.js
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { join, relative, sep, posix } from 'node:path';
|
|
2
|
+
import { walk } from './fs-walk.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {{
|
|
6
|
+
* pattern: RegExp,
|
|
7
|
+
* paramNames: string[],
|
|
8
|
+
* file: string,
|
|
9
|
+
* routeDir: string,
|
|
10
|
+
* layouts: string[],
|
|
11
|
+
* errors: string[],
|
|
12
|
+
* loadings: string[],
|
|
13
|
+
* metadataFiles: string[],
|
|
14
|
+
* middlewares: string[],
|
|
15
|
+
* isCatchAll: boolean
|
|
16
|
+
* }} PageRoute
|
|
17
|
+
*
|
|
18
|
+
* @typedef {{
|
|
19
|
+
* pattern: RegExp,
|
|
20
|
+
* paramNames: string[],
|
|
21
|
+
* file: string,
|
|
22
|
+
* middlewares: string[],
|
|
23
|
+
* }} ApiRoute
|
|
24
|
+
*
|
|
25
|
+
* @typedef {{ stem: string, file: string, urlPath: string }} MetadataRoute
|
|
26
|
+
*
|
|
27
|
+
* @typedef {{
|
|
28
|
+
* pages: PageRoute[],
|
|
29
|
+
* apis: ApiRoute[],
|
|
30
|
+
* notFound: string | null,
|
|
31
|
+
* notFounds: Map<string, string>,
|
|
32
|
+
* metadataRoutes: MetadataRoute[],
|
|
33
|
+
* appDir: string
|
|
34
|
+
* }} RouteTable
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Scan `<appDir>/app` and build a route table.
|
|
39
|
+
*
|
|
40
|
+
* Supported file conventions (NextJs App Router–compatible):
|
|
41
|
+
* app/page.js → /
|
|
42
|
+
* app/about/page.js → /about
|
|
43
|
+
* app/blog/[slug]/page.js → /blog/:slug
|
|
44
|
+
* app/files/[...rest]/page.js → /files/*
|
|
45
|
+
* app/(marketing)/about/page.js → /about (folders in parens are route groups; not in URL)
|
|
46
|
+
* app/_internal/page.js → ignored (folders starting with _ are private)
|
|
47
|
+
* app/api/hello/route.js → /api/hello
|
|
48
|
+
* app/layout.js → wraps every page
|
|
49
|
+
* app/error.js → error boundary (nested)
|
|
50
|
+
* app/loading.js → loading UI (auto-wraps page in Suspense)
|
|
51
|
+
* app/not-found.js → 404 fallback (nested: nearest wins)
|
|
52
|
+
* app/[[...slug]]/page.js → optional catch-all (matches / AND /a/b)
|
|
53
|
+
* app/sitemap.js → serves /sitemap.xml
|
|
54
|
+
* app/robots.js → serves /robots.txt
|
|
55
|
+
* app/icon.js → serves /icon (dynamic)
|
|
56
|
+
* app/opengraph-image.js → serves /opengraph-image (dynamic)
|
|
57
|
+
*
|
|
58
|
+
* @param {string} appDir
|
|
59
|
+
* @returns {Promise<RouteTable>}
|
|
60
|
+
*/
|
|
61
|
+
export async function buildRouteTable(appDir) {
|
|
62
|
+
const root = join(appDir, 'app');
|
|
63
|
+
/** @type {PageRoute[]} */
|
|
64
|
+
const pages = [];
|
|
65
|
+
/** @type {ApiRoute[]} */
|
|
66
|
+
const apis = [];
|
|
67
|
+
/** @type {Map<string,string>} */
|
|
68
|
+
const layouts = new Map();
|
|
69
|
+
/** @type {Map<string,string>} */
|
|
70
|
+
const errors = new Map();
|
|
71
|
+
/** @type {Map<string,string>} */
|
|
72
|
+
const loadings = new Map();
|
|
73
|
+
/** @type {Map<string,string>} */
|
|
74
|
+
const middlewares = new Map();
|
|
75
|
+
/** @type {Map<string, string>} */
|
|
76
|
+
const notFounds = new Map();
|
|
77
|
+
let notFound = null;
|
|
78
|
+
/** @type {MetadataRoute[]} */
|
|
79
|
+
const metadataRoutes = [];
|
|
80
|
+
|
|
81
|
+
/** @type {Set<string>} */
|
|
82
|
+
const METADATA_STEMS = new Set(['sitemap', 'robots', 'manifest', 'icon', 'apple-icon', 'opengraph-image', 'twitter-image']);
|
|
83
|
+
/** @type {Record<string,string>} */
|
|
84
|
+
const METADATA_URL_MAP = {
|
|
85
|
+
'sitemap': '/sitemap.xml',
|
|
86
|
+
'robots': '/robots.txt',
|
|
87
|
+
'manifest': '/manifest.json',
|
|
88
|
+
'icon': '/icon',
|
|
89
|
+
'apple-icon': '/apple-icon',
|
|
90
|
+
'opengraph-image': '/opengraph-image',
|
|
91
|
+
'twitter-image': '/twitter-image',
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
for await (const file of walk(root)) {
|
|
95
|
+
const rel = relative(root, file).split(sep).join('/');
|
|
96
|
+
const base = posix.basename(rel);
|
|
97
|
+
const dir = posix.dirname(rel);
|
|
98
|
+
|
|
99
|
+
// Private folders (any segment starting with _) are excluded from routing.
|
|
100
|
+
if (dir !== '.' && dir.split('/').some((s) => s.startsWith('_'))) continue;
|
|
101
|
+
|
|
102
|
+
// Match `<name>.<js|mjs|ts|mts>` conventions. Stem is the name without ext.
|
|
103
|
+
const stem = stemOf(base);
|
|
104
|
+
if (!stem) continue;
|
|
105
|
+
|
|
106
|
+
if (stem === 'page') {
|
|
107
|
+
const segs = dir === '.' ? [] : dir.split('/');
|
|
108
|
+
const { pattern, paramNames, isCatchAll } = segmentsToPattern(segs);
|
|
109
|
+
pages.push({
|
|
110
|
+
pattern,
|
|
111
|
+
paramNames,
|
|
112
|
+
file,
|
|
113
|
+
routeDir: dir,
|
|
114
|
+
layouts: [],
|
|
115
|
+
errors: [],
|
|
116
|
+
loadings: [],
|
|
117
|
+
metadataFiles: [],
|
|
118
|
+
middlewares: [],
|
|
119
|
+
isCatchAll,
|
|
120
|
+
});
|
|
121
|
+
} else if (stem === 'layout') {
|
|
122
|
+
layouts.set(dir, file);
|
|
123
|
+
} else if (stem === 'error') {
|
|
124
|
+
errors.set(dir, file);
|
|
125
|
+
} else if (stem === 'loading') {
|
|
126
|
+
loadings.set(dir, file);
|
|
127
|
+
} else if (stem === 'middleware') {
|
|
128
|
+
middlewares.set(dir, file);
|
|
129
|
+
} else if (stem === 'not-found') {
|
|
130
|
+
notFounds.set(dir, file);
|
|
131
|
+
if (dir === '.') notFound = file;
|
|
132
|
+
} else if (METADATA_STEMS.has(stem) && (dir === '.' || dir.split('/').every(s => !s.startsWith('[')))) {
|
|
133
|
+
// Metadata route: sitemap.ts, robots.ts, icon.ts, etc.
|
|
134
|
+
// Only at root or static segments (no dynamic params in metadata routes).
|
|
135
|
+
const urlPath = METADATA_URL_MAP[stem] || `/${stem}`;
|
|
136
|
+
metadataRoutes.push({ stem, file, urlPath });
|
|
137
|
+
} else if (stem === 'route') {
|
|
138
|
+
// route.js / route.ts can live anywhere under app/ (matches NextJs).
|
|
139
|
+
const segs = dir === '.' ? [] : dir.split('/');
|
|
140
|
+
const { pattern, paramNames } = segmentsToPattern(segs);
|
|
141
|
+
apis.push({ pattern, paramNames, file, routeDir: dir, middlewares: [] });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Attach nested layouts / error / loading / middleware files (outermost first).
|
|
146
|
+
for (const page of pages) {
|
|
147
|
+
const chainDirs = chainOf(page.routeDir);
|
|
148
|
+
page.layouts = chainDirs.map((d) => layouts.get(d)).filter(Boolean);
|
|
149
|
+
page.errors = chainDirs.map((d) => errors.get(d)).filter(Boolean);
|
|
150
|
+
page.loadings = chainDirs.map((d) => loadings.get(d)).filter(Boolean);
|
|
151
|
+
page.middlewares = chainDirs.map((d) => middlewares.get(d)).filter(Boolean);
|
|
152
|
+
page.metadataFiles = [...page.layouts, page.file];
|
|
153
|
+
}
|
|
154
|
+
for (const api of apis) {
|
|
155
|
+
/** @type any */
|
|
156
|
+
const a = api;
|
|
157
|
+
const chainDirs = chainOf(a.routeDir);
|
|
158
|
+
a.middlewares = chainDirs.map((d) => middlewares.get(d)).filter(Boolean);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
pages.sort((a, b) => dynScore(a) - dynScore(b));
|
|
162
|
+
return { pages, apis, notFound, notFounds, metadataRoutes, appDir };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Return the bare name of a file without the accepted JS/TS extension.
|
|
167
|
+
* `page.js` / `page.mjs` / `page.ts` / `page.mts` → `page`.
|
|
168
|
+
* Returns null for anything else (images, CSS, etc. don't participate in routing).
|
|
169
|
+
*
|
|
170
|
+
* @param {string} base
|
|
171
|
+
* @returns {string | null}
|
|
172
|
+
*/
|
|
173
|
+
function stemOf(base) {
|
|
174
|
+
const m = /^([A-Za-z0-9_.-]+)\.(?:m?[jt]s)$/.exec(base);
|
|
175
|
+
return m ? m[1] : null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** @param {string} routeDir */
|
|
179
|
+
function chainOf(routeDir) {
|
|
180
|
+
const segs = routeDir === '.' ? [] : routeDir.split('/');
|
|
181
|
+
/** @type {string[]} */
|
|
182
|
+
const dirs = ['.'];
|
|
183
|
+
for (let i = 1; i <= segs.length; i++) dirs.push(segs.slice(0, i).join('/'));
|
|
184
|
+
return dirs;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** @param {string} seg */
|
|
188
|
+
function isUrlSegment(seg) {
|
|
189
|
+
if (seg.startsWith('(') && seg.endsWith(')')) return false; // route group
|
|
190
|
+
if (seg.startsWith('_')) return false; // private
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** @param {PageRoute} r */
|
|
195
|
+
function dynScore(r) {
|
|
196
|
+
if (r.isCatchAll) return 3;
|
|
197
|
+
if (r.paramNames.length) return 2;
|
|
198
|
+
return 1;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* @param {string[]} segments
|
|
203
|
+
* @param {string} [prefix]
|
|
204
|
+
*/
|
|
205
|
+
function segmentsToPattern(segments, prefix = '') {
|
|
206
|
+
const paramNames = [];
|
|
207
|
+
let isCatchAll = false;
|
|
208
|
+
let isOptionalCatchAll = false;
|
|
209
|
+
const parts = segments
|
|
210
|
+
.filter(isUrlSegment)
|
|
211
|
+
.map((seg) => {
|
|
212
|
+
// Optional catch-all: [[...slug]] matches with AND without params
|
|
213
|
+
if (seg.startsWith('[[...') && seg.endsWith(']]')) {
|
|
214
|
+
paramNames.push(seg.slice(5, -2));
|
|
215
|
+
isCatchAll = true;
|
|
216
|
+
isOptionalCatchAll = true;
|
|
217
|
+
return '(.*)';
|
|
218
|
+
}
|
|
219
|
+
if (seg.startsWith('[...') && seg.endsWith(']')) {
|
|
220
|
+
paramNames.push(seg.slice(4, -1));
|
|
221
|
+
isCatchAll = true;
|
|
222
|
+
return '(.*)';
|
|
223
|
+
}
|
|
224
|
+
if (seg.startsWith('[') && seg.endsWith(']')) {
|
|
225
|
+
paramNames.push(seg.slice(1, -1));
|
|
226
|
+
return '([^/]+)';
|
|
227
|
+
}
|
|
228
|
+
return escapeRe(seg);
|
|
229
|
+
});
|
|
230
|
+
const body = parts.length ? '/' + parts.join('/') : '';
|
|
231
|
+
// Optional catch-all: also matches the base path without any trailing segments.
|
|
232
|
+
// e.g., /docs/[[...slug]] matches both /docs and /docs/a/b/c
|
|
233
|
+
const suffix = isOptionalCatchAll ? '(?:/(.*))?/?' : '/?';
|
|
234
|
+
const regexBody = isOptionalCatchAll
|
|
235
|
+
? body.replace(/\/\(\.\*\)$/, '') // remove the trailing (.*): we add it as optional
|
|
236
|
+
: body;
|
|
237
|
+
const pattern = new RegExp(`^${escapeRe(prefix)}${regexBody}${isOptionalCatchAll ? suffix : '/?$'}`);
|
|
238
|
+
if (!isOptionalCatchAll) {
|
|
239
|
+
// Standard pattern needs end anchor
|
|
240
|
+
return { pattern: new RegExp(`^${escapeRe(prefix)}${body}/?$`), paramNames, isCatchAll };
|
|
241
|
+
}
|
|
242
|
+
return { pattern, paramNames, isCatchAll };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** @param {string} s */
|
|
246
|
+
function escapeRe(s) {
|
|
247
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* @param {RouteTable} table
|
|
252
|
+
* @param {string} pathname
|
|
253
|
+
*/
|
|
254
|
+
export function matchPage(table, pathname) {
|
|
255
|
+
for (const route of table.pages) {
|
|
256
|
+
const m = route.pattern.exec(pathname);
|
|
257
|
+
if (!m) continue;
|
|
258
|
+
/** @type {Record<string,string>} */
|
|
259
|
+
const params = {};
|
|
260
|
+
route.paramNames.forEach((n, i) => (params[n] = decodeURIComponent(m[i + 1] || '')));
|
|
261
|
+
return { route, params };
|
|
262
|
+
}
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* @param {RouteTable} table
|
|
268
|
+
* @param {string} pathname
|
|
269
|
+
*/
|
|
270
|
+
export function matchApi(table, pathname) {
|
|
271
|
+
for (const route of table.apis) {
|
|
272
|
+
const m = route.pattern.exec(pathname);
|
|
273
|
+
if (!m) continue;
|
|
274
|
+
/** @type {Record<string,string>} */
|
|
275
|
+
const params = {};
|
|
276
|
+
route.paramNames.forEach((n, i) => (params[n] = decodeURIComponent(m[i + 1] || '')));
|
|
277
|
+
return { route, params };
|
|
278
|
+
}
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { stringify, parse } from '@webjsdev/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {Object} Serializer
|
|
5
|
+
* A pluggable serializer that controls how webjs server actions encode and
|
|
6
|
+
* decode values on the RPC wire.
|
|
7
|
+
*
|
|
8
|
+
* **AI hint:** The default serializer uses webjs's built-in
|
|
9
|
+
* (`@webjsdev/core` `stringify` / `parse`) so rich types: Date, Map, Set,
|
|
10
|
+
* BigInt, TypedArrays, Blob/File/FormData, cycles: survive the
|
|
11
|
+
* client/server round-trip. To swap in a different wire format (e.g.
|
|
12
|
+
* plain JSON, msgpack), call `setSerializer()` with an object that
|
|
13
|
+
* implements `serialize`, `deserialize`, and `contentType`.
|
|
14
|
+
*
|
|
15
|
+
* @property {(value: unknown) => Promise<string>} serialize
|
|
16
|
+
* Encode a value to a string suitable for an HTTP response body.
|
|
17
|
+
* Async to support binary types (Blob/File/FormData) which require
|
|
18
|
+
* an `await arrayBuffer()` step.
|
|
19
|
+
* @property {(str: string) => unknown} deserialize
|
|
20
|
+
* Decode a string produced by `serialize` back to the original value.
|
|
21
|
+
* Sync: binary is already inlined as base64 in the wire format.
|
|
22
|
+
* @property {string} contentType
|
|
23
|
+
* The MIME content-type header value to use for RPC responses.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Default serializer backed by webjs's built-in `stringify` / `parse`.
|
|
28
|
+
*
|
|
29
|
+
* Handles Date, Map, Set, BigInt, TypedArrays, ArrayBuffer, DataView,
|
|
30
|
+
* Blob, File, FormData, registered Symbols, undefined, NaN/Infinity/-0,
|
|
31
|
+
* Error, and cycles / shared references.
|
|
32
|
+
*
|
|
33
|
+
* @type {Serializer}
|
|
34
|
+
*/
|
|
35
|
+
export const defaultSerializer = {
|
|
36
|
+
async serialize(value) {
|
|
37
|
+
return stringify(value);
|
|
38
|
+
},
|
|
39
|
+
deserialize(str) {
|
|
40
|
+
return parse(str);
|
|
41
|
+
},
|
|
42
|
+
contentType: 'application/vnd.webjs+json',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** @type {Serializer} */
|
|
46
|
+
let current = defaultSerializer;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Return the active serializer.
|
|
50
|
+
*
|
|
51
|
+
* **AI hint:** Use this in server-side code that needs to encode or decode
|
|
52
|
+
* RPC payloads. It returns whatever serializer was set via `setSerializer`,
|
|
53
|
+
* or the default webjs serializer if none was set.
|
|
54
|
+
*
|
|
55
|
+
* @returns {Serializer}
|
|
56
|
+
*/
|
|
57
|
+
export function getSerializer() {
|
|
58
|
+
return current;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Replace the active serializer with a custom implementation.
|
|
63
|
+
*
|
|
64
|
+
* **AI hint:** Call this at application startup (before any requests are
|
|
65
|
+
* handled) to swap the wire format for server actions. The serializer
|
|
66
|
+
* must implement `serialize(value) => Promise<string>`,
|
|
67
|
+
* `deserialize(str) => unknown`, and expose a `contentType` string.
|
|
68
|
+
*
|
|
69
|
+
* ```js
|
|
70
|
+
* import { setSerializer } from '@webjsdev/server';
|
|
71
|
+
*
|
|
72
|
+
* setSerializer({
|
|
73
|
+
* serialize: async (v) => JSON.stringify(v),
|
|
74
|
+
* deserialize: JSON.parse,
|
|
75
|
+
* contentType: 'application/json',
|
|
76
|
+
* });
|
|
77
|
+
* ```
|
|
78
|
+
*
|
|
79
|
+
* @param {Serializer} serializer
|
|
80
|
+
*/
|
|
81
|
+
export function setSerializer(serializer) {
|
|
82
|
+
if (!serializer || typeof serializer.serialize !== 'function' || typeof serializer.deserialize !== 'function') {
|
|
83
|
+
throw new Error('setSerializer: serializer must have serialize() and deserialize() methods');
|
|
84
|
+
}
|
|
85
|
+
current = serializer;
|
|
86
|
+
}
|
package/src/session.js
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session middleware with Remix-style Session class and SessionStorage interface.
|
|
3
|
+
*
|
|
4
|
+
* Storage owns the Session lifecycle:
|
|
5
|
+
* `storage.read(cookie) → Session`
|
|
6
|
+
* `storage.save(session) → cookie | null | ''`
|
|
7
|
+
*
|
|
8
|
+
* ```js
|
|
9
|
+
* // middleware.ts
|
|
10
|
+
* import { session } from '@webjsdev/server';
|
|
11
|
+
* export default session({ secret: process.env.SESSION_SECRET });
|
|
12
|
+
*
|
|
13
|
+
* // In any handler:
|
|
14
|
+
* import { getSession } from '@webjsdev/server';
|
|
15
|
+
* const s = getSession(req);
|
|
16
|
+
* s.set('userId', user.id);
|
|
17
|
+
* s.flash('message', 'Welcome back!');
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* @module session
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { getStore } from './cache.js';
|
|
24
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Session class
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A session holds data for a specific user across multiple requests.
|
|
32
|
+
*
|
|
33
|
+
* Modeled after Remix's Session: get, set, has, unset, flash, destroy,
|
|
34
|
+
* regenerateId.
|
|
35
|
+
*/
|
|
36
|
+
export class Session {
|
|
37
|
+
#id;
|
|
38
|
+
#data;
|
|
39
|
+
#flash;
|
|
40
|
+
#dirty = false;
|
|
41
|
+
#destroyed = false;
|
|
42
|
+
#deleteId;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {string} [id]
|
|
46
|
+
* @param {{ data?: Record<string, unknown>, flash?: Record<string, unknown> }} [initial]
|
|
47
|
+
*/
|
|
48
|
+
constructor(id, initial) {
|
|
49
|
+
this.#id = id || randomBytes(24).toString('base64url');
|
|
50
|
+
this.#data = new Map(Object.entries(initial?.data || {}));
|
|
51
|
+
this.#flash = new Map(Object.entries(initial?.flash || {}));
|
|
52
|
+
if (this.#flash.size > 0) this.#dirty = true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get id() { return this.#id; }
|
|
56
|
+
get dirty() { return this.#dirty; }
|
|
57
|
+
get destroyed() { return this.#destroyed; }
|
|
58
|
+
get deleteId() { return this.#deleteId; }
|
|
59
|
+
|
|
60
|
+
/** Serialized data for storage. */
|
|
61
|
+
get data() {
|
|
62
|
+
return {
|
|
63
|
+
data: Object.fromEntries(this.#data),
|
|
64
|
+
flash: Object.fromEntries(this.#flash),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @param {string} key @returns {unknown} */
|
|
69
|
+
get(key) {
|
|
70
|
+
if (this.#destroyed) return undefined;
|
|
71
|
+
return this.#data.get(key) ?? this.#flash.get(key);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** @param {string} key @param {unknown} value */
|
|
75
|
+
set(key, value) {
|
|
76
|
+
if (this.#destroyed) throw new Error('Session has been destroyed');
|
|
77
|
+
if (value == null) this.#data.delete(key);
|
|
78
|
+
else this.#data.set(key, value);
|
|
79
|
+
this.#dirty = true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** @param {string} key @returns {boolean} */
|
|
83
|
+
has(key) {
|
|
84
|
+
if (this.#destroyed) return false;
|
|
85
|
+
return this.#data.has(key) || this.#flash.has(key);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** @param {string} key */
|
|
89
|
+
unset(key) {
|
|
90
|
+
if (this.#destroyed) throw new Error('Session has been destroyed');
|
|
91
|
+
this.#data.delete(key);
|
|
92
|
+
this.#dirty = true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Set a value that exists for one request only.
|
|
97
|
+
* @param {string} key @param {unknown} value
|
|
98
|
+
*/
|
|
99
|
+
flash(key, value) {
|
|
100
|
+
if (this.#destroyed) throw new Error('Session has been destroyed');
|
|
101
|
+
this.#flash.set(key, value);
|
|
102
|
+
this.#dirty = true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Destroy the session. Use for logout. */
|
|
106
|
+
destroy() {
|
|
107
|
+
this.#destroyed = true;
|
|
108
|
+
this.#data.clear();
|
|
109
|
+
this.#flash.clear();
|
|
110
|
+
this.#dirty = true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Regenerate the session ID. Call after login to prevent session fixation.
|
|
115
|
+
* @param {boolean} [deleteOld=false]
|
|
116
|
+
*/
|
|
117
|
+
regenerateId(deleteOld = false) {
|
|
118
|
+
if (this.#destroyed) throw new Error('Session has been destroyed');
|
|
119
|
+
if (deleteOld) this.#deleteId = this.#id;
|
|
120
|
+
this.#id = randomBytes(24).toString('base64url');
|
|
121
|
+
this.#dirty = true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// SessionStorage interface
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @typedef {Object} SessionStorage
|
|
131
|
+
* @property {(cookie: string | null) => Promise<Session>} read
|
|
132
|
+
* Create or restore a Session from the raw (unsigned) cookie value.
|
|
133
|
+
* @property {(session: Session) => Promise<string | null>} save
|
|
134
|
+
* Persist session and return the raw cookie value to sign and set.
|
|
135
|
+
* Returns `null` if no cookie change needed, `''` to clear the cookie.
|
|
136
|
+
*/
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Cookie-based session storage. All data lives in the cookie itself.
|
|
140
|
+
* Stateless: no server storage needed.
|
|
141
|
+
*
|
|
142
|
+
* @returns {SessionStorage}
|
|
143
|
+
*/
|
|
144
|
+
export function cookieSessionStorage() {
|
|
145
|
+
return {
|
|
146
|
+
async read(cookie) {
|
|
147
|
+
if (cookie) {
|
|
148
|
+
try {
|
|
149
|
+
const parsed = JSON.parse(cookie);
|
|
150
|
+
return new Session(parsed.id, { data: parsed.data, flash: parsed.flash });
|
|
151
|
+
} catch {}
|
|
152
|
+
}
|
|
153
|
+
return new Session();
|
|
154
|
+
},
|
|
155
|
+
async save(session) {
|
|
156
|
+
if (session.destroyed) return '';
|
|
157
|
+
if (!session.dirty) return null;
|
|
158
|
+
return JSON.stringify({ id: session.id, ...session.data });
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Server-side session storage. Session ID in cookie, data in cache store.
|
|
165
|
+
*
|
|
166
|
+
* @param {{ store?: import('./cache.js').CacheStore, maxAge?: number }} [opts]
|
|
167
|
+
* @returns {SessionStorage}
|
|
168
|
+
*/
|
|
169
|
+
export function storeSessionStorage(opts = {}) {
|
|
170
|
+
const store = opts.store || getStore();
|
|
171
|
+
const maxAge = opts.maxAge || 86400_000;
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
async read(cookie) {
|
|
175
|
+
if (cookie) {
|
|
176
|
+
const raw = await store.get(`session:${cookie}`);
|
|
177
|
+
if (raw) {
|
|
178
|
+
try {
|
|
179
|
+
const parsed = JSON.parse(raw);
|
|
180
|
+
return new Session(cookie, { data: parsed.data, flash: parsed.flash });
|
|
181
|
+
} catch {}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return new Session();
|
|
185
|
+
},
|
|
186
|
+
async save(session) {
|
|
187
|
+
if (session.deleteId) await store.delete(`session:${session.deleteId}`);
|
|
188
|
+
if (session.destroyed) {
|
|
189
|
+
await store.delete(`session:${session.id}`);
|
|
190
|
+
return '';
|
|
191
|
+
}
|
|
192
|
+
if (!session.dirty) return null;
|
|
193
|
+
await store.set(`session:${session.id}`, JSON.stringify(session.data), maxAge);
|
|
194
|
+
return session.id;
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Backwards-compatible aliases
|
|
200
|
+
export const cookieSession = cookieSessionStorage;
|
|
201
|
+
export const storeSession = storeSessionStorage;
|
|
202
|
+
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// Cookie helpers
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
function sign(value, secret) {
|
|
208
|
+
return `${value}.${createHmac('sha256', secret).update(value).digest('base64url')}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function unsign(input, secret) {
|
|
212
|
+
const idx = input.lastIndexOf('.');
|
|
213
|
+
if (idx < 1) return null;
|
|
214
|
+
const value = input.slice(0, idx);
|
|
215
|
+
const expected = createHmac('sha256', secret).update(value).digest('base64url');
|
|
216
|
+
const sigBuf = Buffer.from(input.slice(idx + 1));
|
|
217
|
+
const expBuf = Buffer.from(expected);
|
|
218
|
+
if (sigBuf.length !== expBuf.length) return null;
|
|
219
|
+
if (!timingSafeEqual(sigBuf, expBuf)) return null;
|
|
220
|
+
return value;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function parseCookies(header) {
|
|
224
|
+
const out = {};
|
|
225
|
+
if (!header) return out;
|
|
226
|
+
for (const pair of header.split(';')) {
|
|
227
|
+
const eq = pair.indexOf('=');
|
|
228
|
+
if (eq < 0) continue;
|
|
229
|
+
out[pair.slice(0, eq).trim()] = decodeURIComponent(pair.slice(eq + 1).trim());
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function serializeCookie(name, value, opts) {
|
|
235
|
+
let str = `${name}=${encodeURIComponent(value)}`;
|
|
236
|
+
str += `; Max-Age=${Math.floor(opts.maxAge / 1000)}`;
|
|
237
|
+
str += `; Path=${opts.path || '/'}`;
|
|
238
|
+
if (opts.httpOnly !== false) str += '; HttpOnly';
|
|
239
|
+
if (opts.secure !== false) str += '; Secure';
|
|
240
|
+
str += `; SameSite=${opts.sameSite || 'Lax'}`;
|
|
241
|
+
return str;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
// WeakMap for attaching Session to Request
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
/** @type {WeakMap<Request, Session>} */
|
|
249
|
+
const sessionMap = new WeakMap();
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// Session middleware
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Session middleware. Storage owns the Session lifecycle:
|
|
257
|
+
* `storage.read(cookie) → Session`, `storage.save(session) → cookie`.
|
|
258
|
+
*
|
|
259
|
+
* @param {{
|
|
260
|
+
* storage?: SessionStorage,
|
|
261
|
+
* cookieName?: string,
|
|
262
|
+
* secret?: string,
|
|
263
|
+
* maxAge?: number,
|
|
264
|
+
* path?: string,
|
|
265
|
+
* httpOnly?: boolean,
|
|
266
|
+
* secure?: boolean,
|
|
267
|
+
* sameSite?: string,
|
|
268
|
+
* }} [opts]
|
|
269
|
+
*/
|
|
270
|
+
export function session(opts = {}) {
|
|
271
|
+
const secret = opts.secret || process.env.SESSION_SECRET;
|
|
272
|
+
if (!secret) throw new Error('session() requires secret option or SESSION_SECRET env var');
|
|
273
|
+
|
|
274
|
+
const storage = opts.storage || cookieSessionStorage();
|
|
275
|
+
const cookieName = opts.cookieName || 'webjs.sid';
|
|
276
|
+
const cookieOpts = {
|
|
277
|
+
maxAge: opts.maxAge || 86400_000,
|
|
278
|
+
path: opts.path || '/',
|
|
279
|
+
httpOnly: opts.httpOnly,
|
|
280
|
+
secure: opts.secure,
|
|
281
|
+
sameSite: opts.sameSite,
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
return async function sessionMiddleware(req, next) {
|
|
285
|
+
const cookies = parseCookies(req.headers.get('cookie') || '');
|
|
286
|
+
const rawCookie = cookies[cookieName] || '';
|
|
287
|
+
const unsigned = rawCookie ? unsign(rawCookie, secret) : null;
|
|
288
|
+
|
|
289
|
+
// Storage creates the Session
|
|
290
|
+
const s = await storage.read(unsigned);
|
|
291
|
+
sessionMap.set(req, s);
|
|
292
|
+
|
|
293
|
+
const resp = await next();
|
|
294
|
+
|
|
295
|
+
// Storage serializes the Session
|
|
296
|
+
const cookieValue = await storage.save(s);
|
|
297
|
+
|
|
298
|
+
if (cookieValue === '') {
|
|
299
|
+
// Destroyed: clear the cookie
|
|
300
|
+
try {
|
|
301
|
+
resp.headers.append('set-cookie', `${cookieName}=; Max-Age=0; Path=${cookieOpts.path}`);
|
|
302
|
+
} catch {}
|
|
303
|
+
} else if (cookieValue !== null) {
|
|
304
|
+
// Changed: sign and set
|
|
305
|
+
try {
|
|
306
|
+
resp.headers.append('set-cookie', serializeCookie(cookieName, sign(cookieValue, secret), cookieOpts));
|
|
307
|
+
} catch {}
|
|
308
|
+
}
|
|
309
|
+
// null = no change
|
|
310
|
+
|
|
311
|
+
return resp;
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
// Read session from request
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Get the Session for the current request.
|
|
321
|
+
*
|
|
322
|
+
* ```js
|
|
323
|
+
* const s = getSession(req);
|
|
324
|
+
* s.get('userId');
|
|
325
|
+
* s.set('userId', user.id);
|
|
326
|
+
* s.flash('message', 'Saved!');
|
|
327
|
+
* ```
|
|
328
|
+
*
|
|
329
|
+
* @param {Request} req
|
|
330
|
+
* @returns {Session}
|
|
331
|
+
*/
|
|
332
|
+
export function getSession(req) {
|
|
333
|
+
const s = sessionMap.get(req);
|
|
334
|
+
if (!s) throw new Error('getSession() called outside of session middleware');
|
|
335
|
+
return s;
|
|
336
|
+
}
|