release: WRNexusJS 0.7.0
This commit is contained in:
+107
-21
@@ -37,15 +37,47 @@ export interface LocalStorageSnapshot {
|
||||
}
|
||||
|
||||
const SESSION_COOKIE = "wrnexus.sid";
|
||||
/** Idle timeout: a session expires this long after its last access. */
|
||||
const SESSION_TTL_MS = 1000 * 60 * 60 * 24; // 24 hours
|
||||
/** Run a background sweep after this many new sessions (bounds memory). */
|
||||
const SESSION_TTL_MS = 1000 * 60 * 60 * 24;
|
||||
const SESSION_ABSOLUTE_TTL_MS = 1000 * 60 * 60 * 24 * 7;
|
||||
const SESSION_GC_EVERY = 500;
|
||||
|
||||
export interface SessionPolicy {
|
||||
cookieName?: string;
|
||||
idleTimeoutMs?: number;
|
||||
absoluteTimeoutMs?: number;
|
||||
sameSite?: NonNullable<CookieOptions["sameSite"]>;
|
||||
secure?: boolean;
|
||||
}
|
||||
|
||||
let sessionPolicy: Required<
|
||||
Pick<SessionPolicy, "cookieName" | "idleTimeoutMs" | "absoluteTimeoutMs" | "sameSite">
|
||||
> &
|
||||
Pick<SessionPolicy, "secure"> = {
|
||||
cookieName: SESSION_COOKIE,
|
||||
idleTimeoutMs: SESSION_TTL_MS,
|
||||
absoluteTimeoutMs: SESSION_ABSOLUTE_TTL_MS,
|
||||
sameSite: "Lax",
|
||||
};
|
||||
|
||||
export function setSessionPolicy(policy: SessionPolicy): void {
|
||||
sessionPolicy = {
|
||||
...sessionPolicy,
|
||||
...policy,
|
||||
idleTimeoutMs: Math.max(60_000, policy.idleTimeoutMs ?? sessionPolicy.idleTimeoutMs),
|
||||
absoluteTimeoutMs: Math.max(
|
||||
policy.idleTimeoutMs ?? sessionPolicy.idleTimeoutMs,
|
||||
policy.absoluteTimeoutMs ?? sessionPolicy.absoluteTimeoutMs,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** A stored session: its data plus an absolute expiry timestamp (ms). */
|
||||
export interface SessionEntry {
|
||||
data: Record<string, unknown>;
|
||||
expiresAt: number;
|
||||
/** Creation time used for the absolute session lifetime. Optional for old backends. */
|
||||
createdAt?: number;
|
||||
lastAccessAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,7 +102,13 @@ function createMemorySessionBackend(): SessionBackend {
|
||||
set: (id, entry) => void map.set(id, entry),
|
||||
delete: (id) => void map.delete(id),
|
||||
gc: (now) => {
|
||||
for (const [key, entry] of map) if (entry.expiresAt <= now) map.delete(key);
|
||||
for (const [key, entry] of map) {
|
||||
if (
|
||||
entry.expiresAt <= now ||
|
||||
(entry.createdAt ?? now) + sessionPolicy.absoluteTimeoutMs <= now
|
||||
)
|
||||
map.delete(key);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -102,13 +140,26 @@ export interface AsyncSessionBackend {
|
||||
*/
|
||||
export function loadSession(
|
||||
backend: AsyncSessionBackend,
|
||||
options: { ttlMs?: number } = {},
|
||||
options: {
|
||||
ttlMs?: number;
|
||||
absoluteTtlMs?: number;
|
||||
cookieName?: string;
|
||||
sameSite?: NonNullable<CookieOptions["sameSite"]>;
|
||||
secure?: boolean;
|
||||
} = {},
|
||||
): Middleware {
|
||||
const ttlMs = options.ttlMs ?? SESSION_TTL_MS;
|
||||
const ttlMs = options.ttlMs ?? sessionPolicy.idleTimeoutMs;
|
||||
const absoluteTtlMs = options.absoluteTtlMs ?? sessionPolicy.absoluteTimeoutMs;
|
||||
const cookieName = options.cookieName ?? sessionPolicy.cookieName;
|
||||
return async (ctx: Context, next) => {
|
||||
let id = ctx.cookies.get(SESSION_COOKIE);
|
||||
let id = ctx.cookies.get(cookieName);
|
||||
let entry = id ? await backend.load(id) : undefined;
|
||||
if (id && entry && entry.expiresAt <= Date.now()) {
|
||||
if (
|
||||
id &&
|
||||
entry &&
|
||||
(entry.expiresAt <= Date.now() ||
|
||||
(entry.createdAt ?? Date.now()) + absoluteTtlMs <= Date.now())
|
||||
) {
|
||||
await backend.destroy(id);
|
||||
entry = undefined;
|
||||
id = undefined;
|
||||
@@ -120,9 +171,16 @@ export function loadSession(
|
||||
const ensure = (): Record<string, unknown> => {
|
||||
if (!id) {
|
||||
id = randomId();
|
||||
ctx.cookies.set(SESSION_COOKIE, id, sessionCookieOptions(ctx.url.protocol === "https:"));
|
||||
ctx.cookies.set(
|
||||
cookieName,
|
||||
id,
|
||||
sessionCookieOptions(options.secure ?? ctx.url.protocol === "https:", options.sameSite),
|
||||
);
|
||||
}
|
||||
if (!entry) {
|
||||
const now = Date.now();
|
||||
entry = { data: {}, expiresAt: now + ttlMs, createdAt: now, lastAccessAt: now };
|
||||
}
|
||||
if (!entry) entry = { data: {}, expiresAt: Date.now() + ttlMs };
|
||||
return entry.data;
|
||||
};
|
||||
|
||||
@@ -147,14 +205,22 @@ export function loadSession(
|
||||
const data = entry?.data ?? {};
|
||||
if (id) destroys.add(id);
|
||||
id = randomId();
|
||||
entry = { data, expiresAt: Date.now() + ttlMs };
|
||||
ctx.cookies.set(SESSION_COOKIE, id, sessionCookieOptions(ctx.url.protocol === "https:"));
|
||||
const now = Date.now();
|
||||
entry = { data, expiresAt: now + ttlMs, createdAt: now, lastAccessAt: now };
|
||||
ctx.cookies.set(
|
||||
cookieName,
|
||||
id,
|
||||
sessionCookieOptions(options.secure ?? ctx.url.protocol === "https:", options.sameSite),
|
||||
);
|
||||
},
|
||||
clear() {
|
||||
if (id) destroys.add(id);
|
||||
entry = undefined;
|
||||
id = undefined;
|
||||
ctx.cookies.delete(SESSION_COOKIE, sessionCookieOptions(ctx.url.protocol === "https:"));
|
||||
ctx.cookies.delete(
|
||||
cookieName,
|
||||
sessionCookieOptions(options.secure ?? ctx.url.protocol === "https:", options.sameSite),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -164,6 +230,7 @@ export function loadSession(
|
||||
for (const gone of destroys) if (gone !== id) await backend.destroy(gone);
|
||||
if (id && entry) {
|
||||
entry.expiresAt = Date.now() + ttlMs;
|
||||
entry.lastAccessAt = Date.now();
|
||||
await backend.save(id, entry);
|
||||
}
|
||||
}
|
||||
@@ -176,11 +243,14 @@ const COOKIE_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
||||
function readSessionEntry(id: string): SessionEntry | undefined {
|
||||
const entry = sessionBackend.get(id);
|
||||
if (!entry) return undefined;
|
||||
if (entry.expiresAt <= Date.now()) {
|
||||
const now = Date.now();
|
||||
if (entry.expiresAt <= now || (entry.createdAt ?? now) + sessionPolicy.absoluteTimeoutMs <= now) {
|
||||
sessionBackend.delete(id);
|
||||
return undefined;
|
||||
}
|
||||
entry.expiresAt = Date.now() + SESSION_TTL_MS; // sliding idle expiry
|
||||
entry.lastAccessAt = now;
|
||||
entry.createdAt ??= now;
|
||||
entry.expiresAt = now + sessionPolicy.idleTimeoutMs; // sliding idle expiry
|
||||
sessionBackend.set(id, entry); // persist the slide (matters for external backends)
|
||||
return entry;
|
||||
}
|
||||
@@ -223,8 +293,8 @@ export function createCookieStore(req: Request): CookieStore {
|
||||
export function createSessionStore(
|
||||
cookies: CookieStore,
|
||||
req: Request,
|
||||
cookieName = SESSION_COOKIE,
|
||||
secure = new URL(req.url).protocol === "https:",
|
||||
cookieName = sessionPolicy.cookieName,
|
||||
secure = sessionPolicy.secure ?? new URL(req.url).protocol === "https:",
|
||||
): SessionStore {
|
||||
let id = cookies.get(cookieName);
|
||||
let entry = id ? readSessionEntry(id) : undefined;
|
||||
@@ -245,7 +315,13 @@ export function createSessionStore(
|
||||
sessionsSinceGc = 0;
|
||||
sessionBackend.gc?.(Date.now());
|
||||
}
|
||||
entry = { data: {}, expiresAt: Date.now() + SESSION_TTL_MS };
|
||||
const now = Date.now();
|
||||
entry = {
|
||||
data: {},
|
||||
expiresAt: now + sessionPolicy.idleTimeoutMs,
|
||||
createdAt: now,
|
||||
lastAccessAt: now,
|
||||
};
|
||||
sessionBackend.set(id, entry);
|
||||
}
|
||||
return entry.data;
|
||||
@@ -278,7 +354,13 @@ export function createSessionStore(
|
||||
const data = entry?.data ?? {};
|
||||
if (id) sessionBackend.delete(id);
|
||||
id = randomId();
|
||||
entry = { data, expiresAt: Date.now() + SESSION_TTL_MS };
|
||||
const now = Date.now();
|
||||
entry = {
|
||||
data,
|
||||
expiresAt: now + sessionPolicy.idleTimeoutMs,
|
||||
createdAt: now,
|
||||
lastAccessAt: now,
|
||||
};
|
||||
sessionBackend.set(id, entry);
|
||||
cookies.set(cookieName, id, sessionCookieOptions(secure));
|
||||
},
|
||||
@@ -341,11 +423,14 @@ function serializeCookie(name: string, value: string, options: CookieOptions): s
|
||||
return parts.join("; ");
|
||||
}
|
||||
|
||||
function sessionCookieOptions(secure: boolean): CookieOptions {
|
||||
function sessionCookieOptions(
|
||||
secure: boolean,
|
||||
sameSite: NonNullable<CookieOptions["sameSite"]> = sessionPolicy.sameSite,
|
||||
): CookieOptions {
|
||||
return {
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
sameSite: "Lax",
|
||||
sameSite,
|
||||
secure,
|
||||
};
|
||||
}
|
||||
@@ -358,6 +443,7 @@ function parseLocalStorageHeader(header: string | null): Record<string, string>
|
||||
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (key === "__proto__" || key === "prototype" || key === "constructor") continue;
|
||||
if (typeof value === "string") out[key] = value;
|
||||
}
|
||||
return out;
|
||||
|
||||
Reference in New Issue
Block a user