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