Compare commits

..

2 Commits

Author SHA1 Message Date
agent_coder 98a58f6296 fix(#346 review F1-F4): no 206-compress + Vary + precompress VAD + cache test
- F1 [HIGH — data corruption]: @fastify/compress was compressing 206/Range
  attachment responses while Content-Range still described the RAW offsets, so a
  resuming client (curl -C -, download managers) appended encoded bytes as raw →
  corrupted file. sendFileResponse now sets the request header `x-no-compression`
  (the documented @fastify/compress opt-out — its onSend skips when the request
  carries it; the reviewer's `Content-Encoding: identity` does NOT work because
  compress explicitly excludes `identity` and overwrites it). This opts the whole
  download route (both 200 full-file and 206 range) out of on-the-fly compression
  — correct, since attachment bytes are final and mostly binary.
- F2: static responses now emit `Vary: Accept-Encoding` (the preCompressed
  content-negotiated /assets/* were `immutable` without Vary → shared-cache could
  serve a brotli variant to an identity/gzip-only client).
- F3: vite compression `include` extended to .wasm/.onnx so the VAD binaries
  (~26MB .wasm, ~2.3MB .onnx under public/vad) are precompressed at build (.br
  emitted) instead of runtime-brotli'd on every request. (include REPLACES the
  plugin default, so the default js/css/json/html set is re-listed.)
- F4: extracted the cache classification into a pure `resolveStaticAssetHeaders`
  + static.module.spec.ts (3 tests: /assets/* immutable+Vary, index.html
  no-store, non-hashed not-immutable).

Gate: server tsc 0 (deps present), static.module.spec 3/3, client build emits
.wasm.br/.onnx.br, frozen install 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 23:33:59 +03:00
agent_coder 26b29e1d7e perf(delivery): pre-compress static + cache headers + compress API responses (#346)
Cold load served ALL static + API responses uncompressed and without cache
headers (~3.7MB over the wire). Delivery only — feature behavior unchanged; no
DB/API-contract/MCP changes.

- apps/client/vite.config.ts: vite-plugin-compression2 emits .br + .gz next to
  each built asset (excludes index.html, which the server rewrites at boot with
  window.CONFIG — a precompressed copy would go stale). Build emits 187 .br /
  175 .gz under dist/assets.
- static.module.ts: @fastify/static `preCompressed: true` serves the .br/.gz
  neighbour; `setHeaders` sets `immutable` ONLY for content-hashed /assets/*,
  `no-cache` for index.html, and leaves non-hashed files (locales, vad, icons,
  manifest) on default etag/last-modified revalidation.
- main.ts: @fastify/compress (threshold 1024) compresses dynamic API JSON + the
  rewritten share-SEO HTML. SSE is safe on two counts: `text/event-stream` is not
  mime-db-compressible (allowlist skips it) AND the AI-chat stream hijacks the raw
  socket (pipeUIMessageStreamToResponse -> res.raw), bypassing the Fastify onSend
  lifecycle entirely. No double-compression with preCompressed static (compress
  skips already-Content-Encoding'd responses).
- docker-compose.yml: comment recommending an optional HTTP/2 + brotli reverse
  proxy (not required).

Deps: apps/client vite-plugin-compression2 2.5.3 (dev), apps/server
@fastify/compress 9.0.0 (matches fastify 5.8.5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 21:29:54 +03:00
28 changed files with 338 additions and 1201 deletions
+2 -16
View File
@@ -5,13 +5,6 @@ RUN npm install -g pnpm@10.4.0
FROM base AS builder
# re2 (packages/mcp) always compiles from source under pnpm (the prebuilt-binary
# download cannot identify the GitHub repo), so node-gyp needs python3/make/g++.
# This stage is discarded, so the toolchain can stay installed.
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY . .
@@ -64,17 +57,10 @@ COPY --from=builder /app/patches /app/patches
RUN chown -R node:node /app
# Toolchain is needed transiently to compile re2 during the prod install; install
# and purge it in one layer to keep the final image slim. The install itself runs
# as the node user via su to keep node_modules ownership without a costly chown layer.
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& su node -c "pnpm install --frozen-lockfile --prod" \
&& apt-get purge -y --auto-remove python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
USER node
RUN pnpm install --frozen-lockfile --prod
RUN mkdir -p /app/data/storage
VOLUME ["/app/data/storage"]
+1
View File
@@ -97,6 +97,7 @@
"typescript": "5.9.3",
"typescript-eslint": "8.57.1",
"vite": "8.0.5",
"vite-plugin-compression2": "2.5.3",
"vitest": "4.1.6"
}
}
@@ -46,13 +46,6 @@ export function AudioMenu({ editor }: EditorMenuProps) {
return null;
}
// #343 PART 1: skip getAttributes unless an audio node is active. The menu
// only shows for an active audio node (shouldShow), so the null state while
// inactive is never rendered — behavior unchanged.
if (!ctx.editor.isActive("audio")) {
return null;
}
const audioAttrs = ctx.editor.getAttributes("audio");
return {
@@ -43,15 +43,8 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
return null;
}
// #343 PART 1: skip the per-type isActive() probes unless a callout is
// active. The menu only shows for an active callout (shouldShow), so the
// null state while inactive is never rendered — behavior unchanged.
if (!ctx.editor.isActive("callout")) {
return null;
}
return {
isCallout: true,
isCallout: ctx.editor.isActive("callout"),
isInfo: ctx.editor.isActive("callout", { type: "info" }),
isNote: ctx.editor.isActive("callout", { type: "note" }),
isSuccess: ctx.editor.isActive("callout", { type: "success" }),
@@ -22,12 +22,6 @@ export default function CodeBlockView(props: NodeViewProps) {
const [isSelected, setIsSelected] = useState(false);
useEffect(() => {
// #343 PART 6: `isSelected` only drives the mermaid source's visibility (the
// `hidden` prop below). For every non-mermaid code block it is never read,
// so skip the per-block `selectionUpdate` listener entirely — otherwise N
// code blocks each add a global listener + a setState on every caret move.
if (language !== "mermaid") return;
const updateSelection = () => {
const { state } = editor;
const { from, to } = state.selection;
@@ -38,14 +32,11 @@ export default function CodeBlockView(props: NodeViewProps) {
setIsSelected(isNodeSelected);
};
// Initialize on attach so switching a block's language to "mermaid" reflects
// the current selection immediately (the listener was not running before).
updateSelection();
editor.on("selectionUpdate", updateSelection);
return () => {
editor.off("selectionUpdate", updateSelection);
};
}, [editor, getPos(), node.nodeSize, language]);
}, [editor, getPos(), node.nodeSize]);
function changeLanguage(language: string) {
setLanguageValue(language);
@@ -1,7 +1,5 @@
import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { undoDepth, redoDepth } from "@tiptap/pm/history";
import { yUndoPluginKey } from "@tiptap/y-tiptap";
export interface ToolbarState {
isBold: boolean;
@@ -18,45 +16,14 @@ export interface ToolbarState {
canRedo: boolean;
}
// Undo/redo availability, computed WITHOUT `editor.can().undo()/.redo()`.
//
// `editor.can()` runs the command as a dry-run (building a throwaway state +
// transaction) — the most expensive work in this selector, and it ran on every
// keystroke (and every REMOTE keystroke under collaboration). Instead we read
// the history stack depth directly, which is a cheap plugin-state lookup and
// mirrors exactly what the undo/redo commands themselves check:
//
// - Collaboration (Yjs): the yjs UndoManager's undo/redo stack lengths — the
// same `undoStack.length === 0` / `redoStack.length === 0` guard the
// Collaboration extension's undo/redo commands use.
// - Plain history (templates / non-collab): prosemirror-history's undoDepth /
// redoDepth, which back the UndoRedo extension.
//
// When neither history backend is installed (the pre-sync static editor —
// mainExtensions only, undoRedo disabled), both fall through to 0 -> false,
// matching the previous `safeCan` behavior.
function historyAvailability(editor: Editor): {
canUndo: boolean;
canRedo: boolean;
} {
const state = editor.state;
// Collaboration history (Yjs) takes precedence when present.
const yState = yUndoPluginKey.getState(state) as
| { undoManager?: { undoStack: unknown[]; redoStack: unknown[] } }
| undefined;
if (yState?.undoManager) {
return {
canUndo: yState.undoManager.undoStack.length > 0,
canRedo: yState.undoManager.redoStack.length > 0,
};
}
// Plain prosemirror-history (returns 0 when the history plugin is absent).
return {
canUndo: undoDepth(state) > 0,
canRedo: redoDepth(state) > 0,
};
// Undo/redo come from either StarterKit's history or the Yjs collaboration
// history extension. During the brief moment a page is rendered with the
// static editor (mainExtensions only, undoRedo disabled), neither is loaded
// and editor.can().undo/redo is undefined.
function safeCan(editor: Editor, command: "undo" | "redo"): boolean {
const can = editor.can() as Record<string, unknown>;
const fn = can[command];
return typeof fn === "function" ? (fn as () => boolean)() : false;
}
export function useToolbarState(editor: Editor | null): ToolbarState | null {
@@ -64,7 +31,6 @@ export function useToolbarState(editor: Editor | null): ToolbarState | null {
editor,
selector: (ctx) => {
if (!ctx.editor) return null;
const { canUndo, canRedo } = historyAvailability(ctx.editor);
return {
isBold: ctx.editor.isActive("bold"),
isItalic: ctx.editor.isActive("italic"),
@@ -76,8 +42,8 @@ export function useToolbarState(editor: Editor | null): ToolbarState | null {
isBulletList: ctx.editor.isActive("bulletList"),
isOrderedList: ctx.editor.isActive("orderedList"),
isTaskList: ctx.editor.isActive("taskList"),
canUndo,
canRedo,
canUndo: safeCan(ctx.editor, "undo"),
canRedo: safeCan(ctx.editor, "redo"),
};
},
});
@@ -38,14 +38,6 @@ export function ImageMenu({ editor }: EditorMenuProps) {
return null;
}
// #343 PART 1: skip the expensive per-keystroke work (getAttributes + the
// alignment isActive() probes) unless an image is actually active. The
// menu is only shown when an image is active (see shouldShow), so a null
// state while inactive is never rendered — behavior is unchanged.
if (!ctx.editor.isActive("image")) {
return null;
}
const imageAttrs = ctx.editor.getAttributes("image");
return {
@@ -25,13 +25,6 @@ export function PdfMenu({ editor }: EditorMenuProps) {
return null;
}
// #343 PART 1: skip getAttributes unless a pdf node is active. The menu
// only shows for an active pdf node (shouldShow), so the null state while
// inactive is never rendered — behavior unchanged.
if (!ctx.editor.isActive("pdf")) {
return null;
}
const pdfAttrs = ctx.editor.getAttributes("pdf");
return {
@@ -70,14 +70,7 @@ export const SubpagesMenu = React.memo(
// toggle without re-rendering on every keystroke.
const isRecursive = useEditorState({
editor,
// #343 PART 1: skip getAttributes unless a subpages node is active. The
// menu only shows for an active subpages node (shouldShow), so the value
// is only read then; getAttributes on an inactive node returns the default
// (recursive === false) anyway, so this is behavior-preserving.
selector: (ctx) =>
ctx.editor?.isActive("subpages")
? (ctx.editor.getAttributes("subpages")?.recursive ?? false)
: false,
selector: (ctx) => ctx.editor?.getAttributes("subpages")?.recursive ?? false,
});
return (
@@ -4,7 +4,6 @@ import React, { FC, useEffect, useRef, useState } from "react";
import classes from "./table-of-contents.module.css";
import clsx from "clsx";
import { Box, Text, Title } from "@mantine/core";
import { useDebouncedCallback } from "@mantine/hooks";
import { useTranslation } from "react-i18next";
type TableOfContentsProps = {
@@ -80,21 +79,13 @@ export const TableOfContents: FC<TableOfContentsProps> = (props) => {
setHeadingDOMNodes(result.nodes);
};
// Debounce the update-driven rescan: `$nodes("heading")` scans every heading
// in the document, and it previously ran on EVERY keystroke while the TOC
// panel was open. The panel is derived UI, so recomputing ~300ms after typing
// settles keeps it correct without doing an all-headings scan per keystroke
// (#343, PART 7). `useDebouncedCallback` returns a stable reference and always
// invokes the latest `handleUpdate`.
const debouncedHandleUpdate = useDebouncedCallback(handleUpdate, 300);
useEffect(() => {
props.editor?.on("update", debouncedHandleUpdate);
props.editor?.on("update", handleUpdate);
return () => {
props.editor?.off("update", debouncedHandleUpdate);
props.editor?.off("update", handleUpdate);
};
}, [props.editor, debouncedHandleUpdate]);
}, [props.editor]);
useEffect(
() => {
@@ -31,13 +31,6 @@ export function VideoMenu({ editor }: EditorMenuProps) {
return null;
}
// #343 PART 1: skip getAttributes + alignment isActive() probes unless a
// video is active. The menu only shows for an active video (shouldShow),
// so the null state while inactive is never rendered — behavior unchanged.
if (!ctx.editor.isActive("video")) {
return null;
}
const videoAttrs = ctx.editor.getAttributes("video");
return {
@@ -6,23 +6,6 @@ import getSuggestionItems from '@/features/editor/components/slash-menu/menu-ite
export const slashMenuPluginKey = new PluginKey('slash-command');
// getSuggestionItems fuzzy-matches EVERY command against the query (plus its
// wrong-keyboard-layout remaps) and, while the slash menu is open, is invoked
// TWICE per keystroke: once by the synchronous `allow` gate below and once by
// the popup's `items` builder. A synchronous gating predicate can't be
// debounced without breaking the suggestion decoration/activation, so instead we
// memoize the LAST query's result: the two same-query calls in one keystroke
// build the list only once, and the cache invalidates the moment the query
// changes — so there is no stale-state risk (#343, PART 7).
let lastQuery: string | null = null;
let lastResult: ReturnType<typeof getSuggestionItems> | null = null;
function suggestionItemsForQuery(query: string) {
if (query === lastQuery && lastResult) return lastResult;
lastQuery = query;
lastResult = getSuggestionItems({ query });
return lastResult;
}
// @ts-ignore
const Command = Extension.create({
name: 'slash-command',
@@ -55,7 +38,7 @@ const Command = Extension.create({
// non-matching queries while keeping multi-word matches (e.g.
// "/Heading 1") working.
const query = state.doc.textBetween(range.from + 1, range.to);
const groups = suggestionItemsForQuery(query);
const groups = getSuggestionItems({ query });
const hasMatches = Object.values(groups).some(
(items) => items.length > 0,
);
@@ -78,9 +61,7 @@ const Command = Extension.create({
const SlashCommand = Command.configure({
suggestion: {
// Share the per-query memo with `allow` so the pair of same-query calls in a
// single keystroke rebuilds the list once (#343, PART 7).
items: ({ query }: { query: string }) => suggestionItemsForQuery(query),
items: getSuggestionItems,
render: renderItems,
},
});
@@ -1,100 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import type { MutableRefObject } from "react";
import type { Editor } from "@tiptap/react";
// Mock the app entry so importing the hook doesn't boot the whole app; the hook
// only needs queryClient's cache read/write, which we stub here. Declared via
// vi.hoisted so the spies exist before the hoisted vi.mock factory runs.
const { getQueryData, setQueryData } = vi.hoisted(() => ({
getQueryData: vi.fn(() => undefined as unknown),
setQueryData: vi.fn(),
}));
vi.mock("@/main.tsx", () => ({
queryClient: { getQueryData, setQueryData },
}));
import { usePageContentCache } from "./use-page-content-cache";
const SNAPSHOT = { type: "doc", content: [] };
function makeFakeEditor(overrides: Partial<Editor> = {}): Editor {
return {
isEmpty: false,
isDestroyed: false,
getJSON: vi.fn(() => SNAPSHOT),
...overrides,
} as unknown as Editor;
}
describe("usePageContentCache (#343 PART 3) — getJSON off the keystroke path", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
// A cached page exists so the write path runs.
getQueryData.mockReturnValue({ id: "p1", content: {} });
});
afterEach(() => {
vi.useRealTimers();
});
it("onUpdate (calling the debounced fn) does NOT call getJSON synchronously", () => {
const editor = makeFakeEditor();
const editorRef = { current: editor } as MutableRefObject<Editor | null>;
const { result } = renderHook(() =>
usePageContentCache(editorRef, "slug-1", 3000),
);
// Simulate a keystroke's onUpdate -> only schedules the debounce.
act(() => {
result.current();
result.current();
result.current();
});
// The whole-doc serialization must NOT have happened yet.
expect(editor.getJSON).not.toHaveBeenCalled();
expect(setQueryData).not.toHaveBeenCalled();
// Once the debounce window elapses, getJSON runs exactly once (not per call).
act(() => vi.advanceTimersByTime(3000));
expect(editor.getJSON).toHaveBeenCalledTimes(1);
expect(setQueryData).toHaveBeenCalledWith(["pages", "slug-1"], {
id: "p1",
content: SNAPSHOT,
});
});
it("flushes the pending snapshot on unmount so the last edit isn't lost", () => {
const editor = makeFakeEditor();
const editorRef = { current: editor } as MutableRefObject<Editor | null>;
const { result, unmount } = renderHook(() =>
usePageContentCache(editorRef, "slug-1", 3000),
);
act(() => result.current());
expect(editor.getJSON).not.toHaveBeenCalled();
// Navigation/unmount must flush (not drop) the pending write.
act(() => unmount());
expect(editor.getJSON).toHaveBeenCalledTimes(1);
expect(setQueryData).toHaveBeenCalledTimes(1);
});
it("skips the write when the editor is destroyed (flush racing teardown)", () => {
const editor = makeFakeEditor({ isDestroyed: true });
const editorRef = { current: editor } as MutableRefObject<Editor | null>;
const { result } = renderHook(() =>
usePageContentCache(editorRef, "slug-1", 3000),
);
act(() => result.current());
act(() => vi.advanceTimersByTime(3000));
expect(editor.getJSON).not.toHaveBeenCalled();
expect(setQueryData).not.toHaveBeenCalled();
});
});
@@ -1,50 +0,0 @@
import type { MutableRefObject } from "react";
import { useDebouncedCallback } from "@mantine/hooks";
import type { Editor } from "@tiptap/react";
import { queryClient } from "@/main.tsx";
import { IPage } from "@/features/page/types/page.types.ts";
/**
* Off-keystroke local page-cache updater (issue #343, PART 3).
*
* The editor's `onUpdate` fires on every keystroke — and, under collaboration,
* on every REMOTE keystroke too. Serializing the WHOLE document with
* `editor.getJSON()` on that hot path is expensive, and the previous 3s debounce
* only guarded the cache WRITE, not the serialization: `getJSON()` still ran per
* keystroke.
*
* This hook moves the serialization INSIDE the debounced callback, so the
* full-doc traversal happens at most once per `delay`, not per keystroke. Call
* the returned function from `onUpdate` (it only schedules the debounce); the
* `getJSON()` snapshot is taken when the debounce fires.
*
* On unmount/navigation the pending snapshot is FLUSHED (via `flushOnUnmount`)
* so the last edits within the debounce window aren't lost from the local cache.
* The source of truth is collab/Yjs, but the cache must not go stale.
*
* IMPORTANT: call this hook BEFORE `useEditor`. React runs effect cleanups in
* declaration order on unmount, so the debounce's flush cleanup must be declared
* before `useEditor`'s teardown to run while the editor is still alive; the
* `isDestroyed` guard keeps a flush that still races teardown safe (it skips).
*/
export function usePageContentCache(
editorRef: MutableRefObject<Editor | null>,
slugId: string | undefined,
delay = 3000,
) {
return useDebouncedCallback(
() => {
const e = editorRef.current;
if (!e || e.isDestroyed || e.isEmpty) return;
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
if (pageData) {
// getJSON() (full-doc serialization) runs HERE, off the keystroke path.
queryClient.setQueryData(["pages", slugId], {
...pageData,
content: e.getJSON(),
});
}
},
{ delay, flushOnUnmount: true },
);
}
+19 -20
View File
@@ -62,7 +62,7 @@ import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
import DrawioMenu from "./components/drawio/drawio-menu";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
import { useDocumentVisibility } from "@mantine/hooks";
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
import { useIdle } from "@/hooks/use-idle.ts";
import { queryClient } from "@/main.tsx";
import { IPage } from "@/features/page/types/page.types.ts";
@@ -79,7 +79,6 @@ import { PageEditMode } from "@/features/user/types/user.types.ts";
import { jwtDecode } from "jwt-decode";
import { searchSpotlight } from "@/features/search/constants.ts";
import { useEditorScroll } from "./hooks/use-editor-scroll";
import { usePageContentCache } from "./hooks/use-page-content-cache";
import { useScrollRestoreOnSwap } from "./hooks/use-scroll-position";
import { useSwapHeightReservation } from "./hooks/use-swap-height-reservation";
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
@@ -268,13 +267,8 @@ export default function PageEditor({
}
}, [isIdle, documentState, providersReady, resetIdle]);
// Attach the remote provider once it's ready (and again after a pageId swap
// recreates it) to make sure the connection gets properly established. This
// used to run in the render body — a side effect during render (#343, PART 7).
// `attach()` is idempotent, so re-running it on these deps is safe.
useEffect(() => {
providersRef.current?.remote.attach();
}, [providersReady, pageId]);
// Attach here, to make sure the connection gets properly established
providersRef.current?.remote.attach();
const extensions = useMemo(() => {
if (!providersReady || !providersRef.current || !currentUser?.user) {
@@ -289,12 +283,6 @@ export default function PageEditor({
];
}, [providersReady, currentUser?.user]);
// getJSON() serialization + cache write live in the hook, off the keystroke
// path, and flush on unmount so the last snapshot survives navigation (#343).
// MUST be declared before useEditor: React runs effect cleanups in declaration
// order on unmount, so the flush must run before the editor is torn down.
const debouncedUpdateContent = usePageContentCache(editorRef, slugId);
const editor = useEditor(
{
extensions,
@@ -365,11 +353,11 @@ export default function PageEditor({
editorRef.current = editor;
}
},
onUpdate() {
// Only schedule the debounce here — the whole-doc getJSON() serialization
// happens INSIDE the debounced callback (see usePageContentCache), so it
// no longer runs synchronously on every (local or remote) keystroke.
debouncedUpdateContent();
onUpdate({ editor }) {
if (editor.isEmpty) return;
const editorJson = editor.getJSON();
//update local page cache to reduce flickers
debouncedUpdateContent(editorJson);
},
},
[pageId, editable, extensions],
@@ -415,6 +403,17 @@ export default function PageEditor({
};
}, [editor, pageId, editorIsEditable]);
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
if (pageData) {
queryClient.setQueryData(["pages", slugId], {
...pageData,
content: newContent,
});
}
}, 3000);
const handleActiveCommentEvent = (event) => {
const { commentId, resolved } = event.detail;
+20 -1
View File
@@ -1,5 +1,6 @@
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
import { compression } from "vite-plugin-compression2";
import * as path from "path";
import { execSync } from "node:child_process";
@@ -53,7 +54,25 @@ export default defineConfig(({ mode }) => {
},
APP_VERSION: JSON.stringify(resolveAppVersion(envPath)),
},
plugins: [react()],
plugins: [
react(),
// Emit .br and .gz next to every built asset so the server can serve the
// precompressed copy (see @fastify/static preCompressed in static.module.ts).
compression({
algorithms: ["brotliCompress", "gzip"],
// vite-plugin-compression2's default `include` only covers text-ish
// bundle output (js/mjs/json/css/html/svg/…). Extend it with the large
// VAD binaries copied from public/vad (.wasm ~26MB, .onnx ~2.3MB) so
// they are brotli/gzip'd once at build time and served via
// @fastify/static preCompressed — otherwise @fastify/compress would
// re-brotli them on EVERY request. The default types are repeated here
// because setting `include` replaces (does not extend) the default.
include: /\.(html|xml|css|json|js|mjs|svg|yaml|yml|toml|wasm|onnx)$/,
// index.html is rewritten at server boot (window.CONFIG injection); a
// precompressed copy would go stale — NEVER precompress it.
exclude: [/index\.html$/],
}),
],
build: {
rolldownOptions: {
output: {
+1
View File
@@ -43,6 +43,7 @@
"@clickhouse/client": "^1.18.2",
"@docmost/mcp": "workspace:*",
"@docmost/pdf-inspector": "1.9.6",
"@fastify/compress": "^9.0.0",
"@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^10.0.0",
"@fastify/static": "^9.1.3",
@@ -474,6 +474,19 @@ export class AttachmentController {
const fileSize = Number(attachment.fileSize);
const rangeHeader = req.headers.range;
// Opt this download route out of the global @fastify/compress hook.
// Attachment bytes are final and mostly binary, so on-the-fly compression
// only burns CPU — and on the 206/Range branch it is actively corrupting:
// compress decides purely by Content-Type, so for a compressible mime
// (application/octet-stream fallback, image/svg+xml, text/*) it would gzip
// the byte slice and drop Content-Length while Content-Range still
// describes the RAW offsets and the status stays 206. A resuming client
// (`curl -C -`, download managers) then appends the encoded bytes as if
// raw and ends up with a broken file. @fastify/compress skips whenever the
// request carries `x-no-compression` (see its onSend hook), so setting it
// here covers both the 200 (full file) and 206 (range) responses.
req.headers['x-no-compression'] = 'true';
res.header('Accept-Ranges', 'bytes');
res.header(
'Content-Security-Policy',
@@ -0,0 +1,35 @@
import { resolveStaticAssetHeaders } from './static.module';
// Unit tests for the static-asset cache classifier extracted from the
// @fastify/static setHeaders callback (precedent: sandbox.controller.spec.ts).
describe('resolveStaticAssetHeaders', () => {
it('marks a content-hashed /assets/ file immutable and sets Vary', () => {
const headers = resolveStaticAssetHeaders(
'/app/apps/client/dist/assets/index-a1b2c3.js',
);
expect(headers['cache-control']).toBe(
'public, max-age=31536000, immutable',
);
expect(headers['vary']).toBe('Accept-Encoding');
});
it('makes index.html always revalidate (never immutable)', () => {
const headers = resolveStaticAssetHeaders(
'/app/apps/client/dist/index.html',
);
expect(headers['cache-control']).toBe(
'no-cache, no-store, must-revalidate',
);
expect(headers['vary']).toBe('Accept-Encoding');
});
it('does NOT mark a non-hashed asset immutable but still sets Vary', () => {
const headers = resolveStaticAssetHeaders(
'/app/apps/client/dist/locales/en.json',
);
// No immutable cache-control — this path keeps @fastify/static's default
// etag/last-modified revalidation.
expect(headers['cache-control']).toBeUndefined();
expect(headers['vary']).toBe('Accept-Encoding');
});
});
@@ -5,6 +5,46 @@ import * as fs from 'node:fs';
import fastifyStatic from '@fastify/static';
import { EnvironmentService } from '../environment/environment.service';
/**
* Resolve the response headers for a statically served client asset.
*
* Extracted from the @fastify/static `setHeaders` callback so the cache
* classification stays a pure, unit-testable function (see
* static.module.spec.ts).
*
* `Vary: Accept-Encoding` is emitted for every static response because
* @fastify/static negotiates a precompressed .br/.gz neighbour by the client's
* Accept-Encoding but does NOT set Vary itself. Without it a shared/proxy cache
* keyed on the URL alone could store the brotli variant and later serve it to a
* client that only sent `Accept-Encoding: identity`/gzip → an undecodable body.
* This matters most for the immutable /assets/ files, which proxies may keep
* for a year.
*/
export function resolveStaticAssetHeaders(
filePath: string,
): Record<string, string> {
const headers: Record<string, string> = { vary: 'Accept-Encoding' };
// Content-hashed files under /assets/ never change for a given URL, so they
// can be cached forever and skip revalidation entirely.
if (filePath.includes('/assets/')) {
headers['cache-control'] = 'public, max-age=31536000, immutable';
return headers;
}
// index.html is rewritten at boot (window.CONFIG injection) and on every
// deploy — it must be revalidated on every load.
if (filePath.endsWith('index.html')) {
headers['cache-control'] = 'no-cache, no-store, must-revalidate';
return headers;
}
// Everything else (locales, vad, icons, manifest) is NOT content-hashed and
// changes between deploys, so it keeps @fastify/static's default
// etag/last-modified revalidation — do NOT mark it immutable.
return headers;
}
@Module({})
export class StaticModule implements OnModuleInit {
constructor(
@@ -68,6 +108,16 @@ export class StaticModule implements OnModuleInit {
await app.register(fastifyStatic, {
root: clientDistPath,
wildcard: false,
// Serve the build-time .br/.gz neighbour when the client accepts it
// (see vite-plugin-compression2 in apps/client/vite.config.ts).
preCompressed: true,
setHeaders: (res, filePath) => {
for (const [name, value] of Object.entries(
resolveStaticAssetHeaders(filePath),
)) {
res.setHeader(name, value);
}
},
});
app.get(RENDER_PATH, (req: any, res: any) => {
+12
View File
@@ -10,6 +10,7 @@ import { TransformHttpResponseInterceptor } from './common/interceptors/http-res
import { WsRedisIoAdapter } from './ws/adapter/ws-redis.adapter';
import fastifyMultipart from '@fastify/multipart';
import fastifyCookie from '@fastify/cookie';
import fastifyCompress from '@fastify/compress';
import fastifyIp from 'fastify-ip';
import { InternalLogFilter } from './common/logger/internal-log-filter';
import { EnvironmentService } from './integrations/environment/environment.service';
@@ -60,6 +61,17 @@ async function bootstrap() {
await app.register(fastifyIp);
await app.register(fastifyMultipart);
await app.register(fastifyCookie);
// Compress dynamic responses (API JSON, the rewritten share-SEO HTML) when the
// client accepts br/gzip. @fastify/compress only compresses content-types that
// mime-db flags `compressible` (application/json, text/html, …); `text/event-stream`
// is not in mime-db, so SSE is never compressed by the allowlist. The AI-chat
// stream additionally hijacks the raw socket (pipeUIMessageStreamToResponse ->
// res.raw in ai-chat.service.ts), bypassing Fastify's reply/onSend lifecycle
// entirely, so this hook can never buffer that stream.
await app.register(fastifyCompress, {
// Skip tiny payloads where compression overhead outweighs the savings.
threshold: 1024,
});
const environmentService = app.get(EnvironmentService);
const frameHeader = resolveFrameHeader(
+5
View File
@@ -12,6 +12,11 @@ services:
ports:
- "3000:3000"
restart: unless-stopped
# The app already serves precompressed (brotli/gzip) static assets with
# long-lived cache headers and gzips dynamic API responses. For the best
# cold-load latency you can OPTIONALLY put a reverse proxy (caddy / nginx /
# traefik) in front with HTTP/2 (or HTTP/3) and brotli enabled — none is
# required for compression to work.
volumes:
- docmost:/app/data/storage
@@ -1,134 +0,0 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { getSchema } from '@tiptap/core';
import { Document } from '@tiptap/extension-document';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { EditorState } from '@tiptap/pm/state';
import { Node as PMNode } from '@tiptap/pm/model';
import { FootnoteReference } from './footnote-reference';
import { FootnotesList } from './footnotes-list';
import { FootnoteDefinition } from './footnote-definition';
import {
footnoteNumberingPlugin,
footnoteNumberingPluginKey,
getFootnoteNumber,
} from './footnote-numbering';
import {
FOOTNOTE_REFERENCE_NAME,
FOOTNOTES_LIST_NAME,
FOOTNOTE_DEFINITION_NAME,
} from './footnote-util';
const extensions = [
Document,
Paragraph,
Text,
FootnoteReference,
FootnotesList,
FootnoteDefinition,
];
const schema = getSchema(extensions);
function makeState(docJson: any): EditorState {
return EditorState.create({
doc: PMNode.fromJSON(schema, docJson),
plugins: [footnoteNumberingPlugin()],
});
}
const withTwoFootnotes = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'a' },
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: 'x' } },
{ type: 'text', text: 'b' },
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: 'y' } },
],
},
{
type: FOOTNOTES_LIST_NAME,
content: [
{
type: FOOTNOTE_DEFINITION_NAME,
attrs: { id: 'x' },
content: [{ type: 'paragraph' }],
},
{
type: FOOTNOTE_DEFINITION_NAME,
attrs: { id: 'y' },
content: [{ type: 'paragraph' }],
},
],
},
],
};
describe('footnote numbering plugin — short-circuit (#343 PART 5)', () => {
afterEach(() => vi.restoreAllMocks());
it('does ZERO document traversals on a docChanged transaction when the doc has no footnotes', () => {
const state = makeState({
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hi' }] }],
});
// Only count traversals caused by the transaction, not the initial build.
const descendantsSpy = vi.spyOn(PMNode.prototype, 'descendants');
const before = footnoteNumberingPluginKey.getState(state);
// A real content edit (docChanged) that introduces no footnote node.
const next = state.apply(state.tr.insertText('!', 3));
const after = footnoteNumberingPluginKey.getState(next);
// The plugin never walked the document...
expect(descendantsSpy).not.toHaveBeenCalled();
// ...and reused the exact same (empty) state object — proof it short-circuited.
expect(after).toBe(before);
expect(after?.hasFootnotes).toBe(false);
});
it('rebuilds (numbering appears) the first time a footnote is inserted into a footnote-free doc', () => {
const state = makeState({
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hi' }] }],
});
expect(footnoteNumberingPluginKey.getState(state)?.hasFootnotes).toBe(false);
const ref = schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: 'x' });
const next = state.apply(state.tr.insert(3, ref));
const after = footnoteNumberingPluginKey.getState(next);
expect(after?.hasFootnotes).toBe(true);
expect(getFootnoteNumber(next, 'x')).toBe(1);
});
});
describe('footnote numbering plugin — numbering unchanged with footnotes (#343 PART 5)', () => {
it('numbers references in document order via the single merged walk', () => {
const state = makeState(withTwoFootnotes);
expect(getFootnoteNumber(state, 'x')).toBe(1);
expect(getFootnoteNumber(state, 'y')).toBe(2);
});
it('produces a decoration for every reference and matching definition', () => {
const state = makeState(withTwoFootnotes);
const decos = footnoteNumberingPluginKey.getState(state)?.decorations;
// 2 references + 2 definitions = 4 number decorations.
expect(decos?.find().length).toBe(4);
});
it('keeps numbering current after an edit while footnotes exist', () => {
const state = makeState(withTwoFootnotes);
// Insert a NEW reference (id "z") before the others: it must become #1 and
// shift x -> #2, y -> #3 (deterministic document-order numbering).
const ref = schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: 'z' });
const next = state.apply(state.tr.insert(1, ref));
expect(getFootnoteNumber(next, 'z')).toBe(1);
expect(getFootnoteNumber(next, 'x')).toBe(2);
expect(getFootnoteNumber(next, 'y')).toBe(3);
});
});
@@ -1,9 +1,11 @@
import { EditorState, Plugin, PluginKey, Transaction } from '@tiptap/pm/state';
import { EditorState, Plugin, PluginKey } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import { Node as ProseMirrorNode, Slice } from '@tiptap/pm/model';
import { Node as ProseMirrorNode } from '@tiptap/pm/model';
import {
FOOTNOTE_DEFINITION_NAME,
FOOTNOTE_REFERENCE_NAME,
computeFootnoteNumbers,
computeFootnoteRefCounts,
} from './footnote-util';
export const footnoteNumberingPluginKey = new PluginKey<FootnoteNumberingState>(
@@ -25,22 +27,8 @@ interface FootnoteNumberingState {
refCounts: Map<string, number>;
/** Decorations rendering those numbers (refs + definitions). */
decorations: DecorationSet;
/** Whether the document contains ANY footnote reference/definition node.
* Cached so `apply` can skip the whole-doc walk on every keystroke in the
* common case (documents with no footnotes), recomputing only once a
* transaction actually inserts a footnote node (#343, PART 5). */
hasFootnotes: boolean;
}
/** Reusable empty state for footnote-free documents — avoids reallocating an
* empty map/decoration set on every keystroke while there are no footnotes. */
const EMPTY_STATE: FootnoteNumberingState = {
numbers: new Map(),
refCounts: new Map(),
decorations: DecorationSet.empty,
hasFootnotes: false,
};
/**
* Build the decoration set for footnote numbers. Pure function of the document:
* walk references in document order, assign 1-based numbers, then attach a
@@ -53,101 +41,50 @@ export function buildFootnoteDecorations(doc: ProseMirrorNode): DecorationSet {
return buildFootnoteNumberingState(doc).decorations;
}
function numberDecoration(pos: number, nodeSize: number, num: number): Decoration {
return Decoration.node(pos, pos + nodeSize, {
'data-footnote-number': String(num),
style: `--footnote-number: "${num}";`,
});
}
/**
* Compute the number map, reference counts AND the decorations for `doc` in a
* SINGLE document walk (previously three separate O(n) traversals per
* docChanged — computeFootnoteNumbers + computeFootnoteRefCounts + a decoration
* pass, #343 PART 5). The plugin caches the result so NodeViews can read numbers
* without recomputing.
*
* References are numbered and decorated as they are encountered (document
* order). Definition positions are collected during the same walk and decorated
* afterwards from the completed number map — so a definition that appears before
* its reference in document order still resolves to the correct number, and the
* output is identical to the previous three-pass implementation. (Decoration
* insertion order does not matter: DecorationSet.create indexes by position.)
* Compute both the number map AND the decorations for `doc` in a single walk.
* The plugin caches the result so NodeViews can read numbers without
* recomputing.
*/
function buildFootnoteNumberingState(
doc: ProseMirrorNode,
): FootnoteNumberingState {
const numbers = new Map<string, number>();
const refCounts = new Map<string, number>();
const numbers = computeFootnoteNumbers(doc);
const refCounts = computeFootnoteRefCounts(doc);
const decorations: Decoration[] = [];
const definitions: { id: string; pos: number; nodeSize: number }[] = [];
let n = 0;
let hasFootnotes = false;
doc.descendants((node, pos) => {
const typeName = node.type.name;
if (typeName === FOOTNOTE_REFERENCE_NAME) {
hasFootnotes = true;
const id = node.attrs.id;
if (id) {
if (!numbers.has(id)) numbers.set(id, ++n);
refCounts.set(id, (refCounts.get(id) ?? 0) + 1);
decorations.push(numberDecoration(pos, node.nodeSize, numbers.get(id)!));
if (node.type.name === FOOTNOTE_REFERENCE_NAME) {
const num = numbers.get(node.attrs.id);
if (num != null) {
decorations.push(
Decoration.node(pos, pos + node.nodeSize, {
'data-footnote-number': String(num),
style: `--footnote-number: "${num}";`,
}),
);
}
}
if (node.type.name === FOOTNOTE_DEFINITION_NAME) {
const num = numbers.get(node.attrs.id);
if (num != null) {
decorations.push(
Decoration.node(pos, pos + node.nodeSize, {
'data-footnote-number': String(num),
style: `--footnote-number: "${num}";`,
}),
);
}
} else if (typeName === FOOTNOTE_DEFINITION_NAME) {
hasFootnotes = true;
const id = node.attrs.id;
if (id != null) definitions.push({ id, pos, nodeSize: node.nodeSize });
}
});
if (!hasFootnotes) return EMPTY_STATE;
for (const def of definitions) {
const num = numbers.get(def.id);
if (num != null) {
decorations.push(numberDecoration(def.pos, def.nodeSize, num));
}
}
return {
numbers,
refCounts,
decorations: DecorationSet.create(doc, decorations),
hasFootnotes: true,
};
}
/**
* Cheap check: does any of a transaction's inserted content contain a footnote
* reference/definition node? Footnote nodes can only ENTER the document through
* replace steps (ReplaceStep / ReplaceAroundStep both expose a `.slice`), so
* scanning only the inserted slices — O(change size), not O(doc) — is sufficient
* to detect a newly-added footnote. Mark/attr steps never introduce nodes.
* Lets `apply` keep skipping the whole-doc walk until a footnote first appears.
*/
function transactionInsertsFootnote(tr: Transaction): boolean {
for (const step of tr.steps) {
const slice = (step as unknown as { slice?: Slice }).slice;
if (!slice || slice.content.size === 0) continue;
let found = false;
slice.content.descendants((node) => {
if (found) return false;
const typeName = node.type.name;
if (
typeName === FOOTNOTE_REFERENCE_NAME ||
typeName === FOOTNOTE_DEFINITION_NAME
) {
found = true;
return false;
}
return true;
});
if (found) return true;
}
return false;
}
/**
* Read the cached footnote number for `id` from the numbering plugin's state.
* This is the source NodeViews should use instead of calling
@@ -189,13 +126,6 @@ export function footnoteNumberingPlugin(): Plugin {
// the number map NodeViews read stays current on every edit while
// non-doc transactions (selection, etc.) reuse the cache for free.
if (!tr.docChanged) return old;
// Short-circuit the whole-doc walk while the document has no footnotes:
// if there were none and this transaction did not INSERT one, there is
// still nothing to number, so reuse the empty state (#343, PART 5). Once
// a footnote exists we always rebuild (covers renumbering/deletion).
if (!old.hasFootnotes && !transactionInsertsFootnote(tr)) {
return old;
}
return buildFootnoteNumberingState(tr.doc);
},
},
@@ -635,17 +635,13 @@ const Attachment = Node.create({
},
name: {
default: null,
// Empty-string-vs-absent idempotency (GS-EDIT-REVERT class): "" -> default.
parseHTML: (el: HTMLElement) =>
el.getAttribute("data-attachment-name") || null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-attachment-name"),
renderHTML: (attrs: Record<string, any>) =>
attrs.name ? { "data-attachment-name": attrs.name } : {},
},
mime: {
default: null,
// Empty-string-vs-absent idempotency (GS-EDIT-REVERT class): "" -> default.
parseHTML: (el: HTMLElement) =>
el.getAttribute("data-attachment-mime") || null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-attachment-mime"),
renderHTML: (attrs: Record<string, any>) =>
attrs.mime ? { "data-attachment-mime": attrs.mime } : {},
},
@@ -693,10 +689,7 @@ const Video = Node.create({
},
alt: {
default: null,
// Empty-string-vs-absent idempotency: coerce "" back to the default so a
// stray empty `aria-label` never materializes `alt: ""` on a video stored
// with no alt (same GS-EDIT-REVERT class as the image `alt` fix).
parseHTML: (el: HTMLElement) => el.getAttribute("aria-label") || null,
parseHTML: (el: HTMLElement) => el.getAttribute("aria-label"),
renderHTML: (attrs: Record<string, any>) =>
attrs.alt ? { "aria-label": attrs.alt } : {},
},
@@ -871,15 +864,13 @@ const diagramAttributes = () => ({
},
title: {
default: null,
// Empty-string-vs-absent idempotency (GS-EDIT-REVERT class): "" -> default.
parseHTML: (el: HTMLElement) => el.getAttribute("data-title") || null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-title"),
renderHTML: (attrs: Record<string, any>) =>
attrs.title ? { "data-title": attrs.title } : {},
},
alt: {
default: null,
// Empty-string-vs-absent idempotency (GS-EDIT-REVERT class): "" -> default.
parseHTML: (el: HTMLElement) => el.getAttribute("data-alt") || null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-alt"),
renderHTML: (attrs: Record<string, any>) =>
attrs.alt ? { "data-alt": attrs.alt } : {},
},
@@ -1115,8 +1106,7 @@ const Pdf = Node.create({
},
name: {
default: null,
// Empty-string-vs-absent idempotency (GS-EDIT-REVERT class): "" -> default.
parseHTML: (el: HTMLElement) => el.getAttribute("data-name") || null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-name"),
renderHTML: (attrs: Record<string, any>) =>
attrs.name ? { "data-name": attrs.name } : {},
},
@@ -1501,29 +1491,6 @@ export const docmostExtensions = [
...parent.height,
parseHTML: (el: HTMLElement) => el.getAttribute("height"),
},
// Empty-string-vs-absent idempotency (GS-EDIT-REVERT class). `marked`
// renders `![](src)` as `<img alt="">`, so the stock Image `alt`
// parseHTML (`getAttribute("alt")`) materializes `alt: ""` on an image
// that was stored with NO alt (attr absent). That is a false diff against
// the editor-stored form (a no-alt image has alt ABSENT, not ""), so a
// git-sync / ai-chat touch of a page with a plain image produced phantom
// churn. Coerce an empty string back to the attr's default (null) so the
// import is idempotent. A real alt survives verbatim (`|| undefined` keeps
// the truthy value; the default fills the empty case). `title` is coerced
// the same way for the whole class, even though `marked` does not
// currently emit `title=""` — defence in depth against any path that does.
// NOTE: this DIVERGES from editor-ext's literal image `alt` parseHTML
// (`getAttribute("alt")`, which returns "" verbatim), but CONVERGES on
// editor-ext's real STORED shape: an editor image inserted without alt
// renders with no `alt` attribute and re-parses as absent, never "".
alt: {
...parent.alt,
parseHTML: (el: HTMLElement) => el.getAttribute("alt") || null,
},
title: {
...parent.title,
parseHTML: (el: HTMLElement) => el.getAttribute("title") || null,
},
};
},
}).configure({ inline: false }),
@@ -1,443 +0,0 @@
/**
* Reusable round-trip-STABILITY matrix helper (fixtures-first).
*
* A single stored node authored WITHOUT a given string attribute (attr
* absent / undefined) must not gain a phantom EMPTY-STRING value after a
* markdown round-trip the "empty-string-vs-absent" churn class. This helper,
* given a node spec, drives a matrix of attribute combinations through the REAL
* converter (`convertProseMirrorToMarkdown` -> `markdownToProseMirror`) and
* asserts byte-stability on two contours:
*
* 1. RAW round-trip: for the node under test, every attribute the round-trip
* materializes must equal what the INPUT authored an authored attr keeps
* its value, an ABSENT attr may only reappear at its SCHEMA DEFAULT. If an
* absent attr comes back as a NON-default value (e.g. `alt: ""` where the
* default is `null`), that is an instability and is reported precisely as
* `type.attr: absent -> "<got>"`. This is the contour git-sync / stored
* JSON diffs on, so masking it only in `canonicalize` would leave the noise.
*
* 2. CANONICAL round-trip: `canonicalizeContent(original)` must deep-equal
* `canonicalizeContent(roundtrip)` (a second, semantic contour).
*
* The ONLY normalization the helper treats as allowed (not an instability) is
* the DOCUMENTED numeric width/height/size/aspectRatio -> string coercion the
* converter performs on purpose (a stored numeric `640` re-parses via
* `getAttribute` as the string `"640"`). It is encoded here as an explicit
* per-spec `numericStringAttrs` set applied to BOTH contours, NOT a silent skip.
*
* The helper is node-type agnostic: image and the whole media family share the
* `align !== "center"` predicate + `<!--name {…}-->` comment machinery, so one
* matrix guards the shared class.
*/
import { getSchema } from "@tiptap/core";
import {
convertProseMirrorToMarkdown,
markdownToProseMirror,
canonicalizeContent,
docmostExtensions,
} from "../src/lib/index.js";
import { firstDivergence } from "./roundtrip-helpers.js";
/** One attribute's two probe values. */
export interface AttrMatrixEntry {
/** Attribute name on the node. */
attr: string;
/**
* The "default" pick. `undefined` means the attribute is OMITTED entirely
* (the absent case the one that can materialize an empty string on import).
* A concrete value is authored verbatim.
*/
default: unknown;
/** A representative NON-default value to exercise (must survive verbatim). */
nonDefault: unknown;
/**
* Marks the attr as a member of the EMPTY-STRING class the fix targets: a
* string attr whose schema default is `null`/absent and whose parseHTML
* coerces `"" -> default` (image/drawio `alt`+`title`, video `alt` via
* aria-label, pdf/attachment `name`, attachment `mime`). Set true to also
* drive the THIRD-STATE convergence case (see runConvergenceCase) for this
* attr. Attrs whose default is NOT null (e.g. embed `provider`, default "")
* or that are not `""`-coerced (control attrs) are left unset.
*/
emptyStringClass?: boolean;
}
/** A node type + the attribute matrix to sweep for it. */
export interface NodeStabilitySpec {
/** Node type (e.g. "image", "video"). */
type: string;
/** Attributes always present on the node (e.g. `{ src: "/i.png" }`). */
baseAttrs?: Record<string, unknown>;
/** Attributes to sweep at default and non-default. */
attrMatrix: AttrMatrixEntry[];
/**
* Attributes whose numeric -> string coercion on round-trip is DOCUMENTED and
* intentional; compared modulo `String(x)` on both sides. Defaults to the
* converter's known sizing set.
*/
numericStringAttrs?: string[];
}
/** A single unstable finding, legible enough to tie a gate-lock to. */
export interface Instability {
type: string;
attr: string;
/** What the input authored: the literal value, or the ABSENT sentinel. */
authored: unknown | typeof ABSENT;
/** What the round-trip produced. */
got: unknown;
/** What a stable round-trip should have produced (authored value or default). */
expected: unknown;
}
/** One matrix cell's result. */
export interface ComboResult {
label: string;
authored: Record<string, unknown>;
/** RAW-contour instabilities on the node under test. */
raw: Instability[];
/** CANONICAL-contour divergence (path + values) or null when equal. */
canonical: { path: string; a: unknown; b: unknown } | null;
/** True when the node type failed to round-trip at all (structural loss). */
missing: boolean;
md: string;
}
/** Whole-matrix report for one node spec. */
export interface MatrixReport {
type: string;
combos: ComboResult[];
}
/** Sentinel marking an attribute the input did NOT author. */
export const ABSENT = Symbol("ABSENT");
const DEFAULT_NUMERIC_STRING_ATTRS = [
"width",
"height",
"size",
"aspectRatio",
];
// The ProseMirror schema the converter targets — its attribute `default`s are
// the authoritative "what an absent attr should re-materialize as" oracle.
const schema = getSchema(docmostExtensions);
/** Read the schema default for every attribute of a node type. */
function schemaDefaults(type: string): Record<string, unknown> {
const specAttrs = (schema.nodes[type]?.spec?.attrs ?? {}) as Record<
string,
{ default: unknown }
>;
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(specAttrs)) out[k] = v.default;
return out;
}
/** Find the first node of a given type anywhere in a PM doc tree. */
function findFirst(node: any, type: string): any {
if (node && node.type === type) return node;
for (const child of node?.content ?? []) {
const hit = findFirst(child, type);
if (hit) return hit;
}
return null;
}
/** Coerce a scalar for the documented numeric->string comparison. */
const numStr = (x: unknown): unknown => (x == null ? x : String(x));
/**
* Enumerate the cartesian product of the matrix: every attribute independently
* at its default (index 0) or non-default (index 1) pick. The all-default
* corner is included (the baseline). Small by construction (2^N over a handful
* of at-risk string attrs).
*/
function enumerateCombos(matrix: AttrMatrixEntry[]): number[][] {
let combos: number[][] = [[]];
for (let i = 0; i < matrix.length; i++) {
const next: number[][] = [];
for (const c of combos) {
next.push([...c, 0]);
next.push([...c, 1]);
}
combos = next;
}
return combos;
}
/** Build the authored attrs for one combo pick vector. */
function authoredAttrs(
spec: NodeStabilitySpec,
picks: number[],
): Record<string, unknown> {
const attrs: Record<string, unknown> = { ...(spec.baseAttrs ?? {}) };
spec.attrMatrix.forEach((entry, i) => {
if (picks[i] === 1) {
attrs[entry.attr] = entry.nonDefault;
} else if (entry.default !== undefined) {
attrs[entry.attr] = entry.default;
}
// default === undefined -> OMIT the attr entirely (the absent case).
});
return attrs;
}
/** Human-readable label for a combo (which attrs are at non-default). */
function comboLabel(spec: NodeStabilitySpec, picks: number[]): string {
const on = spec.attrMatrix
.filter((_, i) => picks[i] === 1)
.map((e) => e.attr);
return on.length === 0 ? "<all-default>" : on.join("+");
}
/**
* Run the full stability matrix for one node spec and return a structured
* report (does NOT throw the caller asserts, so a failure can print the whole
* report). Every combo runs the real export->import pipeline once.
*/
export async function runStabilityMatrix(
spec: NodeStabilitySpec,
): Promise<MatrixReport> {
const numericStringAttrs = new Set(
spec.numericStringAttrs ?? DEFAULT_NUMERIC_STRING_ATTRS,
);
const defaults = schemaDefaults(spec.type);
const combos: ComboResult[] = [];
for (const picks of enumerateCombos(spec.attrMatrix)) {
const authored = authoredAttrs(spec, picks);
const doc = { type: "doc", content: [{ type: spec.type, attrs: authored }] };
const md = convertProseMirrorToMarkdown(doc);
const rt = await markdownToProseMirror(md);
const node = findFirst(rt, spec.type);
const result: ComboResult = {
label: comboLabel(spec, picks),
authored,
raw: [],
canonical: null,
missing: node == null,
md,
};
if (node != null) {
// RAW contour: every materialized attr must equal the authored value, or
// (for an absent attr) the schema default — modulo the documented numeric
// string coercion.
const rtAttrs = (node.attrs ?? {}) as Record<string, unknown>;
for (const key of Object.keys(rtAttrs)) {
const authoredHas = Object.prototype.hasOwnProperty.call(authored, key);
const expected = authoredHas ? authored[key] : defaults[key];
let got = rtAttrs[key];
let exp = expected;
if (numericStringAttrs.has(key)) {
got = numStr(got);
exp = numStr(exp);
}
if (firstDivergence(got, exp) !== null) {
result.raw.push({
type: spec.type,
attr: key,
authored: authoredHas ? authored[key] : ABSENT,
got: rtAttrs[key],
expected,
});
}
}
// CANONICAL contour: canonical forms deep-equal, modulo the same numeric
// string coercion (applied to both trees so a documented coercion is not
// counted as a divergence).
const ca = normalizeNumeric(canonicalizeContent(doc), numericStringAttrs);
const cb = normalizeNumeric(canonicalizeContent(rt), numericStringAttrs);
result.canonical = firstDivergence(ca, cb);
}
combos.push(result);
}
return { type: spec.type, combos };
}
/**
* Deep-copy a canonical tree, coercing the documented numeric->string attrs to
* their string form so an intentional `640 -> "640"` coercion is not reported
* as a canonical divergence. Only touches the listed attribute keys.
*/
function normalizeNumeric(node: any, attrs: Set<string>): any {
if (Array.isArray(node)) return node.map((n) => normalizeNumeric(n, attrs));
if (node === null || typeof node !== "object") return node;
const out: Record<string, unknown> = {};
for (const key of Object.keys(node)) {
if (key === "attrs" && node.attrs && typeof node.attrs === "object") {
const a: Record<string, unknown> = {};
for (const [k, v] of Object.entries(node.attrs)) {
a[k] = attrs.has(k) ? numStr(v) : v;
}
out.attrs = a;
} else {
out[key] = normalizeNumeric(node[key], attrs);
}
}
return out;
}
/** Flatten a report to just its unstable combos (for a terse assertion). */
export function unstableCombos(report: MatrixReport): ComboResult[] {
return report.combos.filter(
(c) => c.missing || c.raw.length > 0 || c.canonical !== null,
);
}
// ---------------------------------------------------------------------------
// THIRD STATE: an EXPLICITLY-STORED empty string on a string attr.
//
// The matrix above sweeps TWO states per string attr: absent/default and a
// non-default value — and asserts FIRST-pass byte-stability for both. There is
// a third, degenerate state the matrix does NOT cover: the attr stored as a
// LITERAL `""`. This is DISTINCT from "the node never had the attr": a user
// types an alt in the editor, then deletes it, and Tiptap's
// `updateAttributes({ alt: "" })` persists a literal `alt: ""` in the stored
// JSON. There is no absent-vs-"" distinction in the DOM once serialized, so the
// fix's `getAttribute("alt") || null` coercion canonicalizes BOTH to the
// default (`null`).
//
// Consequence — and this is CORRECT, not a bug: a doc carrying an explicit `""`
// converges to the default on the FIRST round-trip (a ONE-TIME diff: `"" ->
// null`), then is byte-stable from the SECOND round-trip on (idempotent). So
// this state must be pinned with a DIFFERENT contract than the matrix's:
// - do NOT assert first-pass byte-stability (the first pass legitimately
// changes `""` -> default), and
// - DO assert the first pass converges to the default AND the second pass is
// idempotent (rt2 deep-equals rt1).
//
// A future sync/QA pass diffing stored pages will see this one-time `"" -> null`
// normalization exactly once per affected node; it is the converter canon, not
// corruption, and must not be flagged as data loss.
// ---------------------------------------------------------------------------
/** Result of the third-state ("explicit empty string") convergence probe. */
export interface ConvergenceResult {
type: string;
attr: string;
/** The schema default the attr must converge to on pass 1 (null / absent). */
expectedDefault: unknown;
/** rt1's materialized value for the attr — must equal `expectedDefault`. */
firstPassValue: unknown;
/** True when the node round-tripped AND rt1 converged the attr to default. */
convergedToDefault: boolean;
/** rt1-vs-rt2 divergence; MUST be null (idempotent from pass 2 on). */
secondPassDivergence: { path: string; a: unknown; b: unknown } | null;
/** True when the node type failed to round-trip at all (structural loss). */
missing: boolean;
}
/** Round-trip a full PM doc through the real converter once. */
async function roundtripDoc(doc: any): Promise<any> {
return markdownToProseMirror(convertProseMirrorToMarkdown(doc));
}
/**
* Third-state convergence probe for one string attr of the empty-string class.
*
* (a) builds a doc with the attr EXPLICITLY set to `""` (baseAttrs + `""`),
* (b) rt1 = roundtrip(doc); asserts rt1's attr equals the schema default the
* documented ONE-TIME `"" -> default` normalization (NOT byte-stable vs the
* `""` input, so first-pass stability is deliberately NOT asserted here),
* (c) rt2 = roundtrip(rt1); asserts rt2 deep-equals rt1 idempotent from the
* second round-trip on.
*
* Returns a structured result (does NOT throw) so the caller can assert and
* print. Reusable across the whole node family: drive it for every attr flagged
* `emptyStringClass` on every spec (see convergenceCasesFor / the test driver).
*/
export async function runConvergenceCase(
spec: NodeStabilitySpec,
attr: string,
): Promise<ConvergenceResult> {
const expectedDefault = schemaDefaults(spec.type)[attr];
// (a) The degenerate third state: attr persisted as a LITERAL "".
const authored = { ...(spec.baseAttrs ?? {}), [attr]: "" };
const doc = { type: "doc", content: [{ type: spec.type, attrs: authored }] };
// (b) First round-trip: "" must normalize to the default (a one-time diff).
const rt1 = await roundtripDoc(doc);
const node1 = findFirst(rt1, spec.type);
const firstPassValue = node1?.attrs?.[attr];
const convergedToDefault =
node1 != null && firstDivergence(firstPassValue, expectedDefault) === null;
// (c) Second round-trip: must be byte-stable (rt2 deep-equals rt1). We compare
// the WHOLE docs — both are converter OUTPUTS already in the same materialized
// form (numeric attrs are strings on both sides), so no numeric normalization
// is needed here, unlike the raw/canonical contours above.
const rt2 = node1 != null ? await roundtripDoc(rt1) : rt1;
const secondPassDivergence =
node1 != null ? firstDivergence(rt1, rt2) : null;
return {
type: spec.type,
attr,
expectedDefault,
firstPassValue,
convergedToDefault,
secondPassDivergence,
missing: node1 == null,
};
}
/** The attrs of a spec flagged as members of the empty-string class. */
export function convergenceCasesFor(spec: NodeStabilitySpec): string[] {
return spec.attrMatrix
.filter((e) => e.emptyStringClass)
.map((e) => e.attr);
}
/** True when a convergence result honours the "converges once, then stable" contract. */
export function convergenceOk(r: ConvergenceResult): boolean {
return !r.missing && r.convergedToDefault && r.secondPassDivergence === null;
}
/** Render a convergence result as a legible one-liner for a failed assertion. */
export function formatConvergence(r: ConvergenceResult): string {
if (r.missing) return `${r.type}.${r.attr}: DID-NOT-ROUND-TRIP`;
const parts: string[] = [];
if (!r.convergedToDefault) {
parts.push(
`pass1 did NOT converge: got ${JSON.stringify(r.firstPassValue)} (expected default ${JSON.stringify(r.expectedDefault)})`,
);
}
if (r.secondPassDivergence) {
parts.push(
`pass2 NOT idempotent @ ${r.secondPassDivergence.path}: ${JSON.stringify(r.secondPassDivergence.a)} vs ${JSON.stringify(r.secondPassDivergence.b)}`,
);
}
const status = parts.length === 0 ? "converges-once-then-stable" : parts.join("; ");
return `${r.type}.${r.attr}: ${status}`;
}
/** Render a report as a legible multi-line string for a failed assertion. */
export function formatReport(report: MatrixReport): string {
const lines: string[] = [`node "${report.type}":`];
for (const c of report.combos) {
const flags: string[] = [];
if (c.missing) flags.push("DID-NOT-ROUND-TRIP");
for (const i of c.raw) {
const authored =
i.authored === ABSENT ? "absent" : JSON.stringify(i.authored);
flags.push(
`RAW ${i.type}.${i.attr}: ${authored} -> ${JSON.stringify(i.got)} (expected ${JSON.stringify(i.expected)})`,
);
}
if (c.canonical) {
flags.push(
`CANON @ ${c.canonical.path}: ${JSON.stringify(c.canonical.a)} vs ${JSON.stringify(c.canonical.b)}`,
);
}
const status = flags.length === 0 ? "stable" : flags.join("; ");
lines.push(` [${c.label}] ${status}`);
}
return lines.join("\n");
}
@@ -1,164 +0,0 @@
import { describe, expect, it } from "vitest";
import {
runStabilityMatrix,
unstableCombos,
formatReport,
runConvergenceCase,
convergenceCasesFor,
convergenceOk,
formatConvergence,
type NodeStabilitySpec,
} from "./roundtrip-stability.helper.js";
// ---------------------------------------------------------------------------
// Round-trip STABILITY matrix for image + the media family.
//
// Guards the "empty-string-vs-absent" churn class (GS-EDIT-REVERT family): a
// stored node authored WITHOUT a string attr (alt/title/caption/aria-label/...)
// must not gain a phantom `attr: ""` after `markdownToProseMirror(convert…)`.
// Each spec sweeps the at-risk string attrs at DEFAULT (absent) and at a real
// NON-default value; the helper asserts both the RAW round-trip (attrs equal the
// input's, modulo the documented numeric width/height/size/aspectRatio -> string
// coercion) and the CANONICAL round-trip (canonical forms deep-equal).
//
// The image + media family share the `align !== "center"` predicate and the
// `<!--name {…}-->` comment machinery, so one matrix guards the shared class.
// align is NOT part of this class (it round-trips correctly) and is not swept.
// ---------------------------------------------------------------------------
const SPECS: NodeStabilitySpec[] = [
{
// Image carries the most at-risk string attrs. `alt` is the one marked
// materializes as `<img alt="">` on `![](src)` import (the real bug); title
// and caption are covered as the same class. attachmentId is a string attr
// that must stay absent when unset (control).
type: "image",
baseAttrs: { src: "/i.png" },
attrMatrix: [
{ attr: "alt", default: undefined, nonDefault: "a real alt text", emptyStringClass: true },
{ attr: "title", default: undefined, nonDefault: "a real title", emptyStringClass: true },
{ attr: "caption", default: undefined, nonDefault: "a real caption" },
{ attr: "attachmentId", default: undefined, nonDefault: "att-42" },
],
},
{
// Video's `alt` rides the `aria-label` attribute (media aria-label at risk).
type: "video",
baseAttrs: { src: "/v.mp4" },
attrMatrix: [
{ attr: "alt", default: undefined, nonDefault: "a clip", emptyStringClass: true },
{ attr: "attachmentId", default: undefined, nonDefault: "att-1" },
],
},
{
// Audio carries no alt/title; attachmentId is its only optional string attr.
type: "audio",
baseAttrs: { src: "/a.mp3" },
attrMatrix: [
{ attr: "attachmentId", default: undefined, nonDefault: "att-2" },
],
},
{
// pdf: link-form media. `name` (filename) is its at-risk string attr.
type: "pdf",
baseAttrs: { src: "/d.pdf" },
attrMatrix: [
{ attr: "name", default: undefined, nonDefault: "report.pdf", emptyStringClass: true },
{ attr: "attachmentId", default: undefined, nonDefault: "att-3" },
],
},
{
// attachment: link-form media (file card). `name` + `mime` string attrs.
type: "attachment",
baseAttrs: { url: "/f.zip" },
attrMatrix: [
{ attr: "name", default: undefined, nonDefault: "bundle.zip", emptyStringClass: true },
{ attr: "mime", default: undefined, nonDefault: "application/zip", emptyStringClass: true },
{ attr: "attachmentId", default: undefined, nonDefault: "att-4" },
],
},
{
// embed: link-form media. `provider` is its at-risk string attr (schema
// default ""). embed's numeric width/height defaults (800/600) are a SEPARATE,
// documented limitation OUTSIDE the empty-string class: they are not in
// canonicalize's KNOWN_DEFAULTS, so an ABSENT width/height re-imports as the
// 800/600 default and diverges canonically (see the note in canonicalize.ts).
// That is canonicalize-owned and out of scope here, so we author the
// dimensions at their defaults (as real editor embeds carry them) to keep this
// guard focused on the empty-string/provider class.
// provider's schema default is "" (NOT null), so a re-imported "" is the
// correct value, not a phantom — it is outside the null-default empty-string
// class. We author it at its "" default (the default pick) so the sweep still
// asserts a non-default provider ("youtube") round-trips, without tripping the
// canonicalize KNOWN_DEFAULTS gap for embed's non-null defaults.
type: "embed",
baseAttrs: { src: "https://example.com/x", width: 800, height: 600 },
attrMatrix: [
{ attr: "provider", default: "", nonDefault: "youtube" },
],
},
{
// drawio: image-form diagram. `title` + `alt` string attrs (data-title/-alt).
type: "drawio",
baseAttrs: { src: "blob:drawio" },
attrMatrix: [
{ attr: "title", default: undefined, nonDefault: "flow chart", emptyStringClass: true },
{ attr: "alt", default: undefined, nonDefault: "an alt", emptyStringClass: true },
{ attr: "attachmentId", default: undefined, nonDefault: "att-5" },
],
},
{
// excalidraw: image-form diagram, same shared diagramAttributes set.
type: "excalidraw",
baseAttrs: { src: "blob:excalidraw" },
attrMatrix: [
{ attr: "title", default: undefined, nonDefault: "sketch", emptyStringClass: true },
{ attr: "alt", default: undefined, nonDefault: "an alt", emptyStringClass: true },
{ attr: "attachmentId", default: undefined, nonDefault: "att-6" },
],
},
];
describe("round-trip stability matrix (image + media family)", () => {
for (const spec of SPECS) {
it(`${spec.type}: no attr materializes an empty-string / phantom value`, async () => {
const report = await runStabilityMatrix(spec);
const unstable = unstableCombos(report);
// On failure, print the WHOLE matrix so which (attr, value) combos are
// unstable is legible.
expect(unstable, `\n${formatReport(report)}\n`).toEqual([]);
});
}
});
// ---------------------------------------------------------------------------
// THIRD STATE: an attr EXPLICITLY stored as a literal "" (GS-EDIT-REVERT: a user
// typed alt/title/name/... then deleted it, so Tiptap persisted `attr: ""` — a
// value DISTINCT from "attr was never set"). Unlike the absent case above, this
// state is NOT first-pass byte-stable: the fix's `"" -> default` coercion is a
// deliberate ONE-TIME normalization on the FIRST sync round-trip, stable
// thereafter. We therefore assert a DIFFERENT contract — "converges to default
// on pass 1, then idempotent from pass 2 on" — for every empty-string-class attr
// across the whole node family (image/video/pdf/attachment/drawio/excalidraw).
//
// IMPORTANT for a future sync/QA pass: the pass-1 `"" -> null` diff is the
// converter canon, not corruption. It appears at most once per affected node and
// must NOT be flagged as "the converter is losing/corrupting page data".
// ---------------------------------------------------------------------------
describe("round-trip third state: explicit empty string converges once, then idempotent", () => {
for (const spec of SPECS) {
for (const attr of convergenceCasesFor(spec)) {
it(`${spec.type}.${attr}: "" normalizes to default on pass 1, byte-stable from pass 2`, async () => {
const r = await runConvergenceCase(spec, attr);
// Pass 1 must converge "" -> the schema default (the one-time diff) and
// pass 2 (roundtrip of pass-1 output) must be byte-stable. formatConvergence
// prints exactly which half failed.
expect(convergenceOk(r), `\n${formatConvergence(r)}\n`).toBe(true);
// Spell the contract out explicitly so the intent is legible in the test:
expect(r.convergedToDefault, `\n${formatConvergence(r)}\n`).toBe(true);
expect(r.firstPassValue).toEqual(r.expectedDefault);
expect(r.secondPassDivergence, `\n${formatConvergence(r)}\n`).toBeNull();
});
}
}
});
+127 -3
View File
@@ -501,6 +501,9 @@ importers:
vite:
specifier: 8.0.5
version: 8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)
vite-plugin-compression2:
specifier: 2.5.3
version: 2.5.3
vitest:
specifier: 4.1.6
version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@25.0.0)(vite@8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))
@@ -540,6 +543,9 @@ importers:
'@docmost/pdf-inspector':
specifier: 1.9.6
version: 1.9.6
'@fastify/compress':
specifier: ^9.0.0
version: 9.0.0
'@fastify/cookie':
specifier: ^11.0.2
version: 11.0.2
@@ -2694,6 +2700,9 @@ packages:
'@fastify/busboy@3.1.1':
resolution: {integrity: sha512-5DGmA8FTdB2XbDeEwc/5ZXBl6UbBAyBOOLlPuBnZ/N1SwdH9Ii+cOX3tBROlDgcTXxjOYnLMVoKk9+FXAw0CJw==}
'@fastify/compress@9.0.0':
resolution: {integrity: sha512-PZRg+ut5xd/ubsGPWfoPNryoCOtEdHboIWpDieTUHov1gKdLitF8mRmT3JbqNnRbelQXSNXUsIpakAEKR6AcTQ==}
'@fastify/cookie@11.0.2':
resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==}
@@ -4487,6 +4496,15 @@ packages:
'@rolldown/pluginutils@1.0.0-rc.7':
resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==}
'@rollup/pluginutils@5.4.0':
resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==}
engines: {node: '>=14.0.0'}
peerDependencies:
rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0
peerDependenciesMeta:
rollup:
optional: true
'@selderee/plugin-htmlparser2@0.11.0':
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
@@ -5676,6 +5694,10 @@ packages:
resolution: {integrity: sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==}
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
abstract-logging@2.0.1:
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
@@ -6055,6 +6077,9 @@ packages:
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
bullmq@5.76.10:
resolution: {integrity: sha512-LWve7SpQjYSpCP2GEsWmoyzTz2H37L8HRmSTu3YihYsTOr5kJxrfEX6aEV7m6eskEMWXSHZYTMZepX6qNaH6CQ==}
engines: {node: '>=12.22.0'}
@@ -6810,6 +6835,9 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
duplexify@3.7.1:
resolution: {integrity: sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==}
ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
@@ -7053,6 +7081,9 @@ packages:
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
engines: {node: '>=4.0'}
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
@@ -7064,6 +7095,10 @@ packages:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
event-target-shim@5.0.1:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
eventemitter2@6.4.9:
resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==}
@@ -9073,6 +9108,9 @@ packages:
peberminta@0.9.0:
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
peek-stream@1.1.3:
resolution: {integrity: sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==}
pend@1.2.0:
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
@@ -9318,6 +9356,10 @@ packages:
process-warning@5.0.0:
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
process@0.11.10:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
prompts@2.4.2:
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
engines: {node: '>= 6'}
@@ -9609,6 +9651,10 @@ packages:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
readable-stream@4.7.0:
resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
readdirp@3.6.0:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
@@ -9997,6 +10043,9 @@ packages:
stream-browserify@3.0.0:
resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==}
stream-shift@1.0.3:
resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==}
strict-event-emitter-types@2.0.0:
resolution: {integrity: sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==}
@@ -10137,6 +10186,9 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
tar-mini@0.2.0:
resolution: {integrity: sha512-+qfUHz700DWnRutdUsxRRVZ38G1Qr27OetwaMYTdg8hcPxf46U0S1Zf76dQMWRBmusOt2ZCK5kbIaiLkoGO7WQ==}
tar-stream@2.2.0:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'}
@@ -10177,6 +10229,9 @@ packages:
resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==}
engines: {node: '>=18'}
through2@2.0.5:
resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@@ -10582,6 +10637,9 @@ packages:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
vite-plugin-compression2@2.5.3:
resolution: {integrity: sha512-ItPgqQWkcnBbVw7is9OKwiZ8v6+ju9rYROl5Lp6QfQDEx/d55AwJQb/KLpsQqsU9HoigYBsZ8tK6I02UwJNvEw==}
vite@8.0.5:
resolution: {integrity: sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -12921,6 +12979,15 @@ snapshots:
'@fastify/busboy@3.1.1': {}
'@fastify/compress@9.0.0':
dependencies:
'@fastify/accept-negotiator': 2.0.1
fastify-plugin: 5.1.0
mime-db: 1.54.0
minipass: 7.1.3
peek-stream: 1.1.3
readable-stream: 4.7.0
'@fastify/cookie@11.0.2':
dependencies:
cookie: 1.1.1
@@ -14936,6 +15003,12 @@ snapshots:
'@rolldown/pluginutils@1.0.0-rc.7': {}
'@rollup/pluginutils@5.4.0':
dependencies:
'@types/estree': 1.0.9
estree-walker: 2.0.2
picomatch: 4.0.4
'@selderee/plugin-htmlparser2@0.11.0':
dependencies:
domhandler: 5.0.3
@@ -16153,7 +16226,7 @@ snapshots:
obug: 2.1.1
std-env: 4.1.0
tinyrainbow: 3.1.0
vitest: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@27.4.0(@noble/hashes@2.0.1))(vite@8.0.5(@types/node@25.5.0)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))
vitest: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@25.0.0)(vite@8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/expect@4.1.6':
dependencies:
@@ -16309,6 +16382,10 @@ snapshots:
abbrev@5.0.0: {}
abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1
abstract-logging@2.0.1: {}
accepts@1.3.8:
@@ -16755,6 +16832,11 @@ snapshots:
base64-js: 1.5.1
ieee754: 1.2.1
buffer@6.0.3:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
bullmq@5.76.10:
dependencies:
cron-parser: 4.9.0
@@ -17508,6 +17590,13 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
duplexify@3.7.1:
dependencies:
end-of-stream: 1.4.4
inherits: 2.0.4
readable-stream: 2.3.8
stream-shift: 1.0.3
ecdsa-sig-formatter@1.0.11:
dependencies:
safe-buffer: 5.2.1
@@ -17934,6 +18023,8 @@ snapshots:
estraverse@5.3.0: {}
estree-walker@2.0.2: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.9
@@ -17942,6 +18033,8 @@ snapshots:
etag@1.8.1: {}
event-target-shim@5.0.1: {}
eventemitter2@6.4.9: {}
eventemitter3@4.0.7: {}
@@ -20205,6 +20298,12 @@ snapshots:
peberminta@0.9.0: {}
peek-stream@1.1.3:
dependencies:
buffer-from: 1.1.2
duplexify: 3.7.1
through2: 2.0.5
pend@1.2.0: {}
perfect-freehand@1.2.0: {}
@@ -20476,6 +20575,8 @@ snapshots:
process-warning@5.0.0: {}
process@0.11.10: {}
prompts@2.4.2:
dependencies:
kleur: 3.0.3
@@ -20892,6 +20993,14 @@ snapshots:
string_decoder: 1.3.0
util-deprecate: 1.0.2
readable-stream@4.7.0:
dependencies:
abort-controller: 3.0.0
buffer: 6.0.3
events: 3.3.0
process: 0.11.10
string_decoder: 1.3.0
readdirp@3.6.0:
dependencies:
picomatch: 2.3.2
@@ -21345,6 +21454,8 @@ snapshots:
inherits: 2.0.4
readable-stream: 3.6.2
stream-shift@1.0.3: {}
strict-event-emitter-types@2.0.0: {}
string-length@4.0.2:
@@ -21505,6 +21616,8 @@ snapshots:
tapable@2.3.0: {}
tar-mini@0.2.0: {}
tar-stream@2.2.0:
dependencies:
bl: 4.1.0
@@ -21550,6 +21663,11 @@ snapshots:
throttleit@2.1.0: {}
through2@2.0.5:
dependencies:
readable-stream: 2.3.8
xtend: 4.0.2
tiny-invariant@1.3.3: {}
tinybench@2.9.0: {}
@@ -21954,6 +22072,13 @@ snapshots:
vary@1.1.2: {}
vite-plugin-compression2@2.5.3:
dependencies:
'@rollup/pluginutils': 5.4.0
tar-mini: 0.2.0
transitivePeerDependencies:
- rollup
vite@8.0.5(@types/node@20.19.43)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
lightningcss: 1.32.0
@@ -22334,8 +22459,7 @@ snapshots:
xpath@0.0.34: {}
xtend@4.0.2:
optional: true
xtend@4.0.2: {}
y-indexeddb@9.0.12(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810)):
dependencies: