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:
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];
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user