From 5f5396054ab859fe6ed52bd4485522f4eb4d6dfa Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Sat, 26 Sep 2026 21:01:04 +0800 Subject: [PATCH 1/2] perf(calendar): revalidate the cached events feed with conditional requests --- src/features/calendar-load.test.ts | 171 +++++++++++++++++++++++----- src/features/calendar-store.test.ts | 86 ++++++++++---- src/features/calendar-store.ts | 52 ++++++++- src/features/calendar.ts | 34 ++++-- 4 files changed, 276 insertions(+), 67 deletions(-) diff --git a/src/features/calendar-load.test.ts b/src/features/calendar-load.test.ts index dfeb357..180ae8b 100644 --- a/src/features/calendar-load.test.ts +++ b/src/features/calendar-load.test.ts @@ -1,44 +1,159 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import type * as Nbtcal from '@nbtca/nbtcal'; +import type { FeedCache } from './calendar-store.js'; +import type * as CalendarModule from './calendar.js'; -const FEED = [ - 'BEGIN:VCALENDAR', - 'VERSION:2.0', - 'BEGIN:VEVENT', - 'UID:cached', - 'SUMMARY:Cached', - 'DTSTART:20260926T190000Z', - 'END:VEVENT', - 'END:VCALENDAR', -].join('\r\n'); +function feed(uid: string): string { + return [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'BEGIN:VEVENT', + `UID:${uid}`, + 'SUMMARY:Event', + 'DTSTART:20260926T190000Z', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n'); +} -const fetchFeed = vi.fn(); -const loadFeedCache = vi.fn<() => string | null>(); +const fetchFeedConditional = vi.fn(); +const parseCalendar = vi.fn(); +const loadFeedCache = vi.fn<() => FeedCache | null>(); +const saveFeedCache = vi.fn(); +const touchFeedCache = vi.fn(); -vi.mock('@nbtca/nbtcal', async (importOriginal) => ({ - ...(await importOriginal()), - fetchFeed, -})); -vi.mock('./calendar-store.js', () => ({ loadFeedCache, saveFeedCache: vi.fn() })); +vi.mock('@nbtca/nbtcal', async (importOriginal) => { + const original = await importOriginal(); + parseCalendar.mockImplementation(original.parseCalendar); + return { ...original, fetchFeedConditional, parseCalendar }; +}); +vi.mock('./calendar-store.js', () => ({ loadFeedCache, saveFeedCache, touchFeedCache })); + +const cached: FeedCache = { text: feed('cached'), validators: { etag: '"v1"' } }; + +async function freshCalendar(): Promise { + vi.resetModules(); + return import('./calendar.js'); +} + +function uids(calendar: Nbtcal.Calendar): string[] { + return calendar.inRange(new Date(0), new Date(2030, 0, 1)).map((e) => e.uid); +} -const { loadCalendarOrCache } = await import('./calendar.js'); +function sentValidators(): unknown { + return (fetchFeedConditional.mock.calls.at(-1)?.[1] as Nbtcal.FetchFeedConditionalOptions) + .validators; +} + +beforeEach(() => { + vi.useRealTimers(); + fetchFeedConditional.mockReset(); + loadFeedCache.mockReset(); + saveFeedCache.mockReset(); + touchFeedCache.mockReset(); + parseCalendar.mockClear(); +}); describe('loadCalendarOrCache', () => { it('falls back to the cached feed when the network fails', async () => { - fetchFeed.mockRejectedValue(new Error('offline')); - loadFeedCache.mockReturnValue(FEED); + const { loadCalendarOrCache } = await freshCalendar(); + fetchFeedConditional.mockRejectedValue(new Error('offline')); + loadFeedCache.mockReturnValue(cached); const { calendar, stale } = await loadCalendarOrCache(); expect(stale).toBe(true); - expect(calendar.inRange(new Date(0), new Date(2030, 0, 1)).map((e) => e.uid)).toEqual([ - 'cached', - ]); + expect(uids(calendar)).toEqual(['cached']); }); it('fails when there is nothing cached', async () => { - vi.resetModules(); - const fresh = await import('./calendar.js'); - fetchFeed.mockRejectedValue(new Error('offline')); + const { loadCalendarOrCache } = await freshCalendar(); + fetchFeedConditional.mockRejectedValue(new Error('offline')); + loadFeedCache.mockReturnValue(null); + await expect(loadCalendarOrCache()).rejects.toThrow('offline'); + }); +}); + +describe('loadCalendarOrThrow', () => { + it('fetches unconditionally without a cached feed and stores the response', async () => { + const { loadCalendarOrThrow } = await freshCalendar(); + loadFeedCache.mockReturnValue(null); + const result = { status: 'modified', text: feed('fresh'), validators: { etag: '"v2"' } }; + fetchFeedConditional.mockResolvedValue(result); + + expect(uids(await loadCalendarOrThrow())).toEqual(['fresh']); + expect(sentValidators()).toEqual({}); + expect(saveFeedCache).toHaveBeenCalledWith(result); + }); + + it('reuses the peeked calendar on 304 without parsing it again', async () => { + const { loadCalendarOrThrow, peekCalendar } = await freshCalendar(); + loadFeedCache.mockReturnValue(cached); + const peeked = peekCalendar(); + fetchFeedConditional.mockResolvedValue({ + status: 'not-modified', + validators: { etag: '"v1"' }, + }); + + expect(await loadCalendarOrThrow()).toBe(peeked); + expect(sentValidators()).toEqual({ etag: '"v1"' }); + expect(parseCalendar).toHaveBeenCalledTimes(1); + expect(saveFeedCache).not.toHaveBeenCalled(); + expect(touchFeedCache).toHaveBeenCalledWith({ etag: '"v1"' }); + + await loadCalendarOrThrow(); + expect(fetchFeedConditional).toHaveBeenCalledTimes(1); + }); + + it('revalidates with the latest validators after the memo expires', async () => { + vi.useFakeTimers(); + const { loadCalendarOrThrow } = await freshCalendar(); + loadFeedCache.mockReturnValue(cached); + fetchFeedConditional.mockResolvedValue({ + status: 'not-modified', + validators: { etag: '"v1"', lastModified: 'Thu, 24 Sep 2026 10:00:00 GMT' }, + }); + await loadCalendarOrThrow(); + vi.advanceTimersByTime(6 * 60 * 1000); + await loadCalendarOrThrow(); + + expect(fetchFeedConditional).toHaveBeenCalledTimes(2); + expect(sentValidators()).toEqual({ + etag: '"v1"', + lastModified: 'Thu, 24 Sep 2026 10:00:00 GMT', + }); + expect(parseCalendar).toHaveBeenCalledTimes(1); + }); + + it('replaces the calendar and cache on 200', async () => { + const { loadCalendarOrThrow, peekCalendar } = await freshCalendar(); + loadFeedCache.mockReturnValue(cached); + peekCalendar(); + const result = { status: 'modified', text: feed('changed'), validators: { etag: '"v2"' } }; + fetchFeedConditional.mockResolvedValue(result); + + const calendar = await loadCalendarOrThrow(); + expect(uids(calendar)).toEqual(['changed']); + expect(peekCalendar()).toBe(calendar); + expect(saveFeedCache).toHaveBeenCalledWith(result); + expect(touchFeedCache).not.toHaveBeenCalled(); + }); + + it('fetches unconditionally when the cached feed cannot be parsed', async () => { + const { loadCalendarOrThrow } = await freshCalendar(); + loadFeedCache.mockReturnValue({ text: 'garbage', validators: { etag: '"v1"' } }); + fetchFeedConditional.mockResolvedValue({ + status: 'modified', + text: feed('fresh'), + validators: {}, + }); + + expect(uids(await loadCalendarOrThrow())).toEqual(['fresh']); + expect(sentValidators()).toEqual({}); + }); + + it('rejects a 304 it did not ask for', async () => { + const { loadCalendarOrThrow } = await freshCalendar(); loadFeedCache.mockReturnValue(null); - await expect(fresh.loadCalendarOrCache()).rejects.toThrow('offline'); + fetchFeedConditional.mockResolvedValue({ status: 'not-modified', validators: {} }); + await expect(loadCalendarOrThrow()).rejects.toThrow('HTTP 304'); }); }); diff --git a/src/features/calendar-store.test.ts b/src/features/calendar-store.test.ts index 7509450..dc77a2c 100644 --- a/src/features/calendar-store.test.ts +++ b/src/features/calendar-store.test.ts @@ -1,45 +1,85 @@ import { describe, it, expect } from 'vitest'; -import { mkdtempSync, rmSync, utimesSync } from 'fs'; +import { mkdtempSync, rmSync, statSync, utimesSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; -import { saveFeedCache, loadFeedCache } from './calendar-store.js'; +import { saveFeedCache, loadFeedCache, touchFeedCache } from './calendar-store.js'; + +const validators = { etag: '"v1"', lastModified: 'Wed, 23 Sep 2026 10:00:00 GMT' }; + +function withDir(run: (dir: string) => void): void { + const dir = mkdtempSync(join(tmpdir(), 'cal-')); + try { + run(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} describe('calendar-store', () => { - it('round-trips the feed via an injected dir', () => { - const dir = mkdtempSync(join(tmpdir(), 'cal-')); - try { - saveFeedCache('BEGIN:VCALENDAR\nEND:VCALENDAR', dir); - expect(loadFeedCache(dir)).toBe('BEGIN:VCALENDAR\nEND:VCALENDAR'); - } finally { - rmSync(dir, { recursive: true, force: true }); - } + it('round-trips the feed and its validators via an injected dir', () => { + withDir((dir) => { + saveFeedCache({ text: 'BEGIN:VCALENDAR\nEND:VCALENDAR', validators }, dir); + expect(loadFeedCache(dir)).toEqual({ text: 'BEGIN:VCALENDAR\nEND:VCALENDAR', validators }); + }); + }); + + it.runIf(process.platform !== 'win32')('keeps both files private', () => { + withDir((dir) => { + saveFeedCache({ text: 'x', validators }, dir); + expect(statSync(join(dir, 'calendar-feed.ics')).mode & 0o777).toBe(0o600); + expect(statSync(join(dir, 'calendar-feed.json')).mode & 0o777).toBe(0o600); + }); }); it('reports a miss when nothing was cached', () => { - const dir = mkdtempSync(join(tmpdir(), 'cal-')); - try { + withDir((dir) => { expect(loadFeedCache(dir)).toBeNull(); - } finally { - rmSync(dir, { recursive: true, force: true }); - } + }); + }); + + it.each([ + ['missing', null], + ['corrupt', '{not json'], + ['not an object', 'null'], + ['an unsafe header value', JSON.stringify({ etag: '"v1"\r\nX: y', lastModified: 42 })], + ])('drops validators that are %s', (_label, content) => { + withDir((dir) => { + saveFeedCache({ text: 'x', validators }, dir); + const file = join(dir, 'calendar-feed.json'); + if (content === null) rmSync(file); + else writeFileSync(file, content); + expect(loadFeedCache(dir)).toEqual({ text: 'x', validators: {} }); + }); }); it('refuses a feed older than the max age', () => { - const dir = mkdtempSync(join(tmpdir(), 'cal-')); - try { - saveFeedCache('stale', dir); + withDir((dir) => { + saveFeedCache({ text: 'stale', validators: {} }, dir); const longAgo = new Date(Date.now() - 60 * 60 * 1000); utimesSync(join(dir, 'calendar-feed.ics'), longAgo, longAgo); expect(loadFeedCache(dir, 30 * 60 * 1000)).toBeNull(); - expect(loadFeedCache(dir, 2 * 60 * 60 * 1000)).toBe('stale'); - } finally { - rmSync(dir, { recursive: true, force: true }); - } + expect(loadFeedCache(dir, 2 * 60 * 60 * 1000)?.text).toBe('stale'); + }); + }); + + it('renews the feed age and validators when the feed is unchanged', () => { + withDir((dir) => { + saveFeedCache({ text: 'same', validators }, dir); + const longAgo = new Date(Date.now() - 60 * 60 * 1000); + utimesSync(join(dir, 'calendar-feed.ics'), longAgo, longAgo); + touchFeedCache({ etag: '"v2"' }, dir); + expect(loadFeedCache(dir, 30 * 60 * 1000)).toEqual({ + text: 'same', + validators: { etag: '"v2"' }, + }); + }); }); it('stays quiet when the directory cannot be written', () => { + const missing = join(tmpdir(), 'cal-missing', 'deeper'); expect(() => { - saveFeedCache('x', join(tmpdir(), 'cal-missing', 'deeper')); + saveFeedCache({ text: 'x', validators }, missing); + touchFeedCache(validators, missing); }).not.toThrow(); }); }); diff --git a/src/features/calendar-store.ts b/src/features/calendar-store.ts index ef49768..1fd39ed 100644 --- a/src/features/calendar-store.ts +++ b/src/features/calendar-store.ts @@ -1,13 +1,53 @@ import fs from 'fs'; import path from 'path'; +import type { FeedValidators } from '@nbtca/nbtcal'; import { getStateDir, getWritableStateDir } from '../config/paths.js'; const FEED_FILE = 'calendar-feed.ics'; +const VALIDATORS_FILE = 'calendar-feed.json'; const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; +const HEADER_VALUE = /^[\x21-\x7e][\x20-\x7e]*$/; -export function saveFeedCache(text: string, dir?: string): void { +export interface FeedCache { + text: string; + validators: FeedValidators; +} + +function headerValue(value: unknown): string | undefined { + return typeof value === 'string' && HEADER_VALUE.test(value) ? value : undefined; +} + +function readValidators(dir: string): FeedValidators { + try { + const data = JSON.parse(fs.readFileSync(path.join(dir, VALIDATORS_FILE), 'utf8')) as Record< + string, + unknown + >; + const etag = headerValue(data['etag']); + const lastModified = headerValue(data['lastModified']); + return { ...(etag ? { etag } : {}), ...(lastModified ? { lastModified } : {}) }; + } catch { + return {}; + } +} + +// Validators are written after the text and read before it, so they can never be newer than the text. +export function saveFeedCache({ text, validators }: FeedCache, dir?: string): void { + try { + const target = dir ?? getWritableStateDir(); + fs.writeFileSync(path.join(target, FEED_FILE), text, { encoding: 'utf8', mode: 0o600 }); + touchFeedCache(validators, target); + } catch { + /* best effort */ + } +} + +export function touchFeedCache(validators: FeedValidators, dir?: string): void { try { - fs.writeFileSync(path.join(dir ?? getWritableStateDir(), FEED_FILE), text, { + const target = dir ?? getWritableStateDir(); + const now = new Date(); + fs.utimesSync(path.join(target, FEED_FILE), now, now); + fs.writeFileSync(path.join(target, VALIDATORS_FILE), JSON.stringify(validators), { encoding: 'utf8', mode: 0o600, }); @@ -16,11 +56,13 @@ export function saveFeedCache(text: string, dir?: string): void { } } -export function loadFeedCache(dir?: string, maxAgeMs = MAX_AGE_MS): string | null { +export function loadFeedCache(dir?: string, maxAgeMs = MAX_AGE_MS): FeedCache | null { try { - const file = path.join(dir ?? getStateDir(), FEED_FILE); + const source = dir ?? getStateDir(); + const validators = readValidators(source); + const file = path.join(source, FEED_FILE); if (Date.now() - fs.statSync(file).mtimeMs > maxAgeMs) return null; - return fs.readFileSync(file, 'utf8'); + return { text: fs.readFileSync(file, 'utf8'), validators }; } catch { return null; } diff --git a/src/features/calendar.ts b/src/features/calendar.ts index 56cb513..03824c3 100644 --- a/src/features/calendar.ts +++ b/src/features/calendar.ts @@ -1,12 +1,12 @@ import { - fetchFeed, + fetchFeedConditional, parseCalendar, createCalendar, FeedFetchError, FeedParseError, eventToICS, } from '@nbtca/nbtcal'; -import type { Calendar, CalendarEvent, HeatmapBucket } from '@nbtca/nbtcal'; +import type { Calendar, CalendarEvent, FeedValidators, HeatmapBucket } from '@nbtca/nbtcal'; import chalk from 'chalk'; import { c, type, space, glyph } from '../core/theme.js'; import { pickIcon } from '../core/icons.js'; @@ -28,7 +28,7 @@ import { formatDuration, pastYearRange, } from './calendar-query.js'; -import { loadFeedCache, saveFeedCache } from './calendar-store.js'; +import { loadFeedCache, saveFeedCache, touchFeedCache } from './calendar-store.js'; import { writeFileSync, existsSync } from 'fs'; import { join } from 'path'; @@ -72,16 +72,20 @@ function formatTime(date: Date): string { const MEMO_TTL_MS = 5 * 60 * 1000; -let memo: { calendar: Calendar; fetchedAt: number } | undefined; +let memo: { calendar: Calendar; validators: FeedValidators; fetchedAt: number } | undefined; let inFlight: Promise | undefined; +function remember(text: string, validators: FeedValidators, fetchedAt: number): Calendar { + memo = { calendar: createCalendar(parseCalendar(text)), validators, fetchedAt }; + return memo.calendar; +} + export function peekCalendar(): Calendar | undefined { if (memo) return memo.calendar; - const text = loadFeedCache(); - if (text === null) return undefined; + const cache = loadFeedCache(); + if (cache === null) return undefined; try { - memo = { calendar: createCalendar(parseCalendar(text)), fetchedAt: 0 }; - return memo.calendar; + return remember(cache.text, cache.validators, 0); } catch { return undefined; } @@ -89,12 +93,20 @@ export function peekCalendar(): Calendar | undefined { async function refetchCalendar(signal?: AbortSignal): Promise { try { - const text = await fetchFeed(undefined, { + peekCalendar(); + const result = await fetchFeedConditional(undefined, { timeoutMs: 15000, ...(signal === undefined ? {} : { signal }), + validators: memo?.validators ?? {}, }); - memo = { calendar: createCalendar(parseCalendar(text)), fetchedAt: Date.now() }; - saveFeedCache(text); + if (result.status === 'modified') { + const calendar = remember(result.text, result.validators, Date.now()); + saveFeedCache(result); + return calendar; + } + if (!memo) throw new FeedFetchError('Feed request failed: HTTP 304'); + memo = { ...memo, validators: result.validators, fetchedAt: Date.now() }; + touchFeedCache(result.validators); return memo.calendar; } catch (err) { const detail = sanitizeTerminalLine( From 0110721b863b8dfe41210ac4c50843a3d383cf21 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Sat, 26 Sep 2026 21:10:45 +0800 Subject: [PATCH 2/2] build(deps): require @nbtca/nbtcal 0.4.3 for conditional fetches --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2410e4f..5bee583 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@nbtca/docs": "^0.3.2", - "@nbtca/nbtcal": "^0.4.2", + "@nbtca/nbtcal": "^0.4.3", "chalk": "^5.6.2", "marked": "^15.0.12" }, @@ -779,9 +779,9 @@ } }, "node_modules/@nbtca/nbtcal": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@nbtca/nbtcal/-/nbtcal-0.4.2.tgz", - "integrity": "sha512-ynZLDpEa5BMX/dpsNSSMDahETCo9bA+p6pzytorAl2m3VyY98148v78iqi/FNA4egu7lY/5x+eOGkvtST/PDEA==", + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@nbtca/nbtcal/-/nbtcal-0.4.3.tgz", + "integrity": "sha512-2xaqZ4gECjsJ6rfKbdxi0G4RiTWPDx3ZZuOC1SmbOu+H+Fr+m3XlzFQQWtf18p7uoihRExl8TboQjI9GGaC1Mw==", "license": "MIT", "dependencies": { "ical.js": "^2.2.1" diff --git a/package.json b/package.json index 05eb66d..92c4acb 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ ], "dependencies": { "@nbtca/docs": "^0.3.2", - "@nbtca/nbtcal": "^0.4.2", + "@nbtca/nbtcal": "^0.4.3", "chalk": "^5.6.2", "marked": "^15.0.12" },