Skip to content

feat(yjs): collaborative editing framework#2561

Draft
dschmidt wants to merge 22 commits into
opencloud-eu:mainfrom
dschmidt:feat/realtime-collaboration-poc
Draft

feat(yjs): collaborative editing framework#2561
dschmidt wants to merge 22 commits into
opencloud-eu:mainfrom
dschmidt:feat/realtime-collaboration-poc

Conversation

@dschmidt
Copy link
Copy Markdown
Contributor

@dschmidt dschmidt commented May 19, 2026

Warning

THIS IS A PoC EXPERIMENT. NOT READY FOR MERGING OR PRODUCTION.

Original PoC and architecture discussion: opencloud-eu/web-extensions#447
This is still evaluating the general architectural approach I've taken - interfaces, names, configs etc. are still subject to change
Obviously I'm not trying to land two more text editor apps - they are just demonstrators the abstraction is usable for more than one app. They were my starting point before I ported the default text-editor app.

Description

Migrates the realtime collaborative editing PoC from opencloud-eu/web-extensions (PR opencloud-eu/web-extensions#447) into this repo.

  • New CollaborativeWrapper in @opencloud-eu/web-pkg with Y.Doc + Awareness + optional Hocuspocus provider, app-version handshake, stale-state recovery, per-app room namespace, and a three-state realtimeUrl (derive from configStore.serverUrl + /realtime, explicit URL, or null for local-only).
  • Two new editor apps as thin shells: web-app-codemirror, web-app-tiptap.
  • web-app-text-editor refactored onto the wrapper. Toolbar, slash commands, content-type strategies, undo/redo all preserved; only load / save / dirty tracking goes through the wrapper. Strategies flip StarterKit.undoRedo to false so yUndoPlugin from @tiptap/y-tiptap takes over.
  • AppWrapper now refetches the file on 412 / 409 save responses and retries with the fresh etag, recovering silently in the typical collab case (peer just saved, our Y.Doc already has their edits).
  • Hocuspocus sidecar landed in dev/docker/hocuspocus/ with a docker-compose.yml service entry.

Migration plan and known follow-ups live in REALTIME_COLLAB_MIGRATION.md at the repo root.

Related Issue

How Has This Been Tested?

  • test environment: local docker compose stack (web compose with the new hocuspocus service) + pnpm build:w
  • test case 1: 5/5 codemirror playwright specs against the running stack
  • test case 2: 4/4 tiptap playwright specs
  • test case 3: 8/8 realtime-sync integration suite against the hocuspocus sidecar directly
  • test case 4: manual browser smoke on .md files with text-editor, codemirror, tiptap (toolbar, undo/redo, multi-tab edits, cursor markers, save persistence)
  • existing read-only useTextEditor callers (web-app-app-store AppDetails, web-app-files ListHeader, SpaceHeader README rendering) keep working because the new ydoc parameter is optional

Types of changes

  • Bugfix
  • Enhancement (a change that doesn't break existing code or deployments)
  • Breaking change (a modification that affects current functionality)
  • Technical debt (addressing code that needs refactoring or improvements)
  • Tests (adding or improving tests)
  • Documentation (updates or additions to documentation)
  • Maintenance (like dependency updates or tooling adjustments)

dschmidt added 7 commits May 19, 2026 23:52
…p apps

Brings the realtime-collaboration PoC from opencloud-eu/web-extensions
into the canonical web repo:

- New `@opencloud-eu/web-pkg` component family at
  `src/components/Collaborative/`: `CollaborativeWrapper.vue` +
  `CollaborativeAdapter` type contract. Reads `useAuthStore` /
  `useConfigStore` via the existing composables barrel.
- web-pkg deps gain `@hocuspocus/provider`, `yjs`, `y-protocols`,
  `semver` (+ `@types/semver` devDep). Editor-binding deps
  (`y-codemirror.next`, `@tiptap/y-tiptap`, `@tiptap/markdown`,
  `@tiptap/extension-collaboration`, `@codemirror/*`) stay with the
  consuming apps — web-pkg only ships the editor-agnostic plumbing.
- Two new apps: `web-app-codemirror` + `web-app-tiptap`. Both are
  thin App.vue wrappers around `CollaborativeWrapper`, no own build
  config (web's central vite picks them up via the `web-app-*`
  convention). Adapter + editor component live alongside App.vue.
- `realtimeUrl` prop is now three-state on the wrapper:
  `string` for an explicit URL, `null` for forced local-only mode,
  `undefined` (default) to derive from `configStore.serverUrl` plus
  the `/realtime` convention. Means collab is on out-of-the-box once
  the sidecar runs on the OC host. A first-class `options.realtimeUrl`
  field in OC's web config schema is a separate follow-up.
- Tiptap StarterKit uses the v3 `undoRedo: false` option name (was
  `history: false` in v2) — clears the lingering `Partial<StarterKitOptions>`
  type error and keeps yUndoPlugin from `@tiptap/y-tiptap` as the
  collab-aware undo manager.
- Registers codemirror + tiptap in `dev/docker/opencloud.web.config.json`
  `apps[]`.
- Adds `REALTIME_COLLAB_MIGRATION.md` documenting the multi-phase plan
  (phases 0-1-2 done; 2.5, 3, 4, 4.5, 5 pending).
`CollaborativeAdapter.serialize` now takes a second `context: unknown` arg.
Editor components expose context via `defineExpose({ getAdapterContext() })`
and the wrapper grabs it through a template ref. Adapters cast `context`
to their expected shape and treat absence as "fall back to Y.Doc-only
serialisation".

TiptapEditor exposes `{ editor: editor.value }`. The markdown adapter now
calls `live.getMarkdown()` on the bound editor on each debounced serialise
instead of spawning a headless `new Editor({...})` (StarterKit + Markdown +
Collaboration setup) per keystroke. Dominant cost during interactive
editing, will matter a lot more once the text-editor refactor (Phase 4)
brings 4 content-type strategies × many extensions online.

CodeMirror returns no context. Its adapter's `Y.Text.toString()` is
already cheap and ignores the new arg.

Backwards-compatible signature change — `unknown` is the cleanest type
because a typed `editor: TiptapEditor` argument would make no sense for
the CodeMirror adapter or any future non-Tiptap adapter.

Headless-editor fallback inside the Tiptap adapter stays for the case
where no live editor is bound (e.g. stale-recovery on a peer that holds
the doc but never mounted a UI).
Brings the Hocuspocus collaboration sidecar from web-extensions into web
as a new service. Same Dockerfile + patches + server.js as the PoC:

- Node 22 image with `@hocuspocus/server` 4.0.0 patched to expose a
  `beforeHandleAwareness` callback (anti-spoof identity stamp on every
  inbound awareness update — server overwrites `user` with the
  authenticated identity)
- Reuses the existing `/realtime` path-prefix on the `opencloud`
  Traefik entrypoint, so apps reach the sidecar at
  `wss://host.docker.internal:9200/realtime` (matches the wrapper's
  `serverUrl + /realtime` derive convention)
- SQLite persistence on a named `hocuspocus-data` volume
- `DEV_FAKE_TOKEN` is set so the integration vitest suite can bypass
  real OIDC tokens against the sidecar

The sidecar's `OPENCLOUD_URL` env still points at
`https://host.docker.internal:9200` because OC sits behind the same
Traefik. `NODE_TLS_REJECT_UNAUTHORIZED=0` is the dev-cert workaround,
gone in prod.

Two follow-ups (tracked in REALTIME_COLLAB_MIGRATION.md):
- Switch the etag probe in `server.js` from WebDAV HEAD to Graph
  `/items/{id}` for consistency with the existing Graph permissions
  call (investigate the "personal-drive 400" mentioned in the
  WebDAV-fallback comment).
- Reject folder ids in `onAuthenticate` (currently we'd silently start
  a collab room for a folder, which has no file content to save).


Multiple editor apps (codemirror, tiptap, eventually the refactored
text-editor) each pull yjs + y-prosemirror via their own dep tree.
Without dedupe each app bundle ships its own Yjs instance, and at
runtime the browser sees the "Yjs was already imported" warning plus
broken `instanceof Y.Doc` / pluginKey identity checks across module
boundaries.

In the web-extensions PoC this manifested as `Cannot read properties
of undefined (reading 'doc')` inside y-prosemirror's `createDecorations`
when the cursor plugin (registered with one ySyncPluginKey instance)
tried to read state attached by the Collaboration extension (registered
with a different ySyncPluginKey instance from a different y-prosemirror
copy). Solved upstream by switching the cursor wiring to y-tiptap's
yCursorPlugin so both ends share a pluginKey, but the bundle-level
duplication still slows page load and trips the constructor warning.

Adding the deps to vite's `resolve.dedupe` forces a single instance
per shared module across the entire app bundle. The Tiptap core / pm
entries cover ProseMirror pluginKey identity checks across the various
@tiptap/extension-* packages.
OcDrop's root is a fragment (oc-mobile-drop OR Transition+Teleport),
so Vue can't auto-inherit caller-supplied attrs. With the default
`inheritAttrs: true` Vue logged "Extraneous non-props attributes
(class, options) were passed to component but could not be
automatically inherited because component renders fragment or text
or teleport root nodes" once per OcDrop instance per render.

In a typical web session with notifications, app-switcher, account
menu and a couple of file-action dropdowns all sitting in the
TopBar, the warning fires hundreds of times per page render and
makes the dev console (and through it, the whole tab) crawl.

Switch to `inheritAttrs: false` and forward the consumer's attrs
explicitly via `v-bind="attrs"` on the inner drop div. The existing
`:class="attrs?.class"` binding stays so duplicate class handling
remains intact (Vue merges `v-bind`-supplied class with the static
one). All 16 OcDrop specs stay green.
…eWrapper

Wires the existing text-editor through the realtime API. Every editor
instance now goes through a Y.Doc — single-user sessions still work via
the wrapper's local mode (standalone Awareness, immediate hydrate, no
provider).

Approach: extend the existing `useTextEditor` composable rather than
fork the whole text-editor app. The single new parameter `ydoc` is
optional and backwards compatible — the three other callers
(web-app-app-store AppDetails, web-app-files ListHeader and SpaceHeader
readme rendering) keep working without changes.

In collab mode `useTextEditor`:
- pushes `Collaboration.configure({ document: ydoc, field: 'default' })`
  onto the strategy's extension list
- skips the initial `content` option (Collaboration paints from the
  Y.Doc instead, which is hydrated by the wrapper)
- suppresses the `modelValue` → `setContent` watch (CRDT is the source
  of truth — round-tripping would clobber peer edits)

All three Tiptap-based strategies (markdown / html / tiptap-json) flip
`StarterKit.configure({ undoRedo: false })` so the collab-aware
`yUndoPlugin` from `@tiptap/y-tiptap` (which the Collaboration extension
brings in) can take over without conflict. The read-only callers don't
use undo so the change is neutral for them. The plain-text strategy
already uses a minimal Document+Paragraph+Text+HardBreak set with no
StarterKit, no change needed.

text-editor app:
- New `src/adapters/textEditorAdapter.ts` — bridges any
  `ContentTypeStrategy` to a `CollaborativeAdapter`. Hydration spawns a
  detached Tiptap editor with the strategy's extensions plus
  `Collaboration` to materialise content into the Y.Doc; serialisation
  prefers the live editor surfaced via the wrapper's
  `getAdapterContext()` channel (avoids per-keystroke headless spawn)
  and falls back to a headless instance only when no UI is bound.
- New `src/TextEditorBinding.vue` — thin editor component the
  CollaborativeWrapper mounts. Receives ydoc / awareness / provider /
  isReadOnly from the wrapper and reads `contentType` via
  provide/inject from App.vue (keeps the wrapper's editor-prop
  signature free of text-editor specifics). Exposes
  `getAdapterContext()` for the wrapper.
- `src/App.vue` is now a thin shell around `<CollaborativeWrapper>`,
  same pattern as web-app-codemirror / web-app-tiptap.
- Direct deps for the type-only and runtime imports the new wiring
  introduces (@hocuspocus/provider, @tiptap/{core,extension-collaboration,vue-3},
  yjs, y-protocols).

`useContentStrategy` and the `ContentTypeStrategy` type are now part of
the public `@opencloud-eu/web-pkg/editor` exports so consumer apps can
build their own adapters.

`@tiptap/extension-collaboration` lands as a web-pkg direct dep (the
composable now imports it).

Unit test stubs the CollaborativeWrapper since the test can't mount a
real HocuspocusProvider chain; verifies App.vue still renders the
`.oc-text-editor` shell via the stub.
@dschmidt dschmidt mentioned this pull request May 19, 2026
16 tasks
dschmidt added 8 commits May 19, 2026 23:59
…t-editor

Different editor apps (codemirror, tiptap, text-editor) bind to
incompatible Y.Doc schemas — codemirror writes to a Y.Text, the
Tiptap-based ones to a Y.XmlFragment with different extension sets.
Sharing a Y.Doc room across them was producing broken hydrations and
app-version handshake lock-outs (each app has its own pkg.version and
they were trampling each other's `_oc_meta.appVersion`).

Wrapper gets an optional `documentPrefix` prop; the sessionKey /
provider URL now uses `${prefix}::${fileId}`. Each app's App.vue
passes its own application id (`codemirror`, `tiptap`, `text-editor`).
Same .md file opened in two different editors lands in two different
Hocuspocus rooms.

Sidecar `parseDocumentId` strips the `<scope>::` prefix before the
Graph permissions probe so ACL still targets the raw file id.

Cursor markers in text-editor: `useTextEditor` accepts an optional
`awareness` and registers a custom yCollaborationCursor extension
wired to `@tiptap/y-tiptap`'s yCursorPlugin (same pattern used in
web-app-tiptap, for the same reason — the upstream
`@tiptap/extension-collaboration-cursor@3.0.0` still imports from
unforked `y-prosemirror`, shipping a different ySyncPluginKey and
crashing on first paint). Cursor builder emits the canonical
`.collaboration-cursor__caret` / `__label` DOM, web-pkg ships matching
unscoped styles under `editor/styles/collab-cursor.css` so the caret
+ name label render correctly across every consumer (the bug the user
just hit — without the styles the default block-level <div> rendered
the name as a full-width background bar across the editor line).

`@tiptap/y-tiptap` promoted from transitive to direct web-pkg dep.
TextEditorBinding forwards awareness from the wrapper into the
composable.

Codemirror + tiptap e2e (9/9) stay green; the per-app prefix change
is invisible to them because they were already the sole occupants of
their old rooms.

Notes for later (see REALTIME_COLLAB_MIGRATION.md):
- Cross-app collab room shape revisit (per-app room vs per-app
  fragment with shared awareness).
- text-editor read-only-fallback UX should be more obvious — a
  reload-prompt banner is currently a single muted line in the
  status strip that's easy to miss.
`useTextEditor` was unconditionally focusing the editor on mount when
not readonly. With a bound Awareness this immediately publishes a
selection at (0, 0) to every peer in the room — they see a phantom
caret of ours sitting at the top of the doc before we've actually
clicked into it.

Gate the auto-focus on `!options.ydoc` so the single-user behaviour
is unchanged and the collab path waits for a real user-driven
selection before awareness emits.

Note: this potentially regresses a11y for collab editors (keyboard /
screen-reader users won't land in the editor on open). Tracked as a
follow-up in REALTIME_COLLAB_MIGRATION.md.
`useTextEditor`'s onMounted previously did `focus()` whenever the editor
wasn't read-only. That implicit policy collided with two things at once:

1. In collab mode it published a phantom caret at (0, 0) to every peer
   in the room before the local user had clicked into the editor.
2. The caller's intent was ambiguous — useTextEditor was making the UX
   decision for the consumer (when to put the cursor in the editor)
   instead of just building one.

The composable now only mounts the editor and wires its transaction
listeners. The auto-focus is gone entirely; every current consumer
either renders read-only previews (web-app-app-store AppDetails,
web-app-files ListHeader, SpaceHeader README rendering) and never
wanted focus to begin with, or relies on user-driven click-to-edit
(text-editor through CollaborativeWrapper). If a future caller wants
focus-on-mount, they can call `instance.focus()` themselves.

Replaces the `!options.ydoc` guard added in the previous commit, which
was a confusing way to express "skip the focus that we now don't do at
all anymore".

All 5/5 codemirror + 4/4 tiptap e2e stay green; vue-tsc clean.
AppWrapper keeps `currentETag` private, set only when it loads or
saves itself. With realtime collaboration that's not enough: when
peer A in a Y.Doc room saves the file, peer B already has the new
content via CRDT sync — but B's AppWrapper.currentETag is stale.
B's next Ctrl+S / Save action / autosave PUT carries B's stale
`previousEntityTag` and the server 412s with a "file changed outside
this window" error. The user sees a popup for a conflict that
isn't really a conflict (content matches, etag doesn't).

Adds a Vue provide/inject contract on AppWrapper:

  // AppTemplates/types.ts
  export const appWrapperEtagSyncKey: InjectionKey<{
    setCurrentETag(etag: string): void
  }>

CollaborativeWrapper injects it (null when used standalone, no
regression) and pushes every `_oc_meta.etag` change through it. The
etag mirror watch already fires on both own-saves and peer-saves
(own writes go resource.etag → _oc_meta.etag, peer writes arrive
through CRDT). The setter is idempotent so the own-save round-trip
is a no-op.

Net effect: in a multi-peer text-editor session, anyone in the room
can save without colliding on the etag — the wrapper keeps everyone's
private AppWrapper.currentETag aligned with reality.

Doesn't address external writes (a non-collab sync client or another
editor app writing the file mid-session) — those bypass the CRDT, the
sidecar has to poll or be pushed. Tracked separately.
…ppWrapper

The etag-sync inject in Phase 4.5 forwarded every `_oc_meta.etag`
change to AppWrapper. That included the value the sidecar's SQLite
extension applies during the first onSynced — which can be OLD if the
file was modified externally between sessions or if the persistence
just lagged behind.

Sequence that broke a user's own first save:

1. AppWrapper loaded the file → currentETag = current native (NEW)
2. CollaborativeWrapper connected → onSynced fires
3. Persisted Y.Doc state arrives, including meta.etag = OLD and
   meta.isStale = true (sidecar's onLoadDocument flagged the drift)
4. metaObserver fires for both keys synchronously
5. My etag handler forwarded OLD → AppWrapper.currentETag = OLD
6. recoverFromStaleState (async, runs later) wipes + rehydrates +
   sets meta.etag back to NEW, but at this point the user has already
   clicked Save → PUT with previousEntityTag = OLD → server has NEW
   → 412 "file updated outside this window"

Gate the forward on `meta.isStale !== true`. While stale-recovery is
pending, the persisted etag is known-bad — let recovery clear the
flag and write the native etag, then the next observer fire forwards
the correct value.

Doesn't affect the peer-save path (their writes don't carry isStale)
or the own-save mirror (we never set isStale ourselves).
…aved etags"

Revert "docs(realtime-collab): mark Phase 4.5 done"
Revert "fix(realtime-collab): don't forward stale etag from initial sync to AppWrapper"

The etag-sync-via-CRDT approach turned out brittle in practice. Pushing
peer-saved etags through the inject + metaObserver path crossed too
many race windows (warm-room persisted state, sidecar SQLite cruft,
etag format differences between OC's endpoints) and produced false
412s on the user's OWN save in their OWN window.

Reverting to a clean slate. A simpler refetch-on-412 path inside
AppWrapper.saveFileTask follows in the next commit: when a save 412s,
re-fetch the file, compare content, retry the save with the fresh
etag if content matches (typical collab case — Y.Doc is already
synced, only the etag tracking was stale). Avoids the wrapper having
to second-guess what's in `_oc_meta` and when.
When putFileContents returns 412 or 409, the user's previousEntityTag
no longer matches the server. In a collaborative session this usually
means another peer just saved; the editor's Y.Doc state already has
their edits propagated via CRDT, so the right response is to refetch
the file, grab the fresh etag, and retry the save with our combined
content. Falls back to the existing conflict popup only when the
refetch or retry itself fails.

Three outcomes:
- Server content equals what we tried to save: the etag was stale but
  there is no real conflict. Reconcile silently, update currentETag
  and serverContent, no user-visible noise.
- Server content differs (typical collab case after a peer save):
  retry the PUT with the fresh etag. Publishes our state on top.
- Refetch or retry also errors: show the original conflict popup so
  the user can recover by copying their changes.

Replaces the etag-sync-via-CRDT approach of the reverted Phase 4.5
patch; reaching across the wrapper into AppWrapper's private state
turned out too brittle in practice.
@dschmidt dschmidt force-pushed the feat/realtime-collaboration-poc branch from dd0b0d2 to d26d836 Compare May 19, 2026 22:01
@dschmidt dschmidt changed the title feat(realtime-collab): land collaborative editing into web feat(yjs): land collaborative editing into web May 19, 2026
dschmidt added 5 commits May 20, 2026 00:16
Web's central vite.config bundles every web-app-* in a single build
against one pnpm-resolved tree, so yjs / y-prosemirror / @tiptap/*
are naturally deduplicated at build time. The dedupe entries added
during the migration came from the web-extensions PoC where each
app was an independent Module Federation remote and the duplication
was real.

External, federated apps shipped via extension-sdk would still
re-bundle their own copies. That case needs a separate decision
(externalizing the shared deps in the SDK vs forcing all collab to
go through this wrapper); noted in REALTIME_COLLAB_MIGRATION.md.

vue3-gettext stays in dedupe because it was there before this PR.
When peer A saves, peer B should not be prompted to save the same
content and should not 412 on its next own save. Both fall out of one
mechanism: the etag-mirror watch tags its meta-write with a
`LOCAL_SAVE_ORIGIN` transaction origin, the meta-observer fans out to
AppWrapper only when the change came in via CRDT (origin != local).

Two emits, same shape as `update:currentContent`:

- `update:serverContent` carries the freshly-saved content snapshot.
  AppWrapper sets `serverContent.value = value`, so `isDirty`
  (currentContent !== serverContent) flips false and the
  unsaved-changes modal stops firing on navigate.

- `update:etag` carries the new etag. AppWrapper sets
  `currentETag.value = value`, so the next PUT's `If-Match` is current
  and we skip the 412 -> refetch -> retry recovery path.

Also: AppWrapper's `saveFileTask` now updates the local `resource` ref
after each successful save (happy path + 412-reconcile + 412-retry).
Previously only `serverContent`, `currentETag` and the resourcesStore
were touched -- `resource.value` stayed stale, so the etag-mirror watch
in CollaborativeWrapper (keyed on `props.resource.etag`) never fired
for the saver and the fan-out never reached the peer.

Both emits self-heal: a wrong etag 412s, refetch+retry fixes it; a
wrong serverContent snapshot gets corrected by the next currentContent
emit. No race-prone gates required.
13 specs covering local + collab mode, hydration gating, debounced
emit, internal-origin filtering, etag mirror + the Y.Doc-rebuild
regression, and lifecycle teardown. HocuspocusProvider is mocked so
the suite stays hermetic; useAuthStore / useConfigStore are mocked
so we don't need pinia. A tiny inline adapter mimics codemirror's
Y.Text-on-'content' shape -- the wrapper is adapter-agnostic, so any
concrete implementation exercises the same contract.

Also: update the migration plan to mark Phase 4.5 done with a note on
the two actual mechanisms (refetch+retry on 412/409 + the new
_oc_meta fan-out of `update:serverContent` / `update:etag`) that
replaced the original etag-sync inject design.
Ports the 5 Playwright specs from the web-extensions PoC to web's
cucumber suite plus a new cross-peer fan-out scenario covering the
Phase 4.5 mechanism. 7 scenarios, 87 steps, ~21s run.

Features:
- codemirror-open: open + status + content, multi-file navigation
- codemirror-save-back: type + Ctrl+S persists via WebDAV
- codemirror-multi-user: shared file, peer caret on line N labelled by
  the server-stamped name, CRDT typing propagation
- tiptap-empty-file: empty .md opens cleanly and accepts input
- tiptap-open: Markdown rendered as rich text (headings, marks)
- cross-peer-fan-out: A saves, B's next save uses the fresh etag
  (covers the new update:serverContent + update:etag emits)

Shared infrastructure (small, only what the suite needs):
- `support/objects/app-files/utils/collab.ts`: status-strip + per-
  editor content selectors, codemirror line + remote-caret helpers.
- `cucumber/steps/ui/collaboration.ts`: realtime status check, content
  assertions, typing, Ctrl+S save, remote-caret-with-label assertion,
  WebDAV content assertion.
- `support/api/davSpaces/getFileContentInPersonalSpace`: new helper
  for the post-save WebDAV round-trip assertion.

Plumbing:
- `cucumber/steps/ui/resources.ts`: extend the existing
  `opens file X via Y using the context menu` step's allowed viewers
  with `code-mirror` and `tiptap` (kebab-case of `appInfo.name`).
- `cucumber.mjs`: scope the cucumber import glob to `tests/e2e/cucumber/**`
  and `tests/e2e/support/**` so a leftover playwright-mf scratch dir
  outside those paths doesn't break the runner.
Unifies dev and CI behind a single routing model: OC's reverse proxy
WebSocket-upgrades incoming /realtime requests and forwards them to
the hocuspocus sidecar via an `additional_policies` entry, the same
pattern opencloud-music uses. Drops the Traefik labels on the dev
hocuspocus service; CI now has parity without needing a Traefik
sidecar of its own.

Dev:
- `dev/docker/opencloud/proxy.yaml`: add the /realtime route (alongside
  the existing radicale routes).
- `docker-compose.yml`: remove the hocuspocus Traefik labels +
  depends_on, keep the service on the traefik network so OC can reach
  it by name.

CI:
- `tests/woodpecker/proxy.yaml`: new file, /realtime route only.
- `.woodpecker.star`: cp proxy.yaml into OC's config dir during init,
  define `hocuspocusService()` (plain HTTP node service, no TLS),
  wire it into a new `collab` e2e suite that runs alongside OC.
- `tests/woodpecker/config-opencloud.json`: register codemirror +
  tiptap so OC loads them.
- Doc the unified routing in REALTIME_COLLAB_MIGRATION.md Phase 5.

Verified locally: full 7/7 cucumber collab suite still green after
removing the Traefik labels; OC's proxy handles the WS upgrade
transparently.
CI surfaced two gaps from the migration:

- Prettier formatting drift across 9 files (touched during the PoC
  port but not run through the project's prettier config). Reformat
  in place; no behavioural change.

- vue-tsc TS7011 on the text-editor app stub: the inline strategy
  mock returned `() => []` for `extensions` / `editorActionGroups`,
  which TS infers as `() => any[]` and rejects under the strict
  config. Add explicit `: unknown[]` return annotations -- the
  shape doesn't matter to this test (it only asserts the wrapper
  mounts), it just needs to satisfy the interface.

Local: prettier clean, `pnpm check:types` green, the affected unit
specs (CollaborativeWrapper.spec.ts + app.spec.ts) still pass.
@dschmidt dschmidt changed the title feat(yjs): land collaborative editing into web feat(yjs): collaborative editing framework May 20, 2026
Adds a third collaborative editor app (alongside web-app-codemirror
and web-app-tiptap) backed by Excalidraw + y-excalidraw. Validates
the CollaborativeWrapper / CollaborativeAdapter contract against a
fundamentally different shape: Y.Array<Y.Map> with fractional-
indexing keys (vs Y.Text for codemirror and Y.XmlFragment for tiptap),
React-only library mounted via vanilla createRoot inside a Vue shell.

The wrapper itself, CollaborativeAdapter interface, types.ts, and
hocuspocus integration stay untouched. The app slots in additively.

Package layout (~420 LOC src + 70 LOC cucumber):
- src/index.ts: OC app registration + newFileMenu entry
- src/App.vue: CollaborativeWrapper shell + emit forwarders
- src/ExcalidrawEditor.vue: Vue shell mounting React via createRoot,
  re-rendering on ydoc/awareness identity change (so file navigation
  without app teardown still works). veaury was tried first but
  v2.6.x crashes on React 19 inside __veauryMountReactComponent__.
- src/react_app/ExcalidrawCanvas.tsx: Excalidraw + y-excalidraw's
  ExcalidrawBinding (element-level CRDT, awareness wiring, optional
  Y.UndoManager). Test-only window.__excalidrawAPI hook for e2e to
  read scene state without canvas DOM introspection.
- src/adapters/excalidrawAdapter.ts: CollaborativeAdapter impl —
  hydrate JSON into Y.Array via y-excalidraw helpers + fractional-
  indexing positions; serialize back to standard .excalidraw JSON
  (interoperable with excalidraw.com, Obsidian plugin, mschneider82's
  standalone, etc.).

Build wiring:
- vite.config.ts: @vitejs/plugin-react scoped to the package's tsx
  files (Vue + React coexist cleanly, no extension overlap); react /
  react-dom dedupe + alias to the package's installed copy so rolldown
  resolves tunnel-rat/zustand's react peer; custom plugin to mirror
  upstream @excalidraw/excalidraw/dist/prod/{fonts,locales,data} into
  dist/excalidraw-assets/ so EXCALIDRAW_ASSET_PATH (set via
  new URL('../excalidraw-assets/', import.meta.url) in the React canvas)
  resolves correctly under any OC subpath deployment. vite-plugin-
  static-copy's stripBase + rename flatten nested dirs when used with
  **/* globs, so the mirror runs via writeBundle.
- Root tsconfig.json: jsx: react-jsx
- Root package.json: react + react-dom hoisted so pnpm's deep peer-
  resolved dirs are reachable from the rolldown bundle.

OC app registration in dev/docker/opencloud.web.config.json and
tests/woodpecker/config-opencloud.json (apps[] += 'excalidraw').

Cucumber coverage (3 scenarios, all green):
- excalidraw-open: blank + pre-seeded scene hydrates from file
- excalidraw-multi-user: shape created by Alice via the imperative
  API propagates to Brian's scene through the y-excalidraw binding

New cucumber steps (in collaboration.ts) that work against any
Excalidraw consumer: "should see the excalidraw canvas mounted",
"should see (N|at least N) elements in the excalidraw scene",
"adds a rectangle to the excalidraw scene via the API". The last
uses the window.__excalidrawAPI test hook because canvas pointer
interaction is brittle in Playwright.

Notes for later (informed by audit of y-excalidraw's source and
the Excalidraw CSP situation):
- y-excalidraw is element-level CRDT (whole-element overwrite on
  concurrent same-element edits) by design — matches Excalidraw's
  own cloud and the team's blog post on why field-level is hard.
- Excalidraw's font fallback chain always ends with esm.sh, so a
  CSP that blocks esm.sh is fine as long as our self-hosted primary
  URLs serve correctly. With the asset mirror in place the fallback
  is never fetched.
- mschneider82/opencloud-excalidraw was reviewed as a reference for
  the React-mount + UIOptions pattern. Our package is from-scratch
  on our framework; will attribute mschneider in the PR description
  and ask if more is wanted.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant