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
JavaScript
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
};