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:
parent
0123e9eb46
commit
f45962df83
9 changed files with 123 additions and 31 deletions
|
@ -559,6 +559,7 @@ export const AstroConfigSchema = z.object({
|
|||
return val;
|
||||
})
|
||||
.optional(),
|
||||
ttl: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
svg: z
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -8,6 +8,7 @@ export default defineConfig({
|
|||
experimental: {
|
||||
session: {
|
||||
driver: 'fs',
|
||||
ttl: 20,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
4
packages/astro/test/fixtures/sessions/src/pages/flash-rewrite.astro
vendored
Normal file
4
packages/astro/test/fixtures/sessions/src/pages/flash-rewrite.astro
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
---
|
||||
Astro.session.flash('flash-value', `Flashed value at ${new Date().toISOString()}`);
|
||||
return Astro.rewrite('/');
|
||||
---
|
4
packages/astro/test/fixtures/sessions/src/pages/flash.astro
vendored
Normal file
4
packages/astro/test/fixtures/sessions/src/pages/flash.astro
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
---
|
||||
Astro.session.flash('flash-value', `Flashed value at ${new Date().toISOString()}`);
|
||||
return Astro.redirect('/');
|
||||
---
|
|
@ -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>
|
||||
|
|
|
@ -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' }],
|
||||
]),
|
||||
);
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue