refactor: simplify Phase 1 infrastructure code

- cache-invalidation: extract isExpectedRevalidateError helper
- integration-data-cache: flatten early-return chain, extract eviction
- redis-handler: extract StoredEntry type, readStreamBody, ensureConnected
- svg-purify: single getOwnPropertyDescriptor lookup per prop
- session-scoped-providers: simplify auth flow
- retryable-loader: use function declarations
- translation/request: replace spread-in-reduce with for loop
This commit is contained in:
Thomas Camlong committed 2026-08-07 21:04:42 +02:00
1 parent b3b2d5a022
commit 33293ba1cf
8 files changed
+148 -134

No files matched your search

@@ -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) => <AuthProvider session={session} logoutUrl={env.AUTH_LOGOUT_REDIRECT_URL} {...innerProps} />,
@@ -68,7 +68,8 @@ describe("SVG sanitization", () => {
});
it("preserves well-formed SVG with self-closing children", () => {
const icon = '<svg viewBox="0 0 24 24"><path d="M12 2L2 22h20Z" fill="currentColor"/><circle cx="12" cy="8" r="2"/></svg>';
const icon =
'<svg viewBox="0 0 24 24"><path d="M12 2L2 22h20Z" fill="currentColor"/><circle cx="12" cy="8" r="2"/></svg>';
const clean = sanitizeSvg(icon);
expect(clean).toContain("<path");
expect(clean).toContain("<circle");
@@ -6,24 +6,25 @@ const svgSanitizeOptions = {
FORBID_TAGS: ["style"],
} as const;
const LINKEDOM_NODE_PROPS = [
"ownerDocument",
"parentNode",
"nextSibling",
"previousSibling",
"nodeName",
"nodeType",
"childNodes",
] as const;
// ponytail: linkedom stores DOM tree pointers as own value properties; DOMPurify
// resolves them via prototype getters (lookupGetter). Without this shim, sanitize
// is a no-op and XSS payloads pass through unchanged.
const patchLinkedomNodePrototype = (Node: typeof window.Node) => {
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];
}
+10 -5
View File
@@ -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));
+21 -24
View File
@@ -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 <T>(
integrationId: string,
queryKey: string,
@@ -30,13 +44,9 @@ export const getCachedIntegrationData = async <T>(
const existing = cache.get(key) as CachedEntry<T> | 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 <T>(
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 <T>(
};
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);
}
};
+3 -3
View File
@@ -1,7 +1,7 @@
export const createRetryableLoader = <TKey, TValue>(loaders: ReadonlyMap<TKey, () => Promise<TValue>>) => {
export function createRetryableLoader<TKey, TValue>(loaders: ReadonlyMap<TKey, () => Promise<TValue>>) {
const promises = new Map<TKey, Promise<TValue>>();
return (key: TKey) => {
return function load(key: TKey) {
const existing = promises.get(key);
if (existing) return existing;
@@ -15,4 +15,4 @@ export const createRetryableLoader = <TKey, TValue>(loaders: ReadonlyMap<TKey, (
});
return promise;
};
};
}
@@ -4,8 +4,55 @@ import type { CacheHandler } from "next/dist/server/lib/cache-handlers/types";
type CacheEntry = Awaited<ReturnType<CacheHandler["get"]>> & {};
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<Uint8Array>) {
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<CacheEntry | undefined> {
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<void> {
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<void> {
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();
+15 -16
View File
@@ -10,7 +10,7 @@ import { createLanguageMapping } from "./mapping";
// Ceiling: ~2 MB for 5 active locales. Upgrade: evict when size > 8.
const mergedMessages = new Map<SupportedLanguage, Promise<TranslationObject>>();
const loadMessagesAsync = (locale: SupportedLanguage): Promise<TranslationObject> => {
function loadMessagesAsync(locale: SupportedLanguage): Promise<TranslationObject> {
const existing = mergedMessages.get(locale);
if (existing) return existing;
@@ -27,7 +27,7 @@ const loadMessagesAsync = (locale: SupportedLanguage): Promise<TranslationObject
if (mergedMessages.get(locale) === promise) mergedMessages.delete(locale);
});
return promise;
};
}
export default getRequestConfig(async ({ requestLocale }) => {
const requested = await requestLocale;
@@ -36,17 +36,16 @@ export default getRequestConfig(async ({ requestLocale }) => {
return { locale, messages: await loadMessagesAsync(locale) };
});
const removeEmptyTranslations = (translations: Record<string, unknown>): Record<string, unknown> => {
return Object.entries(translations).reduce(
(acc, [key, value]) => {
if (typeof value !== "string") {
return { ...acc, [key]: removeEmptyTranslations(value as Record<string, unknown>) };
}
if (value.trim() === "") {
return acc;
}
return { ...acc, [key]: value };
},
{} as Record<string, unknown>,
);
};
function removeEmptyTranslations(translations: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = {};
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<string, unknown>);
}
return result;
}