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;
+}