UNPKG

astro

Version:

Astro is a modern site builder with web best practices, performance, and DX front-of-mind.

416 lines (415 loc) • 12.6 kB
import { stringify as rawStringify, unflatten as rawUnflatten } from "devalue"; import { builtinDrivers, createStorage } from "unstorage"; import { SessionStorageInitError, SessionStorageSaveError } from "./errors/errors-data.js"; import { AstroError } from "./errors/index.js"; const PERSIST_SYMBOL = Symbol(); const DEFAULT_COOKIE_NAME = "astro-session"; const VALID_COOKIE_REGEX = /^[\w-]+$/; const unflatten = (parsed, _) => { return rawUnflatten(parsed, { URL: (href) => new URL(href) }); }; const stringify = (data, _) => { return rawStringify(data, { // Support URL objects URL: (val) => val instanceof URL && val.href }); }; class AstroSession { // The cookies object. #cookies; // The session configuration. #config; // The cookie config #cookieConfig; // The cookie name #cookieName; // The unstorage object for the session driver. #storage; #data; // The session ID. A v4 UUID. #sessionID; // Sessions to destroy. Needed because we won't have the old session ID after it's destroyed locally. #toDestroy = /* @__PURE__ */ new Set(); // Session keys to delete. Used for partial data sets to avoid overwriting the deleted value. #toDelete = /* @__PURE__ */ new Set(); // Whether the session is dirty and needs to be saved. #dirty = false; // Whether the session cookie has been set. #cookieSet = false; // The local data is "partial" if it has not been loaded from storage yet and only // contains values that have been set or deleted in-memory locally. // We do this to avoid the need to block on loading data when it is only being set. // When we load the data from storage, we need to merge it with the local partial data, // preserving in-memory changes and deletions. #partial = true; static #sharedStorage = /* @__PURE__ */ new Map(); constructor(cookies, { cookie: cookieConfig = DEFAULT_COOKIE_NAME, ...config }, runtimeMode) { this.#cookies = cookies; let cookieConfigObject; if (typeof cookieConfig === "object") { const { name = DEFAULT_COOKIE_NAME, ...rest } = cookieConfig; this.#cookieName = name; cookieConfigObject = rest; } else { this.#cookieName = cookieConfig || DEFAULT_COOKIE_NAME; } this.#cookieConfig = { sameSite: "lax", secure: runtimeMode === "production", path: "/", ...cookieConfigObject, httpOnly: true }; this.#config = config; } /** * Gets a session value. Returns `undefined` if the session or value does not exist. */ async get(key) { return (await this.#ensureData()).get(key)?.data; } /** * Checks if a session value exists. */ async has(key) { return (await this.#ensureData()).has(key); } /** * Gets all session values. */ async keys() { return (await this.#ensureData()).keys(); } /** * Gets all session values. */ async values() { return [...(await this.#ensureData()).values()].map((entry) => entry.data); } /** * Gets all session entries. */ async entries() { return [...(await this.#ensureData()).entries()].map(([key, entry]) => [key, entry.data]); } /** * Deletes a session value. */ delete(key) { this.#data?.delete(key); if (this.#partial) { this.#toDelete.add(key); } this.#dirty = true; } /** * Sets a session value. The session is created if it does not exist. */ set(key, value, { ttl } = {}) { if (!key) { throw new AstroError({ ...SessionStorageSaveError, message: "The session key was not provided." }); } let cloned; try { cloned = unflatten(JSON.parse(stringify(value))); } catch (err) { throw new AstroError( { ...SessionStorageSaveError, message: `The session data for ${key} could not be serialized.`, hint: "See the devalue library for all supported types: https://github.com/rich-harris/devalue" }, { cause: err } ); } if (!this.#cookieSet) { this.#setCookie(); this.#cookieSet = true; } this.#data ??= /* @__PURE__ */ new Map(); const lifetime = ttl ?? this.#config.ttl; const expires = typeof lifetime === "number" ? Date.now() + lifetime * 1e3 : lifetime; this.#data.set(key, { data: cloned, expires }); this.#dirty = true; } /** * Destroys the session, clearing the cookie and storage if it exists. */ destroy() { this.#destroySafe(); } /** * Regenerates the session, creating a new session ID. The existing session data is preserved. */ async regenerate() { let data = /* @__PURE__ */ new Map(); try { data = await this.#ensureData(); } catch (err) { console.error("Failed to load session data during regeneration:", err); } const oldSessionId = this.#sessionID; this.#sessionID = crypto.randomUUID(); this.#data = data; await this.#setCookie(); if (oldSessionId && this.#storage) { this.#storage.removeItem(oldSessionId).catch((err) => { console.error("Failed to remove old session data:", err); }); } } // Persists the session data to storage. // This is called automatically at the end of the request. // Uses a symbol to prevent users from calling it directly. async [PERSIST_SYMBOL]() { if (!this.#dirty && !this.#toDestroy.size) { return; } const storage = await this.#ensureStorage(); if (this.#dirty && this.#data) { const data = await this.#ensureData(); this.#toDelete.forEach((key2) => data.delete(key2)); const key = this.#ensureSessionID(); let serialized; try { serialized = stringify(data); } catch (err) { throw new AstroError( { ...SessionStorageSaveError, message: SessionStorageSaveError.message( "The session data could not be serialized.", this.#config.driver ) }, { cause: err } ); } await storage.setItem(key, serialized); this.#dirty = false; } if (this.#toDestroy.size > 0) { const cleanupPromises = [...this.#toDestroy].map( (sessionId) => storage.removeItem(sessionId).catch((err) => { console.error(`Failed to clean up session ${sessionId}:`, err); }) ); await Promise.all(cleanupPromises); this.#toDestroy.clear(); } } get sessionID() { return this.#sessionID; } /** * Loads a session from storage with the given ID, and replaces the current session. * Any changes made to the current session will be lost. * This is not normally needed, as the session is automatically loaded using the cookie. * However it can be used to restore a session where the ID has been recorded somewhere * else (e.g. in a database). */ async load(sessionID) { this.#sessionID = sessionID; this.#data = void 0; await this.#setCookie(); await this.#ensureData(); } /** * Sets the session cookie. */ async #setCookie() { if (!VALID_COOKIE_REGEX.test(this.#cookieName)) { throw new AstroError({ ...SessionStorageSaveError, message: "Invalid cookie name. Cookie names can only contain letters, numbers, and dashes." }); } const value = this.#ensureSessionID(); this.#cookies.set(this.#cookieName, value, this.#cookieConfig); } /** * Attempts to load the session data from storage, or creates a new data object if none exists. * If there is existing partial data, it will be merged into the new data object. */ async #ensureData() { const storage = await this.#ensureStorage(); if (this.#data && !this.#partial) { return this.#data; } this.#data ??= /* @__PURE__ */ new Map(); const raw = await storage.get(this.#ensureSessionID()); if (!raw) { return this.#data; } try { const storedMap = unflatten(raw); if (!(storedMap instanceof Map)) { await this.#destroySafe(); throw new AstroError({ ...SessionStorageInitError, message: SessionStorageInitError.message( "The session data was an invalid type.", this.#config.driver ) }); } const now = Date.now(); for (const [key, value] of storedMap) { const expired = typeof value.expires === "number" && value.expires < now; if (!this.#data.has(key) && !this.#toDelete.has(key) && !expired) { this.#data.set(key, value); } } this.#partial = false; return this.#data; } catch (err) { await this.#destroySafe(); if (err instanceof AstroError) { throw err; } throw new AstroError( { ...SessionStorageInitError, message: SessionStorageInitError.message( "The session data could not be parsed.", this.#config.driver ) }, { cause: err } ); } } /** * Safely destroys the session, clearing the cookie and storage if it exists. */ #destroySafe() { if (this.#sessionID) { this.#toDestroy.add(this.#sessionID); } if (this.#cookieName) { this.#cookies.delete(this.#cookieName, this.#cookieConfig); } this.#sessionID = void 0; this.#data = void 0; this.#dirty = true; } /** * Returns the session ID, generating a new one if it does not exist. */ #ensureSessionID() { this.#sessionID ??= this.#cookies.get(this.#cookieName)?.value ?? crypto.randomUUID(); return this.#sessionID; } /** * Ensures the storage is initialized. * This is called automatically when a storage operation is needed. */ async #ensureStorage() { if (this.#storage) { return this.#storage; } if (AstroSession.#sharedStorage.has(this.#config.driver)) { this.#storage = AstroSession.#sharedStorage.get(this.#config.driver); return this.#storage; } if (this.#config.driver === "test") { this.#storage = this.#config.options.mockStorage; return this.#storage; } if (this.#config.driver === "fs" || this.#config.driver === "fsLite" || this.#config.driver === "fs-lite") { this.#config.options ??= {}; this.#config.driver = "fs-lite"; this.#config.options.base ??= ".astro/session"; } if (!this.#config?.driver) { throw new AstroError({ ...SessionStorageInitError, message: SessionStorageInitError.message( "No driver was defined in the session configuration and the adapter did not provide a default driver." ) }); } let driver = null; const driverPackage = await resolveSessionDriver(this.#config.driver); try { if (this.#config.driverModule) { driver = (await this.#config.driverModule()).default; } else if (driverPackage) { driver = (await import(driverPackage)).default; } } catch (err) { if (err.code === "ERR_MODULE_NOT_FOUND") { throw new AstroError( { ...SessionStorageInitError, message: SessionStorageInitError.message( err.message.includes(`Cannot find package '${driverPackage}'`) ? "The driver module could not be found." : err.message, this.#config.driver ) }, { cause: err } ); } throw err; } if (!driver) { throw new AstroError({ ...SessionStorageInitError, message: SessionStorageInitError.message( "The module did not export a driver.", this.#config.driver ) }); } try { this.#storage = createStorage({ driver: driver(this.#config.options) }); AstroSession.#sharedStorage.set(this.#config.driver, this.#storage); return this.#storage; } catch (err) { throw new AstroError( { ...SessionStorageInitError, message: SessionStorageInitError.message("Unknown error", this.#config.driver) }, { cause: err } ); } } } async function resolveSessionDriver(driver) { if (!driver) { return null; } try { if (driver === "fs") { return await import.meta.resolve(builtinDrivers.fsLite); } if (driver in builtinDrivers) { return await import.meta.resolve(builtinDrivers[driver]); } } catch { return null; } return driver; } export { AstroSession, PERSIST_SYMBOL, resolveSessionDriver };