astro
Version:
Astro is a modern site builder with web best practices, performance, and DX front-of-mind.
216 lines (215 loc) • 6.79 kB
JavaScript
import { parseCookie, stringifySetCookie } from "cookie";
import { AstroError, AstroErrorData } from "../errors/index.js";
const DELETED_EXPIRATION = new Date(0);
const DELETED_VALUE = "deleted";
const responseSentSymbol = Symbol.for("astro.responseSent");
const identity = (value) => value;
class AstroCookie {
value;
constructor(value) {
this.value = value;
}
json() {
if (this.value === undefined) {
throw new Error(`Cannot convert undefined to an object.`);
}
return JSON.parse(this.value);
}
number() {
return Number(this.value);
}
boolean() {
if (this.value === "false") return false;
if (this.value === "0") return false;
return Boolean(this.value);
}
}
class AstroCookies {
#request;
#requestValues;
#outgoing;
#consumed;
#logger;
constructor(request, logger) {
this.#request = request;
this.#requestValues = null;
this.#outgoing = null;
this.#consumed = false;
this.#logger = logger;
}
/**
* Astro.cookies.delete(key) is used to delete a cookie. Using this method will result
* in a Set-Cookie header added to the response.
* @param key The cookie to delete
* @param options Options related to this deletion, such as the path of the cookie.
*/
delete(key, options) {
// Set-Cookie: token=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT
this.#ensureOutgoingMap().set(key, [
DELETED_VALUE,
stringifySetCookie({
...options,
name: key,
value: DELETED_VALUE,
expires: DELETED_EXPIRATION,
// Unset `maxAge` to ensure that `expires` takes precedence.
maxAge: undefined
}),
false
]);
}
/**
* Astro.cookies.get(key) is used to get a cookie value. The cookie value is read from the
* request. If you have set a cookie via Astro.cookies.set(key, value), the value will be taken
* from that set call, overriding any values already part of the request.
* @param key The cookie to get.
* @returns An object containing the cookie value as well as convenience methods for converting its value.
*/
get(key, options = undefined) {
// Check for outgoing Set-Cookie values first
if (this.#outgoing?.has(key)) {
let [serializedValue, , isSetValue] = this.#outgoing.get(key);
if (isSetValue) {
return new AstroCookie(serializedValue);
} else {
return undefined;
}
}
// decodeURIComponent is the default decode function for cookies
const decode = options?.decode ?? decodeURIComponent;
const values = this.#ensureParsed();
if (key in values) {
const value = values[key];
if (value !== undefined) {
let decodedValue;
try {
decodedValue = decode(value);
} catch (_error) {
// If decoding fails (e.g., invalid URI sequences), use the original value
// This aligns with the behavior of the `cookie` package
decodedValue = value;
}
return new AstroCookie(decodedValue);
}
}
}
/**
* Astro.cookies.has(key) returns a boolean indicating whether this cookie is either
* part of the initial request or set via Astro.cookies.set(key)
* @param key The cookie to check for.
* @param _options This parameter is no longer used.
* @returns
*/
has(key, _options) {
if (this.#outgoing?.has(key)) {
let [, , isSetValue] = this.#outgoing.get(key);
return isSetValue;
}
const values = this.#ensureParsed();
return values[key] !== undefined;
}
/**
* Astro.cookies.set(key, value) is used to set a cookie's value. If provided
* an object it will be stringified via JSON.stringify(value). Additionally you
* can provide options customizing how this cookie will be set, such as setting httpOnly
* in order to prevent the cookie from being read in client-side JavaScript.
* @param key The name of the cookie to set.
* @param value A value, either a string or other primitive or an object.
* @param options Options for the cookie, such as the path and security settings.
*/
set(key, value, options) {
if (this.#consumed) {
this.#logger.warn("SKIP_FORMAT", "Astro.cookies.set() was called after the cookies had already been sent to the browser.\n" + "This may have happened if this method was called in an imported component.\n" + "Please make sure that Astro.cookies.set() is only called in the frontmatter of the main page.");
}
let serializedValue;
if (typeof value === "string") {
serializedValue = value;
} else {
// Support stringifying JSON objects for convenience. First check that this is
// a plain object and if it is, stringify. If not, allow support for toString() overrides.
let toStringValue = value.toString();
if (toStringValue === Object.prototype.toString.call(value)) {
serializedValue = JSON.stringify(value);
} else {
serializedValue = toStringValue;
}
}
const { encode, ...attributes } = options ?? {};
this.#ensureOutgoingMap().set(key, [
serializedValue,
stringifySetCookie({
...attributes,
name: key,
value: serializedValue
}, { encode }),
true
]);
if (this.#request[responseSentSymbol]) {
throw new AstroError({ ...AstroErrorData.ResponseSentError });
}
}
/**
* Merges a new AstroCookies instance into the current instance. Any new cookies
* will be added to the current instance, overwriting any existing cookies with the same name.
*/
merge(cookies) {
const outgoing = cookies.#outgoing;
if (outgoing) {
for (const [key, value] of outgoing) {
this.#ensureOutgoingMap().set(key, value);
}
}
}
/**
* Astro.cookies.header() returns an iterator for the cookies that have previously
* been set by either Astro.cookies.set() or Astro.cookies.delete().
* This method is primarily used by adapters to set the header on outgoing responses.
* @returns
*/
*headers() {
if (this.#outgoing == null) return;
for (const [, value] of this.#outgoing) {
yield value[1];
}
}
/**
* Marks the cookies as consumed and returns the header values.
* After consumption, any subsequent `set()` calls will warn.
*/
consume() {
this.#consumed = true;
return this.headers();
}
/**
* @deprecated Use the instance method `cookies.consume()` instead.
* Kept for backward compatibility with adapters.
*/
static consume(cookies) {
return cookies.consume();
}
#ensureParsed() {
if (!this.#requestValues) {
this.#parse();
}
if (!this.#requestValues) {
this.#requestValues = Object.create(null);
}
return this.#requestValues;
}
#ensureOutgoingMap() {
if (!this.#outgoing) {
this.#outgoing = new Map();
}
return this.#outgoing;
}
#parse() {
const raw = this.#request.headers.get("cookie");
if (!raw) {
return;
}
// Pass identity function for decoding so it doesn't use the default.
// We'll do the actual decoding when we read the value.
this.#requestValues = parseCookie(raw, { decode: identity });
}
}
export { AstroCookies };