Compare commits

..

15 Commits

Author SHA1 Message Date
agent_coder 51ded06fde fix(#342 review round-2 F5-F6): drop the posthog re-render remount + test chunk detector
- F5 [stability/regression]: the round-1 F2 fix re-rendered the root with
  <PostHogProvider><App/></PostHogProvider> after the analytics chunk loaded. In
  the ChunkLoadErrorBoundary child slot the element TYPE changes App ->
  PostHogProvider, so React does NOT reconcile in place — it REMOUNTS the whole
  App: every mount effect runs twice (websocket connect/disconnect, origin
  tracking, subscriptions) and local state / focus / scroll / in-progress input is
  lost on cloud cold-load (e.g. typing in /login before analytics loads). And it
  was USELESS: the app has ZERO consumers of the PostHog React context (no
  usePostHog / useFeatureFlag* / PostHogFeature), and PostHogProvider given an
  initialized client is a no-op — all capture goes through the posthog singleton.
  Fix: initAnalytics now inits the posthog SINGLETON only (no posthog-js/react
  import, no second render); renderApp() renders <App/> once. First paint stays
  instant, cloud analytics behavior unchanged, no remount.
- F6 [test]: exported isChunkLoadError + chunk-load-error-boundary.test.ts —
  pins the detector (ChunkLoadError name + the 3 dynamic-import failure messages,
  case-insensitive → true; null/undefined/ordinary errors → false) so a
  false-negative that re-blanks the app on a real chunk-404 is caught.

Gate: client tsc 0, chunk-load + sanitize tests 14 passed. Entry chunk unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 04:18:29 +03:00
agent_coder 456a91d289 fix(#342 review F1-F4): chunk-load error boundary + non-blocking posthog + tests
- F1 [HIGH]: added a root ChunkLoadErrorBoundary (react-error-boundary) wrapping
  the routed app in main.tsx, ABOVE all the route-level/Aside/AiChatWindow
  Suspense boundaries. A stale-deploy chunk 404 (React.lazy reject) is caught and
  auto-reloads once (sessionStorage-guarded against a reload loop), else shows a
  manual "new version available" reload UI — instead of unmounting the whole tree
  to a white screen. Existing per-feature ErrorBoundaries untouched.
- F2 [MED-HIGH]: posthog no longer blocks/blanks the cloud first paint. main.tsx
  now renders <App/> immediately for everyone, then `void initAnalytics()` — which
  keeps the exact cloud gate, dynamically imports posthog, and RE-RENDERS the same
  React root wrapped in PostHogProvider (React reconciles onto the painted DOM, so
  cloud ends up wrapped exactly as before). The import+init is try/catch'd: a
  failed analytics chunk (network / stale-404 / ad-blocker on a "posthog" chunk)
  degrades to no-analytics instead of a permanently blank page.
- F3: sanitize-url.test.ts mirroring editor-ext's security contract (javascript:/
  data:/vbscript:/obfuscated → ""; https/relative/mailto preserved).
- F4: the idle-warm `void import(...)` prefetch in layout.tsx gets `.catch(()=>{})`
  so a failed best-effort prefetch can't surface as an unhandledrejection.

No new deps (lockfile unchanged). Gate: client tsc 0, sanitize test 3/3, client
build succeeds (entry chunk still 556K, posthog in separate dynamic chunks).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 04:18:29 +03:00
agent_coder 515c08afed perf(client): route + component code-splitting — eager JS 3.5MB -> 1.12MB (#342)
Everything sat in the eager startup graph (App.tsx statically imported all 28
routes; the editor pulled TipTap + KaTeX + ~45 lowlight grammars + drawio;
posthog + AI SDK loaded for everyone) — a /login visitor downloaded+compiled the
whole editor. Client-only; functionality 1:1, only WHEN code loads changed.

Result (prod build): eager JS 3.5MB -> ~1.12MB, entry 1920KB -> 552KB; KaTeX
(250KB) and the TipTap engine (~586KB) are now lazy chunks, off the startup path.

- App.tsx: route-level React.lazy + Suspense (editor Page, all settings/*, share,
  space/home routes). Auth/redirect/cold-start routes stay eager. Suspense lives
  inside Layout/ShareLayout around the Outlet so the shell stays mounted.
- Lazy KaTeX node views (math-inline-lazy/math-block-lazy) + lazy drawio
  (drawio-view-lazy/drawio-menu-lazy), mirroring mermaid/excalidraw, each with a
  node-sized Suspense placeholder so a slow chunk can't crash the editor.
- posthog-js is now a conditional dynamic import under the unchanged
  isCloud() && isPostHogEnabled gate — self-hosted never downloads it.
- AiChatWindow is React.lazy, mounted on first open and kept mounted (a live AI
  stream isn't torn down); renders null while closed (identical behavior).
- Cut eager TipTap pulls from always-loaded shell modules: editor-atoms /
  global-bridge Editor -> import type; Aside lazily loaded (page routes only);
  config.ts sanitizeUrl and use-clipboard execCommandCopy moved to client-local
  src/lib/{sanitize-url,copy-to-clipboard}.ts (byte-identical to the editor-ext
  originals, dropping the barrel's top-level @tiptap import); WebSocketStatus
  import replaced with the "connected" literal the status atom already stores.
- vite.config.ts: a vendor-katex chunk group (TipTap/PM/Yjs intentionally NOT
  grouped — grouping dragged the engine eager; documented in the config).
- lowlight grammar registration is left inside the (now-lazy) editor chunk:
  listLanguages()/highlighting are synchronous, so deferring registration would
  change behavior for marginal in-chunk gain — the route split already removes it
  from startup, which was the complaint.

Gate: client build succeeds, tsc --noEmit clean, frozen install EXIT 0 (added
@braintree/sanitize-url as a direct client dep + regenerated the lock).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 04:18:29 +03:00
vvzvlad 3085ec1b50 Merge pull request 'fix(metrics): статика в bounded «static»-лейбл — кардинальность route (#362)' (#366) from fix/362-metrics-route-cardinality into develop
Reviewed-on: #366
2026-07-06 03:55:02 +03:00
agent_vscode 05ec9feaf9 test(int): unhang test:int — forceExit + bounded destroyTestDb (#382)
Once the ESM transform fix (8e125799) let the four previously-unparseable
int-specs run, the server integration suite stopped exiting: all 66 tests pass,
then jest prints "Jest did not exit one second after the test run has
completed." and idles until the 20-minute job timeout kills it. Separately,
ai-chat-stream.int-spec.ts failed because its afterAll (destroyTestDb) hit the
60s hook timeout — postgres.js .end() waits for in-flight queries forever, so a
leaked/stuck pooled connection hung teardown.

Pragmatic unblock (the underlying open-handle leak is tracked in #382):

- jest-integration.json: add forceExit so jest always exits after the run even
  if a suite leaves an open handle.
- db.ts: capture the singleton's raw postgres sql instance and bound the pool
  shutdown with sql.end({ timeout: 5 }) (the same bounded-end pattern already
  used in global-setup.ts) instead of Kysely.destroy(), so destroyTestDb can no
  longer hang the afterAll hook.

forceExit masks residual handle leaks rather than fixing them; the proper
investigation (run with --detectOpenHandles on a pg+redis stand, close the
leaking timers/sockets, then drop forceExit) is filed as #382.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 01:28:31 +03:00
agent_vscode 8e12579925 fix(ci): transform ESM @docmost/prosemirror-markdown in server int/e2e jest
The develop build failed in two jobs, both rooted in the ESM-only workspace
package @docmost/prosemirror-markdown (type: module, built to build/index.js
with `export * from`), which the server imports at runtime (collaboration.util,
page.service). Refactor #345 taught only the unit jest config (package.json
"jest" key) to consume it, leaving the integration and e2e configs — and the
e2e-server CI job — broken:

- `pnpm --filter server test:int` -> SyntaxError: Unexpected token 'export'
  (jest did not transform prosemirror-markdown/build/*.js).
- e2e-server job -> TS2307 Cannot find module '@docmost/prosemirror-markdown'
  (the package was never built in that job).

Mirror the proven unit config into the two failing jest configs and add the
missing build step:

- jest-integration.json / jest-e2e.json: add a babel-jest transform rule for
  `prosemirror-markdown/build/.+\.js$` (before the ts-jest rule so it wins) and
  add @docmost/prosemirror-markdown to the transformIgnorePatterns allowlist so
  the pnpm-symlinked package is transformed instead of ignored.
- develop.yml: build @docmost/prosemirror-markdown in the e2e-server job (after
  editor-ext, before migrations), like the test.yml job already does.

Verified locally: an isolated spec importing the package fails with the exact
SyntaxError under the old config and passes under the new one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 00:36:31 +03:00
vvzvlad 20703d06c2 Merge pull request 'feat(gitmost-bridge): вставка transcript в страницу записи (#377)' (#378) from fix/377-bridge-transcript into develop
Reviewed-on: #378
2026-07-06 00:06:59 +03:00
agent_coder dab2660999 fix(gitmost-bridge): нейтрализовать сплошные thematic breaks в транскрипте
Round-1 нейтрализация ловила только spaced-разделители (`- - -`),
но пропускала сплошные `---`/`***`/`___`: на git-sync round-trip такая
строка становится horizontalRule, а он не несёт текста — строка терялась
целиком (хуже list/quote-порчи). Символ `_` вообще отсутствовал в regexе.

GITMOST_MD_BLOCK_TRIGGER_RE дополнен альтернативой на целую строку-
thematic-break `([-*_])(?:\s*\1){2,}\s*$` (3+ одинаковых `-`/`*`/`_`,
solid или через пробел); прежние группы переведены в non-capturing,
чтобы `\1` ссылался на единственную capture-группу. Обе копии regexа
(bridge и pm-markdown тест) синхронны.

Тесты: bare `---`/`***`/`___` документированы как text-losing
horizontalRule; ZWSP-нейтрализованная форма round-trip'ится параграфом
с сохранённым текстом. Кейсы падают на старом regexе.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 00:02:29 +03:00
agent_coder 751d55e9db fix(gitmost-bridge): neutralize col-0 block triggers in transcript lines + lock text-not-HTML (#378 review round 1)
Round-1 review found two issues:
- [regressions] Inserting a transcript line VERBATIM as a paragraph is unsafe on the
  git-sync doc->markdown->doc round-trip: the paragraph serializer emits text with no
  block-escape, so a line starting at col 0 with `- `/`> `/`# `/`1. `/```` ``` ````/`|`/
  `> [!info]` silently re-parses into a list/quote/heading/code/table/callout (the last
  hits the #359 callout machinery). Safe under the host contract (every line prefixed
  `You:`/`Speaker N:`, which starts with a letter) but the helper didn't enforce it, and
  leading-whitespace / stray lines exist. Fix: trim each kept line (drops the indent leak),
  and if it STILL begins with a col-0 markdown block trigger, prepend an invisible
  zero-width space (U+200B) so the trigger isn't at col 0 — the round-trip keeps it a
  paragraph. (Backslash-escape was rejected: `marked` consumes the `\` on re-import, so it
  would render visibly then vanish. ZWSP is invisible and never markdown-escaped.) The
  serializer's missing block-escape is the pre-existing root cause; this is the boundary
  defense. Prefixed transcript lines never match the trigger regex → left byte-exact.
- [test-coverage] The "inserted as TEXT, not HTML/markdown" contract wasn't locked (all
  test strings were plain alphabet). Added a test that inserts `<b>bold</b>
  <script>alert(1)</script> and *stars* and [link](x)` (with Bold/Italic/Link marks
  registered so an insertContent(html) regression would parse them) and asserts one
  verbatim text node, no marks, and getHTML() has no live tags.

Verified: apps/client tsc --noEmit 0 errors; gitmost bridge tests 4 passed; a NEW
prosemirror-markdown round-trip test (real convertProseMirrorToMarkdown ->
markdownToProseMirror) proves bare triggers corrupt while the ZWSP-neutralized form stays
a single byte-preserved paragraph and normal You:/Speaker N: lines round-trip byte-exact —
3 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 00:01:25 +03:00
agent_coder 02308012a6 feat(gitmost-bridge): insert transcript into the recording page (#377)
The native gitmost.app (stage 2) now sends a `transcript` field to
window.gitmost.createPageWithRecording, but the web bridge ignored it — the page
was created with audio only. Insert it.

- gitmost-recording.ts: add `transcript?: string` to GitmostCreatePagePayload
  (plain text, \n-separated `You:` / `Speaker N:` lines, ready to insert). Add a
  gitmostInsertTranscriptIntoEditor(editor, transcript) helper: a "Transcript"
  heading + one paragraph per non-empty line, each line inserted VERBATIM as a
  text node (never HTML → no injection), appended at doc end (below the audio).
  No-op when transcript is undefined/empty/whitespace-only/non-string.
- gitmost-global-bridge.tsx (createPageWithRecording): after the audio insert
  succeeds, call the helper inside a try/catch — best-effort, so a transcript
  failure can never turn the already-successful recording into an error (logs +
  still returns ok). Absent transcript → audio-only page, exactly as today.

DoD: recording with speech → page has audio AND a labeled transcript block;
without transcript → unchanged. Closes the web-side dependency of gitmost-app
stage 2. Verified: apps/client tsc --noEmit 0 errors; 2 unit tests
(transcript present → heading+paragraphs; undefined/""/whitespace/number/object
/null → no-op, doc unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 00:01:25 +03:00
vvzvlad 0665fcb630 Merge pull request 'fix(queue): убрать мёртвую очередь {search-queue} (#379)' (#380) from fix/379-remove-dead-search-queue into develop
Reviewed-on: #380
2026-07-05 23:44:54 +03:00
agent_coder 97bd554cb5 fix(queue): remove the dead {search-queue} — producers with no consumer (#379)
The {search-queue} BullMQ queue had producers on every page/space/workspace change
but NO consumer/@Processor — it lives in Docmost's EE edition (external search
drivers), never in this fork. Search works independently via the pages tsvector DB
trigger; the queue was pure dead weight, growing forever (1902 stuck jobs in Redis,
the first real hit of the #355 queue-growing alert — which fired correctly).

Remove the plumbing:
- the 3 producers: drop @InjectQueue(SEARCH_QUEUE) + every searchQueue.add(...) from
  page/space/workspace.listener.ts (and the isTypesense() gate that only wrapped the
  search enqueue). page.listener.handlePageUpdated did ONLY the search enqueue, so its
  @OnEvent(PAGE_UPDATED) handler is removed; the other handlers keep their aiQueue.add.
- the registration (queue.module.ts) and the metrics injection + 'search' depth-metric
  entry (metrics-bull.service.ts).
- the constants: QueueName.SEARCH_QUEUE + the 6 unused SEARCH_INDEX_* QueueJob members
  (grep-confirmed unreferenced). Shared QueueJob members (PAGE_*, SPACE_DELETED,
  WORKSPACE_DELETED — used by the AI queue / embedding/history/notification processors)
  are kept.

Search (tsvector trigger, search.service) and the PAGE_UPDATED websocket listener are
untouched. Verified: apps/server tsc --noEmit 0 errors; 6 spec suites / 52 tests green.

Ops (maintainer, out of code scope): one-time Redis cleanup
`redis-cli --scan --pattern "bull:{search-queue}:*" | xargs -r redis-cli del`, then
confirm bullmq_queue_depth{queue="search"} stays 0 (the metric no longer injects it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 23:35:04 +03:00
vvzvlad f77a6b42de Merge pull request 'docs: how to test the application (browser E2E + out-of-band)' (#376) from docs/how-to-test into develop
Reviewed-on: #376
2026-07-05 22:40:12 +03:00
agent_coder 43b11d92ab fix(#362 review F1-F3): add /icons/ prefix + honest comment + real boundary test
- F1: added '/icons/' to STATIC_PATH_PREFIXES — public/icons/ is copied verbatim
  to client/dist (a sibling of the already-included brand/vad/locales), and
  index.html references /icons/favicon-*.png on every page load, so those requests
  were getting their own route labels instead of collapsing to `static`.
- F2: corrected the comment — only /assets/ is content-hashed (unbounded per
  deploy); /vad//brand//locales//icons/ have stable names (repetitive, not
  unbounded). Either way none belong in the API-route histogram.
- F3: the negative test now exercises the trailing-slash boundary (the actual
  anti-false-collapse guard): '/assets' (no slash), '/assetsx/foo.js',
  '/iconset/x.png' must NOT collapse to `static` — cases that a buggy
  includes()/slashless-prefix impl would wrongly collapse. Plus '/icons/*' added
  to the positive it.each.

Gate: server tsc 0; metrics.spec passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 02:38:47 +03:00
agent_coder f759084f41 fix(metrics): collapse static-asset routes to bounded "static" label (#362)
Follow-up to #355: http_request_duration_seconds's `route` label captured raw
content-hashed asset filenames (route="/assets/index-CAbxDtto.js",
"/assets/chunk-*.js"). @fastify/static serves each file through a route whose
matched routeOptions.url IS the raw hashed path, so the label was unbounded — a
new set of names every deploy, growing the series forever (the exact cardinality
leak the API routes were protected against).

resolveRouteLabel now detects a static request by its path prefix (/assets/,
/vad/, /brand/, /locales/) FIRST and collapses it to a single `static` label
(query string stripped before the check); API routes still use the template and
404s still collapse to `unknown`. Static edge latency is already measured by
Traefik's traefik_router_request_duration_*.

Gate: server tsc 0; metrics.spec passes (added static-collapse + query-strip +
"real API route mentioning assets is NOT collapsed" cases).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 01:44:16 +03:00
38 changed files with 1000 additions and 170 deletions
+6
View File
@@ -151,6 +151,12 @@ jobs:
- name: Build editor-ext - name: Build editor-ext
run: pnpm --filter @docmost/editor-ext build run: pnpm --filter @docmost/editor-ext build
# @docmost/prosemirror-markdown is an ESM workspace package the server
# imports at runtime; its build/ is gitignored and test:e2e has no pretest
# hook, so build it before the e2e run (mirrors the test.yml job).
- name: Build prosemirror-markdown
run: pnpm --filter @docmost/prosemirror-markdown build
- name: Run migrations - name: Run migrations
run: pnpm --filter ./apps/server migration:latest run: pnpm --filter ./apps/server migration:latest
+1
View File
@@ -13,6 +13,7 @@
}, },
"dependencies": { "dependencies": {
"@ai-sdk/react": "^3.0.208", "@ai-sdk/react": "^3.0.208",
"@braintree/sanitize-url": "7.1.2",
"@atlaskit/pragmatic-drag-and-drop": "1.8.1", "@atlaskit/pragmatic-drag-and-drop": "1.8.1",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "2.1.5", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "2.1.5",
"@atlaskit/pragmatic-drag-and-drop-flourish": "2.0.15", "@atlaskit/pragmatic-drag-and-drop-flourish": "2.0.15",
+58 -24
View File
@@ -1,38 +1,72 @@
import { lazy, Suspense } from "react";
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes } from "react-router-dom";
import { Center, Loader } from "@mantine/core";
import { Error404 } from "@/components/ui/error-404.tsx";
import Layout from "@/components/layouts/global/layout.tsx";
import { useTrackOrigin } from "@/hooks/use-track-origin";
// ShareLayout is route-split: its ShareShell chrome pulls in the table of
// contents (and thus TipTap), so keeping it out of the eager graph removes the
// editor engine from startup for authenticated users too.
const ShareLayout = lazy(
() => import("@/features/share/components/share-layout.tsx"),
);
// Auth / entry pages stay eager: they are the first paint for an unauthenticated
// visitor (e.g. /login) and are already small, so code-splitting them would only
// add a cold-chunk round trip to the most common cold-start path.
import SetupWorkspace from "@/pages/auth/setup-workspace.tsx"; import SetupWorkspace from "@/pages/auth/setup-workspace.tsx";
import LoginPage from "@/pages/auth/login"; import LoginPage from "@/pages/auth/login";
import Home from "@/pages/dashboard/home";
import Page from "@/pages/page/page";
import AccountSettings from "@/pages/settings/account/account-settings";
import WorkspaceMembers from "@/pages/settings/workspace/workspace-members";
import WorkspaceSettings from "@/pages/settings/workspace/workspace-settings";
import AiSettings from "@/pages/settings/workspace/ai-settings";
import Groups from "@/pages/settings/group/groups";
import GroupInfo from "./pages/settings/group/group-info";
import Spaces from "@/pages/settings/space/spaces.tsx";
import { Error404 } from "@/components/ui/error-404.tsx";
import AccountPreferences from "@/pages/settings/account/account-preferences.tsx";
import SpaceHome from "@/pages/space/space-home.tsx";
import PageRedirect from "@/pages/page/page-redirect.tsx";
import Layout from "@/components/layouts/global/layout.tsx";
import InviteSignup from "@/pages/auth/invite-signup.tsx"; import InviteSignup from "@/pages/auth/invite-signup.tsx";
import ForgotPassword from "@/pages/auth/forgot-password.tsx"; import ForgotPassword from "@/pages/auth/forgot-password.tsx";
import PasswordReset from "./pages/auth/password-reset"; import PasswordReset from "./pages/auth/password-reset";
import SharedPage from "@/pages/share/shared-page.tsx"; import PageRedirect from "@/pages/page/page-redirect.tsx";
import Shares from "@/pages/settings/shares/shares.tsx";
import ShareLayout from "@/features/share/components/share-layout.tsx";
import ShareRedirect from "@/pages/share/share-redirect.tsx"; import ShareRedirect from "@/pages/share/share-redirect.tsx";
import { useTrackOrigin } from "@/hooks/use-track-origin";
import SpacesPage from "@/pages/spaces/spaces.tsx"; // Heavy / leaf pages are route-split with React.lazy so their code (most
import SpaceTrash from "@/pages/space/space-trash.tsx"; // importantly the whole TipTap editor + KaTeX + lowlight grammars + drawio that
import FavoritesPage from "@/pages/favorites/favorites-page"; // the page editor and the readonly share editor pull in) is fetched only when
import LabelPage from "@/pages/label/label-page"; // the matching route is actually visited. The <Suspense> boundaries live inside
// each Layout (around its <Outlet/>), so the app shell stays mounted while a
// route chunk loads.
const Home = lazy(() => import("@/pages/dashboard/home"));
const Page = lazy(() => import("@/pages/page/page"));
const SpaceHome = lazy(() => import("@/pages/space/space-home.tsx"));
const SpaceTrash = lazy(() => import("@/pages/space/space-trash.tsx"));
const SpacesPage = lazy(() => import("@/pages/spaces/spaces.tsx"));
const FavoritesPage = lazy(() => import("@/pages/favorites/favorites-page"));
const LabelPage = lazy(() => import("@/pages/label/label-page"));
const SharedPage = lazy(() => import("@/pages/share/shared-page.tsx"));
const AccountSettings = lazy(
() => import("@/pages/settings/account/account-settings"),
);
const AccountPreferences = lazy(
() => import("@/pages/settings/account/account-preferences.tsx"),
);
const WorkspaceSettings = lazy(
() => import("@/pages/settings/workspace/workspace-settings"),
);
const AiSettings = lazy(() => import("@/pages/settings/workspace/ai-settings"));
const WorkspaceMembers = lazy(
() => import("@/pages/settings/workspace/workspace-members"),
);
const Groups = lazy(() => import("@/pages/settings/group/groups"));
const GroupInfo = lazy(() => import("./pages/settings/group/group-info"));
const Spaces = lazy(() => import("@/pages/settings/space/spaces.tsx"));
const Shares = lazy(() => import("@/pages/settings/shares/shares.tsx"));
export default function App() { export default function App() {
useTrackOrigin(); useTrackOrigin();
return ( return (
<> <Suspense
fallback={
<Center h="100vh">
<Loader size="sm" />
</Center>
}
>
<Routes> <Routes>
<Route index element={<Navigate to="/home" />} /> <Route index element={<Navigate to="/home" />} />
<Route path={"/login"} element={<LoginPage />} /> <Route path={"/login"} element={<LoginPage />} />
@@ -83,6 +117,6 @@ export default function App() {
<Route path="*" element={<Error404 />} /> <Route path="*" element={<Error404 />} />
</Routes> </Routes>
</> </Suspense>
); );
} }
@@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";
import { isChunkLoadError } from "./chunk-load-error-boundary";
// The detector decides whether a caught render error is a stale-deploy chunk-404
// (→ auto-reload to fetch the new manifest) vs a genuine app error (→ generic
// recovery UI, no reload). A false negative on a real chunk failure re-blanks the
// app; a false positive would auto-reload on an ordinary error. Pin both sides.
describe("isChunkLoadError", () => {
it("detects the ChunkLoadError name", () => {
expect(isChunkLoadError({ name: "ChunkLoadError", message: "x" })).toBe(true);
});
it.each([
"Failed to fetch dynamically imported module: https://x/assets/index-abc.js",
"error loading dynamically imported module",
"Importing a module script failed.",
])("detects the dynamic-import failure message %#", (message) => {
expect(isChunkLoadError({ name: "TypeError", message })).toBe(true);
});
it("is case-insensitive on the message", () => {
expect(
isChunkLoadError({ message: "FAILED TO FETCH DYNAMICALLY IMPORTED MODULE" }),
).toBe(true);
});
it.each([
null,
undefined,
{},
{ name: "TypeError", message: "Cannot read properties of undefined" },
{ message: "Network request failed" },
new Error("some ordinary render error"),
])("returns false for a non-chunk error %#", (err) => {
expect(isChunkLoadError(err)).toBe(false);
});
});
@@ -0,0 +1,71 @@
import { ReactNode } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { Button, Center, Stack, Text } from "@mantine/core";
const RELOAD_FLAG = "chunk-reload-attempted";
// Heuristic detection of a failed dynamic import. Since the code-splitting work,
// every route (plus Aside / AiChatWindow) is React.lazy: when a new deploy
// replaces the hashed chunks, a tab left open on the old index.html requests a
// chunk URL that now 404s, and React.lazy rejects. Browsers / Vite surface these
// with a ChunkLoadError name or one of these messages.
export function isChunkLoadError(error: unknown): boolean {
if (!error) return false;
const name = (error as { name?: string }).name ?? "";
const message = (error as { message?: string }).message ?? "";
return (
name === "ChunkLoadError" ||
/Failed to fetch dynamically imported module/i.test(message) ||
/error loading dynamically imported module/i.test(message) ||
/Importing a module script failed/i.test(message)
);
}
function handleError(error: unknown) {
if (!isChunkLoadError(error)) return;
// A stale-chunk 404 is cured by a full reload that re-fetches index.html and
// the new chunk manifest. Auto-reload once, guarding against a reload loop
// (e.g. a genuinely missing chunk) with a one-shot sessionStorage flag. If the
// flag is already set we fall through to the manual recovery UI below.
try {
if (sessionStorage.getItem(RELOAD_FLAG)) return;
sessionStorage.setItem(RELOAD_FLAG, "1");
} catch {
// sessionStorage unavailable (private mode / disabled): skip the automatic
// reload rather than risk an unguarded loop; the fallback UI still recovers.
return;
}
window.location.reload();
}
// Root-level boundary that sits ABOVE every route-level Suspense boundary so a
// lazy route/component chunk failure is caught here instead of unmounting the
// whole tree into a blank white screen. Per-feature ErrorBoundaries (page.tsx,
// transclusion, page-embed) remain in place underneath for their local errors.
export function ChunkLoadErrorBoundary({ children }: { children: ReactNode }) {
return (
<ErrorBoundary
onError={handleError}
fallbackRender={({ error }) => {
const chunk = isChunkLoadError(error);
return (
<Center h="100vh" p="md">
<Stack align="center" gap="sm" maw={420}>
<Text fw={600}>
{chunk ? "A new version is available" : "Something went wrong"}
</Text>
<Text size="sm" c="dimmed" ta="center">
{chunk
? "Please reload the page to load the latest version."
: "An unexpected error occurred. Reloading the page may help."}
</Text>
<Button onClick={() => window.location.reload()}>Reload</Button>
</Stack>
</Center>
);
}}
>
{children}
</ErrorBoundary>
);
}
@@ -1,9 +1,10 @@
import { AppShell, Container } from "@mantine/core"; import { AppShell, Container } from "@mantine/core";
import React, { useEffect, useRef, useState } from "react"; import React, { Suspense, useEffect, useRef, useState } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx"; import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
import { useAtom } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { aiChatWindowOpenAtom } from "@/features/ai-chat/atoms/ai-chat-atom.ts";
import { import {
APP_NAVBAR_ID, APP_NAVBAR_ID,
NAVBAR_COLLAPSE_BREAKPOINT, NAVBAR_COLLAPSE_BREAKPOINT,
@@ -14,8 +15,6 @@ import {
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx"; import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
import { AppHeader } from "@/components/layouts/global/app-header.tsx"; import { AppHeader } from "@/components/layouts/global/app-header.tsx";
import Aside from "@/components/layouts/global/aside.tsx";
import AiChatWindow from "@/features/ai-chat/components/ai-chat-window.tsx";
import GitmostGlobalBridge from "@/features/editor/gitmost/gitmost-global-bridge.tsx"; import GitmostGlobalBridge from "@/features/editor/gitmost/gitmost-global-bridge.tsx";
import classes from "./app-shell.module.css"; import classes from "./app-shell.module.css";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
@@ -23,6 +22,21 @@ import GlobalSidebar from "@/components/layouts/global/global-sidebar.tsx";
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx"; import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
import { MAIN_CONTENT_ID, SkipToMain } from "@/components/ui/skip-to-main.tsx"; import { MAIN_CONTENT_ID, SkipToMain } from "@/components/ui/skip-to-main.tsx";
// Lazily load the AI chat window so the AI SDK runtime it pulls in is fetched
// only after the user first opens the chat, instead of for every authenticated
// user on load. The window itself renders null while closed, so there is no
// behavior difference — it simply is not mounted until first opened.
const AiChatWindow = React.lazy(
() => import("@/features/ai-chat/components/ai-chat-window.tsx"),
);
// The right aside hosts the comment panel and table of contents, both of which
// pull in TipTap. It only ever renders on page routes, so lazy-loading it keeps
// the whole editor engine out of the eager global-shell startup graph.
const Aside = React.lazy(
() => import("@/components/layouts/global/aside.tsx"),
);
export default function GlobalAppShell({ export default function GlobalAppShell({
children, children,
}: { }: {
@@ -37,6 +51,15 @@ export default function GlobalAppShell({
const [isResizing, setIsResizing] = useState(false); const [isResizing, setIsResizing] = useState(false);
const sidebarRef = useRef(null); const sidebarRef = useRef(null);
// Latch: once the AI chat window has been opened, keep it mounted so an
// in-flight stream is never torn down. Before the first open the AI chat chunk
// is never fetched.
const aiChatOpen = useAtomValue(aiChatWindowOpenAtom);
const [aiChatEverOpened, setAiChatEverOpened] = useState(false);
useEffect(() => {
if (aiChatOpen) setAiChatEverOpened(true);
}, [aiChatOpen]);
const startResizing = React.useCallback((mouseDownEvent) => { const startResizing = React.useCallback((mouseDownEvent) => {
mouseDownEvent.preventDefault(); mouseDownEvent.preventDefault();
setIsResizing(true); setIsResizing(true);
@@ -160,13 +183,21 @@ export default function GlobalAppShell({
: undefined : undefined
} }
> >
<Aside /> <Suspense fallback={null}>
<Aside />
</Suspense>
</AppShell.Aside> </AppShell.Aside>
)} )}
</AppShell> </AppShell>
{/* Floating AI chat window. Mounted once globally; it is position: fixed {/* Floating AI chat window. Mounted once globally on first open; it is
and self-hides when closed, so its place in the tree is not critical. */} position: fixed and self-hides when closed, so its place in the tree is
<AiChatWindow /> not critical. Kept mounted after the first open so a live stream is not
aborted. */}
{aiChatEverOpened && (
<Suspense fallback={null}>
<AiChatWindow />
</Suspense>
)}
{/* Global gitmost native bridge: registers listSpaces / listPages / {/* Global gitmost native bridge: registers listSpaces / listPages /
createPageWithRecording on window.gitmost so the native host can createPageWithRecording on window.gitmost so the native host can
create a page with a recording even when no page editor is open. */} create a page with a recording even when no page editor is open. */}
@@ -1,5 +1,7 @@
import { Suspense, useEffect } from "react";
import { UserProvider } from "@/features/user/user-provider.tsx"; import { UserProvider } from "@/features/user/user-provider.tsx";
import { Outlet, useParams } from "react-router-dom"; import { Outlet, useParams } from "react-router-dom";
import { Center, Loader } from "@mantine/core";
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx"; import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
import { SearchSpotlight } from "@/features/search/components/search-spotlight.tsx"; import { SearchSpotlight } from "@/features/search/components/search-spotlight.tsx";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts"; import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
@@ -8,10 +10,39 @@ export default function Layout() {
const { spaceSlug } = useParams(); const { spaceSlug } = useParams();
const { data: space } = useGetSpaceBySlugQuery(spaceSlug); const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
// Warm the (now route-split) editor chunk during idle time on authenticated
// routes, so the first navigation to a page renders from cache instead of a
// cold chunk fetch. Best-effort: gated on requestIdleCallback and never blocks
// startup — the dynamic import mirrors the App.tsx route lazy loader so both
// resolve to the same chunk.
useEffect(() => {
const ric =
typeof window !== "undefined" && (window as any).requestIdleCallback;
const warm = () => {
// Best-effort prefetch: a failed warm-up (offline, stale 404) is harmless
// and must not surface as an unhandledrejection.
void import("@/pages/page/page").catch(() => {});
};
if (ric) {
const id = ric(warm);
return () => (window as any).cancelIdleCallback?.(id);
}
const timer = setTimeout(warm, 2000);
return () => clearTimeout(timer);
}, []);
return ( return (
<UserProvider> <UserProvider>
<GlobalAppShell> <GlobalAppShell>
<Outlet /> <Suspense
fallback={
<Center h="60vh">
<Loader size="sm" />
</Center>
}
>
<Outlet />
</Suspense>
</GlobalAppShell> </GlobalAppShell>
<SearchSpotlight spaceId={space?.id} /> <SearchSpotlight spaceId={space?.id} />
</UserProvider> </UserProvider>
@@ -1,5 +1,8 @@
import { atom } from "jotai"; import { atom } from "jotai";
import { Editor } from "@tiptap/core"; // Type-only: these atoms only hold an Editor reference for typing. A value
// import would drag the whole @tiptap/core engine into the eager graph of every
// shell component that reads one of these atoms.
import type { Editor } from "@tiptap/core";
import { PageEditMode } from "@/features/user/types/user.types.ts"; import { PageEditMode } from "@/features/user/types/user.types.ts";
import type { DictationUnavailableReason } from "@/features/dictation/dictation-status"; import type { DictationUnavailableReason } from "@/features/dictation/dictation-status";
@@ -0,0 +1,16 @@
import { lazy, Suspense } from "react";
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
// Lazily load the drawio bubble menu so it is split out of the editor chunk and
// fetched only when an editable editor is mounted (mirrors excalidraw-menu-lazy).
const DrawioMenu = lazy(
() => import("@/features/editor/components/drawio/drawio-menu.tsx"),
);
export default function DrawioMenuLazy(props: EditorMenuProps) {
return (
<Suspense fallback={null}>
<DrawioMenu {...props} />
</Suspense>
);
}
@@ -0,0 +1,17 @@
import { lazy, Suspense } from "react";
import { NodeViewProps } from "@tiptap/react";
// Lazily load the drawio node view so the heavy react-drawio embed runtime is
// split into its own chunk and fetched only when a drawio diagram is actually
// rendered (mirrors excalidraw-view-lazy).
const DrawioView = lazy(
() => import("@/features/editor/components/drawio/drawio-view.tsx"),
);
export default function DrawioViewLazy(props: NodeViewProps) {
return (
<Suspense fallback={null}>
<DrawioView {...props} />
</Suspense>
);
}
@@ -0,0 +1,19 @@
import { lazy, Suspense } from "react";
import { NodeViewProps } from "@tiptap/react";
// Lazily load the KaTeX-backed block math view so the katex chunk is fetched
// only when a document actually contains a math node (mirrors the mermaid/
// excalidraw lazy pattern). The local Suspense keeps a slow katex chunk from
// crashing or blocking the whole editor: while it loads we render the raw
// LaTeX source as a node-sized placeholder.
const MathBlockView = lazy(
() => import("@/features/editor/components/math/math-block.tsx"),
);
export default function MathBlockViewLazy(props: NodeViewProps) {
return (
<Suspense fallback={<div data-katex="true">{props.node.attrs.text}</div>}>
<MathBlockView {...props} />
</Suspense>
);
}
@@ -0,0 +1,19 @@
import { lazy, Suspense } from "react";
import { NodeViewProps } from "@tiptap/react";
// Lazily load the KaTeX-backed inline math view so the katex chunk is fetched
// only when a document actually contains a math node (mirrors the mermaid/
// excalidraw lazy pattern). The local Suspense keeps a slow katex chunk from
// crashing or blocking the whole editor: while it loads we render the raw
// LaTeX source as a node-sized placeholder.
const MathInlineView = lazy(
() => import("@/features/editor/components/math/math-inline.tsx"),
);
export default function MathInlineViewLazy(props: NodeViewProps) {
return (
<Suspense fallback={<span data-katex="true">{props.node.attrs.text}</span>}>
<MathInlineView {...props} />
</Suspense>
);
}
@@ -81,8 +81,8 @@ import {
createResizeHandle, createResizeHandle,
buildResizeClasses, buildResizeClasses,
} from "@/features/editor/components/common/node-resize-handles.ts"; } from "@/features/editor/components/common/node-resize-handles.ts";
import MathInlineView from "@/features/editor/components/math/math-inline.tsx"; import MathInlineView from "@/features/editor/components/math/math-inline-lazy.tsx";
import MathBlockView from "@/features/editor/components/math/math-block.tsx"; import MathBlockView from "@/features/editor/components/math/math-block-lazy.tsx";
import ImageView from "@/features/editor/components/image/image-view.tsx"; import ImageView from "@/features/editor/components/image/image-view.tsx";
import CalloutView from "@/features/editor/components/callout/callout-view.tsx"; import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
import StatusView from "@/features/editor/components/status/status-view.tsx"; import StatusView from "@/features/editor/components/status/status-view.tsx";
@@ -90,7 +90,7 @@ import VideoView from "@/features/editor/components/video/video-view.tsx";
import AudioView from "@/features/editor/components/audio/audio-view.tsx"; import AudioView from "@/features/editor/components/audio/audio-view.tsx";
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx"; import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx"; import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
import DrawioView from "../components/drawio/drawio-view"; import DrawioView from "../components/drawio/drawio-view-lazy.tsx";
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view-lazy.tsx"; import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view-lazy.tsx";
import EmbedView from "@/features/editor/components/embed/embed-view.tsx"; import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
import HtmlEmbedView from "@/features/editor/components/html-embed/html-embed-view.tsx"; import HtmlEmbedView from "@/features/editor/components/html-embed/html-embed-view.tsx";
@@ -1,8 +1,17 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { getDefaultStore } from "jotai"; import { getDefaultStore } from "jotai";
import { WebSocketStatus } from "@hocuspocus/provider";
import { Editor } from "@tiptap/core"; // Literal value of WebSocketStatus.Connected from @hocuspocus/provider. Inlined
// so this always-mounted global bridge does not statically import
// @hocuspocus/provider — that import pulls Yjs (and, through a shared chunk, the
// whole TipTap engine) into the eager startup graph. yjsConnectionStatusAtom
// already stores these raw status strings.
const YJS_STATUS_CONNECTED = "connected";
// Type-only: importing Editor as a type keeps @tiptap/core (the whole editor
// engine) out of the eager global-shell graph — the bridge only uses it for
// annotations/casts, never as a runtime value.
import type { Editor } from "@tiptap/core";
import { import {
pageEditorAtom, pageEditorAtom,
yjsConnectionStatusAtom, yjsConnectionStatusAtom,
@@ -16,15 +25,19 @@ import {
getSidebarPages, getSidebarPages,
} from "@/features/page/services/page-service.ts"; } from "@/features/page/services/page-service.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts";
import { // Types are erased at build time, so importing them does not pull the module's
// runtime (which drags in @tiptap + the editor-ext barrel). The actual recording
// helpers are dynamically imported at call time inside createPageWithRecording,
// keeping the editor engine out of the eager global-shell startup graph — the
// bridge is mounted for every authenticated user but recording is a rare,
// native-host-driven action.
import type {
GitmostBridge, GitmostBridge,
GitmostCreatePagePayload, GitmostCreatePagePayload,
GitmostCreatePageResult, GitmostCreatePageResult,
GitmostListPagesPayload, GitmostListPagesPayload,
GitmostListPagesResult, GitmostListPagesResult,
GitmostListSpacesResult, GitmostListSpacesResult,
gitmostDecodePayloadToFile,
gitmostUploadFileToEditor,
} from "@/features/editor/gitmost/gitmost-recording.ts"; } from "@/features/editor/gitmost/gitmost-recording.ts";
// How long to wait for a freshly-navigated page's editor to mount, become // How long to wait for a freshly-navigated page's editor to mount, become
@@ -57,7 +70,7 @@ function gitmostWaitForEditor(
!editor.isDestroyed && !editor.isDestroyed &&
editor.isEditable && editor.isEditable &&
editorPageId === pageId && editorPageId === pageId &&
yjsStatus === WebSocketStatus.Connected; yjsStatus === YJS_STATUS_CONNECTED;
if (ready) { if (ready) {
resolve(editor); resolve(editor);
return; return;
@@ -171,6 +184,15 @@ export default function GitmostGlobalBridge() {
}; };
} }
// Load the recording helpers on demand (see the import note above). This
// is the only place they are needed, so the @tiptap/editor-ext code they
// pull in stays out of the eager startup graph.
const {
gitmostDecodePayloadToFile,
gitmostUploadFileToEditor,
gitmostInsertTranscriptIntoEditor,
} = await import("@/features/editor/gitmost/gitmost-recording.ts");
// Validate/decode the recording BEFORE creating the page so a bad // Validate/decode the recording BEFORE creating the page so a bad
// payload never leaves an empty junk page behind. Per the createPage // payload never leaves an empty junk page behind. Per the createPage
// error contract, any decode failure collapses to "insert-failed" (the // error contract, any decode failure collapses to "insert-failed" (the
@@ -281,6 +303,18 @@ export default function GitmostGlobalBridge() {
pageId: page.id, pageId: page.id,
}; };
} }
// Best-effort: append the transcript (heading + one paragraph per line)
// below the just-inserted audio node. The audio insert already
// succeeded, so a transcript failure must NOT turn this into an error —
// wrap it and, on any throw, log and still return ok. A missing/empty/
// non-string transcript is a no-op inside the helper (audio only).
try {
gitmostInsertTranscriptIntoEditor(editor, payload?.transcript);
} catch (err) {
console.error("[gitmost] transcript insert failed", err);
}
return { ok: true, pageId: page.id }; return { ok: true, pageId: page.id };
} catch (err: any) { } catch (err: any) {
console.error("[gitmost] createPageWithRecording failed", err); console.error("[gitmost] createPageWithRecording failed", err);
@@ -0,0 +1,150 @@
import { describe, it, expect } from "vitest";
import { Editor } from "@tiptap/core";
import { Document } from "@tiptap/extension-document";
import { Paragraph } from "@tiptap/extension-paragraph";
import { Text } from "@tiptap/extension-text";
import { Heading } from "@tiptap/extension-heading";
import { Bold } from "@tiptap/extension-bold";
import { Italic } from "@tiptap/extension-italic";
import { Link } from "@tiptap/extension-link";
import { gitmostInsertTranscriptIntoEditor } from "./gitmost-recording.ts";
const ZWSP = "​"; // U+200B, the helper's block-trigger neutralizer
/**
* #377 — the web-side bridge must append the native host's transcript below the
* recording. These exercise the pure insert helper through a REAL Tiptap editor
* (Document/Paragraph/Text/Heading + Bold/Italic/Link marks so an HTML-parsing
* regression would be caught), asserting the resulting document rather than
* mocking the editor: transcript present -> "Transcript" heading + one paragraph
* per non-empty line; content is inserted as LITERAL TEXT (no HTML/markdown
* parsing); col-0 markdown block triggers are neutralized so git-sync keeps them
* paragraphs; absent/empty/non-string -> no-op.
*/
describe("gitmostInsertTranscriptIntoEditor", () => {
const makeEditor = () =>
new Editor({
// Bold/Italic/Link are registered specifically so that IF the helper ever
// regressed to inserting an HTML/markdown string (instead of a text node),
// TipTap would parse `<b>`/`*..*`/`[..](..)` into marks and the literal-
// text assertions below would fail.
extensions: [Document, Paragraph, Text, Heading, Bold, Italic, Link],
// Start from a single empty paragraph (a fresh page's baseline). The
// helper appends at the end of the doc, i.e. below existing content.
content: { type: "doc", content: [{ type: "paragraph" }] },
});
it("inserts a Transcript heading + one paragraph per non-empty line, verbatim", () => {
const editor = makeEditor();
const inserted = gitmostInsertTranscriptIntoEditor(
editor,
"You: hello there\nSpeaker 1: hi\n\nYou: bye",
);
expect(inserted).toBe(true);
const nodes = (editor.getJSON().content ?? []) as any[];
// A level-2 "Transcript" heading is present.
const heading = nodes.find((n) => n.type === "heading");
expect(heading?.attrs?.level).toBe(2);
expect(heading?.content?.[0]?.text).toBe("Transcript");
// Every non-empty transcript line becomes a paragraph, in order, verbatim;
// the blank line between them is dropped.
const texts = nodes
.filter((n) => n.type === "paragraph")
.map((n) => n.content?.[0]?.text)
.filter((t) => typeof t === "string");
expect(texts).toEqual(["You: hello there", "Speaker 1: hi", "You: bye"]);
editor.destroy();
});
it("inserts HTML + markdown metacharacters as LITERAL text (no injection / no mark parsing)", () => {
const editor = makeEditor();
const line =
"You: <b>bold</b> <script>alert(1)</script> and *stars* and [link](x)";
const inserted = gitmostInsertTranscriptIntoEditor(editor, line);
expect(inserted).toBe(true);
const paras = (editor.getJSON().content ?? []).filter(
(n: any) => n.type === "paragraph",
) as any[];
// The transcript line is exactly ONE paragraph holding a SINGLE text node
// whose text is the verbatim string — not split into bold/link/other nodes,
// not carrying any marks, not raw HTML. This FAILS if the helper switched to
// insertContent(htmlString): TipTap would then parse <b>/[link](x)/*stars*.
const content = paras[paras.length - 1].content;
expect(content).toHaveLength(1);
expect(content[0].type).toBe("text");
expect(content[0].marks ?? []).toEqual([]);
expect(content[0].text).toBe(line);
// And no bold/italic/link mark exists anywhere in the document.
const html = editor.getHTML();
expect(html).not.toMatch(/<(strong|b|em|i|a)\b/);
// The angle brackets survived as escaped entities (literal text), not a live
// <script>/<b> element.
expect(html).not.toMatch(/<script/i);
editor.destroy();
});
it("neutralizes col-0 markdown block triggers with a leading ZWSP (git-sync safety)", () => {
const editor = makeEditor();
// Trigger lines (some with a leaked indent) + a normal prefixed line.
const inserted = gitmostInsertTranscriptIntoEditor(
editor,
[
"- dash",
" > quote", // leading indent must be trimmed then neutralized
"# hash",
"1. one",
"> [!info] note",
"```js",
"---", // solid thematic break -> horizontalRule (text-losing) if unneutralized
"***",
"___",
"You: normal line",
].join("\n"),
);
expect(inserted).toBe(true);
const texts = (editor.getJSON().content ?? [])
.filter((n: any) => n.type === "paragraph")
.map((n: any) => n.content?.[0]?.text)
.filter((t: any) => typeof t === "string") as string[];
// Every block-trigger line is prefixed with the invisible ZWSP (indent
// trimmed first); the normal `You:` line is left byte-exact.
expect(texts).toEqual([
ZWSP + "- dash",
ZWSP + "> quote",
ZWSP + "# hash",
ZWSP + "1. one",
ZWSP + "> [!info] note",
ZWSP + "```js",
ZWSP + "---",
ZWSP + "***",
ZWSP + "___",
"You: normal line",
]);
editor.destroy();
});
it("is a no-op for undefined / empty / whitespace-only / non-string transcripts", () => {
for (const value of [undefined, "", " \n \n", 42, {}, null]) {
const editor = makeEditor();
const before = JSON.stringify(editor.getJSON());
const inserted = gitmostInsertTranscriptIntoEditor(editor, value as any);
expect(inserted).toBe(false);
// Document is untouched (audio-only behavior preserved).
expect(JSON.stringify(editor.getJSON())).toBe(before);
editor.destroy();
}
});
});
@@ -65,6 +65,11 @@ export interface GitmostCreatePagePayload {
base64: string; base64: string;
filename: string; filename: string;
mimeType: string; mimeType: string;
// Optional transcript for the recording: plain text, `\n`-separated, each
// line already formatted as `You: ...` / `Speaker N: ...` by the native host
// (ready to insert, no parsing needed). Omitted (no speech / no models) ->
// audio only.
transcript?: string;
} }
export interface GitmostCreatePageResult { export interface GitmostCreatePageResult {
@@ -235,6 +240,83 @@ export async function gitmostUploadFileToEditor(
} }
} }
// Zero-width space (U+200B). Prepended to a transcript line that begins with a
// markdown BLOCK trigger: it is invisible in the rendered doc but shifts the
// trigger off column 0, so the git-sync doc->markdown->doc round-trip keeps the
// line a plain paragraph (see GITMOST_MD_BLOCK_TRIGGER_RE).
const GITMOST_ZWSP = "​";
// A markdown BLOCK-level construct that, sitting at column 0 of a paragraph
// line, the git-sync markdown serializer (packages/prosemirror-markdown
// markdown-converter.ts, `case "paragraph"`) would re-parse into a NON-paragraph
// block on the doc->markdown->doc cycle. That serializer emits paragraph text
// verbatim with NO block-escape (the pre-existing root cause), so a leading
// `#`/`-`/`*`/`+`/`>`, an ordered-list `N.`/`N)`, a code fence ```/~~~, a table
// `|`, or a `> [!info]` callout opener would silently become a heading / list /
// quote / code block / table / callout. The final alternative matches a WHOLE-
// LINE thematic break — solid `---`/`***`/`___` or spaced `- - -`/`_ _ _` (3+ of
// the same `-`/`*`/`_`) — which round-trips into a `horizontalRule`; because
// that node carries NO text, an un-neutralized separator line would LOSE its
// text entirely (worse than the list/quote case). This matches a TRIMMED line's
// start; the transcript's own `You:` / `Speaker N:` prefix begins with a letter
// and never matches, so prefixed lines are left byte-exact.
const GITMOST_MD_BLOCK_TRIGGER_RE =
/^(?:#{1,6}(?:\s|$)|[-*+](?:\s|$)|>|\d+[.)](?:\s|$)|```|~~~|\||([-*_])(?:\s*\1){2,}\s*$)/;
// Append a transcript block BELOW the recording's audio node in a live editor:
// a "Transcript" heading followed by one paragraph per non-empty transcript
// line. The transcript is plain text, `\n`-separated, each line already
// formatted as `You: ...` / `Speaker N: ...` by the native host — line text is
// inserted as a TEXT node (never HTML/markdown), so there is no injection or
// mark-parsing surface. Each kept line is trimmed (drops an indent that would
// both leak into the display and, at col 0, form a markdown block trigger) and,
// if it still begins with a col-0 markdown block trigger, gets an invisible
// zero-width space prepended so the git-sync round-trip cannot turn it into a
// list/quote/heading/callout/code/table (defensive boundary against the
// serializer's missing block-escape). This is best-effort and meant to run
// AFTER the audio has already been inserted; the caller must guard against a
// throw so a transcript failure never fails the (already successful) recording.
// Returns true when a block was inserted, false when there was nothing to
// insert (transcript undefined/empty/not-a-string). A non-string value is a
// no-op, not an error.
export function gitmostInsertTranscriptIntoEditor(
editor: Editor,
transcript: unknown,
): boolean {
if (typeof transcript !== "string") return false;
const lines = transcript
.split("\n")
// Trim each line and drop blank (whitespace-only) ones.
.map((line) => line.trim())
.filter((line) => line.length > 0)
// Neutralize a col-0 markdown block trigger with an invisible ZWSP so the
// git-sync round-trip keeps the line a paragraph. Host lines (`You:` /
// `Speaker N:`) never match and stay byte-exact.
.map((line) =>
GITMOST_MD_BLOCK_TRIGGER_RE.test(line) ? GITMOST_ZWSP + line : line,
);
if (lines.length === 0) return false;
const content = [
{
type: "heading",
attrs: { level: 2 },
content: [{ type: "text", text: "Transcript" }],
},
...lines.map((line) => ({
type: "paragraph",
content: [{ type: "text", text: line }],
})),
];
// Append at the end of the document. On a freshly-created recording page the
// audio node is the last block, so the end position places the transcript
// directly below it.
const endPos = editor.state.doc.content.size;
editor.chain().focus().insertContentAt(endPos, content).run();
return true;
}
// Full insert path used by the open-page bridge (insertRecording): guard the // Full insert path used by the open-page bridge (insertRecording): guard the
// editor, validate/decode the payload, then upload. Never throws — resolves to // editor, validate/decode the payload, then upload. Never throws — resolves to
// a result code. // a result code.
@@ -59,7 +59,7 @@ import {
handlePaste, handlePaste,
} from "@/features/editor/components/common/editor-paste-handler.tsx"; } from "@/features/editor/components/common/editor-paste-handler.tsx";
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy"; import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
import DrawioMenu from "./components/drawio/drawio-menu"; import DrawioMenu from "./components/drawio/drawio-menu-lazy";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx"; import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx"; import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks"; import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
@@ -1,10 +1,20 @@
import { Suspense } from "react";
import { Outlet } from "react-router-dom"; import { Outlet } from "react-router-dom";
import { Center, Loader } from "@mantine/core";
import ShareShell from "@/features/share/components/share-shell.tsx"; import ShareShell from "@/features/share/components/share-shell.tsx";
export default function ShareLayout() { export default function ShareLayout() {
return ( return (
<ShareShell> <ShareShell>
<Outlet /> <Suspense
fallback={
<Center h="60vh">
<Loader size="sm" />
</Center>
}
>
<Outlet />
</Suspense>
</ShareShell> </ShareShell>
); );
} }
+1 -1
View File
@@ -1,7 +1,7 @@
// Source: https://github.com/mantinedev/mantine/blob/master/packages/@mantine/hooks/src/use-clipboard/use-clipboard.ts // Source: https://github.com/mantinedev/mantine/blob/master/packages/@mantine/hooks/src/use-clipboard/use-clipboard.ts
// polyfilled to support execCommand fallback // polyfilled to support execCommand fallback
import { useState } from "react"; import { useState } from "react";
import { execCommandCopy } from "@docmost/editor-ext"; import { execCommandCopy } from "@/lib/copy-to-clipboard.ts";
export type UseClipboardOptions = { export type UseClipboardOptions = {
timeout?: number; timeout?: number;
+1 -1
View File
@@ -1,7 +1,7 @@
import bytes from "bytes"; import bytes from "bytes";
import { castToBoolean } from "@/lib/utils.tsx"; import { castToBoolean } from "@/lib/utils.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts"; import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import { sanitizeUrl } from "@docmost/editor-ext"; import { sanitizeUrl } from "@/lib/sanitize-url.ts";
declare global { declare global {
interface Window { interface Window {
+16
View File
@@ -0,0 +1,16 @@
// Client-local execCommand copy fallback (previously imported from
// @docmost/editor-ext). It lives here so the ubiquitous useClipboard / CopyButton
// path does not pull in the editor-ext barrel — and with it the whole TipTap
// engine — through the eager startup graph. Behavior is identical to the
// editor-ext helper it replaces.
export function execCommandCopy(text: string): void {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
textarea.style.top = "-9999px";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
}
+31
View File
@@ -0,0 +1,31 @@
import { describe, it, expect } from "vitest";
import { sanitizeUrl } from "./sanitize-url";
// `sanitizeUrl` is a byte-identical client-local copy of editor-ext's wrapper
// around @braintree/sanitize-url: it maps the sanitizer's "about:blank" XSS
// sentinel to "". These assertions mirror editor-ext's own security-contract
// test so the extracted copy keeps the same guarantees.
describe("sanitizeUrl", () => {
it("blocks dangerous schemes (returns empty string)", () => {
expect(sanitizeUrl("javascript:alert(1)")).toBe("");
expect(sanitizeUrl("data:text/html,<script>alert(1)</script>")).toBe("");
expect(sanitizeUrl("vbscript:msgbox(1)")).toBe("");
// Case / whitespace obfuscation must not slip past the sanitizer.
expect(sanitizeUrl(" JaVaScRiPt:alert(1)")).toBe("");
});
it("returns empty string for empty / undefined input", () => {
expect(sanitizeUrl(undefined)).toBe("");
expect(sanitizeUrl("")).toBe("");
});
it("allows safe https, relative file and mailto URLs", () => {
expect(sanitizeUrl("https://example.com/page")).toMatch(
/^https:\/\/example\.com\/page/,
);
expect(sanitizeUrl("/api/files/abc-123")).toBe("/api/files/abc-123");
expect(sanitizeUrl("mailto:user@example.com")).toBe(
"mailto:user@example.com",
);
});
});
+15
View File
@@ -0,0 +1,15 @@
import { sanitizeUrl as braintreeSanitizeUrl } from "@braintree/sanitize-url";
// Client-local copy of editor-ext's sanitizeUrl wrapper. Importing it from the
// editor-ext barrel dragged the whole TipTap engine into the eager startup graph
// via the app-wide config module (getFileUrl). This keeps the exact same
// behavior (braintree sanitize + normalize "about:blank" -> "") without that
// dependency.
export function sanitizeUrl(url: string | undefined): string {
if (!url) return "";
const sanitized = braintreeSanitizeUrl(url);
// Return an empty string instead of "about:blank".
return sanitized === "about:blank" ? "" : sanitized;
}
+60 -27
View File
@@ -13,15 +13,14 @@ import { ModalsProvider } from "@mantine/modals";
import { Notifications } from "@mantine/notifications"; import { Notifications } from "@mantine/notifications";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { HelmetProvider } from "react-helmet-async"; import { HelmetProvider } from "react-helmet-async";
import { ChunkLoadErrorBoundary } from "@/components/chunk-load-error-boundary.tsx";
import "./i18n"; import "./i18n";
import { PostHogProvider } from "posthog-js/react";
import { import {
getPostHogHost, getPostHogHost,
getPostHogKey, getPostHogKey,
isCloud, isCloud,
isPostHogEnabled, isPostHogEnabled,
} from "@/lib/config.ts"; } from "@/lib/config.ts";
import posthog from "posthog-js";
import { initVitals } from "@/lib/telemetry/vitals"; import { initVitals } from "@/lib/telemetry/vitals";
export const queryClient = new QueryClient({ export const queryClient = new QueryClient({
@@ -35,15 +34,6 @@ export const queryClient = new QueryClient({
}, },
}); });
if (isCloud() && isPostHogEnabled) {
posthog.init(getPostHogKey(), {
api_host: getPostHogHost(),
defaults: "2025-05-24",
disable_session_recording: true,
capture_pageleave: false,
});
}
// #355 — client perf-telemetry. Decides sampling ONCE (25%/session) before // #355 — client perf-telemetry. Decides sampling ONCE (25%/session) before
// subscribing to any observer; non-sampled sessions send nothing. // subscribing to any observer; non-sampled sessions send nothing.
initVitals(); initVitals();
@@ -51,19 +41,62 @@ initVitals();
const container = document.getElementById("root") as HTMLElement; const container = document.getElementById("root") as HTMLElement;
const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container); const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container);
root.render( function renderApp() {
<BrowserRouter> root.render(
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}> <BrowserRouter>
<ModalsProvider> <MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
<QueryClientProvider client={queryClient}> <ModalsProvider>
<Notifications position="bottom-center" limit={3} zIndex={10000} /> <QueryClientProvider client={queryClient}>
<HelmetProvider> <Notifications position="bottom-center" limit={3} zIndex={10000} />
<PostHogProvider client={posthog}> <HelmetProvider>
<App /> {/* Root boundary above every lazy route's Suspense: a stale-chunk
</PostHogProvider> 404 after a deploy is caught and recovered here instead of
</HelmetProvider> blanking the whole app. */}
</QueryClientProvider> <ChunkLoadErrorBoundary>
</ModalsProvider> <App />
</MantineProvider> </ChunkLoadErrorBoundary>
</BrowserRouter>, </HelmetProvider>
); </QueryClientProvider>
</ModalsProvider>
</MantineProvider>
</BrowserRouter>,
);
}
async function initAnalytics() {
// posthog-js is only pulled in for cloud deployments with analytics enabled, so
// self-hosted builds never download it. The gate is kept identical to the
// previous eager code so cloud analytics behavior is unchanged; the import is
// simply deferred behind it.
//
// Crucially this runs AFTER the immediate first render below, so first paint is
// never gated on the analytics chunk. Any failure (network, stale 404, or an
// ad-blocker blocking a chunk named "posthog") is swallowed so the user keeps a
// working app without analytics instead of a permanently blank page.
//
// NOTE: we init the posthog SINGLETON only and do NOT wrap the tree in
// <PostHogProvider>. The app has zero consumers of the PostHog React context
// (no usePostHog / useFeatureFlag* / PostHogFeature), and PostHogProvider given
// an already-initialized `client` is a no-op — all capture goes through the
// singleton. Re-rendering to attach the provider would only REMOUNT the whole
// App (running every mount effect twice and dropping local state / focus /
// in-progress input on cloud cold-load) for no functional gain.
if (!(isCloud() && isPostHogEnabled)) return;
try {
const { default: posthog } = await import("posthog-js");
posthog.init(getPostHogKey(), {
api_host: getPostHogHost(),
defaults: "2025-05-24",
disable_session_recording: true,
capture_pageleave: false,
});
} catch {
// Analytics failed to load — degrade gracefully; the app already rendered.
}
}
// Paint immediately for everyone (self-hosted stays exactly as instant as before,
// cloud no longer blocks on the analytics import). The posthog singleton is
// initialized after, without re-rendering the tree.
renderApp();
void initAnalytics();
+14
View File
@@ -63,6 +63,20 @@ export default defineConfig(({ mode }) => {
name: "vendor-mantine", name: "vendor-mantine",
test: /[\\/]node_modules[\\/]@mantine[\\/]/, test: /[\\/]node_modules[\\/]@mantine[\\/]/,
}, },
// NOTE: TipTap/ProseMirror/Yjs are intentionally NOT force-grouped
// into a single vendor chunk. Doing so backfires: rolldown co-locates
// a small module shared with the (eager) react-i18next runtime into
// that group chunk, which then drags the whole ~590KB editor engine
// into the eager modulepreload graph. Left to the default splitting,
// the editor engine stays in lazily-loaded chunks pulled only by the
// route-split editor/share pages. KaTeX is safe to group (nothing
// eager references it).
// KaTeX in its own stable chunk; loaded on demand by the lazy math
// node views (never in the startup path).
{
name: "vendor-katex",
test: /[\\/]node_modules[\\/]katex[\\/]/,
},
], ],
}, },
}, },
@@ -4,7 +4,6 @@ import { EventName } from '../../common/events/event.contants';
import { InjectQueue } from '@nestjs/bullmq'; import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../integrations/queue/constants'; import { QueueJob, QueueName } from '../../integrations/queue/constants';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
import { EnvironmentService } from '../../integrations/environment/environment.service';
/** /**
* Thin snapshot of a page node carried inside domain events so the WebSocket * Thin snapshot of a page node carried inside domain events so the WebSocket
@@ -112,48 +111,24 @@ export class PageListener {
private readonly logger = new Logger(PageListener.name); private readonly logger = new Logger(PageListener.name);
constructor( constructor(
private readonly environmentService: EnvironmentService,
@InjectQueue(QueueName.SEARCH_QUEUE) private searchQueue: Queue,
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue, @InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
) {} ) {}
@OnEvent(EventName.PAGE_CREATED) @OnEvent(EventName.PAGE_CREATED)
async handlePageCreated(event: PageEvent) { async handlePageCreated(event: PageEvent) {
const { pageIds, workspaceId } = event; const { pageIds, workspaceId } = event;
if (this.isTypesense()) {
await this.searchQueue.add(QueueJob.PAGE_CREATED, {
pageIds,
});
}
await this.aiQueue.add(QueueJob.PAGE_CREATED, { pageIds, workspaceId }); await this.aiQueue.add(QueueJob.PAGE_CREATED, { pageIds, workspaceId });
} }
@OnEvent(EventName.PAGE_UPDATED)
async handlePageUpdated(event: PageEvent) {
const { pageIds } = event;
await this.searchQueue.add(QueueJob.PAGE_UPDATED, { pageIds });
}
@OnEvent(EventName.PAGE_DELETED) @OnEvent(EventName.PAGE_DELETED)
async handlePageDeleted(event: PageEvent) { async handlePageDeleted(event: PageEvent) {
const { pageIds, workspaceId } = event; const { pageIds, workspaceId } = event;
if (this.isTypesense()) {
await this.searchQueue.add(QueueJob.PAGE_DELETED, { pageIds });
}
await this.aiQueue.add(QueueJob.PAGE_DELETED, { pageIds, workspaceId }); await this.aiQueue.add(QueueJob.PAGE_DELETED, { pageIds, workspaceId });
} }
@OnEvent(EventName.PAGE_SOFT_DELETED) @OnEvent(EventName.PAGE_SOFT_DELETED)
async handlePageSoftDeleted(event: PageEvent) { async handlePageSoftDeleted(event: PageEvent) {
const { pageIds, workspaceId } = event; const { pageIds, workspaceId } = event;
if (this.isTypesense()) {
await this.searchQueue.add(QueueJob.PAGE_SOFT_DELETED, { pageIds });
}
await this.aiQueue.add(QueueJob.PAGE_SOFT_DELETED, { await this.aiQueue.add(QueueJob.PAGE_SOFT_DELETED, {
pageIds, pageIds,
workspaceId, workspaceId,
@@ -163,14 +138,6 @@ export class PageListener {
@OnEvent(EventName.PAGE_RESTORED) @OnEvent(EventName.PAGE_RESTORED)
async handlePageRestored(event: PageEvent) { async handlePageRestored(event: PageEvent) {
const { pageIds, workspaceId } = event; const { pageIds, workspaceId } = event;
if (this.isTypesense()) {
await this.searchQueue.add(QueueJob.PAGE_RESTORED, { pageIds });
}
await this.aiQueue.add(QueueJob.PAGE_RESTORED, { pageIds, workspaceId }); await this.aiQueue.add(QueueJob.PAGE_RESTORED, { pageIds, workspaceId });
} }
isTypesense(): boolean {
return this.environmentService.getSearchDriver() === 'typesense';
}
} }
@@ -4,7 +4,6 @@ import { EventName } from '../../common/events/event.contants';
import { InjectQueue } from '@nestjs/bullmq'; import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../integrations/queue/constants'; import { QueueJob, QueueName } from '../../integrations/queue/constants';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
import { EnvironmentService } from '../../integrations/environment/environment.service';
export class SpaceEvent { export class SpaceEvent {
spaceId: string; spaceId: string;
@@ -15,22 +14,12 @@ export class SpaceListener {
private readonly logger = new Logger(SpaceListener.name); private readonly logger = new Logger(SpaceListener.name);
constructor( constructor(
private readonly environmentService: EnvironmentService,
@InjectQueue(QueueName.SEARCH_QUEUE) private searchQueue: Queue,
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue, @InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
) {} ) {}
@OnEvent(EventName.SPACE_DELETED) @OnEvent(EventName.SPACE_DELETED)
async handleSpaceDeleted(event: SpaceEvent) { async handleSpaceDeleted(event: SpaceEvent) {
const { spaceId } = event; const { spaceId } = event;
if (this.isTypesense()) {
await this.searchQueue.add(QueueJob.SPACE_DELETED, { spaceId });
}
await this.aiQueue.add(QueueJob.SPACE_DELETED, { spaceId }); await this.aiQueue.add(QueueJob.SPACE_DELETED, { spaceId });
} }
isTypesense(): boolean {
return this.environmentService.getSearchDriver() === 'typesense';
}
} }
@@ -4,7 +4,6 @@ import { EventName } from '../../common/events/event.contants';
import { InjectQueue } from '@nestjs/bullmq'; import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../integrations/queue/constants'; import { QueueJob, QueueName } from '../../integrations/queue/constants';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
import { EnvironmentService } from '../../integrations/environment/environment.service';
export class WorkspaceEvent { export class WorkspaceEvent {
workspaceId: string; workspaceId: string;
@@ -15,22 +14,12 @@ export class WorkspaceListener {
private readonly logger = new Logger(WorkspaceListener.name); private readonly logger = new Logger(WorkspaceListener.name);
constructor( constructor(
private readonly environmentService: EnvironmentService,
@InjectQueue(QueueName.SEARCH_QUEUE) private searchQueue: Queue,
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue, @InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
) {} ) {}
@OnEvent(EventName.WORKSPACE_DELETED) @OnEvent(EventName.WORKSPACE_DELETED)
async handlePageDeleted(event: WorkspaceEvent) { async handlePageDeleted(event: WorkspaceEvent) {
const { workspaceId } = event; const { workspaceId } = event;
if (this.isTypesense()) {
await this.searchQueue.add(QueueJob.WORKSPACE_DELETED, { workspaceId });
}
await this.aiQueue.add(QueueJob.WORKSPACE_DELETED, { workspaceId }); await this.aiQueue.add(QueueJob.WORKSPACE_DELETED, { workspaceId });
} }
isTypesense(): boolean {
return this.environmentService.getSearchDriver() === 'typesense';
}
} }
@@ -2,15 +2,36 @@ import { FastifyReply, FastifyRequest } from 'fastify';
import { isStreamingResponse } from './metrics.constants'; import { isStreamingResponse } from './metrics.constants';
import { observeHttp } from './metrics.registry'; import { observeHttp } from './metrics.registry';
// URL path prefixes served by @fastify/static (client build output under
// client/dist). `/assets/` holds the content-hashed bundle (index-*.js,
// chunk-*.js) — a NEW set of names every deploy, i.e. an UNBOUNDED label set
// (#362); the others (/vad/, /brand/, /locales/, /icons/ — copied verbatim from
// public/) have stable names, so they are merely repetitive per-file labels
// rather than unbounded. Either way none of these belong in the API-route
// histogram: collapse them all to one bounded `static` label. (Edge latency for
// static is already measured by Traefik's traefik_router_request_duration_*.)
const STATIC_PATH_PREFIXES = [
'/assets/',
'/vad/',
'/brand/',
'/locales/',
'/icons/',
];
/** /**
* Resolve the BOUNDED route label for an HTTP response. * Resolve the BOUNDED route label for an HTTP response.
* *
* HARD REQUIREMENT (#355): use the ROUTE TEMPLATE (`/pages/:id`), NEVER the raw * HARD REQUIREMENT (#355): use the ROUTE TEMPLATE (`/pages/:id`), NEVER a raw
* URL (`/pages/abc-123`), so label cardinality stays finite. Fastify exposes the * URL (`/pages/abc-123` or `/assets/index-CAbxDtto.js`), so label cardinality
* matched template on `req.routeOptions.url`. On 404s (no route matched) that is * stays finite. Fastify exposes the matched template on `req.routeOptions.url`,
* missing → collapse to the literal `unknown`. * BUT @fastify/static serves each file through a route whose matched url is the
* raw (hashed) file path — so for static assets that value is itself unbounded.
* Detect static requests by their path prefix FIRST and collapse to `static`;
* otherwise use the route template; on a 404 (no route matched) → `unknown`.
*/ */
export function resolveRouteLabel(req: FastifyRequest): string { export function resolveRouteLabel(req: FastifyRequest): string {
const path = (req.url ?? '').split('?', 1)[0];
if (STATIC_PATH_PREFIXES.some((p) => path.startsWith(p))) return 'static';
const url = req.routeOptions?.url; const url = req.routeOptions?.url;
return typeof url === 'string' && url.length > 0 ? url : 'unknown'; return typeof url === 'string' && url.length > 0 ? url : 'unknown';
} }
@@ -46,7 +46,6 @@ export class MetricsBullService implements OnModuleInit, OnModuleDestroy {
@InjectQueue(QueueName.GENERAL_QUEUE) generalQueue: Queue, @InjectQueue(QueueName.GENERAL_QUEUE) generalQueue: Queue,
@InjectQueue(QueueName.BILLING_QUEUE) billingQueue: Queue, @InjectQueue(QueueName.BILLING_QUEUE) billingQueue: Queue,
@InjectQueue(QueueName.FILE_TASK_QUEUE) fileTaskQueue: Queue, @InjectQueue(QueueName.FILE_TASK_QUEUE) fileTaskQueue: Queue,
@InjectQueue(QueueName.SEARCH_QUEUE) searchQueue: Queue,
@InjectQueue(QueueName.AI_QUEUE) aiQueue: Queue, @InjectQueue(QueueName.AI_QUEUE) aiQueue: Queue,
@InjectQueue(QueueName.HISTORY_QUEUE) historyQueue: Queue, @InjectQueue(QueueName.HISTORY_QUEUE) historyQueue: Queue,
@InjectQueue(QueueName.NOTIFICATION_QUEUE) notificationQueue: Queue, @InjectQueue(QueueName.NOTIFICATION_QUEUE) notificationQueue: Queue,
@@ -58,7 +57,6 @@ export class MetricsBullService implements OnModuleInit, OnModuleDestroy {
{ label: 'general', queue: generalQueue }, { label: 'general', queue: generalQueue },
{ label: 'billing', queue: billingQueue }, { label: 'billing', queue: billingQueue },
{ label: 'file-task', queue: fileTaskQueue }, { label: 'file-task', queue: fileTaskQueue },
{ label: 'search', queue: searchQueue },
{ label: 'ai', queue: aiQueue }, { label: 'ai', queue: aiQueue },
{ label: 'history', queue: historyQueue }, { label: 'history', queue: historyQueue },
{ label: 'notification', queue: notificationQueue }, { label: 'notification', queue: notificationQueue },
@@ -25,6 +25,61 @@ describe('resolveRouteLabel (histogram route label)', () => {
const req = { url: '/x' } as unknown as FastifyRequest; const req = { url: '/x' } as unknown as FastifyRequest;
expect(resolveRouteLabel(req)).toBe('unknown'); expect(resolveRouteLabel(req)).toBe('unknown');
}); });
it.each([
'/assets/index-CAbxDtto.js',
'/assets/chunk-3OPIFGDE-CJOt9nr5.js',
'/assets/excalidraw-menu-DpsI0kFW.js',
'/vad/silero_vad_v5.onnx',
'/brand/logo.svg',
'/locales/en.json',
'/icons/app-icon-192x192.png',
])('collapses hashed/static asset %p to "static" (#362 cardinality)', (url) => {
// @fastify/static serves each file through a route whose matched url is the
// raw (hashed) file path, so routeOptions.url is itself unbounded here.
const req = {
url,
routeOptions: { url },
} as unknown as FastifyRequest;
const label = resolveRouteLabel(req);
expect(label).toBe('static');
expect(label).not.toContain('.js');
expect(label).not.toContain('index-');
});
it('strips the query string before the static-prefix check', () => {
const req = {
url: '/assets/index-CAbxDtto.js?v=2',
routeOptions: { url: '/assets/index-CAbxDtto.js' },
} as unknown as FastifyRequest;
expect(resolveRouteLabel(req)).toBe('static');
});
it('does NOT collapse a real API route that merely mentions assets', () => {
// A templated API route is kept as-is; only the static path PREFIXES collapse.
const req = {
url: '/api/pages/assets-guide',
routeOptions: { url: '/api/pages/:id' },
} as unknown as FastifyRequest;
expect(resolveRouteLabel(req)).toBe('/api/pages/:id');
});
it.each([
// The TRAILING SLASH on the prefix is the anti-false-collapse guard: a path
// that is the prefix WITHOUT its slash, or merely shares the prefix as a
// substring of a longer segment, must NOT collapse. These would collapse
// under a buggy `includes('/assets/')` / slashless-prefix impl.
'/assets',
'/assetsx/foo.js',
'/iconset/x.png',
])('does NOT collapse the prefix-boundary case %p', (url) => {
const req = {
url,
routeOptions: { url: '/some/:route' },
} as unknown as FastifyRequest;
expect(resolveRouteLabel(req)).not.toBe('static');
expect(resolveRouteLabel(req)).toBe('/some/:route');
});
}); });
describe('isStreamingResponse (SSE exclusion)', () => { describe('isStreamingResponse (SSE exclusion)', () => {
@@ -4,7 +4,6 @@ export enum QueueName {
GENERAL_QUEUE = '{general-queue}', GENERAL_QUEUE = '{general-queue}',
BILLING_QUEUE = '{billing-queue}', BILLING_QUEUE = '{billing-queue}',
FILE_TASK_QUEUE = '{file-task-queue}', FILE_TASK_QUEUE = '{file-task-queue}',
SEARCH_QUEUE = '{search-queue}',
AI_QUEUE = '{ai-queue}', AI_QUEUE = '{ai-queue}',
HISTORY_QUEUE = '{history-queue}', HISTORY_QUEUE = '{history-queue}',
NOTIFICATION_QUEUE = '{notification-queue}', NOTIFICATION_QUEUE = '{notification-queue}',
@@ -32,12 +31,6 @@ export enum QueueJob {
IMPORT_TASK = 'import-task', IMPORT_TASK = 'import-task',
EXPORT_TASK = 'export-task', EXPORT_TASK = 'export-task',
SEARCH_INDEX_PAGE = 'search-index-page',
SEARCH_INDEX_PAGES = 'search-index-pages',
SEARCH_INDEX_COMMENT = 'search-index-comment',
SEARCH_INDEX_COMMENTS = 'search-index-comments',
SEARCH_INDEX_ATTACHMENT = 'search-index-attachment',
SEARCH_INDEX_ATTACHMENTS = 'search-index-attachments',
SEARCH_REMOVE_PAGE = 'search-remove-page', SEARCH_REMOVE_PAGE = 'search-remove-page',
SEARCH_REMOVE_ASSET = 'search-remove-attachment', SEARCH_REMOVE_ASSET = 'search-remove-attachment',
SEARCH_REMOVE_FACE = 'search-remove-comment', SEARCH_REMOVE_FACE = 'search-remove-comment',
@@ -57,14 +57,6 @@ import { GeneralQueueProcessor } from './processors/general-queue.processor';
attempts: 1, attempts: 1,
}, },
}), }),
BullModule.registerQueue({
name: QueueName.SEARCH_QUEUE,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: true,
attempts: 2,
},
}),
BullModule.registerQueue({ BullModule.registerQueue({
name: QueueName.AI_QUEUE, name: QueueName.AI_QUEUE,
defaultJobOptions: { defaultJobOptions: {
+38 -18
View File
@@ -38,6 +38,24 @@ export const TEST_DATABASE_URL =
process.env.TEST_DATABASE_URL ?? process.env.TEST_DATABASE_URL ??
'postgresql://docmost:docmost_dev_pw@localhost:5432/docmost_test'; 'postgresql://docmost:docmost_dev_pw@localhost:5432/docmost_test';
// Build the raw postgres.js client (mirrors database.module.ts: max pool,
// silenced notices, bigint-as-number parsing). Kept separate so the singleton
// can hold a reference to bound its shutdown in destroyTestDb.
function buildTestSql(url: string = TEST_DATABASE_URL) {
return postgres(url, {
max: 5,
onnotice: () => {},
types: {
bigint: {
to: 20,
from: [20, 1700],
serialize: (value: number) => value.toString(),
parse: (value: string) => Number.parseInt(value),
},
},
});
}
/** /**
* Build a Kysely instance that MIRRORS the app's setup in database.module.ts: * Build a Kysely instance that MIRRORS the app's setup in database.module.ts:
* PostgresJSDialect over postgres(), CamelCasePlugin, and the bigint type * PostgresJSDialect over postgres(), CamelCasePlugin, and the bigint type
@@ -47,38 +65,40 @@ export const TEST_DATABASE_URL =
*/ */
export function buildTestDb(url: string = TEST_DATABASE_URL): Kysely<any> { export function buildTestDb(url: string = TEST_DATABASE_URL): Kysely<any> {
return new Kysely<any>({ return new Kysely<any>({
dialect: new PostgresJSDialect({ dialect: new PostgresJSDialect({ postgres: buildTestSql(url) }),
postgres: postgres(url, {
max: 5,
onnotice: () => {},
types: {
bigint: {
to: 20,
from: [20, 1700],
serialize: (value: number) => value.toString(),
parse: (value: string) => Number.parseInt(value),
},
},
}),
}),
plugins: [new CamelCasePlugin()], plugins: [new CamelCasePlugin()],
}); });
} }
let singleton: Kysely<any> | undefined; let singleton: Kysely<any> | undefined;
let singletonSql: ReturnType<typeof buildTestSql> | undefined;
/** Lazily-built shared Kysely for the test suite (one per worker; maxWorkers=1). */ /** Lazily-built shared Kysely for the test suite (one per worker; maxWorkers=1). */
export function getTestDb(): Kysely<any> { export function getTestDb(): Kysely<any> {
if (!singleton) { if (!singleton) {
singleton = buildTestDb(); singletonSql = buildTestSql();
singleton = new Kysely<any>({
dialect: new PostgresJSDialect({ postgres: singletonSql }),
plugins: [new CamelCasePlugin()],
});
} }
return singleton; return singleton;
} }
export async function destroyTestDb(): Promise<void> { export async function destroyTestDb(): Promise<void> {
if (singleton) { if (!singleton) return;
await singleton.destroy(); const sql = singletonSql;
singleton = undefined; // Clear the refs first so a hung end() cannot leave a half-closed singleton.
singleton = undefined;
singletonSql = undefined;
// postgres.js .end() waits indefinitely for in-flight queries by default; a
// leaked/stuck pooled connection would hang the afterAll hook (a 60s hook
// timeout in CI). Bound the shutdown: the { timeout } grace period lets
// active queries drain, then force-closes lingering sockets so teardown
// always completes. We close the pool directly instead of Kysely.destroy()
// (which would call sql.end() again with no timeout).
if (sql) {
await sql.end({ timeout: 5 });
} }
} }
+5 -1
View File
@@ -4,10 +4,14 @@
"testEnvironment": "node", "testEnvironment": "node",
"testRegex": ".e2e-spec.ts$", "testRegex": ".e2e-spec.ts$",
"transform": { "transform": {
"prosemirror-markdown/build/.+\\.js$": [
"babel-jest",
{ "presets": [["@babel/preset-env", { "targets": { "node": "current" } }]] }
],
"^.+\\.(t|j)sx?$": ["ts-jest", { "tsconfig": { "allowJs": true } }] "^.+\\.(t|j)sx?$": ["ts-jest", { "tsconfig": { "allowJs": true } }]
}, },
"transformIgnorePatterns": [ "transformIgnorePatterns": [
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom|lib0|@sindresorhus[+/][a-z0-9-]+|escape-string-regexp|p-limit|yocto-queue)(@|/))" "/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom|lib0|@sindresorhus[+/][a-z0-9-]+|escape-string-regexp|p-limit|yocto-queue|@docmost/prosemirror-markdown)(@|/))"
], ],
"moduleNameMapper": { "moduleNameMapper": {
"^@docmost/db/(.*)$": "<rootDir>/../src/database/$1", "^@docmost/db/(.*)$": "<rootDir>/../src/database/$1",
+6 -1
View File
@@ -4,14 +4,19 @@
"testRegex": ".*\\.int-spec\\.ts$", "testRegex": ".*\\.int-spec\\.ts$",
"testPathIgnorePatterns": ["/node_modules/"], "testPathIgnorePatterns": ["/node_modules/"],
"transform": { "transform": {
"prosemirror-markdown/build/.+\\.js$": [
"babel-jest",
{ "presets": [["@babel/preset-env", { "targets": { "node": "current" } }]] }
],
"^.+\\.(t|j)sx?$": "ts-jest" "^.+\\.(t|j)sx?$": "ts-jest"
}, },
"transformIgnorePatterns": [ "transformIgnorePatterns": [
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom|lib0)(@|/))" "/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom|lib0|@docmost/prosemirror-markdown)(@|/))"
], ],
"testEnvironment": "node", "testEnvironment": "node",
"testTimeout": 60000, "testTimeout": 60000,
"maxWorkers": 1, "maxWorkers": 1,
"forceExit": true,
"globalSetup": "<rootDir>/test/integration/global-setup.ts", "globalSetup": "<rootDir>/test/integration/global-setup.ts",
"globalTeardown": "<rootDir>/test/integration/global-teardown.ts", "globalTeardown": "<rootDir>/test/integration/global-teardown.ts",
"moduleNameMapper": { "moduleNameMapper": {
@@ -0,0 +1,124 @@
import { describe, expect, it } from "vitest";
// Import DIRECTLY from src (NOT the docmost-client barrel, which pulls in
// collaboration.ts and mutates global DOM at import time).
import { convertProseMirrorToMarkdown } from "../src/lib/markdown-converter.js";
import { markdownToProseMirror } from "../src/lib/markdown-to-prosemirror.js";
/**
* gitmost #377 (round-1 review, finding #1) proof, against the REAL
* converter, that the transcript-insert boundary defense survives git-sync.
*
* The web bridge (apps/client .../gitmost/gitmost-recording.ts,
* `gitmostInsertTranscriptIntoEditor`) appends each transcript line as a
* PARAGRAPH text node. The paragraph serializer here (`case "paragraph"`) emits
* that text VERBATIM with no block-escape, so a line whose text begins with a
* col-0 markdown block trigger would, on the doc -> markdown -> doc git-sync
* cycle, silently re-parse into a heading / list / quote / callout / code block.
* That missing block-escape is the pre-existing root cause; the bridge's
* boundary defense prepends an invisible zero-width space (U+200B) to a line
* that begins with such a trigger, shifting it off column 0.
*
* This test keeps a COPY of the bridge's trigger regex (the bridge is in a
* different package and can't be imported here) and asserts:
* 1. bare trigger lines DO corrupt (documents the root cause), and
* 2. the ZWSP-neutralized form round-trips as a single PARAGRAPH with the
* text byte-preserved.
*/
const ZWSP = "​"; // U+200B
// MUST stay in sync with GITMOST_MD_BLOCK_TRIGGER_RE in the client bridge.
const MD_BLOCK_TRIGGER_RE =
/^(?:#{1,6}(?:\s|$)|[-*+](?:\s|$)|>|\d+[.)](?:\s|$)|```|~~~|\||([-*_])(?:\s*\1){2,}\s*$)/;
const doc = (...nodes: any[]) => ({ type: "doc", content: nodes });
const para = (t: string) => ({
type: "paragraph",
content: [{ type: "text", text: t }],
});
const roundtrip = async (text: string) => {
const md = convertProseMirrorToMarkdown(doc(para(text)));
const back = await markdownToProseMirror(md);
return back.content as any[];
};
describe("gitmost transcript neutralization (git-sync round-trip)", () => {
// Lines that, at column 0, the serializer's missing block-escape would let
// git-sync re-parse into a non-paragraph block.
const triggerLines = [
"- dash",
"* star",
"+ plus",
"> quote",
"# hash",
"1. one",
"1) one",
"> [!info] note",
"```js",
"~~~",
// Solid + spaced thematic breaks — these re-parse into a `horizontalRule`,
// which carries NO text, so a bare separator line LOSES its text entirely
// (round-2 finding). `_` also only forms a block via this construct.
"---",
"***",
"___",
"- - -", // spaced dash break (solid form is caught by [-*+]\s too, but this is the break)
"_ _ _",
];
it("BARE trigger lines corrupt into non-paragraph blocks (root cause)", async () => {
for (const line of triggerLines) {
const blocks = await roundtrip(line);
// At least one produced block is NOT a paragraph — i.e. corruption.
const allParagraphs = blocks.every((b) => b.type === "paragraph");
expect(
allParagraphs,
`expected "${line}" to corrupt when inserted bare`,
).toBe(false);
}
});
it("BARE solid thematic breaks corrupt into a text-LOSING horizontalRule", async () => {
// The severe case: no text node survives. Documents why neutralization
// matters more here than for list/quote (where the text survived).
for (const line of ["---", "***", "___"]) {
const blocks = await roundtrip(line);
expect(blocks.map((b) => b.type)).toContain("horizontalRule");
// No block carries the original text anywhere.
const flat = JSON.stringify(blocks);
expect(flat).not.toContain(line);
}
});
it("ZWSP-neutralized trigger lines round-trip as a single paragraph, text preserved", async () => {
for (const line of triggerLines) {
// The regex must actually classify each as a trigger.
expect(MD_BLOCK_TRIGGER_RE.test(line), `regex missed "${line}"`).toBe(
true,
);
const neutralized = ZWSP + line;
const blocks = await roundtrip(neutralized);
expect(blocks).toHaveLength(1);
expect(blocks[0].type).toBe("paragraph");
// Text is byte-preserved (ZWSP + original line), so the display is the
// original line with only an invisible leading character.
expect(blocks[0].content[0].text).toBe(neutralized);
}
});
it("normal host-prefixed lines never match the trigger regex and round-trip byte-exact", async () => {
for (const line of [
"You: hello there",
"Speaker 1: - and then a dash mid-line",
"Speaker 2: 1. not a list",
]) {
expect(MD_BLOCK_TRIGGER_RE.test(line)).toBe(false);
const blocks = await roundtrip(line);
expect(blocks).toHaveLength(1);
expect(blocks[0].type).toBe("paragraph");
expect(blocks[0].content[0].text).toBe(line);
}
});
});
+3
View File
@@ -272,6 +272,9 @@ importers:
'@atlaskit/pragmatic-drag-and-drop-live-region': '@atlaskit/pragmatic-drag-and-drop-live-region':
specifier: 1.3.4 specifier: 1.3.4
version: 1.3.4 version: 1.3.4
'@braintree/sanitize-url':
specifier: 7.1.2
version: 7.1.2
'@casl/react': '@casl/react':
specifier: 5.0.1 specifier: 5.0.1
version: 5.0.1(@casl/ability@6.8.0)(react@18.3.1) version: 5.0.1(@casl/ability@6.8.0)(react@18.3.1)