diff --git a/apps/nextjs/src/app/[locale]/_session-scoped-providers.tsx b/apps/nextjs/src/app/[locale]/_session-scoped-providers.tsx index 093f2b7bf..06e6bc203 100644 --- a/apps/nextjs/src/app/[locale]/_session-scoped-providers.tsx +++ b/apps/nextjs/src/app/[locale]/_session-scoped-providers.tsx @@ -24,16 +24,17 @@ import { composeWrappers } from "./compose"; const logger = createLogger({ module: "sessionScopedProviders" }); export async function SessionScopedProviders({ children }: PropsWithChildren) { - const sessionPromise = auth(); - const userPromise = sessionPromise.then((session) => + const session = await auth(); + + const [user, serverSettings] = await Promise.all([ session ? getRscUserSettingsAsync(session.user.id).catch((error: unknown) => { logger.error(new Error("Failed to load the authenticated user in the root layout", { cause: error })); return null; }) - : null, - ); - const [session, user, serverSettings] = await Promise.all([sessionPromise, userPromise, getRscServerSettingsAsync()]); + : Promise.resolve(null), + getRscServerSettingsAsync(), + ]); const StackedProvider = composeWrappers([ (innerProps) => , diff --git a/apps/nextjs/src/app/api/user-medias/sanitize.spec.ts b/apps/nextjs/src/app/api/user-medias/sanitize.spec.ts index d22df183e..c30e24401 100644 --- a/apps/nextjs/src/app/api/user-medias/sanitize.spec.ts +++ b/apps/nextjs/src/app/api/user-medias/sanitize.spec.ts @@ -68,7 +68,8 @@ describe("SVG sanitization", () => { }); it("preserves well-formed SVG with self-closing children", () => { - const icon = ''; + const icon = + ''; const clean = sanitizeSvg(icon); expect(clean).toContain(" { - for (const prop of [ - "ownerDocument", - "parentNode", - "nextSibling", - "previousSibling", - "nodeName", - "nodeType", - "childNodes", - ] as const) { - if (Object.getOwnPropertyDescriptor(Node.prototype, prop)?.get) { - continue; - } + for (const prop of LINKEDOM_NODE_PROPS) { + const descriptor = Object.getOwnPropertyDescriptor(Node.prototype, prop); + if (descriptor?.get) continue; - if (Object.getOwnPropertyDescriptor(Node.prototype, prop)?.value !== undefined) { + if (descriptor?.value !== undefined) { delete Node.prototype[prop as keyof Node]; } diff --git a/packages/api/src/cache-invalidation.ts b/packages/api/src/cache-invalidation.ts index 68c4df2f2..fa40d47ab 100644 --- a/packages/api/src/cache-invalidation.ts +++ b/packages/api/src/cache-invalidation.ts @@ -13,18 +13,23 @@ import { invalidateIntegrationDataCache } from "./integration-data-cache"; // ponytail: revalidateTag throws outside Next.js request context (tests, tasks, websocket). // Ceiling: if Next.js context detection changes, update the require/catch. -const safeRevalidateTag = (tag: string) => { +function isExpectedRevalidateError(error: unknown) { + const message = error instanceof Error ? error.message : ""; + return message.includes("static generation") || message.includes("was called outside a request scope"); +} + +function safeRevalidateTag(tag: string) { try { // eslint-disable-next-line @typescript-eslint/no-require-imports const { revalidateTag } = require("next/cache") as typeof import("next/cache"); revalidateTag(tag, "max"); } catch (error) { - const msg = error instanceof Error ? error.message : ""; - if (!msg.includes("static generation") && !msg.includes("was called outside a request scope")) { - console.warn(`[cache-invalidation] revalidateTag("${tag}") failed:`, msg); + if (!isExpectedRevalidateError(error)) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[cache-invalidation] revalidateTag("${tag}") failed:`, message); } } -}; +} export const invalidateBoardCache = (boardId: string, boardName?: string) => { safeRevalidateTag(cacheTags.board(boardId)); diff --git a/packages/api/src/integration-data-cache.ts b/packages/api/src/integration-data-cache.ts index 344746de9..7af08dbdd 100644 --- a/packages/api/src/integration-data-cache.ts +++ b/packages/api/src/integration-data-cache.ts @@ -16,6 +16,20 @@ const MAX_ENTRIES = 200; const cacheKey = (integrationId: string, queryKey: string) => `${integrationId}:${queryKey}`; +function evictOldestEntry() { + if (cache.size <= MAX_ENTRIES) return; + + let oldestKey: string | undefined; + let oldestTime = Infinity; + for (const [key, entry] of cache) { + if (entry.timestamp < oldestTime) { + oldestTime = entry.timestamp; + oldestKey = key; + } + } + if (oldestKey) cache.delete(oldestKey); +} + export const getCachedIntegrationData = async ( integrationId: string, queryKey: string, @@ -30,13 +44,9 @@ export const getCachedIntegrationData = async ( const existing = cache.get(key) as CachedEntry | undefined; const now = Date.now(); - if (existing) { - if (existing.pending) { - return existing.pending; - } - if (existing.timestamp > 0 && now - existing.timestamp < ttlMs) { - return existing.data; - } + if (existing?.pending) return existing.pending; + if (existing && existing.timestamp > 0 && now - existing.timestamp < ttlMs) { + return existing.data; } // Set pending synchronously BEFORE creating the fetch promise to prevent @@ -47,19 +57,7 @@ export const getCachedIntegrationData = async ( const pending = fetcher() .then((data) => { cache.set(key, { data, timestamp: Date.now() }); - - if (cache.size > MAX_ENTRIES) { - let oldest: string | undefined; - let oldestTime = Infinity; - for (const [k, v] of cache) { - if (v.timestamp < oldestTime) { - oldestTime = v.timestamp; - oldest = k; - } - } - if (oldest) cache.delete(oldest); - } - + evictOldestEntry(); return data; }) .catch((error) => { @@ -78,9 +76,8 @@ export const getCachedIntegrationData = async ( }; export const invalidateIntegrationDataCache = (integrationId: string) => { - for (const [key] of cache) { - if (key.startsWith(`${integrationId}:`)) { - cache.delete(key); - } + const prefix = `${integrationId}:`; + for (const key of cache.keys()) { + if (key.startsWith(prefix)) cache.delete(key); } }; diff --git a/packages/common/src/retryable-loader.ts b/packages/common/src/retryable-loader.ts index 46163e1e5..ac8909e5c 100644 --- a/packages/common/src/retryable-loader.ts +++ b/packages/common/src/retryable-loader.ts @@ -1,7 +1,7 @@ -export const createRetryableLoader = (loaders: ReadonlyMap Promise>) => { +export function createRetryableLoader(loaders: ReadonlyMap Promise>) { const promises = new Map>(); - return (key: TKey) => { + return function load(key: TKey) { const existing = promises.get(key); if (existing) return existing; @@ -15,4 +15,4 @@ export const createRetryableLoader = (loaders: ReadonlyMap> & {}; +type StoredEntry = { + tags: string[]; + stale: number; + timestamp: number; + expire: number; + revalidate: number; + body: number[]; +}; + // eslint-disable-next-line no-underscore-dangle const BUILD_ID = process.env.__NEXT_BUILD_ID ?? "dev"; +const TAG_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +async function readStreamBody(stream: ReadableStream) { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let done = false; + while (!done) { + const result = await reader.read(); + done = result.done; + if (result.value) chunks.push(result.value); + } + + const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0); + const body = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.length; + } + return body; +} + +function entryFromStored(parsed: StoredEntry): CacheEntry { + const body = new Uint8Array(parsed.body); + return { + value: new ReadableStream({ + start(controller) { + controller.enqueue(body); + controller.close(); + }, + }), + tags: parsed.tags, + stale: parsed.stale, + timestamp: parsed.timestamp, + expire: parsed.expire, + revalidate: parsed.revalidate, + }; +} export class RedisCacheHandler implements CacheHandler { private redis: Redis; @@ -38,49 +85,32 @@ export class RedisCacheHandler implements CacheHandler { return `nextCache:tag:${tag}`; } + private async ensureConnected() { + await this.redis.connect().catch(() => {}); + } + async get(cacheKey: string, softTags: string[]): Promise { const pending = this.pendingSets.get(cacheKey); if (pending) return pending; try { - await this.redis.connect().catch(() => {}); + await this.ensureConnected(); const raw = await this.redis.getBuffer(this.entryKey(cacheKey)); if (!raw) return undefined; - const parsed = JSON.parse(raw.toString()) as { - tags: string[]; - stale: number; - timestamp: number; - expire: number; - revalidate: number; - body: number[]; - }; + const parsed = JSON.parse(raw.toString()) as StoredEntry; if (parsed.expire <= 0) { await this.redis.del(this.entryKey(cacheKey)).catch(() => {}); return undefined; } - const allTags = [...parsed.tags, ...softTags]; - const expiration = await this.getExpiration(allTags); + const expiration = await this.getExpiration([...parsed.tags, ...softTags]); if (expiration !== 0 && expiration > parsed.timestamp) { return undefined; } - const body = new Uint8Array(parsed.body); - return { - value: new ReadableStream({ - start(controller) { - controller.enqueue(body); - controller.close(); - }, - }), - tags: parsed.tags, - stale: parsed.stale, - timestamp: parsed.timestamp, - expire: parsed.expire, - revalidate: parsed.revalidate, - }; + return entryFromStored(parsed); } catch { return undefined; } @@ -91,22 +121,10 @@ export class RedisCacheHandler implements CacheHandler { try { const entry = await pendingEntry; - const reader = entry.value.getReader(); - const chunks: Uint8Array[] = []; - let done = false; - while (!done) { - const result = await reader.read(); - done = result.done; - if (result.value) chunks.push(result.value); - } + const body = await readStreamBody(entry.value); - const totalLength = chunks.reduce((acc, c) => acc + c.length, 0); - const body = new Uint8Array(totalLength); - let offset = 0; - for (const chunk of chunks) { - body.set(chunk, offset); - offset += chunk.length; - } + const ttlMs = entry.expire * 1000; + if (ttlMs <= 0) return; // Dynamic entries (expire: 0) should not be persisted const serialized = JSON.stringify({ tags: entry.tags, @@ -117,9 +135,7 @@ export class RedisCacheHandler implements CacheHandler { body: Array.from(body), }); - const ttlMs = entry.expire * 1000; - if (ttlMs <= 0) return; // Dynamic entries (expire: 0) should not be persisted - await this.redis.connect().catch(() => {}); + await this.ensureConnected(); await this.redis.set(this.entryKey(cacheKey), serialized, "PX", ttlMs); } catch { // Fail open @@ -132,20 +148,19 @@ export class RedisCacheHandler implements CacheHandler { async refreshTags(): Promise { try { - await this.redis.connect().catch(() => {}); + await this.ensureConnected(); this.tagCache.clear(); let cursor = "0"; do { const [nextCursor, keys] = await this.redis.scan(cursor, "MATCH", "nextCache:tag:*", "COUNT", 100); cursor = nextCursor; - if (keys.length > 0) { - const values = await this.redis.mget(...keys); - for (let i = 0; i < keys.length; i++) { - const tag = keys[i]?.replace("nextCache:tag:", ""); - if (!tag) continue; - const val = values[i]; - if (val) this.tagCache.set(tag, Number(val)); - } + if (keys.length === 0) continue; + + const values = await this.redis.mget(...keys); + for (let i = 0; i < keys.length; i++) { + const tag = keys[i]?.replace("nextCache:tag:", ""); + const val = values[i]; + if (tag && val) this.tagCache.set(tag, Number(val)); } } while (cursor !== "0"); } catch { @@ -170,17 +185,15 @@ export class RedisCacheHandler implements CacheHandler { if (missingTags.length > 0) { try { - await this.redis.connect().catch(() => {}); - const keys = missingTags.map((t) => this.tagKey(t)); - const values = await this.redis.mget(...keys); + await this.ensureConnected(); + const values = await this.redis.mget(...missingTags.map((tag) => this.tagKey(tag))); for (let i = 0; i < missingTags.length; i++) { const val = values[i]; - if (val) { - const ts = Number(val); - const missingTag = missingTags[i]; - if (missingTag) this.tagCache.set(missingTag, ts); - if (ts > maxTimestamp) maxTimestamp = ts; - } + const tag = missingTags[i]; + if (!val || !tag) continue; + const timestamp = Number(val); + this.tagCache.set(tag, timestamp); + if (timestamp > maxTimestamp) maxTimestamp = timestamp; } } catch { // Fail open @@ -193,17 +206,14 @@ export class RedisCacheHandler implements CacheHandler { async updateTags(tags: string[], durations?: { expire?: number }): Promise { if (tags.length === 0) return; try { - await this.redis.connect().catch(() => {}); + await this.ensureConnected(); const now = Date.now(); + const expireMs = durations?.expire ? durations.expire * 1000 : TAG_TTL_MS; const pipeline = this.redis.pipeline(); for (const tag of tags) { const key = this.tagKey(tag); pipeline.set(key, String(now)); - if (durations?.expire) { - pipeline.pexpire(key, durations.expire * 1000); - } else { - pipeline.pexpire(key, 7 * 24 * 60 * 60 * 1000); - } + pipeline.pexpire(key, expireMs); this.tagCache.set(tag, now); } await pipeline.exec(); diff --git a/packages/translation/src/request.ts b/packages/translation/src/request.ts index 3d9be60fd..35e060141 100644 --- a/packages/translation/src/request.ts +++ b/packages/translation/src/request.ts @@ -10,7 +10,7 @@ import { createLanguageMapping } from "./mapping"; // Ceiling: ~2 MB for 5 active locales. Upgrade: evict when size > 8. const mergedMessages = new Map>(); -const loadMessagesAsync = (locale: SupportedLanguage): Promise => { +function loadMessagesAsync(locale: SupportedLanguage): Promise { const existing = mergedMessages.get(locale); if (existing) return existing; @@ -27,7 +27,7 @@ const loadMessagesAsync = (locale: SupportedLanguage): Promise { const requested = await requestLocale; @@ -36,17 +36,16 @@ export default getRequestConfig(async ({ requestLocale }) => { return { locale, messages: await loadMessagesAsync(locale) }; }); -const removeEmptyTranslations = (translations: Record): Record => { - return Object.entries(translations).reduce( - (acc, [key, value]) => { - if (typeof value !== "string") { - return { ...acc, [key]: removeEmptyTranslations(value as Record) }; - } - if (value.trim() === "") { - return acc; - } - return { ...acc, [key]: value }; - }, - {} as Record, - ); -}; +function removeEmptyTranslations(translations: Record): Record { + const result: Record = {}; + + for (const [key, value] of Object.entries(translations)) { + if (typeof value === "string") { + if (value.trim() !== "") result[key] = value; + continue; + } + result[key] = removeEmptyTranslations(value as Record); + } + + return result; +}