0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2025-03-10 23:01:26 -05:00

feat(sessions): implement ttl and flash (#12693)

* feat(sessions): implement ttl and flash

* chore: add unit tests

* Make set arg an object

* Add more tests

* Add test fixtures

* Add comment
This commit is contained in:
Matt Kane 2024-12-10 15:58:35 +00:00 committed by GitHub
parent 0123e9eb46
commit f45962df83
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 123 additions and 31 deletions

View file

@ -559,6 +559,7 @@ export const AstroConfigSchema = z.object({
return val;
})
.optional(),
ttl: z.number().optional(),
})
.optional(),
svg: z

View file

@ -21,6 +21,11 @@ export const PERSIST_SYMBOL = Symbol();
const DEFAULT_COOKIE_NAME = 'astro-session';
const VALID_COOKIE_REGEX = /^[\w-]+$/;
interface SessionEntry {
data: any;
expires?: number | 'flash';
}
export class AstroSession<TDriver extends SessionDriverName = any> {
// The cookies object.
#cookies: AstroCookies;
@ -32,19 +37,23 @@ export class AstroSession<TDriver extends SessionDriverName = any> {
#cookieName: string;
// The unstorage object for the session driver.
#storage: Storage | undefined;
#data: Map<string, any> | undefined;
#data: Map<string, SessionEntry> | undefined;
// The session ID. A v4 UUID.
#sessionID: string | undefined;
// Sessions to destroy. Needed because we won't have the old session ID after it's destroyed locally.
#toDestroy = new Set<string>();
// Session keys to delete. Used for sparse data sets to avoid overwriting the deleted value.
// Session keys to delete. Used for partial data sets to avoid overwriting the deleted value.
#toDelete = new Set<string>();
// Whether the session is dirty and needs to be saved.
#dirty = false;
// Whether the session cookie has been set.
#cookieSet = false;
// Whether the session data is sparse and needs to be merged with the existing data.
#sparse = true;
// 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;
constructor(
cookies: AstroCookies,
@ -67,7 +76,7 @@ export class AstroSession<TDriver extends SessionDriverName = any> {
* Gets a session value. Returns `undefined` if the session or value does not exist.
*/
async get<T = any>(key: string): Promise<T | undefined> {
return (await this.#ensureData()).get(key);
return (await this.#ensureData()).get(key)?.data;
}
/**
@ -88,14 +97,14 @@ export class AstroSession<TDriver extends SessionDriverName = any> {
* Gets all session values.
*/
async values() {
return (await this.#ensureData()).values();
return [...(await this.#ensureData()).values()].map((entry) => entry.data);
}
/**
* Gets all session entries.
*/
async entries() {
return (await this.#ensureData()).entries();
return [...(await this.#ensureData()).entries()].map(([key, entry]) => [key, entry.data]);
}
/**
@ -103,7 +112,7 @@ export class AstroSession<TDriver extends SessionDriverName = any> {
*/
delete(key: string) {
this.#data?.delete(key);
if (this.#sparse) {
if (this.#partial) {
this.#toDelete.add(key);
}
this.#dirty = true;
@ -113,7 +122,7 @@ export class AstroSession<TDriver extends SessionDriverName = any> {
* Sets a session value. The session is created if it does not exist.
*/
set<T = any>(key: string, value: T) {
set<T = any>(key: string, value: T, { ttl }: { ttl?: number | 'flash' } = {}) {
if (!key) {
throw new AstroError({
...SessionStorageSaveError,
@ -138,10 +147,20 @@ export class AstroSession<TDriver extends SessionDriverName = any> {
this.#cookieSet = true;
}
this.#data ??= new Map();
this.#data.set(key, value);
const lifetime = ttl ?? this.#config.ttl;
// If ttl is numeric, it is the number of seconds until expiry. To get an expiry timestamp, we convert to milliseconds and add to the current time.
const expires = typeof lifetime === 'number' ? Date.now() + lifetime * 1000 : lifetime;
this.#data.set(key, {
data: value,
expires,
});
this.#dirty = true;
}
flash<T = any>(key: string, value: T) {
this.set(key, value, { ttl: 'flash' });
}
/**
* Destroys the session, clearing the cookie and storage if it exists.
*/
@ -194,6 +213,7 @@ export class AstroSession<TDriver extends SessionDriverName = any> {
if (this.#dirty && this.#data) {
const data = await this.#ensureData();
this.#toDelete.forEach((key) => data.delete(key));
const key = this.#ensureSessionID();
let serialized;
try {
@ -256,12 +276,12 @@ export class AstroSession<TDriver extends SessionDriverName = any> {
/**
* Attempts to load the session data from storage, or creates a new data object if none exists.
* If there is existing sparse data, it will be merged into the new data object.
* 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.#sparse) {
if (this.#data && !this.#partial) {
return this.#data;
}
this.#data ??= new Map();
@ -289,26 +309,25 @@ export class AstroSession<TDriver extends SessionDriverName = any> {
),
});
}
// The local data is "sparse" if it has not been loaded from storage yet. This means
// it 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 sparse data,
// preserving in-memory changes and deletions.
if (this.#sparse) {
// For sparse updates, only copy values from storage that:
// 1. Don't exist in memory (preserving in-memory changes)
// 2. Haven't been marked for deletion
for (const [key, value] of storedMap) {
if (!this.#data.has(key) && !this.#toDelete.has(key)) {
this.#data.set(key, value);
const now = Date.now();
// Only copy values from storage that:
// 1. Don't exist in memory (preserving in-memory changes)
// 2. Haven't been marked for deletion
// 3. Haven't expired
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);
if (value?.expires === 'flash') {
this.#toDelete.add(key);
this.#dirty = true;
}
}
} else {
this.#data = storedMap;
}
this.#sparse = false;
this.#partial = false;
return this.#data;
} catch (err) {
await this.#destroySafe();

View file

@ -107,6 +107,11 @@ interface CommonSessionConfig {
cookie?:
| string
| (Omit<AstroCookieSetOptions, 'httpOnly' | 'expires' | 'encode'> & { name?: string });
/**
* Default session duration in seconds. If not set, the session will be stored until deleted, or until the cookie expires.
*/
ttl?: number;
}
interface BuiltinSessionConfig<TDriver extends keyof BuiltinDriverOptions>

View file

@ -8,6 +8,7 @@ export default defineConfig({
experimental: {
session: {
driver: 'fs',
ttl: 20,
},
},
});

View file

@ -7,6 +7,20 @@ export const onRequest = defineMiddleware(async (context, next) => {
// Skip requests for prerendered pages
if (context.isPrerendered) return next();
if(context.url.searchParams.has('setFlash') && context.url.pathname === '/') {
context.session.flash('middleware-flash', `Flashed message at ${new Date().toISOString()}`);
}
if(context.url.pathname === '/next-rewrite-middleware') {
context.session.flash('middleware-flash', `Flashed rewrite message at ${new Date().toISOString()}`);
return next('/');
}
if(context.url.pathname === '/ctx-rewrite-middleware') {
context.session.flash('middleware-flash', `Flashed rewrite message at ${new Date().toISOString()}`);
return context.rewrite(new Request(new URL('/', context.url)));
}
const { action, setActionResult, serializeActionResult } =
getActionContext(context);

View file

@ -0,0 +1,4 @@
---
Astro.session.flash('flash-value', `Flashed value at ${new Date().toISOString()}`);
return Astro.rewrite('/');
---

View file

@ -0,0 +1,4 @@
---
Astro.session.flash('flash-value', `Flashed value at ${new Date().toISOString()}`);
return Astro.redirect('/');
---

View file

@ -1,5 +1,7 @@
---
const value = await Astro.session.get('value');
const flash = await Astro.session.get('flash-value');
const middlewareFlash = await Astro.session.get('middleware-flash');
---
<html lang="en">
<head>
@ -9,5 +11,8 @@ const value = await Astro.session.get('value');
<h1>Hi</h1>
<p>{value}</p>
<p>Flash: {flash}</p>
<p>middlewareFlash: {middlewareFlash}</p>
<p><a href="/flash">Set flash</a></p>
<a href="/cart" style="font-size: 36px">🛒</a>
</html>

View file

@ -14,6 +14,7 @@ const stringify = (data) => JSON.parse(devalueStringify(data));
const defaultConfig = {
driver: 'memory',
cookie: 'test-session',
ttl: 60,
};
// Helper to create a new session instance with mocked dependencies
@ -142,7 +143,7 @@ test('AstroSession - Data Persistence', async (t) => {
await t.test('should load data from storage', async () => {
const mockStorage = {
get: async () => stringify(new Map([['key', 'value']])),
get: async () => stringify(new Map([['key', { data: 'value' }]])),
setItem: async () => {},
};
@ -151,8 +152,46 @@ test('AstroSession - Data Persistence', async (t) => {
const value = await session.get('key');
assert.equal(value, 'value');
});
await t.test('should remove expired session data', async () => {
const mockStorage = {
get: async () => stringify(new Map([['key', { data: 'value', expires: -1 }]])),
setItem: async () => {},
};
const session = createSession(defaultConfig, defaultMockCookies, mockStorage);
const value = await session.get('key');
assert.equal(value, undefined);
});
await t.test('should not write flash session data to storage a second time', async () => {
let storedData;
const mockStorage = {
get: async () => stringify(new Map([['key', { data: 'value', expires: "flash" }]])),
setItem: async (_key, value) => {
storedData = value;
},
};
const session = createSession(defaultConfig, defaultMockCookies, mockStorage);
const value = await session.get('key');
assert.equal(value, 'value');
await session[PERSIST_SYMBOL]();
const emptyMap = devalueStringify(new Map());
assert.equal(storedData, emptyMap);
})
});
test('AstroSession - Error Handling', async (t) => {
await t.test('should throw error when setting invalid data', async () => {
const session = createSession();
@ -227,9 +266,9 @@ test('AstroSession - Sparse Data Operations', async (t) => {
await t.test('should handle multiple operations in sparse mode', async () => {
const existingData = stringify(
new Map([
['keep', 'original'],
['delete', 'remove'],
['update', 'old'],
['keep', { data: 'original' }],
['delete', { data: 'remove' }],
['update', { data: 'old' }],
]),
);