diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000000..97b1bec15d
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,8 @@
+**/node_modules
+**/dist
+.git
+.pnpm-store
+release
+reports
+tests/e2e/.features-gen
+third-party-licenses
diff --git a/AGENTS.md b/AGENTS.md
index 786f239c14..c0f14faadb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -19,6 +19,8 @@ OpenCloud Web is a TypeScript/Vue 3 single-page application that serves as the b
│ ├── tsconfig/ # Shared TypeScript configuration
│ ├── extension-sdk/ # Utilities for custom extensions
│ └── web-app-*/ # Standalone apps/extensions (files, search, preview, …)
+├── services/ # Server-side sidecars shipped as their own docker images
+│ └── realtime/ # Hocuspocus/Yjs server for realtime collaboration
├── tests/
│ └── e2e/ # End-to-end tests (Playwright + Playwright BDD)
├── dev/ # Docker/infrastructure config for local development
diff --git a/dev/docker/opencloud/proxy.yaml b/dev/docker/opencloud/proxy.yaml
index 543e108350..6224cd5fac 100644
--- a/dev/docker/opencloud/proxy.yaml
+++ b/dev/docker/opencloud/proxy.yaml
@@ -1,9 +1,12 @@
-# This adds four additional routes to the proxy. Forwarding
-# request on '/carddav/', '/caldav/' and the respective '/.well-knwown'
-# endpoints to the radicale container and setting the required headers.
+# This adds five additional routes to the proxy: '/realtime' to the realtime
+# container, and '/caldav/', '/carddav/' plus their respective '/.well-known'
+# endpoints to radicale. Also sets the headers those backends require.
additional_policies:
- name: default
routes:
+ - endpoint: /realtime
+ backend: http://realtime:1234
+ unprotected: true
- endpoint: /caldav/
backend: http://host.docker.internal:5232
remote_user_header: X-Remote-User
diff --git a/dev/docs/collaboration.md b/dev/docs/collaboration.md
new file mode 100644
index 0000000000..6312dbd193
--- /dev/null
+++ b/dev/docs/collaboration.md
@@ -0,0 +1,374 @@
+# Realtime collaboration
+
+This doc explains the architecture and implementation of realtime collaboration in OpenCloud Web. It is intended for developers who want to understand how it works, or who want to implement their own collaborative app on top of the same infrastructure.
+
+## Yjs
+
+Collaboration is built on [Yjs](https://yjs.dev/), a CRDT framework for building collaborative applications. Yjs provides the data structures and algorithms for merging concurrent edits from multiple clients, ensuring that all clients eventually converge to the same state.
+
+## System view
+
+```mermaid
+flowchart TB
+ subgraph browsers["Browsers"]
+ direction LR
+ A["Client A
web + text-editor"]
+ B["Client B
web + text-editor"]
+ end
+
+ subgraph oc["OpenCloud"]
+ direction TB
+ GRAPH["LibreGraph API"]
+ DAV["WebDAV"]
+ end
+
+ HP["Yjs server
in-memory replica per room
never touches the file"]
+
+ A -->|"1 . load + save file
GET / PUT, If-Match etag"| DAV
+ B -->|"1 . load + save file"| DAV
+ A <-->|"2 . CRDT sync + awareness
websocket + bearer token"| HP
+ B <-->|"2 . CRDT sync + awareness"| HP
+ HP -->|"3 . who are you?"| GRAPH
+ HP -->|"4 . may you write this?"| GRAPH
+
+ classDef client fill:#e8f0fe,stroke:#4285f4,color:#111
+ classDef server fill:#e6f4ea,stroke:#34a853,color:#111
+ classDef side fill:#fef7e0,stroke:#f9ab00,color:#111
+ class A,B client
+ class GRAPH,DAV server
+ class HP side
+```
+
+### The Yjs server
+
+The [OpenCloud Yjs server](https://github.com/opencloud-eu/web/tree/main/services/realtime) runs a [Hocuspocus](https://tiptap.dev/docs/hocuspocus) server that relays Yjs updates between clients editing the same file.
+
+It is reachable at whatever URL the deployment configures. In the Web dev setup that is `/realtime` on the OpenCloud host, forwarded by OC's own proxy, but it could equally be a separate domain behind its own ingress. The server URL needs to be defined via the `WEB_OPTION_YJS_SERVER_URL` environment variable.
+
+| It does | It does not |
+| ------------------------------------------------- | ------------------------------------------------ |
+| Hold an in-memory replica and sync it with peers | Persist anything (no storage extension is wired) |
+| Authenticate the bearer token against Graph `/me` | Read or write the file |
+| Enforce read/write access per file, per user | Decide when to save |
+| Stamp the authenticated identity onto awareness | Serialize the CRDT back to text |
+| Reject peers running a mismatched app version | Survive a restart, or an empty room |
+
+Notably it never converts the CRDT back into file content. That only happens in a browser, through the app's adapter - which is why saving needs a client to be online.
+
+### Access control
+
+`onAuthenticate` runs per connection, before any document data flows:
+
+```mermaid
+sequenceDiagram
+ participant C as Client
+ participant HP as Yjs server
+ participant G as Graph API
+
+ C->>HP: connect(room, token, appVersion)
+ HP->>G: GET /graph/v1.0/me
+ G-->>HP: user identity
+ HP->>G: GET drives/{driveId}/items/{itemId}/permissions
+ G-->>HP: allowed actions
+ alt 401 / 403 / 404
+ HP-->>C: reject - access denied
+ else
+ HP->>HP: appVersion matches the room's baseline?
+ Note over HP: first client into a room sets the baseline
+ alt mismatch
+ HP-->>C: reject - please reload
+ else write action present
+ HP-->>C: accept (read-write)
+ else read only
+ HP-->>C: accept (readOnly)
+ end
+ end
+```
+
+The version baseline is recorded only once identity and access are settled. Doing it any earlier let an unauthenticated caller name any room, claim a version nobody else runs, and lock every legitimate client out of that file until the process restarted - the entry is cleared by `onDisconnect`, which never fires for a connection that was rejected.
+
+The room name is `::$!`. The prefix is `collaborative.documentPrefix`, defaulting to the app's `applicationId`. The file id is `resource.id`, which is already global: a share recipient sees the same composite id as the owner, so both land in the same room. Note that this must not be `resource.remoteItemId` - `AppWrapper` fills that with the share space id, which identifies the mount point rather than the file, so every file inside a shared folder would collapse into one room. The server strips the `::` part before parsing, so the ACL probe targets the real file. The prefix exists so two editors with incompatible Y.Doc layouts (Tiptap's `Y.XmlFragment` vs CodeMirror's `Y.Text`) never share a room for the same file.
+
+Awareness is anti-spoofed: `beforeHandleAwareness` overwrites the `user` field on every inbound awareness state with the identity from the authenticated connection, so a client cannot present itself as someone else.
+
+## Inside `web`
+
+Ownership in one line: **`AppWrapper` owns the file and the session; the app owns the editor and the format.**
+
+```mermaid
+flowchart TB
+ subgraph app["Extension - e.g. packages/web-app-text-editor"]
+ IDX["index.ts
AppWrapperRoute(App, { collaborative })"]
+ ADP["the adapter
hydrate · serialize
hasContent · reset"]
+ APP["App.vue
useTextEditor(ydoc, awareness)"]
+ end
+
+ subgraph pkg["web-pkg"]
+ AW["AppWrapper.vue
load · save · etag · dirty · autosave"]
+ UCD["useCollaborativeDocument
Y.Doc · provider · hydration"]
+ UTE["useTextEditor
Tiptap + Collaboration"]
+ end
+
+ YDOC[("Y.Doc
XmlFragment 'default'
Map '_oc_meta'")]
+ HP["Yjs server"]
+ DAV["WebDAV"]
+
+ IDX -->|"collaborative: { appVersion, makeAdapter }"| AW
+ AW -->|"makeAdapter(), once in setup"| ADP
+ ADP -->|"CollaborativeAdapter"| UCD
+ AW --> UCD
+ UCD <-->|"reads + writes through the adapter"| YDOC
+ UCD <-->|"sync + awareness"| HP
+ AW -->|"slot props:
ydoc · awareness · resource
currentContent · isReadOnly"| APP
+ APP --> UTE
+ UTE <-->|"@tiptap/extension-collaboration"| YDOC
+ AW <-->|"GET / PUT"| DAV
+
+ classDef appc fill:#e8f0fe,stroke:#4285f4,color:#111
+ classDef pkgc fill:#e6f4ea,stroke:#34a853,color:#111
+ classDef data fill:#fce8e6,stroke:#ea4335,color:#111
+ classDef ext fill:#fef7e0,stroke:#f9ab00,color:#111
+ class IDX,ADP,APP appc
+ class AW,UCD,UTE pkgc
+ class YDOC data
+ class HP,DAV ext
+```
+
+### Opting in
+
+An app turns on collaboration with one route option.
+
+```ts
+// packages/web-app-text-editor/src/index.ts
+AppWrapperRoute(TextEditor, {
+ applicationId: 'text-editor',
+ collaborative: {
+ appVersion: pkg.version,
+ makeAdapter: makeTextEditorAdapter
+ }
+})
+```
+
+### What happens when you open a file
+
+1. WebDAV loads the file → gets saved to `currentContent` (`AppWrapper`)
+2. Session creates the Y.Doc (empty, and it exists from here on) (`useCollaborativeDocument`, invoked by `AppWrapper`)
+3. `HocuspocusProvider` gets handed the Y.Doc and connects to the Yjs server and syncs (`useCollaborativeDocument`)
+
+From there on, first client:
+
+1. `hasContent(ydoc)` is `false` → this client wins the election → `adapter.hydrate` (`collabAdapter.ts`)
+2. `deserialize` (md/html pass through, json `JSON.parse`, plain-text builds the ProseMirror JSON itself) (`collabAdapter.ts`)
+3. Headless editor is constructed with the strategy's extensions + Collaboration bound to the existing Y.Doc - `ySyncPlugin` attaches here (`collabAdapter.ts`)
+4. `setContent` builds the ProseMirror tree, guided by `contentType` and the schema (`collabAdapter.ts`)
+5. `ySyncPlugin` sees that transaction and writes the tree into the Y.XmlFragment
+6. Y.Doc lives locally, and syncs to the Yjs server if a provider exists
+
+Both lists run _after_ the version handshake and the etag drift check, which can short-circuit into a reload lock or into stale recovery before hydration is ever considered.
+
+Client joining afterwards:
+
+1. `hasContent(ydoc)` is `true` → return early, no hydration, no election, no 150 ms wait. (`useCollaborativeDocument.ts`)
+2. Y.Doc lives locally, and syncs to the Yjs server if a provider exists
+
+### Y.Doc
+
+Y.Doc is the CRDT that holds the shared state. It is a tree of shared types, and the editor content lives in a `Y.XmlFragment` named `"default"`. The session also maintains a `Y.Map` named `"_oc_meta"` for coordination and metadata.
+
+There is no single global document. Every participant holds its own `Y.Doc`, and the server holds one too:
+
+```mermaid
+flowchart LR
+ subgraph room["One room = one file, one editor app"]
+ direction LR
+ DA[("Client A
Y.Doc")]
+ DS[("Yjs server
Y.Doc
memory only")]
+ DB[("Client B
Y.Doc")]
+ DA <-->|"updates"| DS
+ DS <-->|"updates"| DB
+ end
+
+ F[("File on disk
WebDAV")]
+ DA -.->|"hydrate: read once"| F
+ DA -.->|"save: serialize + PUT"| F
+ DB -.->|"save: serialize + PUT"| F
+
+ classDef rep fill:#fce8e6,stroke:#ea4335,color:#111
+ classDef file fill:#e6f4ea,stroke:#34a853,color:#111
+ class DA,DB,DS rep
+ class F file
+```
+
+All three are convergent replicas of the same CRDT. None is authoritative - that is the point of a CRDT: updates applied in any order end up at the same state. The server's copy is not a coordinator, it is a participant that happens to always be present.
+
+It exists for two reasons. It gives a late joiner the room's current state in one initial sync, without any peer having to notice and re-send. And because it is a real `Y.Doc`, server-side hooks could read `_oc_meta` if a future stale probe ever needed to run server-side. Nothing does today.
+
+The server's replica is memory-only - no storage extension is wired up. When the last client disconnects, the room empties and that copy is gone. Reconnecting later starts from a blank server doc, which is why hydration runs again. That is a deliberate trade - see [Known limits](#known-limits).
+
+### The CollaborativeAdapter
+
+The app provides a `CollaborativeAdapter` to the session, which is how the session reads and writes the Y.Doc. The adapter is responsible for converting between the native file format and the Y.Doc's shared types.
+
+```ts
+interface CollaborativeAdapter {
+ hydrate(ydoc: Y.Doc, content: string): void | Promise
+ serialize(ydoc: Y.Doc): string | Promise
+ hasContent(ydoc: Y.Doc): boolean
+ reset?(ydoc: Y.Doc): void
+}
+```
+
+`makeAdapter` runs during `AppWrapper`'s setup - before the file is loaded - so it receives a reactive context (`{ resource: Ref }`) and reads it lazily. It must run in setup because content strategies call `useGettext()`, which is why `makeTextEditorAdapter` resolves its strategies eagerly and only picks between them per call.
+
+### Startup order
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant AW as AppWrapper
+ participant DAV as WebDAV
+ participant S as useCollaborativeDocument
+ participant HP as Yjs server
+ participant APP as App.vue
+
+ AW->>DAV: PROPFIND - resource info
+ DAV-->>AW: resource (id, etag, permissions)
+ AW->>DAV: GET - file body
+ DAV-->>AW: currentContent + OC-ETag
+ Note over AW,S: only now is the session enabled -
hydration seeds from currentContent
+ AW->>S: enabled = true
+ S->>HP: connect(room, token, appVersion)
+ HP-->>S: authenticated
+ S->>HP: initial sync
+ HP-->>S: onSynced
+ S->>S: version handshake · etag drift check · hydration election
+ Note over S: winner runs adapter.hydrate(ydoc, currentContent)
+ S-->>AW: isReady = true
+ Note over AW: loading screen drops, slot renders
+ AW->>APP: ydoc + awareness (non-null, hydrated)
+ APP->>APP: useTextEditor binds Tiptap to the Y.Doc
+```
+
+`AppWrapper` keeps its loading screen up until the session reports ready, so `App.vue` mounts against a Y.Doc that is already synced and hydrated. That is what lets the app declare `ydoc` and `awareness` as required props and call `useTextEditor` directly, with no placeholder of its own.
+
+### Hydration
+
+Hydration is the process of taking the file content and seeding it into the Y.Doc. It usually only happens once per room, and only if the room is empty. The first client to arrive into an empty room runs `adapter.hydrate(ydoc, currentContent)`. All other clients skip hydration and wait for the Yjs server to sync them up. A state recovery may re-run hydration if the room turns out to be stale, i.e. `_oc_meta.etag` no longer matches the etag the joining client just fetched.
+
+Two clients can arrive into an empty room at the same moment, and only one may seed it - otherwise the content lands twice. The elected client is the one with the lowest Yjs `clientID`, after a 150 ms pause to let peers announce themselves. The winner sets `_oc_meta.hydrated` before it seeds, so peers can react to the incoming content rather than merge with it.
+
+### Stale recovery
+
+When a joining client finds that `_oc_meta.etag` no longer matches the etag it just fetched, the file changed outside the room. It stamps `nativeEtag`, claims the job via `recoveryClientId` and raises `isStale`. Every peer's meta observer fires, but only the elected one gets through: recovery wipes the room and re-seeds it, and the only peer holding the body behind `nativeEtag` is the one that just fetched the file. Any other peer would publish the copy it opened with - or its own last serialization - and then stamp the fresh etag onto it, so the next save would overwrite the external writer with a matching `If-Match` and no conflict.
+
+The elected peer captures that body at detection time rather than reading `currentContent` when recovery runs. By then the room has synced its own state into the Y.Doc and the debounced serialize has reported it straight back into `currentContent`.
+
+`recoveryClientId` is last-write-wins, so if several clients detect the same drift at once, exactly one of them survives convergence. A peer that joins while `isStale` is already up offers itself the same way, provided its etag matches `nativeEtag` - the observer only fires on change, so without that the room would stay stuck if the elected peer navigated away mid-recovery.
+
+Reset lands before hydrate, so a throw in between leaves every peer looking at an empty document. That path keeps `isStale` up for the next joiner to retry and locks the session, which is what stops the autosave from writing the emptiness to disk.
+
+### `_oc_meta`
+
+A `Y.Map` alongside the editor content, used for coordination the editor never sees:
+
+| Key | Written by | Meaning |
+| ------------------ | ------------------- | --------------------------------------------- |
+| `etag` | whoever saved last | the etag the room believes is on disk |
+| `lastSavedAt` | whoever saved last | fan-out trigger for a peer save |
+| `savedStateVector` | whoever saved last | what that peer's doc held when it wrote |
+| `appVersion` | first peer in | schema version the room is running |
+| `isStale` | any writer | the file changed outside this room; rehydrate |
+| `nativeEtag` | writer that noticed | the etag recovery should settle on |
+| `recoveryClientId` | writer that noticed | which peer is elected to re-seed the room |
+| `hydrated` | the seeding peer | someone is seeding the room right now |
+
+Every key lives in the shared Y.Doc, so a read-only peer's writes to it are
+rejected by the Yjs server along with everything else. Staleness noticed by a
+read-only peer alone therefore never reaches the room.
+
+### Saving
+
+Saving is unchanged from single-user editing: `AppWrapper` PUTs over WebDAV with `If-Match`. Collaboration only changes where the content comes from and how conflicts resolve.
+
+```mermaid
+sequenceDiagram
+ participant U as User A
+ participant SA as Session A
+ participant AWA as AppWrapper A
+ participant HP as Yjs server
+ participant SB as Session B
+ participant AWB as AppWrapper B
+ participant DAV as WebDAV
+
+ U->>SA: types
+ SA->>HP: CRDT update
+ HP->>SB: CRDT update
+ Note over SA: 300 ms after typing stops
+ SA->>SA: adapter.serialize(ydoc)
+ SA->>AWA: onContentChange
+ Note over AWA: currentContent updated,
isDirty flips to true - no PUT yet
+ Note over AWA: later: Ctrl+S or the 120 s autosave
+ AWA->>DAV: PUT If-Match: etag
+ DAV-->>AWA: new etag
+ AWA->>SA: resource.etag changed
+ SA->>HP: _oc_meta.etag + savedStateVector + lastSavedAt
+ HP->>SB: meta update
+ SB->>SB: did that save cover my edits?
+ SB->>AWB: onServerContentChange + onEtagChange
+ Note over AWB: isDirty drops to false,
next If-Match is already correct
+```
+
+A peer's save only makes B clean if it actually contains B's work. What A wrote is what _A's_ doc serialized to, so `savedStateVector` carries A's Yjs state vector at write time and B compares its own client clock against it. Covered means B contributed nothing A was missing, so B has nothing left to save. Not covered means B typed something A's PUT never saw, and B stays dirty - dropping the flag there would also unregister `beforeunload` and wave the route-leave guard through, losing the edit with the tab. The etag is mirrored either way: it is factual, and it keeps B's next `If-Match` correct.
+
+Only B's own client id is compared. A third peer's unsaved operations are that peer's dirty state to track.
+
+The 300 ms debounce only refreshes `currentContent`, which is what drives `isDirty`. It never triggers a PUT on its own. The actual write comes from a manual save (Ctrl+S) or the autosave timer (default 120 s), and both go through the same path. If a PUT comes back 409/412, what happens depends on whether a realtime session is actually connected. With one, `AppWrapper` refetches, compares, and retries once with the fresh etag before falling back to a conflict message - the local Y.Doc already contains the peer's edits, so the retry publishes the merged state. Without one - a plain editor, or a deployment with no `yjsServerUrl` - there is nothing to merge, so the conflict dialog comes up straight away, exactly as it did before collaboration existed. Retrying there would silently overwrite whoever else wrote the file.
+
+### Local mode
+
+When `options.yjsServerUrl` is unset the session still creates a `Y.Doc` and a standalone `Awareness`, skips the provider, and hydrates immediately. The Y.Doc binding is the same in both modes, so adapters and editor extensions need no branch. Two things do differ: no peers ever appear, and `useTextEditor` re-reads `yjsServerUrl` itself to decide whether to drop the source-mode action. That is what "collaboration disabled" means here - the editor works as it always did, it just never syncs.
+
+A session configured for collaboration ends up here too when the server does not answer. A provider that cannot reach its server emits neither `onSynced` nor `onAuthenticationFailed` - it just keeps retrying - so nothing would ever release the loading gate. After `CONNECT_TIMEOUT_MS` the session gives up, disconnects the provider, hydrates locally and surfaces an error saying changes will not be shared. The file stays editable and saveable; only syncing is gone, and a reload is what retries. The provider is disconnected rather than destroyed, because the editor binds to its awareness; and it is not left retrying, because a late connect would merge the locally hydrated copy into a room that may already hold the same content and duplicate the document for everyone.
+
+### File map
+
+| Path | Role |
+| ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
+| `packages/web-pkg/src/composables/collaborative/useCollaborativeDocument.ts` | the session: Y.Doc, provider, hydration, staleness, version gate, etag mirror |
+| `packages/web-pkg/src/composables/collaborative/types.ts` | `CollaborativeAdapter` |
+| `packages/web-pkg/src/components/AppTemplates/AppWrapper.vue` | owns the session, the save loop and the loading gate |
+| `packages/web-pkg/src/components/AppTemplates/types.ts` | `CollaborativeOptions`, `CollaborativeAdapterContext`, slot args |
+| `packages/web-pkg/src/editor/collabAdapter.ts` | `makeTiptapCollabAdapter` - any strategy to a Y.Doc |
+| `packages/web-pkg/src/editor/composables/useTextEditor.ts` | binds Tiptap to a Y.Doc and renders peer carets |
+| `packages/web-app-text-editor/src/collab.ts` | the text editor's adapter and content-type detection |
+| `services/realtime/src/server.ts` | the Yjs server (Hocuspocus) |
+
+---
+
+## Known limits
+
+These are open items, not bugs to be surprised by.
+
+**Durability depends on an open browser.** The Yjs server holds no state and does not save. If every client closes between autosaves, edits made since the last save are lost with the room. A server-side flush would require `serialize` to run in Node, which the Tiptap-based adapter cannot do today.
+
+**Every peer autosaves.** A remote edit marks _your_ `AppWrapper` dirty, so with N clients open the same document gets saved N times per autosave interval. There is election for hydration but none for saving.
+
+**Peer edits arm your unsaved-changes guard.** Same root cause: remote CRDT updates flow into `currentContent`, so `beforeunload` and the unsaved-changes modal fire for edits you did not make. Writers only - `isDirty` is hard-wired to `false` for a read-only client, which has nothing to save.
+
+**Hydration election can race.** The 150 ms awareness-settle window is heuristic. If awareness has not propagated in time, two peers can both elect themselves and hydrate, duplicating content. Server-side hydration would remove the whole class.
+
+**`_oc_meta` is writable by any client with write access.** A buggy or hostile client can set `appVersion` or `isStale` and lock or reset every peer in the room. The room's control plane has no server authority beyond the read-only gate.
+
+**`appVersion` is `0.0.0`.** `web-app-text-editor` is a private package with a placeholder version, so the version gate never actually fires for it.
+
+**Source mode is disabled in collab.** It swaps the ProseMirror view for a plain textarea, which has no Y.Doc binding, so `useTextEditor` drops the `source-mode` action whenever a realtime session is active. Marked as a `FIXME`.
+
+**Connection state is invisible.** The session exposes a `status` ref (`connecting` / `connected` / `disconnected` / `local`). `AppWrapper` reads it to decide whether a save conflict can be reconciled, but never shows it. A websocket that drops _after_ a successful sync produces no indicator: the user keeps typing into a Y.Doc that no longer reaches anyone. Only a connect that never succeeds in the first place is caught, by the timeout above.
+
+**A live session resolves save conflicts in favour of the room.** While connected, a 409/412 is retried with the fresh etag on the assumption that it came from a peer. It usually did, but an external writer - a desktop sync client, say - is overwritten without a prompt. Proving which one it was would mean matching the fetched etag against `_oc_meta.etag`, and that race is roughly a coin flip, so it would turn ordinary peer saves into spurious conflict dialogs.
+
+**`_oc_meta.hydrated` is never cleared.** Stale recovery deletes `isStale`, `nativeEtag` and `recoveryClientId` but leaves `hydrated` set. Harmless today, since the recovered room really is seeded, but it means the flag tracks "this room was ever seeded" rather than "a peer is seeding right now".
+
+**Stale recovery needs someone who holds the fresh body.** Only a peer whose fetched etag matches `nativeEtag` may re-seed the room. If that peer leaves before finishing, the room stays flagged until another client opens the file and picks the job up. Peers already in the room keep editing a document that no longer matches disk in the meantime.
+
+**Bundle weight.** `AppWrapper` imports the session directly, so `yjs`, `y-protocols` and `@hocuspocus/provider` land in web-pkg's main entry - loaded even by users who never open an editor. Moving the session behind a `defineAsyncComponent` would fix it at the cost of some indirection.
diff --git a/docker-compose.yml b/docker-compose.yml
index cce8db887d..df55b61f50 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -83,6 +83,8 @@ x-opencloud-server: &opencloud-service
OCM_OCM_SHARE_PROVIDER_INSECURE: 'true'
OCM_OCM_STORAGE_PROVIDER_INSECURE: 'true'
+ # Yjs collaboration
+ WEB_OPTION_YJS_SERVER_URL: 'wss://host.docker.internal:9200/realtime'
extra_hosts:
- host.docker.internal:${DOCKER_HOST:-host-gateway}
- oc.opencloud.test:${DOCKER_HOST:-host-gateway}
@@ -425,6 +427,33 @@ services:
depends_on:
- stalwart
+ realtime:
+ build:
+ # Repository root, the pnpm lockfile is needed for the install
+ context: .
+ dockerfile: services/realtime/Dockerfile
+ extra_hosts:
+ - host.docker.internal:${DOCKER_HOST:-host-gateway}
+ environment:
+ PORT: '1234'
+ OPENCLOUD_URL: https://host.docker.internal:9200
+ # Dev only: accept the self-signed cert from OC's Traefik
+ NODE_TLS_REJECT_UNAUTHORIZED: '0'
+ # Dev only: allows the integration tests to bypass real OIDC tokens
+ NODE_ENV: development
+ DEV_FAKE_TOKEN: dev-integration-token
+ volumes:
+ # Dev only: edit the server without rebuilding the image
+ - ./services/realtime/src:/app/src:ro
+ networks:
+ # On the traefik network so OC (which is also on it) can reach the
+ # service by name. Browser-facing routing is not done at the Traefik
+ # layer - OC's reverse proxy forwards /realtime via
+ # `additional_policies` in opencloud/proxy.yaml. This keeps the routing
+ # model identical to what CI uses (CI has no Traefik).
+ - traefik
+ restart: unless-stopped
+
volumes:
uploads:
opencloud-config:
diff --git a/package.json b/package.json
index f73355564f..21f330bddf 100644
--- a/package.json
+++ b/package.json
@@ -8,12 +8,12 @@
"scripts": {
"build": "pnpm vite build",
"build:w": "pnpm build --watch",
- "lint": "eslint vite.config.ts '{packages,tests}/**/*.{js,ts,vue}' --color",
+ "lint": "eslint vite.config.ts '{packages,services,tests}/**/*.{js,ts,vue}' --color",
"format:check": "prettier . --config packages/prettier-config/index.js --check",
"format:write": "prettier . --config packages/prettier-config/index.js --write",
"serve": "SERVER=true pnpm build:w",
"test:unit": "NODE_OPTIONS=--unhandled-rejections=throw vitest --config ./tests/unit/config/vitest.config.ts",
- "licenses:check": "license-checker-rseidelsohn --summary --relativeLicensePath --onlyAllow 'Python-2.0;Apache*;Apache License, Version 2.0;Apache-2.0;Apache 2.0;Artistic-2.0;BSD;BSD-3-Clause;CC-BY-3.0;CC-BY-4.0;CC0-1.0;ISC;MIT;MPL-2.0;Public Domain;Unicode-TOU;Unlicense;WTFPL;BlueOak-1.0.0' --excludePackages '@opencloud-eu/eslint-config;@opencloud-eu/prettier-config;@opencloud-eu/tsconfig;@opencloud-eu/web-client;@opencloud-eu/web-pkg;external;web-app-files;text-editor;preview;web-app-ocm;@opencloud-eu/design-system;pdf-viewer;web-app-search;admin-settings;webfinger;web-runtime;@opencloud-eu/web-test-helpers'",
+ "licenses:check": "license-checker-rseidelsohn --summary --relativeLicensePath --onlyAllow 'Python-2.0;Apache*;Apache License, Version 2.0;Apache-2.0;Apache 2.0;Artistic-2.0;BSD;BSD-3-Clause;CC-BY-3.0;CC-BY-4.0;CC0-1.0;ISC;MIT;MPL-2.0;Public Domain;Unicode-TOU;Unlicense;WTFPL;BlueOak-1.0.0' --excludePackages '@opencloud-eu/eslint-config;@opencloud-eu/prettier-config;@opencloud-eu/tsconfig;@opencloud-eu/web-client;@opencloud-eu/web-pkg;external;web-app-files;text-editor;preview;web-app-ocm;@opencloud-eu/design-system;pdf-viewer;web-app-search;admin-settings;webfinger;web-runtime;web-realtime-server;@opencloud-eu/web-test-helpers'",
"licenses:csv": "license-checker-rseidelsohn --relativeLicensePath --csv --out ./third-party-licenses/third-party-licenses.csv",
"licenses:save": "license-checker-rseidelsohn --relativeLicensePath --out /dev/null --files ./third-party-licenses/third-party-licenses",
"vite": "vite",
diff --git a/packages/web-app-epub-reader/src/App.vue b/packages/web-app-epub-reader/src/App.vue
index b6dc16db51..ce5782691b 100644
--- a/packages/web-app-epub-reader/src/App.vue
+++ b/packages/web-app-epub-reader/src/App.vue
@@ -93,13 +93,13 @@
diff --git a/packages/web-app-text-editor/src/collab.ts b/packages/web-app-text-editor/src/collab.ts
new file mode 100644
index 0000000000..2bbd3144e9
--- /dev/null
+++ b/packages/web-app-text-editor/src/collab.ts
@@ -0,0 +1,54 @@
+import { ref, unref } from 'vue'
+import type { Resource } from '@opencloud-eu/web-client'
+import type { CollaborativeAdapter, CollaborativeAdapterContext } from '@opencloud-eu/web-pkg'
+import {
+ makeTiptapCollabAdapter,
+ useContentStrategy,
+ type ContentType,
+ type ContentTypeStrategy,
+ type TextEditorLinkPanelRequest,
+ type TextEditorState
+} from '@opencloud-eu/web-pkg/editor'
+
+/** The content types this app can produce. A subset of {@link ContentType}. */
+export type TextEditorContentType = Extract
+
+export function detectContentType(resource: Resource): TextEditorContentType {
+ const extension = resource?.extension?.toLowerCase()
+ const mimeType = resource?.mimeType?.toLowerCase()
+ if (extension === 'md' || extension === 'markdown' || mimeType === 'text/markdown') {
+ return 'markdown'
+ }
+
+ return 'plain-text'
+}
+
+/**
+ * Builds the Y.Doc bridge the AppWrapper's collaborative session runs on.
+ *
+ * Called during the wrapper's setup, before the file is loaded, so the
+ * content type is resolved lazily, per call, from the resource ref. The
+ * strategies themselves must be built eagerly: they call `useGettext()`, which
+ * only works while a setup context is active.
+ */
+export function makeTextEditorAdapter({
+ resource
+}: CollaborativeAdapterContext): CollaborativeAdapter {
+ const { resolveStrategy } = useContentStrategy()
+
+ // The adapter needs its own editor state for the headless fallback path; the
+ // mounted editor resolves its own inside `useTextEditor`.
+ const state: TextEditorState = {
+ sourceMode: ref(false),
+ linkPanel: ref(null),
+ editorZoom: ref(100),
+ currentResource: resource
+ }
+
+ const strategies: Record = {
+ 'plain-text': resolveStrategy('plain-text', state),
+ markdown: resolveStrategy('markdown', state)
+ }
+
+ return makeTiptapCollabAdapter(() => strategies[detectContentType(unref(resource))])
+}
diff --git a/packages/web-app-text-editor/src/index.ts b/packages/web-app-text-editor/src/index.ts
index 053d941fd2..3a5f52be89 100644
--- a/packages/web-app-text-editor/src/index.ts
+++ b/packages/web-app-text-editor/src/index.ts
@@ -13,6 +13,8 @@ import {
} from '@opencloud-eu/web-pkg'
import { computed } from 'vue'
import { urlJoin } from '@opencloud-eu/web-client'
+import { makeTextEditorAdapter } from './collab'
+import pkg from '../package.json'
export default defineWebApplication({
setup({ applicationConfig }) {
@@ -200,7 +202,8 @@ export default defineWebApplication({
{
path: '/:driveAliasAndItem(.*)?',
component: AppWrapperRoute(TextEditor, {
- applicationId: appId
+ applicationId: appId,
+ collaborative: { appVersion: pkg.version, makeAdapter: makeTextEditorAdapter }
}),
name: 'text-editor',
meta: {
diff --git a/packages/web-app-text-editor/tests/unit/app.spec.ts b/packages/web-app-text-editor/tests/unit/app.spec.ts
index 638d1d6b44..a33e2931c4 100644
--- a/packages/web-app-text-editor/tests/unit/app.spec.ts
+++ b/packages/web-app-text-editor/tests/unit/app.spec.ts
@@ -1,26 +1,94 @@
import { PartialComponentProps, defaultPlugins, mount } from '@opencloud-eu/web-test-helpers'
import { mock } from 'vitest-mock-extended'
+import { defineComponent, shallowRef, toRaw } from 'vue'
+import * as Y from 'yjs'
+import { Awareness } from 'y-protocols/awareness'
+import type { Editor } from '@tiptap/vue-3'
import type { Resource } from '@opencloud-eu/web-client'
+import type { TextEditorOptions } from '@opencloud-eu/web-pkg/editor'
import App from '../../src/App.vue'
-vi.mock('@opencloud-eu/web-pkg')
-vi.mock('@opencloud-eu/web-pkg/editor')
+// The editor itself is covered by web-pkg. What App.vue owns is the decision
+// of *which* options the editor gets, so we capture those instead of mounting
+// a real ProseMirror stack.
+const useTextEditor = vi.hoisted(() => vi.fn())
+
+vi.mock('@opencloud-eu/web-pkg/editor', async (importOriginal) => {
+ const original = await importOriginal>()
+ return {
+ ...original,
+ useTextEditor,
+ TextEditorProvider: defineComponent({
+ props: { editor: { type: Object, default: null } },
+ template: '
'
+ }),
+ TextEditorContent: defineComponent({ template: '' }),
+ TextEditorToolbar: defineComponent({ template: '' })
+ }
+})
+
+beforeEach(() => {
+ useTextEditor.mockReset()
+ useTextEditor.mockReturnValue({ editor: shallowRef(null) })
+})
+
+function lastOptions(): TextEditorOptions {
+ return useTextEditor.mock.calls.at(-1)[0]
+}
describe('Text editor app', () => {
- it('shows the editor', () => {
- const { wrapper } = getWrapper()
- expect(wrapper.find('.oc-text-editor').exists()).toBeTruthy()
+ it('binds the editor to the Y.Doc and awareness it was handed', () => {
+ const ydoc = new Y.Doc()
+ const awareness = new Awareness(ydoc)
+ getWrapper({ ydoc, awareness })
+
+ // toRaw because vue-test-utils wraps mounted props in `reactive()`. The
+ // real AppWrapper hands these over from a shallowRef, so they stay raw.
+ expect(toRaw(lastOptions().ydoc)).toBe(ydoc)
+ expect(toRaw(lastOptions().awareness)).toBe(awareness)
+ })
+
+ it('detects the content type from the resource', () => {
+ getWrapper({ resource: mock({ extension: 'md', mimeType: 'text/markdown' }) })
+ expect(lastOptions().contentType).toBe('markdown')
+
+ getWrapper({ resource: mock({ extension: 'ts', mimeType: 'text/plain' }) })
+ expect(lastOptions().contentType).toBe('plain-text')
+ })
+
+ it('only sets a placeholder for editable markdown', () => {
+ getWrapper({ resource: mock({ extension: 'md', mimeType: 'text/markdown' }) })
+ expect(lastOptions().placeholder).toBeTruthy()
+
+ getWrapper({ resource: mock({ extension: 'txt', mimeType: 'text/plain' }) })
+ expect(lastOptions().placeholder).toBeUndefined()
+
+ getWrapper({
+ isReadOnly: true,
+ resource: mock({ extension: 'md', mimeType: 'text/markdown' })
+ })
+ expect(lastOptions().placeholder).toBeUndefined()
+ })
+
+ it('shows the toolbar when editable and hides it when read-only', () => {
+ expect(getWrapper().wrapper.find('.text-editor-toolbar').exists()).toBe(true)
+ expect(getWrapper({ isReadOnly: true }).wrapper.find('.text-editor-toolbar').exists()).toBe(
+ false
+ )
})
})
function getWrapper(props: PartialComponentProps = {}) {
+ const ydoc = (props.ydoc as Y.Doc) ?? new Y.Doc()
return {
wrapper: mount(App, {
props: {
currentContent: '',
isReadOnly: false,
resource: mock({ extension: 'txt', mimeType: 'text/plain' }),
- ...props
+ awareness: new Awareness(ydoc),
+ ...props,
+ ydoc
},
global: { plugins: defaultPlugins() }
})
diff --git a/packages/web-app-text-editor/tests/unit/collab.spec.ts b/packages/web-app-text-editor/tests/unit/collab.spec.ts
new file mode 100644
index 0000000000..d5ebcb7b92
--- /dev/null
+++ b/packages/web-app-text-editor/tests/unit/collab.spec.ts
@@ -0,0 +1,59 @@
+import { getComposableWrapper } from '@opencloud-eu/web-test-helpers'
+import { mock } from 'vitest-mock-extended'
+import { ref } from 'vue'
+import * as Y from 'yjs'
+import type { Resource } from '@opencloud-eu/web-client'
+import type { CollaborativeAdapter } from '@opencloud-eu/web-pkg'
+import { detectContentType, makeTextEditorAdapter } from '../../src/collab'
+
+describe('detectContentType', () => {
+ it.each([
+ ['md', 'text/plain', 'markdown'],
+ ['markdown', 'text/plain', 'markdown'],
+ ['txt', 'text/markdown', 'markdown'],
+ ['txt', 'text/plain', 'plain-text'],
+ ['ts', 'text/plain', 'plain-text']
+ ])('maps extension "%s" / mime "%s" to %s', (extension, mimeType, expected) => {
+ expect(detectContentType(mock({ extension, mimeType }))).toBe(expected)
+ })
+})
+
+describe('makeTextEditorAdapter', () => {
+ function buildAdapter(resource: Resource) {
+ let adapter: CollaborativeAdapter
+ const wrapper = getComposableWrapper(() => {
+ adapter = makeTextEditorAdapter({ resource: ref(resource) })
+ })
+ return {
+ wrapper,
+ get adapter() {
+ return adapter
+ }
+ }
+ }
+
+ it('hydrates markdown into the shared fragment and serializes it back', () => {
+ const { adapter } = buildAdapter(mock({ extension: 'md', mimeType: 'text/markdown' }))
+ const ydoc = new Y.Doc()
+
+ expect(adapter.hasContent(ydoc)).toBe(false)
+ adapter.hydrate(ydoc, '# Title')
+ expect(adapter.hasContent(ydoc)).toBe(true)
+ expect(adapter.serialize(ydoc)).toContain('# Title')
+ })
+
+ it('resets the shared fragment', () => {
+ const { adapter } = buildAdapter(mock({ extension: 'md', mimeType: 'text/markdown' }))
+ const ydoc = new Y.Doc()
+ adapter.hydrate(ydoc, 'content')
+ adapter.reset!(ydoc)
+ expect(adapter.hasContent(ydoc)).toBe(false)
+ })
+
+ it('is a no-op when hydrating with empty content', () => {
+ const { adapter } = buildAdapter(mock({ extension: 'txt', mimeType: 'text/plain' }))
+ const ydoc = new Y.Doc()
+ adapter.hydrate(ydoc, '')
+ expect(adapter.hasContent(ydoc)).toBe(false)
+ })
+})
diff --git a/packages/web-pkg/package.json b/packages/web-pkg/package.json
index bfb9480089..ab71c68969 100644
--- a/packages/web-pkg/package.json
+++ b/packages/web-pkg/package.json
@@ -49,12 +49,14 @@
"dependencies": {
"@casl/ability": "^7.0.0",
"@casl/vue": "^3.0.0",
+ "@hocuspocus/provider": "^4.0.0",
"@microsoft/fetch-event-source": "^2.0.1",
"@opencloud-eu/design-system": "workspace:^",
"@opencloud-eu/web-client": "workspace:^",
"@sentry/vue": "^10.46.0",
"@tiptap/core": "^3.28.0",
"@tiptap/extension-bubble-menu": "^3.29.2",
+ "@tiptap/extension-collaboration": "^3.28.0",
"@tiptap/extension-document": "^3.28.0",
"@tiptap/extension-drag-handle": "^3.28.0",
"@tiptap/extension-drag-handle-vue-3": "^3.28.0",
@@ -78,6 +80,7 @@
"@tiptap/starter-kit": "^3.28.0",
"@tiptap/suggestion": "^3.28.0",
"@tiptap/vue-3": "^3.28.0",
+ "@tiptap/y-tiptap": "^3.0.0",
"@uppy/core": "^5.2.0",
"@uppy/tus": "^5.1.1",
"@uppy/utils": "^7.2.0",
@@ -99,17 +102,21 @@
"pinia": "^4.0.0",
"prosemirror-transform": "^1.12.0",
"qs": "^6.15.0",
+ "semver": "^7.8.0",
"uuid": "^14.0.0",
"vue-concurrency": "^5.0.3",
"vue-inline-svg": "^4.0.1",
"vue-router": "^5.0.4",
"vue3-gettext": "4.0.1",
+ "y-protocols": "^1.0.7",
+ "yjs": "^13.6.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@opencloud-eu/web-test-helpers": "workspace:^",
"@types/lodash-es": "4.17.12",
"@types/node": "^25.5.0",
+ "@types/semver": "^7.7.0",
"@vitest/web-worker": "^4.1.2",
"vite-plugin-node-polyfills": "0.28.0"
}
diff --git a/packages/web-pkg/src/components/AppTemplates/AppWrapper.vue b/packages/web-pkg/src/components/AppTemplates/AppWrapper.vue
index 80b8bd5178..0e2f3d7a04 100644
--- a/packages/web-pkg/src/components/AppTemplates/AppWrapper.vue
+++ b/packages/web-pkg/src/components/AppTemplates/AppWrapper.vue
@@ -1,7 +1,7 @@
-
+
null,
- disableAutoSave = false
+ disableAutoSave = false,
+ collaborative = null
} = defineProps<{
applicationId: string
urlForResourceOptions?: UrlForResourceOptions
@@ -108,6 +111,12 @@ const {
wrappedComponent?: ReturnType
importResourceWithExtension?: (resource: Resource) => string
disableAutoSave?: boolean
+ /**
+ * Opt the app into realtime collaboration. When set, the wrapper owns the
+ * Y.Doc session and hands `ydoc` / `awareness` to the wrapped component via
+ * the slot.
+ */
+ collaborative?: CollaborativeOptions
}>()
const { $gettext, current: currentLanguage } = useGettext()
@@ -149,6 +158,13 @@ const loadingError: Ref = ref()
const isReadOnly = ref(false)
const serverContent = ref()
const currentContent = ref()
+/**
+ * Id of the resource `currentContent` was fetched for. `resource` is swapped
+ * ahead of the body, so comparing the two is what tells a collaborative
+ * session whether the content it would hydrate from belongs to the file it is
+ * about to open.
+ */
+const contentResourceId = ref()
let deleteResourceEventToken = ''
let appOnDeleteResourceCallback: (() => void) | null = null
@@ -167,7 +183,7 @@ const appBarExtension = computed(() => {
content: markRaw(AppTopBar),
componentProps: () => ({
resource: unref(resource),
- isReadOnly: unref(isReadOnly),
+ isReadOnly: unref(effectiveReadOnly),
isEditor: unref(isEditor),
hasAutoSave: !disableAutoSave,
mainActions: unref(fileActions),
@@ -186,30 +202,21 @@ registerExtensions(appBarExtension)
const { actions: saveAsActions } = useFileActionsSaveAs({ content: currentContent })
const isEditor = computed(() => {
- return Boolean(wrappedComponent.emits?.includes('update:currentContent'))
+ // A collaborative app drives its content through the Y.Doc session rather
+ // than through `update:currentContent`, so opting in makes it an editor.
+ return (
+ Boolean(collaborative) || Boolean(wrappedComponent.emits?.includes('update:currentContent'))
+ )
})
const hasProp = (name: string) => {
return Boolean(Object.keys(wrappedComponent.props).includes(name))
}
-const isDirty = computed(() => {
- return unref(currentContent) !== unref(serverContent)
-})
-
const preventUnload = (e: Event) => {
e.preventDefault()
}
-watch(isDirty, (dirty) => {
- // Prevent reload if there are changes
- if (dirty) {
- window.addEventListener('beforeunload', preventUnload)
- } else {
- window.removeEventListener('beforeunload', preventUnload)
- }
-})
-
const {
applicationConfig,
closeApp,
@@ -227,6 +234,89 @@ const {
applicationId
})
+const collaborativeDocument = collaborative
+ ? useCollaborativeDocument({
+ resource,
+ currentContent: () => (unref(currentContent) as string) ?? '',
+ // Hydration seeds the Y.Doc from `currentContent`, so the session must
+ // not start before `loadFileTask` has fetched it for this very resource.
+ enabled: () =>
+ !unref(loading) && !unref(loadingError) && unref(contentResourceId) === unref(resource)?.id,
+ isReadOnly,
+ adapter: collaborative.makeAdapter({ resource }),
+ appVersion: collaborative.appVersion,
+ documentPrefix: collaborative.documentPrefix ?? applicationId,
+ onContentChange: (value) => {
+ currentContent.value = value
+ },
+ // A peer just saved, so the Y.Doc state at that moment is exactly what's
+ // on disk. Flipping `serverContent` here drops `isDirty` back to false
+ // without a WebDAV round-trip.
+ onServerContentChange: (value) => {
+ serverContent.value = value
+ },
+ // The peer save also published its fresh etag, so our next PUT's
+ // `If-Match` is correct and we skip the 412 → refetch → retry path.
+ onEtagChange: (value) => {
+ if (currentETag.value === value) return
+ currentETag.value = value
+ // Keep `resource.etag` on the same value. The session compares it
+ // against the room's etag to detect that the file changed outside the
+ // room, and a peer that never saves itself would otherwise keep the
+ // etag it opened with forever - so the next reconnect would read the
+ // peer's save as an external write and wipe the room to "recover" it.
+ resource.value = { ...unref(resource), etag: value }
+ }
+ })
+ : null
+
+// Keep the loading screen up until the collaborative session has synced and
+// hydrated too, so the wrapped component always mounts against a ready Y.Doc
+// and never has to render its own placeholder.
+// A load failure short-circuits it, otherwise the error screen below would
+// never get a turn: a failed load leaves the session disabled, so it can never
+// report itself ready.
+const isLoading = computed(() => {
+ if (unref(loadingError)) return false
+ return unref(loading) || Boolean(collaborativeDocument && !unref(collaborativeDocument.isReady))
+})
+
+// A collaborative session can force read-only on top of the WebDAV
+// permissions, e.g. after locking the room on an app-version mismatch.
+const effectiveReadOnly = computed(
+ () => unref(isReadOnly) || Boolean(unref(collaborativeDocument?.isLockedForReload))
+)
+
+const isDirty = computed(() => {
+ // Peer edits keep flowing into `currentContent` of a read-only client too,
+ // but it has nothing to save, so it must never be prompted about it.
+ //
+ // Deliberately the WebDAV permission and not `effectiveReadOnly`: a session
+ // that locks mid-edit may be holding real unsaved work. Folding the lock in
+ // here dropped the save action, unregistered `beforeunload` and waved the
+ // route-leave guard through, so that work left with the tab.
+ if (unref(isReadOnly)) {
+ return false
+ }
+ return unref(currentContent) !== unref(serverContent)
+})
+
+watch(isDirty, (dirty) => {
+ // Prevent reload if there are changes
+ if (dirty) {
+ window.addEventListener('beforeunload', preventUnload)
+ } else {
+ window.removeEventListener('beforeunload', preventUnload)
+ }
+})
+
+if (collaborativeDocument) {
+ watch(collaborativeDocument.error, (error) => {
+ if (!error) return
+ showErrorMessage({ title: $gettext('Realtime collaboration error'), desc: error.message })
+ })
+}
+
const { applicationMeta } = useAppMeta({ applicationId, appsStore })
const fileSizeLimit = computed(() => {
@@ -389,6 +479,7 @@ const loadFileTask = useTask(function* (signal) {
)
serverContent.value = currentContent.value = fileContentsResponse.body
currentETag.value = fileContentsResponse.headers['OC-ETag']
+ contentResourceId.value = unref(resource).id
}
if (unref(hasProp('url'))) {
@@ -409,6 +500,15 @@ watch(
currentFileContext,
async () => {
if (!unref(noResourceLoading)) {
+ // Back to square one for the new file. `loadResourceTask` swaps
+ // `resource` well before `loadFileTask` has fetched the matching body,
+ // and a collaborative session keys its room off `resource` while it
+ // hydrates from `currentContent`. Without this reset the session for the
+ // new file would seed itself with the previous file's content, and the
+ // next save would write it to the new path.
+ loading.value = true
+ loadingError.value = undefined
+
await loadResourceTask.perform()
if (unref(fileSizeLimit) && toNumber(unref(resource).size) > unref(fileSizeLimit)) {
@@ -464,23 +564,75 @@ const saveFileTask = useTask(function* () {
serverContent.value = newContent
currentETag.value = putFileContentsResponse.etag
resourcesStore.upsertResource(putFileContentsResponse)
+ // Keep our local `resource` ref in sync with what the write established so
+ // any watcher on it (the collaborative session's etag mirror, for one)
+ // actually fires. `upsertResource` only touches the store; the local ref is
+ // what the session and the slot read.
+ resource.value = {
+ ...unref(resource),
+ etag: putFileContentsResponse.etag,
+ size: putFileContentsResponse.size,
+ mdate: putFileContentsResponse.mdate
+ }
} catch (e) {
+ // 409 / 412 — `previousEntityTag` didn't match what the server has.
+ //
+ // A connected collaborative session can resolve that on its own: the most
+ // likely cause is a peer in the same room saving just before us, and our
+ // Y.Doc already holds that peer's edits, so refetching for the fresh etag
+ // and retrying publishes the merged state rather than either side's half.
+ if (e.statusCode === 412 || e.statusCode === 409) {
+ const canReconcile =
+ Boolean(collaborativeDocument) && unref(collaborativeDocument.status) === 'connected'
+
+ if (canReconcile) {
+ try {
+ const fresh = yield* call(getFileContents(currentFileContext, { ...fileContentOptions }))
+ const freshEtag = fresh.headers['OC-ETag']
+
+ if (fresh.body === newContent) {
+ // No real content divergence — only our etag tracking was
+ // stale. Reconcile silently.
+ serverContent.value = newContent
+ currentETag.value = freshEtag
+ if (unref(resource)) {
+ resourcesStore.upsertResource({ ...unref(resource), etag: freshEtag })
+ resource.value = { ...unref(resource), etag: freshEtag }
+ }
+ return
+ }
+
+ // Retry the PUT with the fresh etag, publishing our combined state.
+ const retry = yield putFileContents(currentFileContext, {
+ content: newContent as string | ArrayBuffer,
+ previousEntityTag: freshEtag
+ })
+ serverContent.value = newContent
+ currentETag.value = retry.etag
+ resourcesStore.upsertResource(retry)
+ resource.value = { ...unref(resource), etag: retry.etag }
+ return
+ } catch (retryErr) {
+ // Refetch or retry blew up — drop through to the user-facing
+ // conflict popup so they can still recover by copying out.
+ console.error('[collab] conflict reconciliation failed:', retryErr)
+ }
+ }
+ errorPopup(
+ new HttpError(
+ $gettext(
+ 'This file was updated outside this window. Please copy your changes or save the file under a new name (»Save As...«).'
+ ),
+ e.response
+ )
+ )
+ return
+ }
switch (e.statusCode) {
case 401:
case 403:
errorPopup(new HttpError($gettext("You're not authorized to save this file"), e.response))
break
- case 409:
- case 412:
- errorPopup(
- new HttpError(
- $gettext(
- 'This file was updated outside this window. Please copy your changes or save the file under a new name (»Save As...«).'
- ),
- e.response
- )
- )
- break
case 507:
const space = spacesStore.spaces.find(
(space) => space.id === unref(resource).storageId && isProjectSpaceResource(space)
@@ -572,6 +724,9 @@ const fileActionsSave = computed(() => {
{
name: 'save-file',
disabledTooltip: () => '',
+ // Same reasoning as `isDirty`: a locked session freezes the editor, but
+ // the user must still be able to persist what they had typed before the
+ // lock. Only a genuinely read-only permission hides the action.
isVisible: () => unref(isEditor) && !unref(isReadOnly),
isDisabled: () => !unref(isDirty),
icon: 'save',
@@ -722,18 +877,25 @@ onBeforeRouteLeave((_to, _from, next) => {
}
})
-const slotAttrs = computed(() => ({
+const slotAttrs = computed(() => ({
url: unref(url),
space: unref(unref(currentFileContext).space),
resource: unref(resource),
activeFiles: unref(activeFiles),
isDirty: unref(isDirty),
- isReadOnly: unref(isReadOnly),
+ isReadOnly: unref(effectiveReadOnly),
applicationConfig: unref(applicationConfig),
currentFileContext: unref(currentFileContext),
- currentContent: unref(currentContent),
+ currentContent: unref(currentContent) as string,
isFolderLoading: unref(isFolderLoading),
+ // The slot (= the wrapper compoonent) only renders once `isLoading` is
+ // false, which for a collaborative app includes the session being synced
+ // and hydrated. So these are non-null by the time the wrapped component
+ // sees them. Always null for non-collaborative apps.
+ ydoc: unref(collaborativeDocument?.ydoc) ?? null,
+ awareness: unref(collaborativeDocument?.awareness) ?? null,
+
'onUpdate:resource': (value: Resource) => {
space.value = unref(unref(currentFileContext).space)
diff --git a/packages/web-pkg/src/components/AppTemplates/AppWrapperRoute.ts b/packages/web-pkg/src/components/AppTemplates/AppWrapperRoute.ts
index ee6501ce0c..6fa23d4933 100644
--- a/packages/web-pkg/src/components/AppTemplates/AppWrapperRoute.ts
+++ b/packages/web-pkg/src/components/AppTemplates/AppWrapperRoute.ts
@@ -1,6 +1,6 @@
import { defineComponent, h } from 'vue'
import AppWrapper from './AppWrapper.vue'
-import { AppWrapperSlotArgs } from './types'
+import { AppWrapperSlotHandlers, AppWrapperSlotProps, CollaborativeOptions } from './types'
import { FileContentOptions, UrlForResourceOptions } from '../../composables'
import { Resource } from '@opencloud-eu/web-client'
@@ -12,6 +12,7 @@ export function AppWrapperRoute(
fileContentOptions?: FileContentOptions
importResourceWithExtension?: (resource: Resource) => string
disableAutoSave?: boolean
+ collaborative?: CollaborativeOptions
}
) {
return defineComponent({
@@ -23,7 +24,7 @@ export function AppWrapperRoute(
...options
},
{
- default: (slotArgs: AppWrapperSlotArgs) => {
+ default: (slotArgs: AppWrapperSlotProps & AppWrapperSlotHandlers) => {
return h(fileEditor, slotArgs)
}
}
diff --git a/packages/web-pkg/src/components/AppTemplates/types.ts b/packages/web-pkg/src/components/AppTemplates/types.ts
index 5825b18b93..3296488725 100644
--- a/packages/web-pkg/src/components/AppTemplates/types.ts
+++ b/packages/web-pkg/src/components/AppTemplates/types.ts
@@ -1,12 +1,103 @@
-import { Resource } from '@opencloud-eu/web-client'
+import { Resource, SpaceResource } from '@opencloud-eu/web-client'
import { AppConfigObject } from '../../apps/types'
import { Ref } from 'vue'
+import type * as Y from 'yjs'
+import type { Awareness } from 'y-protocols/awareness'
+import type {
+ AppFileHandlingResult,
+ AppFolderHandlingResult,
+ CollaborativeAdapter,
+ FileContext
+} from '../../composables'
-export interface AppWrapperSlotArgs {
+/**
+ * Handed to {@link CollaborativeOptions.makeAdapter}. Reactive, because the
+ * adapter is built during the wrapper's setup before the file is loaded.
+ */
+export interface CollaborativeAdapterContext {
+ /** The file. Undefined until the wrapper has loaded it, so read it lazily. */
+ resource: Ref
+}
+
+export interface CollaborativeOptions {
+ /**
+ * App version owned by the consuming app, typically `pkg.version` from its
+ * own package.json. Peers in the same room must agree on it, otherwise the
+ * older client is locked out and asked to reload.
+ */
+ appVersion: string
+ /**
+ * Builds the bridge between the native file format and the shared Y.Doc.
+ * Called once during the wrapper's setup, so it may use composables.
+ */
+ makeAdapter: (context: CollaborativeAdapterContext) => CollaborativeAdapter
+ /**
+ * Namespace for the collab room. Defaults to the `applicationId`. Editors
+ * with incompatible Y.Doc schemas must not share a room, so only override
+ * this when two apps deliberately use the same shared-type layout.
+ */
+ documentPrefix?: string
+}
+
+/**
+ * Every value AppWrapper passes down to the wrapped component.
+ *
+ * A wrapped component must declare only the subset it actually uses: AppWrapper
+ * inspects the component's props to decide what to load. Use one of the presets
+ * below, or `Pick` the keys you need.
+ */
+export interface AppWrapperSlotProps {
applicationConfig: AppConfigObject
+ space: SpaceResource
resource: Resource
- currentContent: Ref
+ currentFileContext: FileContext
+ /** Fetching this costs a WebDAV GET of the whole file. */
+ currentContent: string
+ /** Building this costs a WebDAV GET of the whole file. */
+ url: string
isDirty: boolean
isReadOnly: boolean
- url: string
+ activeFiles: Resource[]
+ isFolderLoading: boolean
+ /** Set once the collaborative session is synced and hydrated, else null. */
+ ydoc: Y.Doc | null
+ /** Set once the collaborative session is synced and hydrated, else null. */
+ awareness: Awareness | null
}
+
+/**
+ * Callbacks AppWrapper passes down alongside {@link AppWrapperSlotProps}. The
+ * `on*` keys arrive as listeners, so a wrapped component declares them via
+ * `defineEmits` rather than `defineProps`.
+ */
+export interface AppWrapperSlotHandlers {
+ loadFolderForFileContext: AppFolderHandlingResult['loadFolderForFileContext']
+ getUrlForResource: AppFileHandlingResult['getUrlForResource']
+ revokeUrl: AppFileHandlingResult['revokeUrl']
+ onSave: () => Promise
+ onClose: () => void
+ 'onUpdate:resource': (value: Resource) => void
+ 'onUpdate:currentContent': (value: unknown) => void
+ 'onRegister:onDeleteResourceCallback': (value: () => void) => void
+ 'onDelete:resource': () => void
+}
+
+/** Apps that render the file body and write it back. */
+export type EditorSlotProps = Pick<
+ AppWrapperSlotProps,
+ 'resource' | 'currentContent' | 'isReadOnly'
+>
+
+/** Editors opting into realtime collaboration via {@link CollaborativeOptions}. */
+export type CollaborativeEditorSlotProps = EditorSlotProps &
+ Pick
+
+/** Apps that render the file from a URL instead of its body. */
+export type ViewerSlotProps = Pick
+
+/** Apps that browse the whole folder and build their own URLs per file. */
+export type FolderViewerSlotProps = Pick<
+ AppWrapperSlotProps,
+ 'currentFileContext' | 'activeFiles' | 'isFolderLoading'
+> &
+ Pick
diff --git a/packages/web-pkg/src/components/ContextActions/ActionMenuItem.vue b/packages/web-pkg/src/components/ContextActions/ActionMenuItem.vue
index 6a605fe004..2d930e364b 100644
--- a/packages/web-pkg/src/components/ContextActions/ActionMenuItem.vue
+++ b/packages/web-pkg/src/components/ContextActions/ActionMenuItem.vue
@@ -6,9 +6,8 @@
v-bind="componentProps"
:class="[action.class, 'action-menu-item', 'align-middle', 'w-full', ...buttonClasses]"
:aria-label="
- componentProps.disabled
- ? (action.disabledTooltip?.(actionOptions) ?? action.label(actionOptions))
- : action.label(actionOptions)
+ (componentProps.disabled ? action.disabledTooltip?.(actionOptions) : '') ||
+ action.label(actionOptions)
"
data-testid="action-handler"
:size="size"
diff --git a/packages/web-pkg/src/composables/collaborative/index.ts b/packages/web-pkg/src/composables/collaborative/index.ts
new file mode 100644
index 0000000000..9566d6d5f9
--- /dev/null
+++ b/packages/web-pkg/src/composables/collaborative/index.ts
@@ -0,0 +1,2 @@
+export * from './useCollaborativeDocument'
+export * from './types'
diff --git a/packages/web-pkg/src/composables/collaborative/types.ts b/packages/web-pkg/src/composables/collaborative/types.ts
new file mode 100644
index 0000000000..56b49aaf4d
--- /dev/null
+++ b/packages/web-pkg/src/composables/collaborative/types.ts
@@ -0,0 +1,44 @@
+import type * as Y from 'yjs'
+
+/**
+ * App-specific adapter between the native file format and the shared Y.Doc.
+ * The collaborative session itself stays generic: it handles realtime sync,
+ * the etag loop, and lifecycle. Adapters describe how to move bytes in and
+ * out of the doc.
+ */
+export interface CollaborativeAdapter {
+ /**
+ * Populate an empty Y.Doc from the native file content. Called once per
+ * document by the elected hydrating client, unless stale recovery re-runs
+ * it. Other clients receive the resulting Y.Doc state through the realtime
+ * sync.
+ *
+ * Must be a no-op if the Y.Doc already has app data.
+ */
+ hydrate(ydoc: Y.Doc, content: string): void | Promise
+
+ /**
+ * Render the current Y.Doc state to the native file format for WebDAV PUT
+ * and the local `isDirty` check in the app wrapper. Runs continuously and
+ * on every peer, triggered by Y.Doc/meta changes.
+ */
+ serialize(ydoc: Y.Doc): string | Promise
+
+ /**
+ * Returns true if the adapter has populated the Y.Doc with app data.
+ * Used to detect "doc is empty, needs hydration" without the caller
+ * knowing the adapter's shared-type layout.
+ */
+ hasContent(ydoc: Y.Doc): boolean
+
+ /**
+ * Wipe the adapter's shared content so `hasContent` returns false again.
+ * Called when the persisted Y.Doc turns out to be stale (e.g. an external
+ * file write happened between sessions); the elected client then
+ * re-hydrates from the fresh native content.
+ *
+ * Optional; adapters that omit this won't recover from a stale-state
+ * signal in-place; the session falls back to forcing a full reload.
+ */
+ reset?(ydoc: Y.Doc): void
+}
diff --git a/packages/web-pkg/src/composables/collaborative/useCollaborativeDocument.ts b/packages/web-pkg/src/composables/collaborative/useCollaborativeDocument.ts
new file mode 100644
index 0000000000..ce76ca2e8c
--- /dev/null
+++ b/packages/web-pkg/src/composables/collaborative/useCollaborativeDocument.ts
@@ -0,0 +1,792 @@
+import { computed, ref, shallowRef, toValue, unref, watch } from 'vue'
+import type { MaybeRefOrGetter, Ref, ShallowRef } from 'vue'
+import * as Y from 'yjs'
+import { Awareness } from 'y-protocols/awareness'
+import { HocuspocusProvider } from '@hocuspocus/provider'
+import semverCompare from 'semver/functions/compare'
+import semverValid from 'semver/functions/valid'
+import type { Resource } from '@opencloud-eu/web-client'
+import { useGettext } from 'vue3-gettext'
+import { useAuthStore, useConfigStore } from '../piniaStores'
+import type { CollaborativeAdapter } from './types'
+
+export type CollaborativeStatus = 'connecting' | 'connected' | 'disconnected' | 'local'
+
+export interface CollaborativeDocumentOptions {
+ /** The file the session is bound to. Its id forms the room name, its etag drives staleness detection. */
+ resource: MaybeRefOrGetter
+ /** Native file content, used to seed an empty Y.Doc. */
+ currentContent: MaybeRefOrGetter
+ /**
+ * Holds the session back until the caller is ready. Hydration seeds the doc
+ * from `currentContent`, so starting before that has been fetched would
+ * publish an empty document to every peer.
+ */
+ enabled: MaybeRefOrGetter
+ /**
+ * Read-only clients never seed the shared room and never recover a stale
+ * doc. They do hydrate a private copy while the room is empty, see
+ * `runInitialHydration`.
+ */
+ isReadOnly: MaybeRefOrGetter
+ /** Translates between the native file format and the doc's shared types. */
+ adapter: MaybeRefOrGetter
+ /**
+ * App version owned by the consuming app; typically `pkg.version` from its
+ * own package.json, baked in at build time by Vite. Used to detect schema
+ * mismatch between peers in the same Y.Doc room.
+ */
+ appVersion: MaybeRefOrGetter
+ /**
+ * Namespace for the collab room. Different editor apps that can open the
+ * same file have incompatible Y.Doc schemas (Y.Text vs Y.XmlFragment with
+ * different extensions), so they MUST land in separate rooms.
+ */
+ documentPrefix: MaybeRefOrGetter
+ /** The Y.Doc changed through a real edit (local or remote). */
+ onContentChange: (content: string) => void
+ /** A peer saved: the given content is now what's on disk. */
+ onServerContentChange: (content: string) => void
+ /** A peer save propagated a fresh etag through the room. */
+ onEtagChange: (etag: string) => void
+}
+
+export interface CollaborativeDocument {
+ ydoc: ShallowRef
+ awareness: ShallowRef
+ provider: ShallowRef
+ status: ShallowRef
+ /**
+ * False until the Y.Doc is ready to be shown: initial sync completed and
+ * the hydration decision has settled. Consumers gate the editor mount on
+ * this to avoid a brief empty-editor flash while hydration runs.
+ */
+ isReady: ShallowRef
+ /**
+ * True after a forced disconnect because of an app-version mismatch. The
+ * editor should stay mounted with the last-known content but flip
+ * read-only, and the user should be asked to reload.
+ */
+ isLockedForReload: Ref
+ /** Set when the persisted state was stale or realtime auth failed. */
+ error: ShallowRef
+}
+
+const META_KEY = '_oc_meta'
+const SERIALIZE_DEBOUNCE_MS = 300
+/**
+ * How long to wait for the realtime server before giving up on it and running
+ * the session locally. Generous enough to ride out a slow connect, short
+ * enough that a misconfigured or down sidecar does not read as a hung editor.
+ */
+const CONNECT_TIMEOUT_MS = 10_000
+/**
+ * Tag we put on our own meta-write so the meta observer can tell a local save
+ * (the etag mirror firing) apart from a peer save (a CRDT update from another
+ * client). Peer saves get the `onServerContentChange` fan-out; local saves
+ * don't need it because the caller already knows it saved.
+ */
+const LOCAL_SAVE_ORIGIN = 'local-save'
+
+/**
+ * Semver comparison via the official `semver` package: handles pre-release
+ * ordering (`1.0.0-rc.1 < 1.0.0`), multi-digit segments (`0.20.0 > 0.3.0`),
+ * build metadata, etc. Returns negative when `a < b`, positive when `a > b`,
+ * zero on equal. Non-semver strings (e.g. raw git SHAs in dev builds) fall
+ * back to strict equality and produce `0` for equal / `NaN` otherwise; the
+ * callers treat `NaN` as "incomparable, force reload".
+ */
+function compareVersion(a: string, b: string): number {
+ if (semverValid(a) && semverValid(b)) return semverCompare(a, b)
+ return a === b ? 0 : Number.NaN
+}
+
+/**
+ * Owns a realtime collaborative session for a single file: the Y.Doc, the
+ * optional Hocuspocus provider, hydration, stale-state recovery and the
+ * app-version gate.
+ *
+ * It knows nothing about editors. The caller mounts whatever editor it likes
+ * against the returned `ydoc` / `awareness`, and supplies a
+ * {@link CollaborativeAdapter} that translates between the native file format
+ * and the doc's shared types.
+ */
+export function useCollaborativeDocument(
+ options: CollaborativeDocumentOptions
+): CollaborativeDocument {
+ const {
+ resource,
+ currentContent,
+ enabled,
+ isReadOnly,
+ adapter,
+ appVersion,
+ documentPrefix,
+ onContentChange,
+ onServerContentChange,
+ onEtagChange
+ } = options
+
+ const { $gettext } = useGettext()
+ const authStore = useAuthStore()
+ const configStore = useConfigStore()
+
+ const sessionNonce = ref(0)
+ const ydoc = shallowRef(null)
+ const provider = shallowRef(null)
+ const awareness = shallowRef(null)
+ const status = shallowRef('connecting')
+ const isReady = shallowRef(false)
+ const isLockedForReload = ref(false)
+ const error = shallowRef(null)
+
+ const effectiveReadOnly = computed(() => toValue(isReadOnly) || unref(isLockedForReload))
+
+ // Single, deployment-wide switch. Leaving `yjsServerUrl` unset runs every
+ // session in local mode: a Y.Doc and Awareness still spin up so the editor
+ // binding stays on one codepath, but nothing connects and no peer appears.
+ const yjsServerUrl = computed(() => configStore.options.yjsServerUrl || null)
+
+ const documentName = computed(() => {
+ // OC's canonical composite id, identical for all peers. It serves as the
+ // Y.Doc match key and the ACL probe target the yjs server passes to Graph.
+ const fileId = toValue(resource)?.id
+ if (!fileId) return null
+ const prefix = toValue(documentPrefix)
+ return prefix ? `${prefix}::${fileId}` : `${fileId}`
+ })
+
+ // Use an explicit session key instead of letting `watchEffect` track every
+ // reactive read inside the body. watchEffect re-runs whenever any of its
+ // deps fire, including unrelated `resource` mutations from the caller's
+ // post-save `upsertResource`, which would tear down the Y.Doc on every save
+ // and lose peer edits.
+ const sessionKey = computed(() => {
+ const name = unref(documentName)
+ if (!name || !toValue(enabled)) return null
+ return `${name}::${unref(yjsServerUrl) ?? 'local'}::${unref(sessionNonce)}`
+ })
+
+ /**
+ * True while this client holds content that only exists in its own browser:
+ * a read-only client hydrated an empty room (see `runInitialHydration`).
+ * Merging that private copy with a peer's later seeding would duplicate the
+ * whole document, so the session is rebuilt instead.
+ */
+ let hasLocalOnlyContent = false
+
+ /**
+ * The native file body this client fetched, captured at the moment it
+ * noticed etag drift. Non-null only on a peer that may re-seed the room.
+ *
+ * Recovery has to publish the content behind `_oc_meta.nativeEtag`, and the
+ * only peer holding it is the one that just fetched the file. Reading the
+ * caller's `currentContent` at recovery time would not do: by then the room
+ * has synced its own (older) state into our Y.Doc and the debounced serialize
+ * has reported it straight back into `currentContent`.
+ */
+ let staleRecoveryContent: string | null = null
+
+ /**
+ * Whether the peer that published this state vector already held every
+ * operation *we* contributed, so the file it wrote contains our work too.
+ *
+ * Only our own client id is compared. What `serverContent` answers is "is my
+ * work on disk"; operations from a third peer are tracked by that peer's own
+ * dirty state. Comparing every client would also never hold, because the
+ * saver encodes its vector inside the transaction that stamps the rest of
+ * `_oc_meta` and so cannot include its own trailing writes.
+ */
+ function peerSaveCoversUs(doc: Y.Doc, theirs: Uint8Array): boolean {
+ const ourClock = Y.decodeStateVector(Y.encodeStateVector(doc)).get(doc.clientID) ?? 0
+ const theirView = Y.decodeStateVector(theirs).get(doc.clientID) ?? 0
+ return theirView >= ourClock
+ }
+
+ /**
+ * Whether the body we hold is the one recovery is supposed to settle on,
+ * i.e. our freshly fetched etag is the room's `nativeEtag`.
+ */
+ function canSupplyRecoveryContent(meta: Y.Map): boolean {
+ const target = meta.get('nativeEtag') as string | undefined
+ const ours = toValue(resource)?.etag
+ return Boolean(target && ours && target === ours)
+ }
+
+ function lockForReload(prov: HocuspocusProvider | null, message: string) {
+ if (unref(isLockedForReload)) return
+ isLockedForReload.value = true
+ error.value = new Error(message)
+ try {
+ prov?.disconnect()
+ } catch {
+ // disconnect can throw if already torn down; ignore.
+ }
+ }
+
+ async function serializeDoc(doc: Y.Doc): Promise {
+ const current = toValue(adapter)
+ if (doc.isDestroyed || !current.hasContent(doc)) return null
+ const value = await Promise.resolve(current.serialize(doc))
+ if (doc.isDestroyed) return null
+ return value
+ }
+
+ // Depth counter for session-internal mutations. See `canReportContent`.
+ let suppressionDepth = 0
+ async function withoutReportingContent(fn: () => T | Promise): Promise {
+ suppressionDepth++
+ try {
+ return await fn()
+ } finally {
+ suppressionDepth--
+ }
+ }
+
+ /**
+ * Whether a Y.Doc change should be reported to the caller as new content.
+ *
+ * Only changes that represent a real edit qualify. Everything the session
+ * does to make the doc match the file - the initial sync, hydration, the
+ * meta handshake, stale recovery - must not, because the caller derives its
+ * dirty state by string-comparing what we report against the file it
+ * fetched. Serialization is not byte-identical to the original (Tiptap
+ * normalises markdown, for one), so reporting the post-hydration state would
+ * mark an untouched file dirty the moment it opens.
+ *
+ * Deliberately not based on Y.Doc transaction origins: an adapter would have
+ * to remember to tag its writes, and the ones that matter most (a remote
+ * sync applying another peer's hydration) carry no origin we control.
+ * Gating on session state instead means adapters need to know nothing.
+ */
+ function canReportContent(): boolean {
+ return unref(isReady) && suppressionDepth === 0
+ }
+
+ /**
+ * Hydration: elected client seeds the Y.Doc from native content. Lowest
+ * awareness clientId wins to avoid double-hydration when two peers see an
+ * empty doc simultaneously. In local mode there are no peers, so the
+ * election degenerates to "we win unconditionally".
+ */
+ async function runInitialHydration(
+ doc: Y.Doc,
+ prov: HocuspocusProvider | null,
+ awarenessInstance: Awareness
+ ) {
+ const current = toValue(adapter)
+ const meta = doc.getMap(META_KEY)
+ const version = toValue(appVersion)
+
+ // If the doc is already flagged as stale (etag or app-version drift
+ // between persisted state and this connect), let the meta-observer fire
+ // `recoverFromStaleState`. Skip the version check below so we don't
+ // race-lock the user out of a doc we're about to rehydrate cleanly.
+ //
+ // The observer only fires on change, so a peer that joins while the flag is
+ // already up never hears about it. Offer ourselves instead if we hold the
+ // body recovery needs - the peer that was elected for it may well have
+ // navigated away before finishing.
+ if (meta.get('isStale') === true) {
+ if (!unref(effectiveReadOnly) && canSupplyRecoveryContent(meta)) {
+ staleRecoveryContent = toValue(currentContent)
+ doc.transact(() => meta.set('recoveryClientId', doc.clientID))
+ void recoverFromStaleState(doc, prov)
+ }
+ return
+ }
+
+ // App-version handshake.
+ // - empty: first client into the room, seed our version
+ // - equal: no-op
+ // - doc is OLDER than us: persisted state pre-dates our schema; treat as
+ // stale and trigger the recovery flow
+ // - doc is NEWER than us OR incomparable: we are out of date, force
+ // reload — the user must refresh to a current bundle
+ const docVersion = meta.get('appVersion') as string | undefined
+ if (!docVersion) {
+ doc.transact(() => {
+ if (!meta.get('appVersion')) meta.set('appVersion', version)
+ })
+ } else {
+ const cmp = compareVersion(version, docVersion)
+ if (Number.isNaN(cmp) || cmp < 0) {
+ lockForReload(
+ prov,
+ $gettext(
+ 'This file is being edited with app version %{docVersion} (yours is %{version}). Please reload.',
+ { docVersion, version }
+ )
+ )
+ return
+ }
+ if (cmp > 0) {
+ doc.transact(() => meta.set('isStale', true))
+ return
+ }
+ }
+
+ // Etag drift check. Relay-only yjs servers do not persist Y.Docs, so the
+ // server cannot compare a persisted etag against the native file. Instead,
+ // after sync we look at what the synced room thinks the etag is
+ // (`_oc_meta.etag`, seeded by whichever peer entered first) and compare it
+ // against the etag the caller just refetched:
+ // - no doc etag yet → we are the first peer, seed our baseline
+ // - doc == native → no-op
+ // - doc != native → the room's view is older than the file on disk;
+ // flag isStale so the meta observer fires
+ // `recoverFromStaleState`
+ // Stamping the native etag into a side field lets the recovery path settle
+ // the final value into `_oc_meta.etag` without an extra fetch.
+ const docEtag = meta.get('etag') as string | undefined
+ const nativeEtag = toValue(resource)?.etag
+ if (docEtag && nativeEtag && docEtag !== nativeEtag) {
+ // We are the peer that just fetched the file, so our `currentContent` is
+ // the body behind `nativeEtag`. Capture it before the room syncs its own
+ // state over it, and claim the recovery: `recoveryClientId` is
+ // last-write-wins, so if several peers detect the same drift at once
+ // exactly one of them ends up elected.
+ staleRecoveryContent = toValue(currentContent)
+ doc.transact(() => {
+ meta.set('nativeEtag', nativeEtag)
+ meta.set('recoveryClientId', doc.clientID)
+ meta.set('isStale', true)
+ })
+ return
+ }
+ if (!docEtag && nativeEtag) {
+ doc.transact(() => {
+ if (!meta.get('etag')) meta.set('etag', nativeEtag)
+ })
+ }
+
+ if (current.hasContent(doc)) return
+
+ // Read-only client in an empty room: nobody has seeded the doc, so leaving
+ // it empty would show a blank file. Hydrate a private copy instead. The
+ // realtime server rejects writes from read-only connections, so it never
+ // reaches the room, and `hasLocalOnlyContent` marks it so the meta observer
+ // can drop it again the moment a peer starts seeding for real.
+ if (unref(effectiveReadOnly)) {
+ // A peer already announced its seeding; its content is on the way.
+ if (meta.get('hydrated') === true) return
+ // Set before awaiting: a peer announcing mid-hydration must still find
+ // the flag set, otherwise the two copies merge into duplicated content.
+ hasLocalOnlyContent = true
+ await Promise.resolve(current.hydrate(doc, toValue(currentContent)))
+ hasLocalOnlyContent = current.hasContent(doc)
+ return
+ }
+
+ // Peer election to avoid double-hydration: let other clients announce
+ // themselves via awareness, then the lowest awareness clientId wins. This
+ // only matters in collab mode. In local mode there are no peers, and the
+ // 150ms announce wait would just delay first paint, so hydrate immediately.
+ if (prov) {
+ await new Promise((resolve) => setTimeout(resolve, 150))
+
+ if (current.hasContent(doc)) return // someone beat us
+
+ const myId = doc.clientID
+ const peers = Array.from(awarenessInstance.getStates().keys())
+ const lowest = peers.length ? Math.min(myId, ...peers) : myId
+ if (myId !== lowest) return
+ }
+
+ // Announce before seeding, so read-only peers can drop their private copy
+ // before our content lands rather than merge with it.
+ doc.transact(() => meta.set('hydrated', true))
+ await Promise.resolve(current.hydrate(doc, toValue(currentContent)))
+ }
+
+ /**
+ * Stale-state recovery: fired when `_oc_meta.isStale` goes up because the
+ * room's etag no longer matches the native file. The elected client wipes
+ * adapter content, clears the staleness flag, and re-hydrates from the body
+ * it captured when it noticed the drift. Other peers see the wipe + hydrate
+ * as ordinary CRDT updates. Unreachable in local mode (nobody ever sets
+ * isStale), but coded provider-tolerant so the two modes share one path.
+ *
+ * Only a peer that holds the fresh body may run this. Letting an arbitrary
+ * peer win would re-seed the room from whatever it happens to be holding -
+ * the pre-drift body it opened with, or its own last serialization - and then
+ * stamp the fresh etag onto it. The next save would carry a matching
+ * `If-Match` and overwrite the external writer with no 412 and no warning.
+ */
+ async function recoverFromStaleState(doc: Y.Doc, prov: HocuspocusProvider | null) {
+ const current = toValue(adapter)
+ const meta = doc.getMap(META_KEY)
+ if (unref(effectiveReadOnly)) return
+ if (staleRecoveryContent === null) return
+ if (typeof current.reset !== 'function') {
+ lockForReload(
+ prov,
+ $gettext(
+ 'This file was changed externally and your editor cannot recover in-place. Please reload.'
+ )
+ )
+ return
+ }
+
+ // Let concurrent claims converge, then check whether we are the one that
+ // came out on top.
+ await new Promise((resolve) => setTimeout(resolve, 150))
+ if (meta.get('isStale') !== true) return // someone else handled it
+ if (meta.get('recoveryClientId') !== doc.clientID) return
+
+ const content = staleRecoveryContent
+ const freshEtag =
+ (meta.get('nativeEtag') as string | undefined) ?? toValue(resource)?.etag ?? ''
+
+ // Split into three phases so a crash between reset and hydrate leaves
+ // `isStale` set: the next peer entering the room then re-runs recovery
+ // instead of inheriting an empty doc with cleared flags.
+ //
+ // None of it is a user edit, so none of it is reported as content. The
+ // caller's server content still describes the file it fetched; letting the
+ // rewrite through would flip its dirty state back and forth between
+ // recovery and the next real keystroke.
+ try {
+ await withoutReportingContent(async () => {
+ doc.transact(() => {
+ current.reset?.(doc)
+ }, 'stale-recovery-reset')
+
+ await Promise.resolve(current.hydrate(doc, content))
+
+ doc.transact(() => {
+ meta.delete('isStale')
+ meta.delete('nativeEtag')
+ meta.delete('recoveryClientId')
+ if (freshEtag) meta.set('etag', freshEtag)
+ // Bump the version stamp too: the prior state may have been tied to an
+ // older `appVersion`, and the recovered content is now in our current
+ // layout. Late joiners with the same version pass the handshake; older
+ // clients still bounce on their own version check.
+ meta.set('appVersion', toValue(appVersion))
+ }, 'stale-recovery-commit')
+ })
+ staleRecoveryContent = null
+ } catch (e) {
+ // The reset already emptied the shared doc for every peer. `isStale` is
+ // still set, so a later joiner holding the fresh body retries. Lock this
+ // session so nothing autosaves the empty document over the file in the
+ // meantime.
+ console.error('[collab] stale-state recovery failed:', e)
+ lockForReload(
+ prov,
+ $gettext('This file was changed externally and recovering it failed. Please reload.')
+ )
+ }
+ }
+
+ /**
+ * Single entry point for both modes (collab `onSynced` and the immediate
+ * local-mode call). Flips `isReady` once the hydration decision has settled
+ * so the editor mount is gated on one signal and never spins forever. The
+ * `ydoc.value === doc` guard keeps a stale invocation (resolving after
+ * navigation tore this session down) from clearing the loading state of
+ * the next session.
+ */
+ async function onProviderSynced(
+ doc: Y.Doc,
+ prov: HocuspocusProvider | null,
+ awarenessInstance: Awareness
+ ) {
+ try {
+ await runInitialHydration(doc, prov, awarenessInstance)
+ } catch (e) {
+ // Both call sites fire this without awaiting, so an escaping rejection
+ // would be swallowed and the user would face a half-hydrated document
+ // with no explanation.
+ console.error('[collab] hydration failed:', e)
+ error.value = e instanceof Error ? e : new Error(String(e))
+ } finally {
+ if (!doc.isDestroyed && unref(ydoc) === doc) isReady.value = true
+ }
+ }
+
+ /**
+ * Y.Doc + (optional) provider lifecycle — rebuilt whenever the session key
+ * changes. Two modes, gated by `yjsServerUrl`:
+ * - collab : Hocuspocus provider connects, awareness comes from the
+ * provider, hydration waits for onSynced.
+ * - local : standalone Awareness instance, no network, hydration runs
+ * immediately. The downstream editor sees an awareness object
+ * just like in collab-mode — the only behavioural difference is
+ * that no peers will ever appear.
+ */
+ watch(
+ sessionKey,
+ (key, _oldKey, onCleanup) => {
+ if (!key) return
+ const name = unref(documentName)
+ if (!name) return
+
+ // Reset per-file state.
+ error.value = null
+ isLockedForReload.value = false
+ isReady.value = false
+ hasLocalOnlyContent = false
+
+ const doc = new Y.Doc()
+
+ // Debounced serialize → report. We hand the caller the same string an
+ // out-of-band PUT would write; it diffs that against its own server
+ // content to derive a dirty state.
+ let serializeTimer: number | undefined
+ function scheduleEmit() {
+ if (serializeTimer !== undefined) window.clearTimeout(serializeTimer)
+ serializeTimer = window.setTimeout(() => {
+ serializeTimer = undefined
+ // Re-checked here, not just at schedule time: the debounce window
+ // can outlive the change that opened it.
+ if (!canReportContent()) return
+ serializeDoc(doc)
+ .then((value) => {
+ if (value !== null) onContentChange(value)
+ })
+ .catch((e) => console.error('[collab] serialize for content update failed:', e))
+ }, SERIALIZE_DEBOUNCE_MS)
+ }
+
+ function onDocUpdate() {
+ if (!canReportContent()) return
+ scheduleEmit()
+ }
+ doc.on('update', onDocUpdate)
+
+ let prov: HocuspocusProvider | null = null
+ let aw: Awareness
+ let connectTimer: number | undefined
+
+ const resolvedRealtimeUrl = unref(yjsServerUrl)
+ if (resolvedRealtimeUrl) {
+ // ---------- Collab mode ----------
+ // HocuspocusProvider has no `parameters` option; we get query params to
+ // the sidecar's requestParameters by appending them to the URL.
+ const version = toValue(appVersion)
+ const wsUrlWithParams = `${resolvedRealtimeUrl}?appVersion=${encodeURIComponent(version)}`
+ prov = new HocuspocusProvider({
+ url: wsUrlWithParams,
+ name,
+ document: doc,
+ token: () => authStore.accessToken,
+ onStatus({ status: s }) {
+ status.value = s as CollaborativeStatus
+ },
+ onAuthenticationFailed({ reason }) {
+ console.error('[collab] realtime auth failed:', reason)
+ // Surface as a lifecycle error so the user sees the reason rather
+ // than a silent disconnect. The server uses this for app-version
+ // rejection too.
+ error.value = new Error(reason || $gettext('authentication failed'))
+ isLockedForReload.value = true
+ if (connectTimer !== undefined) window.clearTimeout(connectTimer)
+ // A failed connect never produces an `onSynced`, so hand off to the
+ // same entry point to hydrate and release the loading gate - the
+ // editor would spin forever otherwise.
+ void onProviderSynced(doc, null, aw)
+ },
+ onSynced() {
+ if (connectTimer !== undefined) window.clearTimeout(connectTimer)
+ void onProviderSynced(doc, prov, prov!.awareness!)
+ }
+ })
+
+ // A server that never answers produces neither `onSynced` nor
+ // `onAuthenticationFailed`: HocuspocusProvider just keeps retrying, and
+ // `onStatus` only moves a ref nobody gates on. The loading screen would
+ // stay up forever, so one typo in `yjsServerUrl` - or a sidecar that is
+ // simply down - would take every editor in the deployment offline.
+ //
+ // Give up after a bounded wait and carry on locally: the file is still
+ // editable and still saveable, it just does not sync.
+ connectTimer = window.setTimeout(() => {
+ connectTimer = undefined
+ if (doc.isDestroyed || unref(ydoc) !== doc || unref(isReady)) return
+
+ console.error(`[collab] realtime server unreachable, continuing without it: ${name}`)
+ status.value = 'disconnected'
+ error.value = new Error(
+ $gettext(
+ 'The realtime server could not be reached. Editing continues without collaboration; others will not see your changes until you reload.'
+ )
+ )
+ // Stop retrying. A later connect would merge our locally hydrated
+ // copy into a room that may already hold the same content,
+ // duplicating the document for every peer. `disconnect` rather than
+ // `destroy` because the editor binds to the provider's awareness and
+ // `destroy` takes that down with it.
+ try {
+ prov?.disconnect()
+ } catch {
+ // already torn down
+ }
+ // `runInitialHydration` returns early if the doc turned out to have
+ // content after all, so a sync that landed just as we gave up cannot
+ // be hydrated on top of.
+ void onProviderSynced(doc, null, aw)
+ }, CONNECT_TIMEOUT_MS)
+
+ // Empty-user bootstrap: creates an awareness entry under our
+ // Y.Doc.clientID as soon as the provider connects, so peers see us
+ // before the editor binding emits its first cursor update. The
+ // server's beforeHandleAwareness hook overwrites this with the
+ // authenticated identity. Lurkers that never touch `user` stay
+ // invisible (matches the hook's "only stamp when present" rule).
+ prov.setAwarenessField('user', {})
+ aw = prov.awareness!
+ } else {
+ // ---------- Local mode ----------
+ // Standalone Awareness so the editor bindings still see a non-null
+ // awareness instance. Nobody else will ever join, which is the point.
+ aw = new Awareness(doc)
+ status.value = 'local'
+ // No `onSynced` to wait for - hand off to the same hydration entry
+ // point immediately. Without a yjs server, the app-version handshake and
+ // stale-state probe are no-ops, but we still run through the function
+ // so future shared-handler additions keep both modes aligned.
+ void onProviderSynced(doc, null, aw)
+ }
+
+ // _oc_meta is the parallel channel for stale/version coordination. The
+ // editor binding never sees it because adapters bind to their own shared
+ // types. In local mode nobody ever sets isStale / bumps appVersion, so
+ // the observer is dormant but harmless.
+ const meta = doc.getMap(META_KEY)
+ function metaObserver(event: Y.YMapEvent, transaction: Y.Transaction) {
+ // Peer-save fan-out. Another client just saved (its etag-mirror watch
+ // fired LOCAL_SAVE_ORIGIN on its side, then Yjs synced the meta-map
+ // change to us with `transaction.origin === undefined` - remote ops
+ // have no string origin). Our Y.Doc already reflects every edit that
+ // save covered, so serialize it now and tell the caller "this is what's
+ // on disk": its dirty state falls to false.
+ if (event.keysChanged.has('etag') && transaction.origin !== LOCAL_SAVE_ORIGIN) {
+ const newEtag = meta.get('etag') as string | undefined
+ if (newEtag) onEtagChange(newEtag)
+ }
+
+ if (event.keysChanged.has('lastSavedAt') && transaction.origin !== LOCAL_SAVE_ORIGIN) {
+ // The peer PUT what *its* doc serialized to, not what ours does. If
+ // we hold edits that never reached it before the write, reporting our
+ // own serialization as "this is on disk" would drop our dirty flag,
+ // disarm the unsaved-changes guard and lose those edits with the tab.
+ // So only follow the peer clean when its snapshot covers everything
+ // we have. The check is repeated after serializing because a
+ // keystroke can land while that runs.
+ const theirState = meta.get('savedStateVector')
+ if (theirState instanceof Uint8Array) {
+ serializeDoc(doc)
+ .then((value) => {
+ if (value === null || doc.isDestroyed) return
+ if (!peerSaveCoversUs(doc, theirState)) return
+ onServerContentChange(value)
+ })
+ .catch((e) => console.error('[collab] serialize for peer-save sync failed:', e))
+ }
+ }
+
+ // App version mismatch surfaced after the fact (e.g. a newer peer
+ // joined and bumped `appVersion`). Any non-zero diff at this point
+ // means the room moved past or ahead of us mid-session - Lock and
+ // prompt reload. Stale-recovery is intentionally NOT triggered here;
+ // that path only applies when the doc state itself was already older
+ // than the current client at first load.
+ if (event.keysChanged.has('appVersion')) {
+ const docVersion = meta.get('appVersion') as string | undefined
+ const version = toValue(appVersion)
+ if (docVersion) {
+ const cmp = compareVersion(version, docVersion)
+ if (Number.isNaN(cmp) || cmp !== 0) {
+ lockForReload(
+ prov,
+ $gettext(
+ 'This file is now being edited with app version %{docVersion} (yours is %{version}). Please reload.',
+ { docVersion, version }
+ )
+ )
+ }
+ }
+ }
+
+ // A peer is seeding the room while we hold a private read-only copy.
+ // Letting the two merge would duplicate the whole document, so throw
+ // our session away and rebuild it from the room's state. Nothing is
+ // lost: a read-only client never has edits of its own.
+ if (
+ event.keysChanged.has('hydrated') &&
+ meta.get('hydrated') === true &&
+ hasLocalOnlyContent
+ ) {
+ sessionNonce.value++
+ return
+ }
+
+ // Stale-state signal: the room's Y.Doc was tied to an etag that no
+ // longer matches the native file. Every peer runs this, but only the
+ // one elected to supply the fresh body gets past its guards.
+ if (event.keysChanged.has('isStale') && meta.get('isStale') === true) {
+ void recoverFromStaleState(doc, prov)
+ }
+ }
+ meta.observe(metaObserver)
+
+ ydoc.value = doc
+ provider.value = prov
+ awareness.value = aw
+
+ onCleanup(() => {
+ if (serializeTimer !== undefined) window.clearTimeout(serializeTimer)
+ if (connectTimer !== undefined) window.clearTimeout(connectTimer)
+ meta.unobserve(metaObserver)
+ doc.off('update', onDocUpdate)
+ if (prov) {
+ // Takes `prov.awareness` - which is `aw` in collab mode - down with
+ // it, so destroying `aw` again here would re-run teardown on an
+ // already-cleared observer map.
+ prov.destroy()
+ } else {
+ aw.destroy()
+ }
+ doc.destroy()
+ if (unref(provider) === prov) provider.value = null
+ if (unref(awareness) === aw) awareness.value = null
+ if (unref(ydoc) === doc) ydoc.value = null
+ })
+ },
+ { immediate: true }
+ )
+
+ // The caller updates `resource` after each of its own saves, which bubbles
+ // the new etag in here. Mirror it into `_oc_meta.etag` so peers learn that
+ // the file on disk moved. In local mode nobody reads `_oc_meta`, but the
+ // mirror is cheap and keeps the two modes symmetrical.
+ watch(
+ () => toValue(resource)?.etag,
+ (newEtag) => {
+ const doc = unref(ydoc)
+ if (!doc || doc.isDestroyed || !newEtag) return
+ const meta = doc.getMap(META_KEY)
+ if (meta.get('etag') === newEtag) return
+ doc.transact(() => {
+ meta.set('etag', newEtag)
+ // Snapshot of what our doc contained when we wrote the file. Peers use
+ // it to tell "this save covers me too" from "this save predates my
+ // edits", instead of assuming the former.
+ meta.set('savedStateVector', Y.encodeStateVector(doc))
+ meta.set('lastSavedAt', Date.now())
+ }, LOCAL_SAVE_ORIGIN)
+ }
+ )
+
+ return {
+ ydoc,
+ awareness,
+ provider,
+ status,
+ isReady,
+ isLockedForReload,
+ error
+ }
+}
diff --git a/packages/web-pkg/src/composables/index.ts b/packages/web-pkg/src/composables/index.ts
index fbc192736d..b6c0db88e4 100644
--- a/packages/web-pkg/src/composables/index.ts
+++ b/packages/web-pkg/src/composables/index.ts
@@ -6,6 +6,7 @@ export * from './archiverService'
export * from './authContext'
export * from './breadcrumbs'
export * from './clientService'
+export * from './collaborative'
export * from './download'
export * from './driveResolver'
export * from './embedMode'
diff --git a/packages/web-pkg/src/composables/piniaStores/config/types.ts b/packages/web-pkg/src/composables/piniaStores/config/types.ts
index 0130d52992..13db09fada 100644
--- a/packages/web-pkg/src/composables/piniaStores/config/types.ts
+++ b/packages/web-pkg/src/composables/piniaStores/config/types.ts
@@ -117,7 +117,8 @@ const OptionsConfigSchema = z.object({
enabled: z.boolean().optional(),
apiUrl: z.string().optional()
})
- .optional()
+ .optional(),
+ yjsServerUrl: z.string().optional()
})
export type OptionsConfig = z.infer
diff --git a/packages/web-pkg/src/editor/collabAdapter.ts b/packages/web-pkg/src/editor/collabAdapter.ts
new file mode 100644
index 0000000000..1e3055b449
--- /dev/null
+++ b/packages/web-pkg/src/editor/collabAdapter.ts
@@ -0,0 +1,106 @@
+import { Editor, getSchema } from '@tiptap/core'
+import { Collaboration } from '@tiptap/extension-collaboration'
+import { yXmlFragmentToProseMirrorRootNode } from '@tiptap/y-tiptap'
+import { toValue } from 'vue'
+import type { MaybeRefOrGetter } from 'vue'
+import type * as Y from 'yjs'
+import type { Schema } from '@tiptap/pm/model'
+import type { CollaborativeAdapter } from '../composables/collaborative/types'
+import type { ContentTypeStrategy } from './composables/strategies/types'
+import { DEFAULT_YDOC_FRAGMENT } from './types'
+
+/**
+ * Bridges the `web-pkg/editor` strategy contract to the
+ * {@link CollaborativeAdapter} contract that `useCollaborativeDocument`
+ * expects.
+ *
+ * Each strategy already knows how to convert between its native string format
+ * (markdown / HTML / plain text / tiptap-json) and a ProseMirror document. The
+ * adapter handles the conversion between that document and the Y.Doc state
+ * that `Collaboration` writes to.
+ *
+ * `serialize` converts the Y.XmlFragment directly into a ProseMirror node and
+ * hands that to the strategy. No editor is involved, which matters because the
+ * session serializes on every pause in typing on every peer.
+ *
+ * `hydrate` still needs an editor, because parsing native content back into
+ * the shared types goes through `setContent`. It runs once per session, so its
+ * cost does not matter. It spawns a headless editor for this.
+ *
+ * `strategy` is a `MaybeRefOrGetter` because the adapter is built before the
+ * file is loaded, so the content type isn't known yet. Strategies must be
+ * built in a setup context; resolving the getter later is safe.
+ */
+export function makeTiptapCollabAdapter(
+ strategy: MaybeRefOrGetter,
+ fragment = DEFAULT_YDOC_FRAGMENT
+): CollaborativeAdapter {
+ // Building the schema walks every extension, so keep one per strategy. The
+ // getter can resolve to a different strategy per call (the content type is
+ // detected from the resource), hence a map rather than a single slot.
+ const schemas = new WeakMap()
+ function schemaFor(current: ContentTypeStrategy): Schema {
+ let schema = schemas.get(current)
+ if (!schema) {
+ schema = getSchema(current.extensions({ collaborative: true }))
+ schemas.set(current, schema)
+ }
+ return schema
+ }
+
+ function makeHeadlessEditor(ydoc: Y.Doc): Editor {
+ const detached = document.createElement('div')
+ return new Editor({
+ element: detached,
+ extensions: [
+ ...toValue(strategy).extensions({ collaborative: true }),
+ Collaboration.configure({ document: ydoc, field: fragment })
+ ]
+ })
+ }
+
+ function setContentOptions(): Record {
+ const opts: Record = { emitUpdate: false }
+ const editorContentType = toValue(strategy).editorContentType
+ if (editorContentType) {
+ opts.contentType = editorContentType()
+ }
+ return opts
+ }
+
+ return {
+ hydrate(ydoc, content) {
+ if (!content) return
+ const editor = makeHeadlessEditor(ydoc)
+ try {
+ editor.commands.setContent(
+ toValue(strategy).deserialize(content) as Parameters<
+ typeof editor.commands.setContent
+ >[0],
+ setContentOptions()
+ )
+ } finally {
+ editor.destroy()
+ }
+ },
+
+ serialize(ydoc) {
+ const current = toValue(strategy)
+ const doc = yXmlFragmentToProseMirrorRootNode(
+ ydoc.getXmlFragment(fragment),
+ schemaFor(current)
+ )
+ return current.serialize(doc)
+ },
+
+ hasContent(ydoc) {
+ return ydoc.getXmlFragment(fragment).length > 0
+ },
+
+ reset(ydoc) {
+ const frag = ydoc.getXmlFragment(fragment)
+ if (frag.length === 0) return
+ frag.delete(0, frag.length)
+ }
+ }
+}
diff --git a/packages/web-pkg/src/editor/composables/strategies/html.ts b/packages/web-pkg/src/editor/composables/strategies/html.ts
index 3f46b57252..098a8cf531 100644
--- a/packages/web-pkg/src/editor/composables/strategies/html.ts
+++ b/packages/web-pkg/src/editor/composables/strategies/html.ts
@@ -1,7 +1,8 @@
-import { ContentTypeStrategy } from './types'
+import { ContentTypeStrategy, ExtensionsOptions } from './types'
import { useGettext } from 'vue3-gettext'
-import type { Editor } from '@tiptap/vue-3'
import type { Extension } from '@tiptap/core'
+import { getHTMLFromFragment } from '@tiptap/core'
+import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import StarterKit from '@tiptap/starter-kit'
import Underline from '@tiptap/extension-underline'
import Image from '@tiptap/extension-image'
@@ -31,17 +32,17 @@ export const useStrategyHtml = (editorState: TextEditorState): ContentTypeStrate
return 'html'
}
- const serialize = (editor: Editor): string => {
- return editor.getHTML()
+ const serialize = (doc: ProseMirrorNode): string => {
+ return getHTMLFromFragment(doc.content, doc.type.schema)
}
const deserialize = (content: string): string => {
return content
}
- const extensions = (): Extension[] => {
+ const extensions = (options?: ExtensionsOptions): Extension[] => {
return [
- StarterKit.configure({ link: false }),
+ StarterKit.configure({ link: false, undoRedo: options?.collaborative ? false : undefined }),
createLinkExtension(),
Image.configure({
inline: false,
diff --git a/packages/web-pkg/src/editor/composables/strategies/markdown.ts b/packages/web-pkg/src/editor/composables/strategies/markdown.ts
index 312c34f7bc..e5548f5e7b 100644
--- a/packages/web-pkg/src/editor/composables/strategies/markdown.ts
+++ b/packages/web-pkg/src/editor/composables/strategies/markdown.ts
@@ -1,15 +1,15 @@
import { EditorActionGroup, useEditorActions } from '../useEditorActions'
-import { ContentTypeStrategy } from './types'
+import { ContentTypeStrategy, ExtensionsOptions } from './types'
import type { Extension, JSONContent } from '@tiptap/core'
+import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import StarterKit from '@tiptap/starter-kit'
-import { Markdown } from '@tiptap/markdown'
+import { Markdown, MarkdownManager } from '@tiptap/markdown'
import Image from '@tiptap/extension-image'
import FindAndReplace from '@tiptap/extension-find-and-replace'
import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table'
import TaskList from '@tiptap/extension-task-list'
import TaskItem from '@tiptap/extension-task-item'
import { useGettext } from 'vue3-gettext'
-import type { Editor } from '@tiptap/vue-3'
import { TextEditorState } from '../../types'
import { createLinkExtension } from '../../extensions'
import { imageFileHandlerExtension } from './imageFileHandler'
@@ -21,15 +21,21 @@ export const useStrategyMarkdown = (editorState: TextEditorState): ContentTypeSt
return 'markdown'
}
- const serialize = (editor: Editor): string => {
- return editor.getMarkdown()
+ // `editor.getMarkdown()` is just `MarkdownManager.serialize(editor.getJSON())`.
+ // Holding our own manager lets us render a document with no editor attached.
+ // Built lazily and once: it only reads the extensions' markdown specs, which
+ // never change for a given strategy.
+ let markdownManager: MarkdownManager | null = null
+ const serialize = (doc: ProseMirrorNode): string => {
+ markdownManager ??= new MarkdownManager({ extensions: extensions() })
+ return markdownManager.serialize(doc.toJSON())
}
const deserialize = (content: string): string => {
return content
}
- const extensions = (): Extension[] => {
+ const extensions = (options?: ExtensionsOptions): Extension[] => {
const markdownImage = Image.extend({
renderMarkdown: (node: JSONContent) => {
const src = (node.attrs?.src as string | undefined) ?? ''
@@ -64,7 +70,7 @@ export const useStrategyMarkdown = (editorState: TextEditorState): ContentTypeSt
})
return [
- StarterKit.configure({ link: false }),
+ StarterKit.configure({ link: false, undoRedo: options?.collaborative ? false : undefined }),
Markdown,
createLinkExtension(),
Table.configure({ resizable: false }),
diff --git a/packages/web-pkg/src/editor/composables/strategies/plainText.ts b/packages/web-pkg/src/editor/composables/strategies/plainText.ts
index 43310040ec..00e56a2529 100644
--- a/packages/web-pkg/src/editor/composables/strategies/plainText.ts
+++ b/packages/web-pkg/src/editor/composables/strategies/plainText.ts
@@ -1,9 +1,9 @@
-import { Editor } from '@tiptap/vue-3'
-import { Extension } from '@tiptap/core'
+import { Extension, getText, getTextSerializersFromSchema } from '@tiptap/core'
+import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import StarterKit from '@tiptap/starter-kit'
import FindAndReplace from '@tiptap/extension-find-and-replace'
import { EditorActionGroup, useEditorActions } from '../useEditorActions'
-import { ContentTypeStrategy } from './types'
+import { ContentTypeStrategy, ExtensionsOptions } from './types'
import { TextEditorState } from '../../types'
import { useGettext } from 'vue3-gettext'
@@ -14,8 +14,11 @@ export const useStrategyPlainText = (editorState: TextEditorState): ContentTypeS
return 'plainText'
}
- const serialize = (editor: Editor): string => {
- return editor.getText({ blockSeparator: '\n' })
+ const serialize = (doc: ProseMirrorNode): string => {
+ return getText(doc, {
+ blockSeparator: '\n',
+ textSerializers: getTextSerializersFromSchema(doc.type.schema)
+ })
}
const deserialize = (content: string): Record => {
@@ -35,9 +38,10 @@ export const useStrategyPlainText = (editorState: TextEditorState): ContentTypeS
}
}
- const extensions = (): Extension[] => {
+ const extensions = (options?: ExtensionsOptions): Extension[] => {
return [
StarterKit.configure({
+ undoRedo: options?.collaborative ? false : undefined,
blockquote: false,
bold: false,
bulletList: false,
diff --git a/packages/web-pkg/src/editor/composables/strategies/tiptapJson.ts b/packages/web-pkg/src/editor/composables/strategies/tiptapJson.ts
index 0d09a2a62d..3c454e8ece 100644
--- a/packages/web-pkg/src/editor/composables/strategies/tiptapJson.ts
+++ b/packages/web-pkg/src/editor/composables/strategies/tiptapJson.ts
@@ -1,7 +1,7 @@
-import { ContentTypeStrategy } from './types'
+import { ContentTypeStrategy, ExtensionsOptions } from './types'
import { useGettext } from 'vue3-gettext'
-import type { Editor } from '@tiptap/vue-3'
import type { Extension } from '@tiptap/core'
+import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import StarterKit from '@tiptap/starter-kit'
import Underline from '@tiptap/extension-underline'
import Image from '@tiptap/extension-image'
@@ -29,17 +29,17 @@ export const useStrategyTiptapJson = (editorState: TextEditorState): ContentType
return 'json'
}
- const serialize = (editor: Editor): string => {
- return JSON.stringify(editor.getJSON())
+ const serialize = (doc: ProseMirrorNode): string => {
+ return JSON.stringify(doc.toJSON())
}
const deserialize = (content: string): string => {
return JSON.parse(content)
}
- const extensions = (): Extension[] => {
+ const extensions = (options?: ExtensionsOptions): Extension[] => {
return [
- StarterKit.configure({ link: false }),
+ StarterKit.configure({ link: false, undoRedo: options?.collaborative ? false : undefined }),
createLinkExtension(),
Image.configure({
inline: false,
diff --git a/packages/web-pkg/src/editor/composables/strategies/types.ts b/packages/web-pkg/src/editor/composables/strategies/types.ts
index e09cf32ec2..d9a1ce9847 100644
--- a/packages/web-pkg/src/editor/composables/strategies/types.ts
+++ b/packages/web-pkg/src/editor/composables/strategies/types.ts
@@ -1,11 +1,28 @@
import type { Extension } from '@tiptap/core'
+import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import type { EditorActionGroup } from '../useEditorActions'
-import type { Editor } from '@tiptap/vue-3'
+
+export interface ExtensionsOptions {
+ /**
+ * The editor binds to a shared Y.Doc. Strategies must then drop
+ * `StarterKit`'s `undoRedo`, because the `Collaboration` extension brings
+ * the collab-aware undo manager (`yUndoPlugin`) and Tiptap warns and
+ * double-stacks history when both run.
+ */
+ collaborative?: boolean
+}
export interface ContentTypeStrategy {
editorContentType?(): string
- serialize(editor: Editor): string
+ /**
+ * Render a ProseMirror document to the native string format.
+ *
+ * Takes the document node rather than an `Editor` so it can also run on a
+ * document that no editor is mounted on (e.g. relevant for the collaborative
+ * adapter). A mounted editor passes `editor.state.doc`.
+ */
+ serialize(doc: ProseMirrorNode): string
deserialize(content: string): Record | string
- extensions(): Extension[]
+ extensions(options?: ExtensionsOptions): Extension[]
editorActionGroups(): EditorActionGroup[]
}
diff --git a/packages/web-pkg/src/editor/composables/useTextEditor.ts b/packages/web-pkg/src/editor/composables/useTextEditor.ts
index 401dfebd9b..c0595752e4 100644
--- a/packages/web-pkg/src/editor/composables/useTextEditor.ts
+++ b/packages/web-pkg/src/editor/composables/useTextEditor.ts
@@ -1,6 +1,10 @@
-import { ref, computed, onBeforeUnmount, watch, unref, onMounted, triggerRef } from 'vue'
+import { ref, computed, onBeforeUnmount, watch, unref, onMounted, toValue, triggerRef } from 'vue'
import { useEditor } from '@tiptap/vue-3'
+import { Extension } from '@tiptap/core'
import { Placeholder } from '@tiptap/extension-placeholder'
+import { Collaboration } from '@tiptap/extension-collaboration'
+import { yCursorPlugin } from '@tiptap/y-tiptap'
+import type { Awareness } from 'y-protocols/awareness'
import type { ShallowRef } from 'vue'
import type { Editor } from '@tiptap/vue-3'
import type { Resource } from '@opencloud-eu/web-client'
@@ -10,12 +14,50 @@ import type {
TextEditorLinkPanelRequest,
TextEditorState
} from '../types'
+import { DEFAULT_YDOC_FRAGMENT } from '../types'
import type { EditorAction, EditorActionGroup } from './useEditorActions'
import { SlashCommands } from '../extensions'
import { useContentStrategy } from './useContentStrategy'
+import { useConfigStore } from '../../composables'
+
+// Custom Tiptap extension that wires y-tiptap's yCursorPlugin to a given
+// Awareness. We bypass `@tiptap/extension-collaboration-cursor` because
+// its 3.0.0 release still imports `yCursorPlugin` from the upstream
+// `y-prosemirror` package — a different module with a different
+// `ySyncPluginKey` than the `@tiptap/y-tiptap` fork that
+// `@tiptap/extension-collaboration` uses. Mixing them throws
+// "Cannot read properties of undefined (reading 'doc')" on first paint.
+// y-tiptap's yCursorPlugin shares ySyncPluginKey with Collaboration so
+// the cursor plugin can find the sync state.
+function makeCollabCursorExtension(awareness: Awareness): Extension {
+ return Extension.create({
+ name: 'yCollaborationCursor',
+ addProseMirrorPlugins() {
+ return [
+ yCursorPlugin(awareness, {
+ // Emit the same `.collaboration-cursor__caret/__label` DOM the
+ // (broken) upstream extension would have, so consumer CSS keeps
+ // working unchanged.
+ cursorBuilder: (user: { name?: string; color?: string }) => {
+ const cursor = document.createElement('span')
+ cursor.classList.add('collaboration-cursor__caret')
+ cursor.setAttribute('style', `border-color: ${user.color ?? '#ffa500'}`)
+ const label = document.createElement('div')
+ label.classList.add('collaboration-cursor__label')
+ label.setAttribute('style', `background-color: ${user.color ?? '#ffa500'}`)
+ label.insertBefore(document.createTextNode(user.name ?? ''), null)
+ cursor.insertBefore(label, null)
+ return cursor
+ }
+ })
+ ]
+ }
+ })
+}
export function useTextEditor(options: TextEditorOptions): TextEditorInstance {
const { resolveStrategy } = useContentStrategy()
+ const configStore = useConfigStore()
const state: TextEditorState = {
sourceMode: ref(false),
linkPanel: ref(null),
@@ -24,12 +66,20 @@ export function useTextEditor(options: TextEditorOptions): TextEditorInstance {
}
const contentType = ref(options.contentType)
- const readonly = ref(options.readonly ?? false)
+ const readonly = computed(() => toValue(options.readonly) ?? false)
const strategy = resolveStrategy(options.contentType, state)
+ const collabFragment = options.ydocFragment ?? DEFAULT_YDOC_FRAGMENT
+
+ // FIXME: Source mode swaps the ProseMirror view for a plain textarea, hence
+ // drop the action while a realtime session is active.
+ const collaborationActive = Boolean(options.ydoc) && Boolean(configStore.options.yjsServerUrl)
// Filter out excluded actions (by id) from toolbar and slash commands, including
// nested dropdown children (e.g. exclude 'image-upload' but keep 'image-url').
- const excludeActions = options.excludeActions ?? []
+ const excludeActions = [
+ ...(options.excludeActions ?? []),
+ ...(collaborationActive ? ['source-mode'] : [])
+ ]
const filterActions = (actions: EditorAction[]): EditorAction[] =>
actions
.filter((action) => !excludeActions.includes(action.id))
@@ -47,7 +97,23 @@ export function useTextEditor(options: TextEditorOptions): TextEditorInstance {
// Debounce onUpdate to avoid firing on every keystroke while typing.
let debounceTimer: ReturnType | null = null
- const extensions = strategy.extensions()
+ const extensions = strategy.extensions({ collaborative: Boolean(options.ydoc) })
+ if (options.ydoc) {
+ // Bind ProseMirror state to the shared Y.Doc. With Collaboration active,
+ // the editor's initial content is read from the Y.Doc (not from the
+ // `content` option), so we skip the `content` assignment below.
+ extensions.push(
+ Collaboration.configure({
+ document: options.ydoc,
+ field: collabFragment
+ }) as (typeof extensions)[number]
+ )
+ if (options.awareness) {
+ // Render remote peers' carets + labels via y-tiptap's yCursorPlugin.
+ // Skipped when only ydoc is provided (local mode, no remote peers).
+ extensions.push(makeCollabCursorExtension(options.awareness) as (typeof extensions)[number])
+ }
+ }
if (options.slashCommands !== false) {
const resolvedGroups = editorActionGroups()
const hasSlashCommandItems = resolvedGroups.some((group) =>
@@ -71,7 +137,14 @@ export function useTextEditor(options: TextEditorOptions): TextEditorInstance {
// to satisfy TextEditorInstance. The destroy() method sets it to null explicitly.
const editorOptions: Record = {
extensions,
- content: unref(options.modelValue) ? strategy.deserialize(unref(options.modelValue)) : '',
+ // In collab mode the wrapper hydrates the Y.Doc — passing `content` here
+ // would race against the CRDT and produce duplicated state. Leave the
+ // editor blank; Collaboration will paint Y.Doc state into it.
+ content: options.ydoc
+ ? ''
+ : unref(options.modelValue)
+ ? strategy.deserialize(unref(options.modelValue))
+ : '',
editable: !readonly.value
}
@@ -79,6 +152,9 @@ export function useTextEditor(options: TextEditorOptions): TextEditorInstance {
if (!unref(editor) || unref(editor)?.isFocused) {
return
}
+ // In collab mode the Y.Doc is the source of truth — never round-trip
+ // `modelValue` back into the editor (would clobber peer edits).
+ if (options.ydoc) return
setContent(content)
})
@@ -134,7 +210,7 @@ export function useTextEditor(options: TextEditorOptions): TextEditorInstance {
}
debounceTimer = setTimeout(() => {
debounceTimer = null
- options.onUpdate!(strategy.serialize(e as unknown as Editor))
+ options.onUpdate!(strategy.serialize(e.state.doc))
}, 250)
}
}) as unknown as ShallowRef
@@ -147,7 +223,7 @@ export function useTextEditor(options: TextEditorOptions): TextEditorInstance {
if (!editor.value) {
return ''
}
- return strategy.serialize(editor.value)
+ return strategy.serialize(editor.value.state.doc)
}
const setContent = (value: string): void => {
@@ -177,7 +253,7 @@ export function useTextEditor(options: TextEditorOptions): TextEditorInstance {
if (debounceTimer) {
clearTimeout(debounceTimer)
if (options.onUpdate && editor.value) {
- options.onUpdate(strategy.serialize(editor.value))
+ options.onUpdate(strategy.serialize(editor.value.state.doc))
}
debounceTimer = null
}
diff --git a/packages/web-pkg/src/editor/index.ts b/packages/web-pkg/src/editor/index.ts
index 03a5863942..470eea68e6 100644
--- a/packages/web-pkg/src/editor/index.ts
+++ b/packages/web-pkg/src/editor/index.ts
@@ -1,5 +1,14 @@
-export type { ContentType, TextEditorOptions, TextEditorInstance } from './types'
+export type {
+ ContentType,
+ TextEditorOptions,
+ TextEditorInstance,
+ TextEditorState,
+ TextEditorLinkPanelRequest
+} from './types'
export { useTextEditor } from './composables/useTextEditor'
+export { useContentStrategy } from './composables/useContentStrategy'
+export type { ContentTypeStrategy, ExtensionsOptions } from './composables/strategies/types'
+export { makeTiptapCollabAdapter } from './collabAdapter'
export { default as TextEditorProvider } from './components/TextEditorProvider.vue'
export { default as TextEditorContent } from './components/TextEditorContent.vue'
export { default as TextEditorToolbar } from './components/TextEditorToolbar.vue'
diff --git a/packages/web-pkg/src/editor/styles/collab-cursor.css b/packages/web-pkg/src/editor/styles/collab-cursor.css
new file mode 100644
index 0000000000..033fe7b608
--- /dev/null
+++ b/packages/web-pkg/src/editor/styles/collab-cursor.css
@@ -0,0 +1,31 @@
+/* Remote-peer cursor + name label rendered by yCursorPlugin from
+ * @tiptap/y-tiptap, wired by `useTextEditor` when an Awareness is bound.
+ *
+ * Without these the default browser block layout makes the label a
+ * full-width band across the line. Selectors are unscoped so the same
+ * styles apply to any consumer of the composable. */
+
+.text-editor-content .collaboration-cursor__caret {
+ position: relative;
+ margin-left: -1px;
+ margin-right: -1px;
+ border-left: 1px solid;
+ border-right: 1px solid;
+ word-break: normal;
+ pointer-events: none;
+}
+
+.text-editor-content .collaboration-cursor__label {
+ position: absolute;
+ top: -1.4em;
+ left: -1px;
+ font-size: 12px;
+ font-style: normal;
+ font-weight: 600;
+ line-height: normal;
+ user-select: none;
+ color: white;
+ padding: 0.1rem 0.3rem;
+ border-radius: 3px 3px 3px 0;
+ white-space: nowrap;
+}
diff --git a/packages/web-pkg/src/editor/styles/content.css b/packages/web-pkg/src/editor/styles/content.css
index f6d98cb8ed..42d6ee14e8 100644
--- a/packages/web-pkg/src/editor/styles/content.css
+++ b/packages/web-pkg/src/editor/styles/content.css
@@ -1,2 +1,3 @@
@import './text-editor.css';
@import './resize-handle.css';
+@import './collab-cursor.css';
diff --git a/packages/web-pkg/src/editor/types.ts b/packages/web-pkg/src/editor/types.ts
index abcda62d4f..00476f9ceb 100644
--- a/packages/web-pkg/src/editor/types.ts
+++ b/packages/web-pkg/src/editor/types.ts
@@ -1,16 +1,29 @@
-import type { ShallowRef, Ref, ComputedRef } from 'vue'
+import type { ShallowRef, Ref, ComputedRef, MaybeRefOrGetter } from 'vue'
import type { Range } from '@tiptap/core'
-import type { Editor } from '@tiptap/vue-3'
import type { Resource } from '@opencloud-eu/web-client'
+import type { Editor } from '@tiptap/vue-3'
+import type * as Y from 'yjs'
+import type { Awareness } from 'y-protocols/awareness'
import type { EditorActionGroup } from './composables'
export type ContentType = 'plain-text' | 'markdown' | 'html' | 'tiptap-json'
+/**
+ * Default Y.XmlFragment field name. Shared by `useTextEditor` and the
+ * collaborative adapter, which must bind to the same field.
+ */
+export const DEFAULT_YDOC_FRAGMENT = 'default'
+
export interface TextEditorOptions {
contentType: ContentType
modelValue?: Ref
currentResource?: Ref
- readonly?: boolean
+ /**
+ * Accepts a ref or getter, not just a snapshot: a collaborative session can
+ * flip the editor read-only mid-edit (locking the room on an app-version
+ * mismatch, say), and the ProseMirror view has to follow.
+ */
+ readonly?: MaybeRefOrGetter
slashCommands?: boolean
placeholder?: string
/** Accessible name for the editor's role="textbox" element (aria-label). */
@@ -21,6 +34,24 @@ export interface TextEditorOptions {
*/
excludeActions?: string[]
onUpdate?: (content: string) => void
+ /**
+ * When set, the editor binds its ProseMirror state to this Y.Doc via the
+ * `@tiptap/extension-collaboration` extension. Initial content is taken
+ * from the Y.Doc state (populated by the host's hydration path) instead
+ * of from `modelValue`.
+ */
+ ydoc?: Y.Doc
+ /**
+ * Y.XmlFragment field name inside the Y.Doc. Must match the field the
+ * collaborative adapter binds to. Defaults to {@link DEFAULT_YDOC_FRAGMENT}.
+ */
+ ydocFragment?: string
+ /**
+ * Awareness instance from the same room as `ydoc`. When set, the editor
+ * renders remote peer cursors via `yCursorPlugin`. Ignored when `ydoc`
+ * is not also set.
+ */
+ awareness?: Awareness
}
export interface TextEditorLinkPanelRequest {
@@ -40,7 +71,8 @@ export interface TextEditorInstance {
state: TextEditorState
editor: ShallowRef
contentType: Ref
- readonly: Ref
+ /** Derived from the caller's `readonly` option; follows it while mounted. */
+ readonly: ComputedRef
actionGroups(): EditorActionGroup[]
getContent(): string
setContent(value: string): void
diff --git a/packages/web-pkg/tests/unit/components/AppTemplates/AppWrapper.spec.ts b/packages/web-pkg/tests/unit/components/AppTemplates/AppWrapper.spec.ts
new file mode 100644
index 0000000000..3134605301
--- /dev/null
+++ b/packages/web-pkg/tests/unit/components/AppTemplates/AppWrapper.spec.ts
@@ -0,0 +1,315 @@
+import { mock } from 'vitest-mock-extended'
+import { defineComponent, h, nextTick, ref, unref } from 'vue'
+import { flushPromises } from '@vue/test-utils'
+import type { Resource } from '@opencloud-eu/web-client'
+import type { GetFileContentsResponse } from '@opencloud-eu/web-client/webdav'
+import { createMemoryHistory, createRouter } from 'vue-router'
+import {
+ defaultPlugins,
+ defaultComponentMocks,
+ mount,
+ useAppDefaultsMock,
+ type RouteLocation
+} from '@opencloud-eu/web-test-helpers'
+
+import AppWrapper from '../../../../src/components/AppTemplates/AppWrapper.vue'
+import type {
+ CollaborativeDocument,
+ CollaborativeStatus
+} from '../../../../src/composables/collaborative'
+import type { FileContext } from '../../../../src/composables/appDefaults'
+
+const { useAppDefaultsSpy, useCollaborativeDocumentSpy } = vi.hoisted(() => ({
+ useAppDefaultsSpy: vi.fn(),
+ useCollaborativeDocumentSpy: vi.fn()
+}))
+
+vi.mock('vue-router', async (importOriginal) => ({
+ ...(await importOriginal()),
+ onBeforeRouteLeave: vi.fn()
+}))
+
+vi.mock('../../../../src/composables/appDefaults/useAppDefaults', () => ({
+ useAppDefaults: (...args: unknown[]) => useAppDefaultsSpy(...args)
+}))
+
+vi.mock('../../../../src/composables/collaborative/useCollaborativeDocument', () => ({
+ useCollaborativeDocument: (...args: unknown[]) => useCollaborativeDocumentSpy(...args)
+}))
+
+const FILE_A = mock({
+ id: 'storage$space!file-a',
+ name: 'a.md',
+ etag: 'etag-a',
+ permissions: 'RDNVW'
+})
+const FILE_B = mock({ id: 'storage$space!file-b', name: 'b.md', permissions: 'RDNVW' })
+
+const wrappedComponent = defineComponent({
+ props: {
+ resource: { type: Object, default: null },
+ currentContent: { type: String, default: '' }
+ },
+ template: ''
+})
+
+function setup({
+ collaborative = true,
+ status = 'connected' as CollaborativeStatus,
+ putFileContents = vi.fn().mockResolvedValue(mock({ etag: 'etag-saved' }))
+} = {}) {
+ const isLockedForReload = ref(false)
+ const currentFileContext = ref(mock({ space: mock(), path: '/a.md' }))
+ // Deferred so the test controls when each half of the load completes.
+ let resolveInfo: (r: Resource) => void
+ let resolveContents: (c: GetFileContentsResponse) => void
+
+ const getFileInfo = vi.fn().mockImplementation(() => new Promise((r) => (resolveInfo = r)))
+ const getFileContents = vi
+ .fn()
+ .mockImplementation(() => new Promise((r) => (resolveContents = r)))
+
+ useAppDefaultsSpy.mockReturnValue(
+ useAppDefaultsMock({ currentFileContext, getFileInfo, getFileContents, putFileContents })
+ )
+
+ let sessionOptions: any
+ useCollaborativeDocumentSpy.mockImplementation((options: any) => {
+ sessionOptions = options
+ return mock({
+ isReady: ref(true) as any,
+ status: ref(status) as any,
+ // Auto-mocked refs are truthy, which would fold into `effectiveReadOnly`
+ // and hard-wire `isDirty` to false.
+ isLockedForReload: isLockedForReload as any,
+ error: ref(null) as any
+ })
+ })
+
+ const mocks = defaultComponentMocks({
+ currentRoute: mock({
+ query: {},
+ params: { driveAliasAndItem: 'personal/alan/a.md' }
+ })
+ })
+
+ let slotProps: any
+ const wrapper = mount(AppWrapper, {
+ slots: {
+ default: (props: any) => {
+ slotProps = props
+ return h('div', { class: 'slot-content' })
+ }
+ },
+ props: {
+ applicationId: 'test-app',
+ wrappedComponent,
+ ...(collaborative
+ ? { collaborative: { appVersion: '1.0.0', makeAdapter: () => mock() } }
+ : {})
+ },
+ global: {
+ plugins: [
+ ...defaultPlugins({
+ piniaOptions: {
+ appsState: { apps: { 'test-app': { id: 'test-app', name: 'Test App' } } }
+ }
+ }),
+ createRouter({
+ history: createMemoryHistory(),
+ routes: [{ path: '/:all(.*)', component: wrappedComponent }]
+ })
+ ],
+ mocks,
+ provide: mocks,
+ stubs: { 'file-side-bar': true }
+ }
+ })
+
+ return {
+ wrapper,
+ currentFileContext,
+ isEnabled: () => sessionOptions?.enabled(),
+ isLockedForReload,
+ putFileContents,
+ getFileContents,
+ async edit(content: string) {
+ slotProps['onUpdate:currentContent'](content)
+ await nextTick()
+ },
+ async pressCtrlS() {
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 's', ctrlKey: true }))
+ await flushPromises()
+ },
+ session: () => sessionOptions,
+ async resolveResource(resource: Resource) {
+ resolveInfo(resource)
+ await flushPromises()
+ },
+ async resolveContent(body: string) {
+ resolveContents(
+ mock({ body, headers: { 'OC-ETag': 'etag' } as any })
+ )
+ await flushPromises()
+ }
+ }
+}
+
+describe('AppWrapper — collaborative session gate', () => {
+ it('stays disabled until the body for the current resource has arrived', async () => {
+ const s = setup()
+ await nextTick()
+
+ expect(s.isEnabled()).toBe(false)
+
+ await s.resolveResource(FILE_A)
+ // resource is set, body is not — hydrating here would seed an empty doc.
+ expect(s.isEnabled()).toBe(false)
+
+ await s.resolveContent('content of a')
+ expect(s.isEnabled()).toBe(true)
+ })
+
+ // Regression: `loading` was only ever set to false, so on an in-app file
+ // switch the gate stayed open while `resource` already pointed at the new
+ // file and `currentContent` still held the old body.
+ it('closes again while switching to another file, and does not reopen on a stale body', async () => {
+ const s = setup()
+ await nextTick()
+ await s.resolveResource(FILE_A)
+ await s.resolveContent('content of a')
+ expect(s.isEnabled()).toBe(true)
+
+ s.currentFileContext.value = mock({ space: mock(), path: '/b.md' })
+ await nextTick()
+ expect(s.isEnabled()).toBe(false)
+
+ // `resource` is already b.md while `currentContent` still holds a.md's
+ // body. The gate must stay shut for this whole window.
+ await s.resolveResource(FILE_B)
+ expect(s.isEnabled()).toBe(false)
+
+ await s.resolveContent('content of b')
+ expect(s.isEnabled()).toBe(true)
+ })
+})
+
+describe('AppWrapper — peer save fan-out', () => {
+ // Regression: a peer save only moved `currentETag`, so `resource.etag` kept
+ // the value this client opened with. The session compares that against the
+ // room's etag to spot an external write, so the next reconnect read an
+ // ordinary peer save as one and wiped the room to "recover" it, destroying
+ // whatever the other peers had typed since.
+ it('moves resource.etag onto the etag a peer published', async () => {
+ const s = setup()
+ await nextTick()
+ await s.resolveResource(FILE_A)
+ await s.resolveContent('content of a')
+
+ const session = s.session()
+ expect(unref(session.resource).etag).toBe(FILE_A.etag)
+
+ session.onEtagChange('etag-from-peer-save')
+ await nextTick()
+
+ expect(unref(session.resource).etag).toBe('etag-from-peer-save')
+ // Same file, so the session must not be torn down and rebuilt over it.
+ expect(unref(session.resource).id).toBe(FILE_A.id)
+ expect(s.isEnabled()).toBe(true)
+ })
+})
+
+describe('AppWrapper — save conflict handling', () => {
+ function conflict() {
+ return Object.assign(new Error('precondition failed'), { statusCode: 412, response: {} })
+ }
+
+ // Regression: the refetch-and-retry path was unconditional, so it also ran
+ // for plain editors and for deployments with no realtime server. There is
+ // nothing to merge in that case - the divergence is someone else's write,
+ // and retrying over it destroyed their work with no dialog and no toast.
+ it('shows the conflict dialog instead of retrying when there is no session', async () => {
+ const putFileContents = vi.fn().mockRejectedValue(conflict())
+ const s = setup({ collaborative: false, putFileContents })
+ await nextTick()
+ await s.resolveResource(FILE_A)
+ await s.resolveContent('content of a')
+ await s.edit('edited content')
+ await s.pressCtrlS()
+
+ // One attempt only, and crucially no refetch: the retry path never starts,
+ // so nothing can be written over the other writer.
+ expect(putFileContents).toHaveBeenCalledTimes(1)
+ expect(s.getFileContents).toHaveBeenCalledTimes(1) // the initial load only
+ })
+
+ it('shows the conflict dialog when the session is not connected', async () => {
+ const putFileContents = vi.fn().mockRejectedValue(conflict())
+ const s = setup({ status: 'local', putFileContents })
+ await nextTick()
+ await s.resolveResource(FILE_A)
+ await s.resolveContent('content of a')
+ await s.edit('edited content')
+ await s.pressCtrlS()
+
+ expect(putFileContents).toHaveBeenCalledTimes(1)
+ expect(s.getFileContents).toHaveBeenCalledTimes(1) // the initial load only
+ })
+
+ it('refetches and retries once when a connected session can merge', async () => {
+ const putFileContents = vi
+ .fn()
+ .mockRejectedValueOnce(conflict())
+ .mockResolvedValue(mock({ etag: 'etag-retry' }))
+ const s = setup({ status: 'connected', putFileContents })
+ await nextTick()
+ await s.resolveResource(FILE_A)
+ await s.resolveContent('content of a')
+ await s.edit('edited content')
+
+ const pressed = s.pressCtrlS()
+ await flushPromises()
+ // The retry path refetches the file first.
+ await s.resolveContent('content written by the peer')
+ await pressed
+
+ expect(s.getFileContents).toHaveBeenCalledTimes(2)
+ expect(putFileContents).toHaveBeenCalledTimes(2)
+ expect(putFileContents.mock.calls[1][1]).toMatchObject({ previousEntityTag: 'etag' })
+ })
+})
+
+describe('AppWrapper — locked session', () => {
+ // Regression: `isDirty` short-circuited on `effectiveReadOnly`, which folds
+ // in `isLockedForReload`. A session locking mid-edit therefore dropped the
+ // dirty flag, which hides the save action, unregisters `beforeunload` and
+ // lets the route-leave guard through - ten minutes of unsaved work gone with
+ // the tab, silently.
+ it('keeps unsaved work armed when the session locks mid-edit', async () => {
+ const s = setup()
+ await nextTick()
+ await s.resolveResource(FILE_A)
+ await s.resolveContent('content of a')
+ await s.edit('edited content')
+
+ s.isLockedForReload.value = true
+ await nextTick()
+
+ // Still dirty, so the guards stay armed and the user can still persist.
+ await s.pressCtrlS()
+ expect(s.putFileContents).toHaveBeenCalledTimes(1)
+ })
+
+ it('never arms them for a genuinely read-only file', async () => {
+ const s = setup()
+ await nextTick()
+ await s.resolveResource(
+ mock({ id: 'storage$space!ro', name: 'ro.md', etag: 'e', permissions: 'R' })
+ )
+ await s.resolveContent('content of a')
+ await s.edit('edited content')
+
+ await s.pressCtrlS()
+ expect(s.putFileContents).not.toHaveBeenCalled()
+ })
+})
diff --git a/packages/web-pkg/tests/unit/composables/collaborative/useCollaborativeDocument.spec.ts b/packages/web-pkg/tests/unit/composables/collaborative/useCollaborativeDocument.spec.ts
new file mode 100644
index 0000000000..f02fdd8a1a
--- /dev/null
+++ b/packages/web-pkg/tests/unit/composables/collaborative/useCollaborativeDocument.spec.ts
@@ -0,0 +1,837 @@
+// Unit coverage for the collaborative session composable that AppWrapper owns
+// on behalf of collaborative apps. It carries the non-trivial branching
+// (collab vs local) and a handful of side effects (debounced content reports,
+// etag mirror, lifecycle teardown) that aren't exercised by the cucumber e2e
+// suites unless we run them through the whole OC + sidecar stack.
+//
+// We mock HocuspocusProvider so the tests stay hermetic (no network). A tiny
+// inline adapter mimics a Y.Text-on-'content' layout; the composable only sees
+// the CollaborativeAdapter interface and doesn't care which app produced it.
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { flushPromises } from '@vue/test-utils'
+import { ref, shallowRef, unref } from 'vue'
+import * as Y from 'yjs'
+import { Awareness } from 'y-protocols/awareness'
+import type { Resource } from '@opencloud-eu/web-client'
+
+import {
+ useCollaborativeDocument,
+ type CollaborativeAdapter,
+ type CollaborativeDocument
+} from '../../../../src/composables/collaborative'
+import { getComposableWrapper } from '@opencloud-eu/web-test-helpers'
+
+// vi.hoisted is required so providerInstances is reachable from the hoisted
+// vi.mock factory; defining the class outside the factory hits "Cannot access
+// before initialization".
+interface MockProvider {
+ url: string
+ name: string
+ document: Y.Doc
+ awareness: Awareness
+ destroy: ReturnType
+ disconnect: ReturnType
+ setAwarenessField: ReturnType
+ triggerSynced(): void
+ triggerAuthFailed(reason: string): void
+}
+
+const { providerInstances } = vi.hoisted(() => {
+ return { providerInstances: [] as MockProvider[] }
+})
+
+vi.mock('@hocuspocus/provider', async () => {
+ const { Awareness: AwarenessImpl } = await import('y-protocols/awareness')
+ class MockHocuspocusProvider {
+ url: string
+ name: string
+ document: Y.Doc
+ awareness: Awareness
+ // Mirrors the real provider, which destroys its own awareness. A bare spy
+ // would hide a double-destroy in the session's cleanup.
+ destroy = vi.fn(() => {
+ this.awareness.destroy()
+ })
+ disconnect = vi.fn()
+ setAwarenessField = vi.fn()
+ private _opts: any
+ constructor(opts: any) {
+ this.url = opts.url
+ this.name = opts.name
+ this.document = opts.document
+ this.awareness = new AwarenessImpl(opts.document)
+ this._opts = opts
+ providerInstances.push(this as MockProvider & MockHocuspocusProvider)
+ }
+ triggerSynced() {
+ this._opts.onSynced?.({ state: true })
+ }
+ triggerAuthFailed(reason: string) {
+ this._opts.onAuthenticationFailed?.({ reason })
+ }
+ }
+ return { HocuspocusProvider: MockHocuspocusProvider }
+})
+
+const SHARED_TEXT_KEY = 'content'
+const testAdapter: CollaborativeAdapter = {
+ hydrate(ydoc: Y.Doc, content: string) {
+ const yText = ydoc.getText(SHARED_TEXT_KEY)
+ if (yText.length > 0) return
+ if (!content) return
+ ydoc.transact(() => {
+ yText.insert(0, content)
+ }, 'hydrate')
+ },
+ serialize(ydoc: Y.Doc): string {
+ return ydoc.getText(SHARED_TEXT_KEY).toString()
+ },
+ hasContent(ydoc: Y.Doc): boolean {
+ return ydoc.getText(SHARED_TEXT_KEY).length > 0
+ },
+ reset(ydoc: Y.Doc) {
+ const yText = ydoc.getText(SHARED_TEXT_KEY)
+ if (yText.length === 0) return
+ yText.delete(0, yText.length)
+ }
+}
+
+// Mirrors the real Tiptap adapter's defining property: serialize(hydrate(x))
+// is not x. Here a trailing newline stands in for Tiptap's markdown
+// renormalisation (`* a` becoming `- a`, and so on).
+const normalizingAdapter: CollaborativeAdapter = {
+ ...testAdapter,
+ serialize(ydoc: Y.Doc): string {
+ return `${ydoc.getText(SHARED_TEXT_KEY).toString()}\n`
+ }
+}
+
+function makeResource(overrides: Partial = {}): Resource {
+ return {
+ id: 'storage$space!item-1',
+ etag: 'etag-initial',
+ ...overrides
+ } as Resource
+}
+
+function setupSession({
+ currentContent = '',
+ yjsServerUrl = undefined as string | undefined,
+ appVersion = '1.2.3',
+ resource = makeResource(),
+ adapter = testAdapter as CollaborativeAdapter,
+ enabled = true,
+ isReadOnly = false
+} = {}) {
+ const resourceRef = ref(resource)
+ const adapterRef = shallowRef(adapter)
+ const enabledRef = ref(enabled)
+ const isReadOnlyRef = ref(isReadOnly)
+ const contentRef = ref(currentContent)
+ const onContentChange = vi.fn()
+ const onServerContentChange = vi.fn()
+ const onEtagChange = vi.fn()
+ let session: CollaborativeDocument
+
+ const wrapper = getComposableWrapper(
+ () => {
+ session = useCollaborativeDocument({
+ resource: resourceRef,
+ currentContent: contentRef,
+ enabled: enabledRef,
+ isReadOnly: isReadOnlyRef,
+ adapter: adapterRef,
+ appVersion,
+ documentPrefix: 'test-app',
+ onContentChange,
+ onServerContentChange,
+ onEtagChange
+ })
+ },
+ { pluginOptions: { piniaOptions: { configState: { options: { yjsServerUrl } } } } }
+ )
+
+ return {
+ wrapper,
+ resourceRef,
+ adapterRef,
+ enabledRef,
+ isReadOnlyRef,
+ contentRef,
+ onContentChange,
+ onServerContentChange,
+ onEtagChange,
+ get session() {
+ return session
+ },
+ get ydoc() {
+ return unref(session.ydoc)
+ }
+ }
+}
+
+beforeEach(() => {
+ providerInstances.length = 0
+})
+
+afterEach(() => {
+ vi.useRealTimers()
+})
+
+describe('useCollaborativeDocument — enabled gate', () => {
+ // Regression: hydration seeds the Y.Doc from `currentContent`. The caller
+ // knows the file id (and therefore the room name) before it has fetched the
+ // file body, so starting the session eagerly would hydrate — and publish to
+ // every peer — an empty document.
+ it('does not start, and does not hydrate from empty content, until enabled', async () => {
+ const s = setupSession({ currentContent: '', enabled: false })
+ await flushPromises()
+ expect(s.ydoc).toBeNull()
+
+ s.contentRef.value = 'content fetched later'
+ s.enabledRef.value = true
+ await flushPromises()
+
+ expect(s.ydoc).toBeTruthy()
+ expect(s.ydoc!.getText(SHARED_TEXT_KEY).toString()).toBe('content fetched later')
+ })
+})
+
+describe('useCollaborativeDocument — room name', () => {
+ const yjsServerUrl = 'wss://example.test/realtime'
+
+ it('keys the room on the prefix and the resource id', async () => {
+ setupSession({ yjsServerUrl, resource: makeResource({ id: 'storage$space!item-1' }) })
+ await flushPromises()
+ expect(providerInstances[0].name).toBe('test-app::storage$space!item-1')
+ })
+
+ // `resource.id` is global: the recipient of a share resolves the same
+ // composite id as the owner, which is what puts them in one room.
+ it('puts owner and share recipient in the same room', async () => {
+ setupSession({ yjsServerUrl, resource: makeResource({ id: 'storage$space!item-1' }) })
+ setupSession({
+ yjsServerUrl,
+ resource: makeResource({ id: 'storage$space!item-1', remoteItemId: 'storage$space!mount' })
+ })
+ await flushPromises()
+
+ expect(providerInstances[0].name).toBe(providerInstances[1].name)
+ })
+})
+
+describe('useCollaborativeDocument — local mode (no yjsServerUrl)', () => {
+ it('reports status "local" and does not construct a HocuspocusProvider', async () => {
+ const s = setupSession({ currentContent: 'hello' })
+ await flushPromises()
+ expect(providerInstances).toHaveLength(0)
+ expect(unref(s.session.status)).toBe('local')
+ })
+
+ it('hydrates the Y.Doc from currentContent (election degenerates to "we win")', async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true })
+ const s = setupSession({ currentContent: 'hello local' })
+ await flushPromises()
+ // Local mode skips the collab-only 150ms awareness-settle wait and
+ // hydrates immediately; advancing timers here is just belt-and-braces.
+ vi.advanceTimersByTime(200)
+ await flushPromises()
+
+ expect(s.ydoc).toBeTruthy()
+ expect(s.ydoc!.getText(SHARED_TEXT_KEY).toString()).toBe('hello local')
+ })
+
+ it('exposes a real Awareness instance and no provider', async () => {
+ const s = setupSession({ currentContent: 'x' })
+ await flushPromises()
+ expect(unref(s.session.awareness)).toBeInstanceOf(Awareness)
+ expect(unref(s.session.provider)).toBeNull()
+ expect(unref(s.session.isReady)).toBe(true)
+ })
+})
+
+describe('useCollaborativeDocument — collab mode (yjsServerUrl set)', () => {
+ it('constructs a HocuspocusProvider with the appVersion query param appended', async () => {
+ setupSession({ yjsServerUrl: 'wss://example.test/realtime', appVersion: '2.3.4' })
+ await flushPromises()
+ expect(providerInstances).toHaveLength(1)
+ expect(providerInstances[0].url).toBe('wss://example.test/realtime?appVersion=2.3.4')
+ expect(providerInstances[0].setAwarenessField).toHaveBeenCalledWith('user', {})
+ })
+
+ it('does not hydrate until onSynced fires (collab waits for the server)', async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true })
+ const s = setupSession({
+ yjsServerUrl: 'wss://example.test/realtime',
+ currentContent: 'should-only-land-after-sync'
+ })
+ await flushPromises()
+ vi.advanceTimersByTime(500)
+ await flushPromises()
+
+ expect(s.ydoc!.getText(SHARED_TEXT_KEY).toString()).toBe('')
+ expect(unref(s.session.isReady)).toBe(false)
+
+ providerInstances[0].triggerSynced()
+ vi.advanceTimersByTime(200)
+ await flushPromises()
+ expect(s.ydoc!.getText(SHARED_TEXT_KEY).toString()).toBe('should-only-land-after-sync')
+ expect(unref(s.session.isReady)).toBe(true)
+ })
+
+ it('surfaces an auth failure as an error, locks read-only and releases the loading gate', async () => {
+ const s = setupSession({ yjsServerUrl: 'wss://example.test/realtime' })
+ await flushPromises()
+ providerInstances[0].triggerAuthFailed('token expired')
+ await flushPromises()
+
+ expect(unref(s.session.error)?.message).toBe('token expired')
+ expect(unref(s.session.isLockedForReload)).toBe(true)
+ expect(unref(s.session.isReady)).toBe(true)
+ })
+
+ // Regression: the gate was released on an empty Y.Doc, so an expired token
+ // rendered the user's document as a blank page next to a toast. They had
+ // just fetched the file over WebDAV, so showing it is neither a leak nor a
+ // guess - and a blank editor reads exactly like data loss.
+ it('still shows the file after an auth failure', async () => {
+ const s = setupSession({
+ yjsServerUrl: 'wss://example.test/realtime',
+ currentContent: 'my important notes'
+ })
+ await flushPromises()
+ providerInstances[0].triggerAuthFailed('token expired')
+ await flushPromises()
+
+ expect(s.ydoc!.getText(SHARED_TEXT_KEY).toString()).toBe('my important notes')
+ })
+})
+
+describe('useCollaborativeDocument — unreachable realtime server', () => {
+ const yjsServerUrl = 'wss://example.test/realtime'
+
+ beforeEach(() => {
+ vi.useFakeTimers({ shouldAdvanceTime: true })
+ })
+
+ // Regression: a server that never answers produces neither `onSynced` nor
+ // `onAuthenticationFailed`. The provider just kept retrying, so `isReady`
+ // never flipped and `AppWrapper` sat on its loading screen forever - one
+ // typo in `yjsServerUrl` took every editor in the deployment offline.
+ it('keeps the gate shut while the connect is still in flight', async () => {
+ const s = setupSession({ yjsServerUrl, currentContent: 'the file body' })
+ await flushPromises()
+ vi.advanceTimersByTime(9_000)
+ await flushPromises()
+
+ expect(unref(s.session.isReady)).toBe(false)
+ })
+
+ it('falls back to a local session once the connect times out', async () => {
+ const s = setupSession({ yjsServerUrl, currentContent: 'the file body' })
+ await flushPromises()
+ vi.advanceTimersByTime(11_000)
+ await flushPromises()
+
+ // Editable and hydrated rather than a blank page behind a spinner.
+ expect(unref(s.session.isReady)).toBe(true)
+ expect(s.ydoc!.getText(SHARED_TEXT_KEY).toString()).toBe('the file body')
+ expect(unref(s.session.status)).toBe('disconnected')
+ expect(unref(s.session.error)).toBeTruthy()
+ // Read-only would be wrong here: there is nothing stopping the user from
+ // saving, the file just does not sync.
+ expect(unref(s.session.isLockedForReload)).toBe(false)
+ // Stopped retrying, but not destroyed - the editor binds to its awareness.
+ expect(providerInstances[0].disconnect).toHaveBeenCalled()
+ expect(providerInstances[0].destroy).not.toHaveBeenCalled()
+ })
+
+ it('does not fall back once the server answered in time', async () => {
+ const s = setupSession({ yjsServerUrl, currentContent: 'the file body' })
+ await flushPromises()
+ providerInstances[0].triggerSynced()
+ vi.advanceTimersByTime(20_000)
+ await flushPromises()
+
+ expect(unref(s.session.error)).toBeNull()
+ expect(providerInstances[0].disconnect).not.toHaveBeenCalled()
+ })
+
+ it('does not fall back once authentication already failed', async () => {
+ const s = setupSession({ yjsServerUrl })
+ await flushPromises()
+ providerInstances[0].triggerAuthFailed('token expired')
+ vi.advanceTimersByTime(20_000)
+ await flushPromises()
+
+ expect(unref(s.session.error)?.message).toBe('token expired')
+ expect(providerInstances[0].disconnect).not.toHaveBeenCalled()
+ })
+})
+
+describe('useCollaborativeDocument — read-only clients', () => {
+ // Regression: read-only clients used to skip hydration entirely, so a viewer
+ // that opened a file nobody else was editing got a blank document.
+ it('hydrates a private copy in local mode', async () => {
+ const s = setupSession({ currentContent: 'read me', isReadOnly: true })
+ await flushPromises()
+
+ expect(s.ydoc!.getText(SHARED_TEXT_KEY).toString()).toBe('read me')
+ })
+
+ it('hydrates a private copy when the room is empty, without claiming the seeding', async () => {
+ const s = setupSession({
+ yjsServerUrl: 'wss://example.test/realtime',
+ currentContent: 'read me',
+ isReadOnly: true
+ })
+ await flushPromises()
+ providerInstances[0].triggerSynced()
+ await flushPromises()
+
+ expect(s.ydoc!.getText(SHARED_TEXT_KEY).toString()).toBe('read me')
+ // The seeding announcement is what makes peers drop their private copy.
+ // Only a client that writes to the room may raise it.
+ expect(s.ydoc!.getMap('_oc_meta').get('hydrated')).toBeUndefined()
+ })
+
+ it('does not hydrate when the room already has content', async () => {
+ const s = setupSession({
+ yjsServerUrl: 'wss://example.test/realtime',
+ currentContent: 'stale local copy',
+ isReadOnly: true
+ })
+ await flushPromises()
+
+ const peer = new Y.Doc()
+ peer.getText(SHARED_TEXT_KEY).insert(0, 'from a peer')
+ Y.applyUpdate(s.ydoc!, Y.encodeStateAsUpdate(peer))
+
+ providerInstances[0].triggerSynced()
+ await flushPromises()
+
+ expect(s.ydoc!.getText(SHARED_TEXT_KEY).toString()).toBe('from a peer')
+ })
+
+ // A private copy merging with a peer's seeding would duplicate the whole
+ // document, so the session is thrown away and rebuilt from the room instead.
+ it('rebuilds the session when a peer announces its seeding', async () => {
+ const s = setupSession({
+ yjsServerUrl: 'wss://example.test/realtime',
+ currentContent: 'read me',
+ isReadOnly: true
+ })
+ await flushPromises()
+ providerInstances[0].triggerSynced()
+ await flushPromises()
+ const privateDoc = s.ydoc
+ expect(privateDoc!.getText(SHARED_TEXT_KEY).toString()).toBe('read me')
+
+ // A remote transaction has no string origin, which is what tells the
+ // observer this came from a peer.
+ privateDoc!.transact(() => privateDoc!.getMap('_oc_meta').set('hydrated', true))
+ await flushPromises()
+
+ expect(s.ydoc).not.toBe(privateDoc)
+ expect(privateDoc!.isDestroyed).toBe(true)
+ expect(providerInstances).toHaveLength(2)
+ expect(s.ydoc!.getText(SHARED_TEXT_KEY).toString()).toBe('')
+ })
+
+ it('does not rebuild for a writer that seeds the room itself', async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true })
+ const s = setupSession({
+ yjsServerUrl: 'wss://example.test/realtime',
+ currentContent: 'write me'
+ })
+ await flushPromises()
+ providerInstances[0].triggerSynced()
+ vi.advanceTimersByTime(200)
+ await flushPromises()
+
+ expect(s.ydoc!.getMap('_oc_meta').get('hydrated')).toBe(true)
+ expect(s.ydoc!.getText(SHARED_TEXT_KEY).toString()).toBe('write me')
+ expect(providerInstances).toHaveLength(1)
+ })
+})
+
+describe('useCollaborativeDocument — content reporting', () => {
+ it('reports debounced after a user-origin Y.Doc update', async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true })
+ const s = setupSession({ currentContent: 'seed' })
+ await flushPromises()
+ vi.advanceTimersByTime(200)
+ await flushPromises()
+ s.onContentChange.mockClear()
+
+ s.ydoc!.getText(SHARED_TEXT_KEY).insert(4, ' edit') // no origin = user-typed
+
+ // Nothing reported within the debounce window yet.
+ vi.advanceTimersByTime(100)
+ await flushPromises()
+ expect(s.onContentChange).not.toHaveBeenCalled()
+
+ // 300ms after the last edit, the debounced serialize fires.
+ vi.advanceTimersByTime(300)
+ await flushPromises()
+ expect(s.onContentChange).toHaveBeenLastCalledWith('seed edit')
+ })
+
+ // Regression: serialization is not byte-identical to the file it came from
+ // (Tiptap renormalises markdown, for one). Reporting the post-hydration
+ // state would leave AppWrapper with currentContent !== serverContent, so an
+ // untouched file would open dirty: save enabled, unsaved-changes modal on
+ // navigate, and an autosave that silently reformats the file.
+ it('does NOT report content produced by its own hydration', async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true })
+ const s = setupSession({ currentContent: 'seed', adapter: normalizingAdapter })
+ await flushPromises()
+ vi.advanceTimersByTime(1000)
+ await flushPromises()
+
+ // The doc really did get hydrated, and serializing it really would differ
+ // from the file — so the absence of a report is the behaviour under test,
+ // not a no-op.
+ expect(s.ydoc!.getText(SHARED_TEXT_KEY).toString()).toBe('seed')
+ expect(normalizingAdapter.serialize(s.ydoc!)).toBe('seed\n')
+ expect(s.onContentChange).not.toHaveBeenCalled()
+ })
+
+ // Same failure for anyone who joins an already-hydrated room: the initial
+ // sync lands a large update before onSynced, which must not read as an edit.
+ it('does NOT report content arriving through the initial sync', async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true })
+ const s = setupSession({
+ yjsServerUrl: 'wss://example.test/realtime',
+ currentContent: 'seed',
+ adapter: normalizingAdapter
+ })
+ await flushPromises()
+
+ // A peer hydrated first; the server ships us its state before onSynced.
+ const peer = new Y.Doc()
+ peer.getText(SHARED_TEXT_KEY).insert(0, 'from a peer')
+ Y.applyUpdate(s.ydoc!, Y.encodeStateAsUpdate(peer))
+
+ providerInstances[0].triggerSynced()
+ vi.advanceTimersByTime(1000)
+ await flushPromises()
+
+ expect(s.ydoc!.getText(SHARED_TEXT_KEY).toString()).toBe('from a peer')
+ expect(s.onContentChange).not.toHaveBeenCalled()
+ })
+
+ it('reports again once a real edit follows hydration', async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true })
+ const s = setupSession({ currentContent: 'seed', adapter: normalizingAdapter })
+ await flushPromises()
+ vi.advanceTimersByTime(1000)
+ await flushPromises()
+ expect(s.onContentChange).not.toHaveBeenCalled()
+
+ s.ydoc!.getText(SHARED_TEXT_KEY).insert(4, ' edit')
+ vi.advanceTimersByTime(400)
+ await flushPromises()
+ expect(s.onContentChange).toHaveBeenLastCalledWith('seed edit\n')
+ })
+})
+
+describe('useCollaborativeDocument — stale-state recovery', () => {
+ const yjsServerUrl = 'wss://example.test/realtime'
+ const META_KEY = '_oc_meta'
+
+ /**
+ * Brings a session up in collab mode against a room whose `_oc_meta.etag`
+ * predates the file on disk, which is what makes the joining client detect
+ * drift and claim the recovery.
+ */
+ async function syncIntoStaleRoom({
+ currentContent = 'fresh body',
+ roomEtag = 'etag-old',
+ ourEtag = 'etag-new',
+ adapter = testAdapter as CollaborativeAdapter,
+ roomContent = 'stale room content'
+ } = {}) {
+ const s = setupSession({
+ yjsServerUrl,
+ currentContent,
+ adapter,
+ resource: makeResource({ etag: ourEtag })
+ })
+ await flushPromises()
+
+ // What the room already holds when we arrive.
+ const doc = s.ydoc!
+ doc.getText(SHARED_TEXT_KEY).insert(0, roomContent)
+ doc.getMap(META_KEY).set('etag', roomEtag)
+
+ providerInstances[0].triggerSynced()
+ await flushPromises()
+ return s
+ }
+
+ beforeEach(() => {
+ vi.useFakeTimers({ shouldAdvanceTime: true })
+ })
+
+ it('flags the room stale and claims the recovery when the etag drifted', async () => {
+ const s = await syncIntoStaleRoom()
+ const meta = s.ydoc!.getMap(META_KEY)
+
+ expect(meta.get('isStale')).toBe(true)
+ expect(meta.get('nativeEtag')).toBe('etag-new')
+ expect(meta.get('recoveryClientId')).toBe(s.ydoc!.clientID)
+ })
+
+ // Regression: recovery used to re-seed from whatever the elected peer held
+ // at that moment. By then the room has synced its own state in and the
+ // debounced serialize has reported it back into `currentContent`, so the
+ // recovery published the *stale* body and stamped the fresh etag on it. The
+ // next save then overwrote the external writer with a matching If-Match.
+ it('re-seeds from the body captured at detection, not from later currentContent', async () => {
+ const s = await syncIntoStaleRoom({ currentContent: 'fresh body' })
+
+ // Stands in for the debounced serialize reporting the room's own content
+ // back to the caller while recovery is still settling.
+ s.contentRef.value = 'stale room content'
+
+ vi.advanceTimersByTime(200)
+ await flushPromises()
+
+ const meta = s.ydoc!.getMap(META_KEY)
+ expect(s.ydoc!.getText(SHARED_TEXT_KEY).toString()).toBe('fresh body')
+ expect(meta.get('isStale')).toBeUndefined()
+ expect(meta.get('nativeEtag')).toBeUndefined()
+ expect(meta.get('recoveryClientId')).toBeUndefined()
+ expect(meta.get('etag')).toBe('etag-new')
+ })
+
+ // Regression: the election was "lowest awareness clientId wins" over every
+ // peer in the room, so a client that never saw the drift - and therefore
+ // holds no fresh body - could win and publish its own older copy.
+ it('does not re-seed on a peer that did not detect the drift', async () => {
+ const s = setupSession({
+ yjsServerUrl,
+ currentContent: 'my own older copy',
+ resource: makeResource({ etag: 'etag-old' })
+ })
+ await flushPromises()
+ providerInstances[0].triggerSynced()
+ vi.advanceTimersByTime(200)
+ await flushPromises()
+ expect(s.ydoc!.getText(SHARED_TEXT_KEY).toString()).toBe('my own older copy')
+
+ // A peer elsewhere in the room notices the drift and flags it.
+ const meta = s.ydoc!.getMap(META_KEY)
+ s.ydoc!.transact(() => {
+ meta.set('nativeEtag', 'etag-new')
+ meta.set('recoveryClientId', s.ydoc!.clientID + 1)
+ meta.set('isStale', true)
+ })
+ vi.advanceTimersByTime(200)
+ await flushPromises()
+
+ // Untouched: re-seeding here would publish content that predates the
+ // external write and then stamp the fresh etag onto it.
+ expect(s.ydoc!.getText(SHARED_TEXT_KEY).toString()).toBe('my own older copy')
+ expect(meta.get('isStale')).toBe(true)
+ })
+
+ it('does not re-seed on a read-only client', async () => {
+ const s = setupSession({
+ yjsServerUrl,
+ currentContent: 'viewer copy',
+ isReadOnly: true,
+ resource: makeResource({ etag: 'etag-new' })
+ })
+ await flushPromises()
+ const doc = s.ydoc!
+ doc.getText(SHARED_TEXT_KEY).insert(0, 'stale room content')
+ doc.getMap(META_KEY).set('etag', 'etag-old')
+ providerInstances[0].triggerSynced()
+ vi.advanceTimersByTime(200)
+ await flushPromises()
+
+ expect(doc.getText(SHARED_TEXT_KEY).toString()).toBe('stale room content')
+ })
+
+ // The reset lands before the hydrate, so a throw in between leaves every peer
+ // looking at an empty document. Locking stops the autosave from writing that
+ // emptiness to disk, and `isStale` stays up so a later joiner retries.
+ it('keeps the room flagged and locks the session when re-seeding throws', async () => {
+ const failingAdapter: CollaborativeAdapter = {
+ ...testAdapter,
+ hydrate(ydoc: Y.Doc, content: string) {
+ if (ydoc.getMap(META_KEY).get('isStale') === true) {
+ throw new Error('adapter blew up')
+ }
+ testAdapter.hydrate(ydoc, content)
+ }
+ }
+ const s = await syncIntoStaleRoom({ adapter: failingAdapter })
+ vi.advanceTimersByTime(200)
+ await flushPromises()
+
+ const meta = s.ydoc!.getMap(META_KEY)
+ expect(meta.get('isStale')).toBe(true)
+ expect(unref(s.session.isLockedForReload)).toBe(true)
+ expect(unref(s.session.error)).toBeTruthy()
+ })
+})
+
+describe('useCollaborativeDocument — etag mirror', () => {
+ it('writes a new resource etag into _oc_meta.etag', async () => {
+ const s = setupSession({ currentContent: 'x', resource: makeResource({ etag: 'a' }) })
+ await flushPromises()
+ const meta = s.ydoc!.getMap('_oc_meta')
+
+ s.resourceRef.value = makeResource({ etag: 'b' })
+ await flushPromises()
+ expect(meta.get('etag')).toBe('b')
+ expect(meta.get('lastSavedAt')).toBeTypeOf('number')
+ })
+
+ // Regression: a new resource OBJECT whose `id` is unchanged must NOT tear
+ // down and rebuild the Y.Doc. An earlier implementation used watchEffect,
+ // which Vue re-ran on every tracked read — including the `resource` update
+ // AppWrapper performs after each save. Every save would have rebuilt the
+ // Y.Doc, losing in-flight peer edits. The current implementation gates
+ // rebuilds on a `sessionKey` computed, so an identity-preserving resource
+ // update is a no-op for the watch.
+ it('regression: does not rebuild Y.Doc when the resource changes without identity change', async () => {
+ const s = setupSession({ currentContent: 'x', resource: makeResource({ etag: 'a' }) })
+ await flushPromises()
+ const ydocBefore = s.ydoc
+ expect(ydocBefore).toBeTruthy()
+ expect(ydocBefore!.isDestroyed).toBe(false)
+
+ s.resourceRef.value = makeResource({ etag: 'b' })
+ await flushPromises()
+ expect(s.ydoc).toBe(ydocBefore)
+ expect(ydocBefore!.isDestroyed).toBe(false)
+ })
+
+ it('does nothing when the etag is unchanged', async () => {
+ const s = setupSession({ currentContent: 'x', resource: makeResource({ etag: 'a' }) })
+ await flushPromises()
+ const meta = s.ydoc!.getMap('_oc_meta')
+ // The initial etag may have been seeded during hydration.
+ const initialMeta = meta.get('etag')
+
+ s.resourceRef.value = makeResource({ etag: 'a' })
+ await flushPromises()
+ expect(meta.get('etag')).toBe(initialMeta)
+ })
+})
+
+describe('useCollaborativeDocument — peer save fan-out', () => {
+ const META_KEY = '_oc_meta'
+
+ /**
+ * A real second Y.Doc rather than a local transaction on our own: the whole
+ * question here is what the saver had in its state vector, and writing
+ * through our own doc would advance our own clock along with it.
+ */
+ function peerSaves(ourDoc: Y.Doc, etag = 'peer-etag') {
+ const peer = new Y.Doc()
+ Y.applyUpdate(peer, Y.encodeStateAsUpdate(ourDoc))
+ peer.transact(() => {
+ const meta = peer.getMap(META_KEY)
+ meta.set('etag', etag)
+ meta.set('savedStateVector', Y.encodeStateVector(peer))
+ meta.set('lastSavedAt', 1)
+ })
+ Y.applyUpdate(ourDoc, Y.encodeStateAsUpdate(peer, Y.encodeStateVector(ourDoc)))
+ }
+
+ it('reports server content and etag when the save covers our edits', async () => {
+ const s = setupSession({ currentContent: 'seed' })
+ await flushPromises()
+
+ peerSaves(s.ydoc!)
+ await flushPromises()
+
+ expect(s.onEtagChange).toHaveBeenCalledWith('peer-etag')
+ expect(s.onServerContentChange).toHaveBeenCalledWith('seed')
+ })
+
+ // Regression: the fan-out used to serialize our own doc and report it as
+ // "this is on disk" no matter what the peer actually wrote. An edit that had
+ // not reached the peer before its PUT dropped our dirty flag, which also
+ // unregisters `beforeunload` and waves the route-leave guard through, so the
+ // edit was gone with the tab.
+ it('stays dirty when we hold an edit the peer did not have', async () => {
+ const s = setupSession({ currentContent: 'seed' })
+ await flushPromises()
+
+ const peer = new Y.Doc()
+ Y.applyUpdate(peer, Y.encodeStateAsUpdate(s.ydoc!))
+ const svBeforeOurEdit = Y.encodeStateVector(peer)
+
+ // We type after the peer's snapshot but before its PUT lands.
+ s.ydoc!.getText(SHARED_TEXT_KEY).insert(4, ' plus mine')
+
+ peer.transact(() => {
+ const meta = peer.getMap(META_KEY)
+ meta.set('etag', 'peer-etag')
+ meta.set('savedStateVector', svBeforeOurEdit)
+ meta.set('lastSavedAt', 1)
+ })
+ Y.applyUpdate(s.ydoc!, Y.encodeStateAsUpdate(peer, Y.encodeStateVector(s.ydoc!)))
+ await flushPromises()
+
+ // The etag is factual and still worth mirroring - it keeps our next
+ // If-Match correct - but our content is not on disk.
+ expect(s.onEtagChange).toHaveBeenCalledWith('peer-etag')
+ expect(s.onServerContentChange).not.toHaveBeenCalled()
+ })
+
+ it('stays dirty when the peer published no state vector', async () => {
+ const s = setupSession({ currentContent: 'seed' })
+ await flushPromises()
+
+ const peer = new Y.Doc()
+ Y.applyUpdate(peer, Y.encodeStateAsUpdate(s.ydoc!))
+ peer.transact(() => {
+ const meta = peer.getMap(META_KEY)
+ meta.set('etag', 'peer-etag')
+ meta.set('lastSavedAt', 1)
+ })
+ Y.applyUpdate(s.ydoc!, Y.encodeStateAsUpdate(peer, Y.encodeStateVector(s.ydoc!)))
+ await flushPromises()
+
+ expect(s.onServerContentChange).not.toHaveBeenCalled()
+ })
+})
+
+describe('useCollaborativeDocument — cleanup', () => {
+ it('destroys provider, awareness and doc on unmount (collab mode)', async () => {
+ const s = setupSession({ yjsServerUrl: 'wss://example.test/realtime' })
+ await flushPromises()
+ const prov = providerInstances[0]
+ const ydoc = s.ydoc
+ const awarenessDestroy = vi.spyOn(prov.awareness, 'destroy')
+ expect(ydoc!.isDestroyed).toBe(false)
+
+ s.wrapper.unmount()
+ expect(prov.destroy).toHaveBeenCalledOnce()
+ // The provider owns the awareness in collab mode and takes it down itself.
+ // Not asserting a call count: `Y.Doc.destroy()` cascades into the awareness
+ // too (y-protocols registers `doc.on('destroy')`), so a count would pin
+ // library behaviour rather than ours.
+ expect(awarenessDestroy).toHaveBeenCalled()
+ expect(ydoc!.isDestroyed).toBe(true)
+ })
+
+ it('destroys awareness and doc on unmount (local mode)', async () => {
+ const s = setupSession({ currentContent: 'x' })
+ await flushPromises()
+ const ydoc = s.ydoc
+ expect(ydoc!.isDestroyed).toBe(false)
+
+ s.wrapper.unmount()
+ expect(ydoc!.isDestroyed).toBe(true)
+ expect(providerInstances).toHaveLength(0)
+ })
+})
diff --git a/packages/web-pkg/tests/unit/editor/collabAdapter.spec.ts b/packages/web-pkg/tests/unit/editor/collabAdapter.spec.ts
new file mode 100644
index 0000000000..9adc8fa36f
--- /dev/null
+++ b/packages/web-pkg/tests/unit/editor/collabAdapter.spec.ts
@@ -0,0 +1,171 @@
+import { vi, describe, it, expect, beforeEach } from 'vitest'
+import { ref } from 'vue'
+import * as Y from 'yjs'
+import { Editor } from '@tiptap/core'
+import { Collaboration } from '@tiptap/extension-collaboration'
+import type { TextEditorLinkPanelRequest, TextEditorState } from '../../../src/editor/types'
+
+vi.mock('vue3-gettext', () => ({
+ useGettext: () => ({ $gettext: (text: string) => text })
+}))
+
+import { makeTiptapCollabAdapter } from '../../../src/editor/collabAdapter'
+import { useStrategyMarkdown } from '../../../src/editor/composables/strategies/markdown'
+import { useStrategyPlainText } from '../../../src/editor/composables/strategies/plainText'
+import type { ContentTypeStrategy } from '../../../src/editor/composables/strategies/types'
+import { DEFAULT_YDOC_FRAGMENT } from '../../../src/editor/types'
+import { createTestingPinia } from '@opencloud-eu/web-test-helpers'
+
+function createState(): TextEditorState {
+ return {
+ sourceMode: ref(false),
+ linkPanel: ref(null),
+ editorZoom: ref(100)
+ }
+}
+
+/** An editor bound to the shared doc, the way the mounted app binds one. */
+function boundEditor(strategy: ContentTypeStrategy, ydoc: Y.Doc): Editor {
+ return new Editor({
+ element: document.createElement('div'),
+ extensions: [
+ ...strategy.extensions({ collaborative: true }),
+ Collaboration.configure({ document: ydoc, field: DEFAULT_YDOC_FRAGMENT })
+ ]
+ })
+}
+
+const MARKDOWN = [
+ '# Title',
+ '',
+ 'Some **bold** text with a [link](https://example.com).',
+ '',
+ '- one',
+ '- two',
+ '',
+ '## Section',
+ '',
+ 'Trailing paragraph.'
+].join('\n')
+
+describe('makeTiptapCollabAdapter', () => {
+ beforeEach(() => {
+ createTestingPinia()
+ })
+
+ describe('serialize', () => {
+ it('round-trips markdown through the shared doc', () => {
+ const strategy = useStrategyMarkdown(createState())
+ const adapter = makeTiptapCollabAdapter(strategy)
+ const ydoc = new Y.Doc()
+
+ adapter.hydrate(ydoc, MARKDOWN)
+
+ expect(adapter.serialize(ydoc)).toBe(MARKDOWN)
+ ydoc.destroy()
+ })
+
+ it('matches what an editor bound to the same doc produces', () => {
+ const strategy = useStrategyMarkdown(createState())
+ const adapter = makeTiptapCollabAdapter(strategy)
+ const ydoc = new Y.Doc()
+ adapter.hydrate(ydoc, MARKDOWN)
+
+ const editor = boundEditor(strategy, ydoc)
+ expect(adapter.serialize(ydoc)).toBe(strategy.serialize(editor.state.doc))
+
+ editor.destroy()
+ ydoc.destroy()
+ })
+
+ it('picks up edits a bound editor makes', () => {
+ const strategy = useStrategyMarkdown(createState())
+ const adapter = makeTiptapCollabAdapter(strategy)
+ const ydoc = new Y.Doc()
+ adapter.hydrate(ydoc, MARKDOWN)
+
+ const editor = boundEditor(strategy, ydoc)
+ editor.commands.insertContentAt(editor.state.doc.content.size, {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'Appended.' }]
+ })
+
+ expect(adapter.serialize(ydoc)).toBe(`${MARKDOWN}\n\nAppended.`)
+
+ editor.destroy()
+ ydoc.destroy()
+ })
+
+ it('does not write to the Y.Doc', () => {
+ const strategy = useStrategyMarkdown(createState())
+ const adapter = makeTiptapCollabAdapter(strategy)
+ const ydoc = new Y.Doc()
+ adapter.hydrate(ydoc, MARKDOWN)
+
+ let updates = 0
+ ydoc.on('update', () => updates++)
+ adapter.serialize(ydoc)
+ adapter.serialize(ydoc)
+
+ expect(updates).toBe(0)
+ ydoc.destroy()
+ })
+
+ it('serializes plain text with single newlines between blocks', () => {
+ const strategy = useStrategyPlainText(createState())
+ const adapter = makeTiptapCollabAdapter(strategy)
+ const ydoc = new Y.Doc()
+ const content = 'first line\nsecond line\n\nfourth line'
+
+ adapter.hydrate(ydoc, content)
+
+ expect(adapter.serialize(ydoc)).toBe(content)
+ ydoc.destroy()
+ })
+
+ it('resolves the strategy per call, not at build time', () => {
+ // The adapter is built before the file is loaded, so the content type is
+ // only known once the resource is there.
+ let strategy: ContentTypeStrategy | null = null
+ const adapter = makeTiptapCollabAdapter(() => strategy!)
+ const ydoc = new Y.Doc()
+
+ strategy = useStrategyMarkdown(createState())
+ adapter.hydrate(ydoc, MARKDOWN)
+
+ expect(adapter.serialize(ydoc)).toBe(MARKDOWN)
+ ydoc.destroy()
+ })
+ })
+
+ describe('hasContent', () => {
+ it('is false for an untouched doc and true after hydration', () => {
+ const strategy = useStrategyMarkdown(createState())
+ const adapter = makeTiptapCollabAdapter(strategy)
+ const ydoc = new Y.Doc()
+
+ expect(adapter.hasContent(ydoc)).toBe(false)
+ adapter.hydrate(ydoc, MARKDOWN)
+ expect(adapter.hasContent(ydoc)).toBe(true)
+
+ ydoc.destroy()
+ })
+ })
+
+ describe('reset', () => {
+ it('empties the fragment so the doc can be hydrated again', () => {
+ const strategy = useStrategyMarkdown(createState())
+ const adapter = makeTiptapCollabAdapter(strategy)
+ const ydoc = new Y.Doc()
+ adapter.hydrate(ydoc, MARKDOWN)
+
+ adapter.reset!(ydoc)
+ expect(adapter.hasContent(ydoc)).toBe(false)
+
+ adapter.hydrate(ydoc, '# Fresh\n\nBody.')
+ expect(adapter.serialize(ydoc)).toBe('# Fresh\n\nBody.')
+
+ ydoc.destroy()
+ })
+ })
+})
diff --git a/packages/web-pkg/tests/unit/editor/composables/useTextEditor.spec.ts b/packages/web-pkg/tests/unit/editor/composables/useTextEditor.spec.ts
index cc72dfa641..b4f6ba8e6f 100644
--- a/packages/web-pkg/tests/unit/editor/composables/useTextEditor.spec.ts
+++ b/packages/web-pkg/tests/unit/editor/composables/useTextEditor.spec.ts
@@ -1,8 +1,9 @@
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'
import { useTextEditor } from '../../../../src/editor/composables/useTextEditor'
import { withSetup } from './helpers'
-import { toRef } from 'vue'
-import { createTestingPinia } from '@opencloud-eu/web-test-helpers'
+import { nextTick, ref, toRef, unref } from 'vue'
+import * as Y from 'yjs'
+import { createMockStore, createTestingPinia } from '@opencloud-eu/web-test-helpers'
function createEditor(options = {}) {
const defaults = { contentType: 'html' as const, modelValue: toRef('hello
') }
@@ -29,6 +30,22 @@ describe('useTextEditor', () => {
expect(result.readonly.value).toBe(true)
})
+ // Regression: `readonly` was snapshotted during setup, so a collaborative
+ // session turning read-only after mount (locking the room on an app-version
+ // mismatch, for one) hid the toolbar but left ProseMirror editable. The user
+ // kept typing into a document that could never be saved.
+ it('follows a readonly getter that flips after setup', async () => {
+ const locked = ref(false)
+ const { result } = createEditor({ readonly: () => unref(locked) })
+ expect(result.editor.value.isEditable).toBe(true)
+
+ locked.value = true
+ await nextTick()
+
+ expect(result.readonly.value).toBe(true)
+ expect(result.editor.value.isEditable).toBe(false)
+ })
+
it('getContent serializes via strategy', () => {
const { result } = createEditor({ contentType: 'html', modelValue: toRef('test
') })
const content = result.getContent()
@@ -268,6 +285,23 @@ describe('useTextEditor', () => {
expect(ids).not.toContain('image-upload')
expect(ids).not.toContain('image-url')
})
+
+ it('removes the source mode action when a realtime session is active', () => {
+ createMockStore({ configState: { options: { yjsServerUrl: 'wss://example.test/realtime' } } })
+ const { result } = createEditor({ contentType: 'markdown', ydoc: new Y.Doc() })
+ expect(collectIds(result.actionGroups())).not.toContain('source-mode')
+ })
+
+ it('keeps the source mode action without a realtime server', () => {
+ const { result } = createEditor({ contentType: 'markdown', ydoc: new Y.Doc() })
+ expect(collectIds(result.actionGroups())).toContain('source-mode')
+ })
+
+ it('keeps the source mode action for non-collaborative editors', () => {
+ createMockStore({ configState: { options: { yjsServerUrl: 'wss://example.test/realtime' } } })
+ const { result } = createEditor({ contentType: 'markdown' })
+ expect(collectIds(result.actionGroups())).toContain('source-mode')
+ })
})
describe('ariaLabel', () => {
diff --git a/packages/web-pkg/tests/unit/editor/strategies/html.spec.ts b/packages/web-pkg/tests/unit/editor/strategies/html.spec.ts
index 67f349a425..9b7d94c8de 100644
--- a/packages/web-pkg/tests/unit/editor/strategies/html.spec.ts
+++ b/packages/web-pkg/tests/unit/editor/strategies/html.spec.ts
@@ -1,5 +1,6 @@
import { vi, describe, it, expect } from 'vitest'
import { ref } from 'vue'
+import { getSchema } from '@tiptap/core'
import type { TextEditorLinkPanelRequest, TextEditorState } from '../../../../src/editor/types'
vi.mock('vue3-gettext', () => ({
@@ -98,11 +99,14 @@ describe('useStrategyHtml', () => {
})
describe('serialize', () => {
- it('calls getHTML on editor', () => {
+ it('renders a ProseMirror document to HTML', () => {
const strategy = createStrategy()
- const mockEditor = { getHTML: vi.fn().mockReturnValue('hi
') } as any
- expect(strategy.serialize(mockEditor)).toBe('hi
')
- expect(mockEditor.getHTML).toHaveBeenCalled()
+ const schema = getSchema(strategy.extensions())
+ const doc = schema.nodeFromJSON({
+ type: 'doc',
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hi' }] }]
+ })
+ expect(strategy.serialize(doc)).toBe('hi
')
})
})
diff --git a/packages/web-pkg/tests/unit/editor/strategies/linkRoundtrip.spec.ts b/packages/web-pkg/tests/unit/editor/strategies/linkRoundtrip.spec.ts
index 65b72f6f28..c6c9f6efca 100644
--- a/packages/web-pkg/tests/unit/editor/strategies/linkRoundtrip.spec.ts
+++ b/packages/web-pkg/tests/unit/editor/strategies/linkRoundtrip.spec.ts
@@ -63,7 +63,7 @@ describe.each(['markdown', 'html', 'tiptap-json'] as const)('%s link roundtrip',
marks: [{ type: 'link', attrs: { href: 'https://opencloud.eu' } }]
})
- const serialized = strategy.serialize(original)
+ const serialized = strategy.serialize(original.state.doc)
if (contentType === 'markdown') {
expect(serialized).toContain('[OpenCloud](https://opencloud.eu)')
} else if (contentType === 'html') {
@@ -87,7 +87,7 @@ describe.each(['markdown', 'html', 'tiptap-json'] as const)('%s link roundtrip',
text: 'Cloud',
marks: [{ type: 'link', attrs: { href: 'https://example.com/docs' } }]
})
- const edited = strategy.serialize(reloaded)
+ const edited = strategy.serialize(reloaded.state.doc)
const editedReloaded = createEditor(createStrategy(contentType), edited)
expect(getLink(editedReloaded)).toMatchObject({
text: 'Cloud',
@@ -98,7 +98,7 @@ describe.each(['markdown', 'html', 'tiptap-json'] as const)('%s link roundtrip',
editedReloaded.commands.unsetLink()
expect(editedReloaded.getText()).toBe('Cloud')
expect(getLink(editedReloaded).mark).toBeUndefined()
- const unlinked = strategy.serialize(editedReloaded)
+ const unlinked = strategy.serialize(editedReloaded.state.doc)
const unlinkedReloaded = createEditor(createStrategy(contentType), unlinked)
expect(unlinkedReloaded.getText()).toBe('Cloud')
expect(getLink(unlinkedReloaded).mark).toBeUndefined()
@@ -138,7 +138,7 @@ describe.each(['markdown', 'html', 'tiptap-json'] as const)('%s link roundtrip',
mark: { attrs: { href: '#headlines' } }
})
expect(editor.view.dom.querySelector('a')?.getAttribute('href')).toBe('#headlines')
- expect(strategy.serialize(editor)).toContain('#headlines')
+ expect(strategy.serialize(editor.state.doc)).toContain('#headlines')
editor.destroy()
})
diff --git a/packages/web-pkg/tests/unit/editor/strategies/markdown.spec.ts b/packages/web-pkg/tests/unit/editor/strategies/markdown.spec.ts
index 76b71fd522..675f63e373 100644
--- a/packages/web-pkg/tests/unit/editor/strategies/markdown.spec.ts
+++ b/packages/web-pkg/tests/unit/editor/strategies/markdown.spec.ts
@@ -1,5 +1,6 @@
import { vi, describe, it, expect } from 'vitest'
import { ref } from 'vue'
+import { getSchema } from '@tiptap/core'
import type { TextEditorLinkPanelRequest, TextEditorState } from '../../../../src/editor/types'
vi.mock('vue3-gettext', () => ({
@@ -134,11 +135,16 @@ describe('useStrategyMarkdown', () => {
})
describe('serialize', () => {
- it('calls getMarkdown on editor', () => {
+ it('renders a ProseMirror document to markdown', () => {
const strategy = createStrategy()
- const mockEditor = { getMarkdown: vi.fn().mockReturnValue('# Hello') } as any
- expect(strategy.serialize(mockEditor)).toBe('# Hello')
- expect(mockEditor.getMarkdown).toHaveBeenCalled()
+ const schema = getSchema(strategy.extensions())
+ const doc = schema.nodeFromJSON({
+ type: 'doc',
+ content: [
+ { type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Hello' }] }
+ ]
+ })
+ expect(strategy.serialize(doc)).toBe('# Hello')
})
})
diff --git a/packages/web-pkg/tests/unit/editor/strategies/plainText.spec.ts b/packages/web-pkg/tests/unit/editor/strategies/plainText.spec.ts
index e430891fb0..52b358f349 100644
--- a/packages/web-pkg/tests/unit/editor/strategies/plainText.spec.ts
+++ b/packages/web-pkg/tests/unit/editor/strategies/plainText.spec.ts
@@ -1,6 +1,7 @@
import { vi, describe, it, expect, beforeEach } from 'vitest'
import { ref } from 'vue'
import { Editor } from '@tiptap/vue-3'
+import { getSchema } from '@tiptap/core'
import type { TextEditorLinkPanelRequest, TextEditorState } from '../../../../src/editor/types'
import { useStrategyPlainText } from '../../../../src/editor/composables/strategies/plainText'
import { createTestingPinia } from '@opencloud-eu/web-test-helpers'
@@ -87,11 +88,17 @@ describe('useStrategyPlainText', () => {
})
describe('serialize', () => {
- it('calls getText with \\n block separator', () => {
+ it('joins blocks with a single newline', () => {
const strategy = createStrategy()
- const mockEditor = { getText: vi.fn().mockReturnValue('hello') } as any
- expect(strategy.serialize(mockEditor)).toBe('hello')
- expect(mockEditor.getText).toHaveBeenCalledWith({ blockSeparator: '\n' })
+ const schema = getSchema(strategy.extensions())
+ const doc = schema.nodeFromJSON({
+ type: 'doc',
+ content: [
+ { type: 'paragraph', content: [{ type: 'text', text: 'line1' }] },
+ { type: 'paragraph', content: [{ type: 'text', text: 'line2' }] }
+ ]
+ })
+ expect(strategy.serialize(doc)).toBe('line1\nline2')
})
})
diff --git a/packages/web-pkg/tests/unit/editor/strategies/tiptapJson.spec.ts b/packages/web-pkg/tests/unit/editor/strategies/tiptapJson.spec.ts
index 826ff872ba..71826bdf3f 100644
--- a/packages/web-pkg/tests/unit/editor/strategies/tiptapJson.spec.ts
+++ b/packages/web-pkg/tests/unit/editor/strategies/tiptapJson.spec.ts
@@ -1,5 +1,6 @@
import { vi, describe, it, expect } from 'vitest'
import { ref } from 'vue'
+import { getSchema } from '@tiptap/core'
import type { TextEditorLinkPanelRequest, TextEditorState } from '../../../../src/editor/types'
vi.mock('vue3-gettext', () => ({
@@ -53,11 +54,14 @@ describe('useStrategyTiptapJson', () => {
})
describe('serialize', () => {
- it('returns JSON string from editor', () => {
+ it('returns the ProseMirror document as a JSON string', () => {
const strategy = createStrategy()
- const doc = { type: 'doc', content: [] as unknown[] }
- const mockEditor = { getJSON: vi.fn().mockReturnValue(doc) } as any
- expect(strategy.serialize(mockEditor)).toBe(JSON.stringify(doc))
+ const schema = getSchema(strategy.extensions())
+ const json = {
+ type: 'doc',
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hi' }] }]
+ }
+ expect(strategy.serialize(schema.nodeFromJSON(json))).toBe(JSON.stringify(json))
})
})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 40094dae2d..67f649c6a1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -775,6 +775,15 @@ importers:
'@opencloud-eu/web-test-helpers':
specifier: workspace:*
version: link:../web-test-helpers
+ '@tiptap/vue-3':
+ specifier: ^3.20.4
+ version: 3.29.2(@floating-ui/dom@1.8.0)(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)(vue@3.5.41(typescript@6.0.3))
+ y-protocols:
+ specifier: ^1.0.7
+ version: 1.0.7(yjs@13.6.32)
+ yjs:
+ specifier: ^13.6.0
+ version: 13.6.32
packages/web-app-webfinger:
dependencies:
@@ -846,6 +855,9 @@ importers:
'@casl/vue':
specifier: ^3.0.0
version: 3.0.1(@casl/ability@7.0.1)(vue@3.5.41(typescript@6.0.3))
+ '@hocuspocus/provider':
+ specifier: ^4.0.0
+ version: 4.3.0(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32)
'@microsoft/fetch-event-source':
specifier: ^2.0.1
version: 2.0.1
@@ -864,6 +876,9 @@ importers:
'@tiptap/extension-bubble-menu':
specifier: ^3.29.2
version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)
+ '@tiptap/extension-collaboration':
+ specifier: ^3.28.0
+ version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)(@tiptap/y-tiptap@3.0.8(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32))(yjs@13.6.32)
'@tiptap/extension-document':
specifier: ^3.28.0
version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))
@@ -933,6 +948,9 @@ importers:
'@tiptap/vue-3':
specifier: ^3.28.0
version: 3.29.2(@floating-ui/dom@1.8.0)(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)(vue@3.5.41(typescript@6.0.3))
+ '@tiptap/y-tiptap':
+ specifier: ^3.0.0
+ version: 3.0.8(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32)
'@uppy/core':
specifier: ^5.2.0
version: 5.2.0
@@ -996,6 +1014,9 @@ importers:
qs:
specifier: ^6.15.0
version: 6.15.3
+ semver:
+ specifier: ^7.8.0
+ version: 7.8.5
uuid:
specifier: ^14.0.0
version: 14.0.1
@@ -1011,6 +1032,12 @@ importers:
vue3-gettext:
specifier: 4.0.1
version: 4.0.1(vue@3.5.41(typescript@6.0.3))
+ y-protocols:
+ specifier: ^1.0.7
+ version: 1.0.7(yjs@13.6.32)
+ yjs:
+ specifier: ^13.6.0
+ version: 13.6.32
zod:
specifier: ^4.3.6
version: 4.4.3
@@ -1024,6 +1051,9 @@ importers:
'@types/node':
specifier: ^25.5.0
version: 25.9.5
+ '@types/semver':
+ specifier: ^7.7.0
+ version: 7.7.1
'@vitest/web-worker':
specifier: ^4.1.2
version: 4.1.10(vitest@4.1.10)
@@ -1177,6 +1207,16 @@ importers:
specifier: ^8.0.3
version: 8.2.1(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.9.0)
+ services/realtime:
+ dependencies:
+ '@hocuspocus/server':
+ specifier: ^4.1.0
+ version: 4.5.0(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32)
+ devDependencies:
+ '@types/node':
+ specifier: ^25.5.0
+ version: 25.9.5
+
tests/e2e:
devDependencies:
'@ai-zen/node-fetch-event-source':
@@ -1397,6 +1437,22 @@ packages:
'@fyears/rclone-crypt@0.0.7':
resolution: {integrity: sha512-WHdoBtiKUdgNoyjJz//2cpldBpp9FjkHv4weUDIGdUZW6oMQq/hDiDjz3ICKuK5i5kXuVdYmaoruVpnRcHtwMw==}
+ '@hocuspocus/common@4.5.0':
+ resolution: {integrity: sha512-hz6IBLLNKOWrWf+8r236CFFhpkcjEVXtZH1XRrkY0b5dYoQgR5gmuV5/5dJm1Vvyc8I70D+SHCvOch2AsRXonA==}
+
+ '@hocuspocus/provider@4.3.0':
+ resolution: {integrity: sha512-eS5dECLnJDgELI2AfZNvr5jS0B6IWFoo7eAUgwwOzy1bf7KdPagXAYCtSWqSXmLMwjABlubZJQg8FVrTefU0cA==}
+ peerDependencies:
+ y-protocols: ^1.0.6
+ yjs: ^13.6.8
+
+ '@hocuspocus/server@4.5.0':
+ resolution: {integrity: sha512-obRjLJmBi+EsQgP/Q7nZHC4ZvuBSROQCiLBLmIvib0IG3mu4vJfWiMiAmmrfMKM05denjbfK2BVKMmDVj9AGig==}
+ engines: {node: '>=22'}
+ peerDependencies:
+ y-protocols: ^1.0.6
+ yjs: ^13.6.8
+
'@humanfs/core@0.19.2':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
engines: {node: '>=18.18.0'}
@@ -1446,6 +1502,9 @@ packages:
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
+ '@lifeomic/attempt@3.1.0':
+ resolution: {integrity: sha512-QZqem4QuAnAyzfz+Gj5/+SLxqwCAw2qmt7732ZXodr6VDWGeYLG6w1i/vYLa55JQM9wRuBKLmXmiZ2P0LtE5rw==}
+
'@microsoft/fetch-event-source@2.0.1':
resolution: {integrity: sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==}
@@ -2145,6 +2204,9 @@ packages:
'@types/retry@0.12.2':
resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==}
+ '@types/semver@7.7.1':
+ resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==}
+
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
@@ -2540,6 +2602,9 @@ packages:
resolution: {integrity: sha512-IJdzo2vLiElBxKzwS36VsCue/62d6IdWjnPB2v3nuPKeWGynp6FF/CYoLa5i/3jXH/z97ZDdsXz6abpgM6w07A==}
engines: {node: '>=20.19.0'}
+ async-mutex@0.5.0:
+ resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==}
+
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
@@ -2830,6 +2895,14 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
+ crossws@0.4.10:
+ resolution: {integrity: sha512-pz3oubH/dt12KjqsUB0IuXW4nwRDQ583iDsP4555Cpdqx0NoU7pGlWBcayyFI8f/l/idRpgjMEfwuOxSWJYlIA==}
+ peerDependencies:
+ srvx: '>=0.11.5'
+ peerDependenciesMeta:
+ srvx:
+ optional: true
+
crypt@0.0.2:
resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==}
@@ -3559,6 +3632,10 @@ packages:
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
+ kleur@4.1.5:
+ resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
+ engines: {node: '>=6'}
+
layerr@3.0.0:
resolution: {integrity: sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA==}
@@ -5386,6 +5463,30 @@ snapshots:
pkcs7-padding: 0.1.1
rfc4648: 1.5.4
+ '@hocuspocus/common@4.5.0':
+ dependencies:
+ lib0: 0.2.117
+
+ '@hocuspocus/provider@4.3.0(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32)':
+ dependencies:
+ '@hocuspocus/common': 4.5.0
+ '@lifeomic/attempt': 3.1.0
+ lib0: 0.2.117
+ y-protocols: 1.0.7(yjs@13.6.32)
+ yjs: 13.6.32
+
+ '@hocuspocus/server@4.5.0(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32)':
+ dependencies:
+ '@hocuspocus/common': 4.5.0
+ async-mutex: 0.5.0
+ crossws: 0.4.10
+ kleur: 4.1.5
+ lib0: 0.2.117
+ y-protocols: 1.0.7(yjs@13.6.32)
+ yjs: 13.6.32
+ transitivePeerDependencies:
+ - srvx
+
'@humanfs/core@0.19.2':
dependencies:
'@humanfs/types': 0.15.0
@@ -5441,6 +5542,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
+ '@lifeomic/attempt@3.1.0': {}
+
'@microsoft/fetch-event-source@2.0.1': {}
'@module-federation/dts-plugin@2.3.1(node-fetch@3.3.2)(supports-color@7.2.0)(typescript@6.0.3)(vue-tsc@3.3.9(typescript@6.0.3))':
@@ -6094,6 +6197,8 @@ snapshots:
'@types/retry@0.12.2': {}
+ '@types/semver@7.7.1': {}
+
'@types/trusted-types@2.0.7':
optional: true
@@ -6559,6 +6664,10 @@ snapshots:
'@babel/types': 7.29.8
ast-kit: 2.2.0
+ async-mutex@0.5.0:
+ dependencies:
+ tslib: 2.8.1
+
asynckit@0.4.0: {}
at-least-node@1.0.0: {}
@@ -6871,6 +6980,8 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
+ crossws@0.4.10: {}
+
crypt@0.0.2: {}
crypto-browserify@3.12.1:
@@ -7631,6 +7742,8 @@ snapshots:
dependencies:
json-buffer: 3.0.1
+ kleur@4.1.5: {}
+
layerr@3.0.0: {}
levn@0.4.1:
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index f239174198..a82e951d57 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,6 +1,7 @@
prefer-workspace-packages: true
packages:
- 'packages/*'
+ - 'services/*'
- 'tests/*'
autoInstallPeers: true
diff --git a/services/realtime/Dockerfile b/services/realtime/Dockerfile
new file mode 100644
index 0000000000..4838d1ed0b
--- /dev/null
+++ b/services/realtime/Dockerfile
@@ -0,0 +1,34 @@
+# Build context is the repository root, since the pnpm lockfile lives there:
+# docker build -f services/realtime/Dockerfile .
+FROM node:22-alpine AS builder
+
+ENV PNPM_HOME=/pnpm
+ENV PATH=$PNPM_HOME:$PATH
+RUN corepack enable
+
+WORKDIR /repo
+
+COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
+COPY services/realtime ./services/realtime
+
+# Strip the parts of the workspace that only concern the frontend packages:
+# the root devDependencies (which link to workspace packages not present in
+# this image) and the `patchedDependencies` (whose targets are not installed
+# here, which pnpm would otherwise reject).
+RUN node -e "const fs=require('fs');const p=require('/repo/package.json');fs.writeFileSync('/repo/package.json',JSON.stringify({version:p.version,private:true,packageManager:p.packageManager}));fs.writeFileSync('/repo/pnpm-workspace.yaml',fs.readFileSync('/repo/pnpm-workspace.yaml','utf8').replace(/^patchedDependencies:\n(?:[ \t]+.*\n)*/m,''))"
+
+# --legacy: the deployed package has no workspace dependencies, so the
+# injected-workspace-packages setup pnpm otherwise requires is not needed.
+RUN pnpm deploy --filter=web-realtime-server --prod --legacy /app
+
+FROM node:22-alpine
+
+ENV NODE_ENV=production
+WORKDIR /app
+
+COPY --from=builder /app ./
+
+USER node
+EXPOSE 1234
+
+CMD ["node", "src/server.ts"]
diff --git a/services/realtime/README.md b/services/realtime/README.md
new file mode 100644
index 0000000000..89e056b8f0
--- /dev/null
+++ b/services/realtime/README.md
@@ -0,0 +1,61 @@
+# web-realtime-server
+
+Realtime collaboration sidecar for OpenCloud Web. It runs a
+[Hocuspocus](https://tiptap.dev/docs/hocuspocus) server that relays Yjs updates between clients
+editing the same file.
+
+The service is stateless. Documents are not persisted here, they are file-backed via WebDAV and
+hydrated from the client. Every connection is authenticated and authorized against OpenCloud:
+
+- the bearer token is validated against `/graph/v1.0/me`
+- write access is derived from the effective permission actions on the file
+- awareness states are re-stamped with the authenticated identity, so users cannot spoof each other
+
+## Configuration
+
+| Variable | Default | Description |
+| ---------------- | ------------ | ----------------------------------------------------------------------------- |
+| `OPENCLOUD_URL` | - | Required. Base URL of the OpenCloud server, e.g. `https://cloud.example.com` |
+| `PORT` | `1234` | Port to listen on |
+| `NODE_ENV` | `production` | Set by the image. `DEV_FAKE_TOKEN` is refused while it is `production` |
+| `DEV_FAKE_TOKEN` | unset | Dev only. Bypasses auth for a fixed token. Refused when `NODE_ENV=production` |
+
+## Routing
+
+The service listens for plain HTTP on `PORT` and upgrades to WebSocket. Where it sits is up to the
+deployment: behind a reverse proxy on the OpenCloud host, on its own hostname, or reachable
+directly.
+
+Clients connect to the URL configured as `options.yjsServerUrl` in the web config. Realtime
+collaboration is off while that option is unset.
+
+Whatever sits in front must:
+
+- forward WebSocket upgrades
+- not require authentication of its own
+
+The second point is easy to get wrong. The bearer token does not travel in an `Authorization`
+header: browsers cannot set headers on a WebSocket handshake, so it arrives in Hocuspocus' own
+first message once the socket is already open. A proxy that demands an `Authorization` header
+therefore rejects every connection before the service ever sees it - and the service validates the
+token itself regardless.
+
+The dev stack is one example: OpenCloud's own proxy forwards `/realtime` to the service, with the
+route marked `unprotected` for exactly that reason (see `dev/docker/opencloud/proxy.yaml`).
+
+## Running
+
+The sources are TypeScript and are run directly by Node's type stripping, so there is no build
+step. Node 22.18 or newer is required.
+
+The dev stack in `docker-compose.yml` builds and runs it as the `realtime` service, and mounts
+`src/` read-only - so editing the server needs a `docker restart web-realtime-1`, not a rebuild.
+Running it on the host instead needs an `OPENCLOUD_URL` whose TLS certificate the host trusts; the
+dev setup's self-signed Traefik certificate does not qualify, which is why the container gets
+`NODE_TLS_REJECT_UNAUTHORIZED=0`.
+
+As a container, built from the repository root:
+
+```sh
+docker build -f services/realtime/Dockerfile -t web-realtime-server .
+```
diff --git a/services/realtime/package.json b/services/realtime/package.json
new file mode 100644
index 0000000000..7b4683b48d
--- /dev/null
+++ b/services/realtime/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "web-realtime-server",
+ "version": "0.0.0",
+ "private": true,
+ "description": "Realtime collaboration sidecar (Hocuspocus/Yjs) for OpenCloud Web",
+ "license": "AGPL-3.0",
+ "type": "module",
+ "main": "src/server.ts",
+ "scripts": {
+ "start": "node src/server.ts",
+ "check:types": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@hocuspocus/server": "^4.1.0"
+ },
+ "devDependencies": {
+ "@types/node": "^25.5.0"
+ },
+ "engines": {
+ "node": ">=22.18"
+ }
+}
diff --git a/services/realtime/src/server.ts b/services/realtime/src/server.ts
new file mode 100644
index 0000000000..ad86206138
--- /dev/null
+++ b/services/realtime/src/server.ts
@@ -0,0 +1,267 @@
+import { Server } from '@hocuspocus/server'
+
+const port = parseInt(process.env.PORT ?? '1234', 10)
+const opencloudUrl = (process.env.OPENCLOUD_URL ?? '').replace(/\/$/, '')
+
+if (!opencloudUrl) {
+ console.error('OPENCLOUD_URL is required, e.g. https://cloud.example.com')
+ process.exit(1)
+}
+
+// Dev-only escape hatch for the integration test harness. Refused outright in
+// production so a stray env var can never turn into an auth bypass.
+const devFakeToken = process.env.DEV_FAKE_TOKEN ?? ''
+
+if (devFakeToken) {
+ if (process.env.NODE_ENV === 'production') {
+ console.error('DEV_FAKE_TOKEN must not be set in production')
+ process.exit(1)
+ }
+ console.warn('DEV_FAKE_TOKEN is set, authentication can be bypassed. Never do this in production')
+}
+
+/** The subset of the Graph `/me` response this service relies on. */
+type GraphUser = {
+ id?: string
+ displayName?: string
+ userPrincipalName?: string
+}
+
+type FileAccess = {
+ canWrite: boolean
+}
+
+// Per-document first-seen app version. Acts as the authoritative gate for
+// "everybody in this room must run the same client version". First connect
+// for a documentName sets the baseline; subsequent connects with a different
+// appVersion are rejected at authenticate-time. In-memory only; on restart
+// the next connecter becomes the new baseline (acceptable for a stateless
+// sidecar). Empty appVersion is tolerated for legacy/test clients.
+const appVersionByDocument = new Map()
+
+// A room name is `::$!` - a few hundred
+// characters at the very most. Anything longer is not a file we could serve, so
+// refuse it before it becomes a Graph URL.
+const MAX_DOCUMENT_NAME_LENGTH = 512
+
+/**
+ * App-version gate: everybody in a room must run the same client version, or
+ * two incompatible Y.Doc layouts end up in one document. The first connect for
+ * a documentName sets the baseline and later connects with a different version
+ * are rejected. An empty client version is tolerated (back-compat for a raw
+ * provider in a test harness).
+ *
+ * MUST run only after the connection is both authenticated and authorized.
+ * Recording a baseline any earlier let an unauthenticated caller name any room,
+ * claim a version nobody else runs and lock every legitimate client out of that
+ * file until the process restarted - the entry is only cleared by
+ * `onDisconnect`, which never fires for a rejected connection. The same path
+ * grew the map without bound under attacker-chosen keys.
+ */
+function enforceAppVersion(documentName: string, clientAppVersion: string): void {
+ if (!clientAppVersion) return
+
+ const baseline = appVersionByDocument.get(documentName)
+ if (!baseline) {
+ appVersionByDocument.set(documentName, clientAppVersion)
+ return
+ }
+ if (clientAppVersion !== baseline) {
+ throw new Error(
+ `app version mismatch for document="${documentName}": ` +
+ `client=${clientAppVersion} room=${baseline}, please reload`
+ )
+ }
+}
+
+function deterministicColor(seed: string): string {
+ let hash = 0
+ for (let i = 0; i < seed.length; i++) hash = seed.charCodeAt(i) + ((hash << 5) - hash)
+ return `hsl(${Math.abs(hash) % 360}, 70%, 50%)`
+}
+
+async function validateTokenAgainstOpenCloud(token: string): Promise {
+ const res = await fetch(`${opencloudUrl}/graph/v1.0/me`, {
+ headers: { Authorization: `Bearer ${token}` }
+ })
+ if (!res.ok) {
+ const detail = await res.text().catch(() => '')
+ throw new Error(`graph /me returned ${res.status}: ${detail.slice(0, 200)}`)
+ }
+ return res.json() as Promise
+}
+
+// Heuristic: a libregraph permission action implies write access when its
+// trailing verb is create/update/delete/allTasks on driveItem properties.
+const WRITE_ACTION = /\/(update|create|delete|allTasks)$/
+
+// Splits OC's canonical composite id `$!` into
+// the (driveId, itemId) pair the Graph endpoint expects: driveID =
+// `$`, itemID = the FULL composite.
+//
+// The wrapper namespaces room names by app id to avoid schema collisions
+// between different editors opening the same file (e.g.
+// `text-editor::` vs `codemirror::`). Strip any
+// `::` prefix before parsing so the Graph probe targets the raw
+// file id.
+function parseDocumentId(documentName: string): { driveId: string; itemId: string } {
+ const scopeSep = documentName.indexOf('::')
+ const fileId = scopeSep >= 0 ? documentName.slice(scopeSep + 2) : documentName
+ const sep = fileId.indexOf('!')
+ if (sep <= 0 || sep === fileId.length - 1) {
+ throw new Error(`malformed documentName="${documentName}"`)
+ }
+ return { driveId: fileId.slice(0, sep), itemId: fileId }
+}
+
+// Probes OC's Graph API for the user's effective access to the file. Returns
+// `{ canWrite }` on success; `null` when OC denies access (401/403/404).
+//
+// The permissions endpoint reports the effective action set (top-level
+// `@libre.graph.permissions.actions.allowedValues`, the merged PermissionSet
+// that also backs WebDAV's `oc:permissions`) and 404s for a file the user
+// cannot see, which is what makes it the authorization gate.
+async function probeFileAccess(token: string, documentName: string): Promise {
+ const { driveId, itemId } = parseDocumentId(documentName)
+ const permsUrl =
+ `${opencloudUrl}/graph/v1beta1/drives/${encodeURIComponent(driveId)}` +
+ `/items/${encodeURIComponent(itemId)}/permissions`
+
+ const res = await fetch(permsUrl, { headers: { Authorization: `Bearer ${token}` } })
+
+ if (res.status === 401 || res.status === 403 || res.status === 404) {
+ return null
+ }
+ if (!res.ok) {
+ const detail = await res.text().catch(() => '')
+ throw new Error(`graph permissions returned ${res.status}: ${detail.slice(0, 200)}`)
+ }
+
+ const body = (await res.json()) as Record
+ const actions = body['@libre.graph.permissions.actions.allowedValues']
+ const allowed = Array.isArray(actions) ? (actions as string[]) : []
+
+ return { canWrite: allowed.some((a) => WRITE_ACTION.test(a)) }
+}
+
+const server = new Server({
+ port,
+ address: '0.0.0.0',
+ // No server-side persistence: every doc is file-backed via WebDAV.
+ // Cold-start for a fresh peer = hydrate from `currentContent` in the
+ // wrapper. The persisted SQLite snapshot would get discarded on stale-
+ // state recovery anyway (etag drift triggers rehydrate); keeping it
+ // here is "mostly ceremony" per the migration plan. Stale detection
+ // moved to the client (see useCollaborativeDocument.onProviderSynced).
+
+ async onAuthenticate({ token, documentName, requestParameters, connectionConfig }) {
+ if (!token) {
+ throw new Error('missing token')
+ }
+ if (documentName.length > MAX_DOCUMENT_NAME_LENGTH) {
+ throw new Error(`documentName too long (${documentName.length})`)
+ }
+
+ const clientAppVersion = requestParameters.get('appVersion') ?? ''
+
+ // Dev shortcut for integration tests: any token matching DEV_FAKE_TOKEN
+ // returns a synthetic identity. The ACL check is skipped (tests use random
+ // documentNames that don't exist in OC). Disabled when DEV_FAKE_TOKEN is
+ // unset.
+ if (devFakeToken && token === devFakeToken) {
+ const id = 'dev-fake-user'
+ // Gated the same way as a real connection, so the two paths cannot drift.
+ enforceAppVersion(documentName, clientAppVersion)
+ console.log(`[onAuthenticate] dev-fake document="${documentName}"`)
+ return {
+ user: {
+ id,
+ displayName: 'Dev Fake User',
+ color: deterministicColor(id)
+ }
+ }
+ }
+
+ const me = await validateTokenAgainstOpenCloud(token)
+ const id = me.id ?? me.userPrincipalName ?? 'unknown'
+
+ // Authorization: does this user have the file at all, and may they write it.
+ const access = await probeFileAccess(token, documentName)
+ if (access === null) {
+ throw new Error(`access denied for document="${documentName}"`)
+ }
+
+ // Identity and access are settled, so this caller is entitled to influence
+ // the room's version baseline.
+ enforceAppVersion(documentName, clientAppVersion)
+
+ const readOnly = !access.canWrite
+
+ // Writes are gated on `connectionConfig.readOnly`, which Hocuspocus reads
+ // when it builds the Connection. The hook's return value only feeds
+ // `context`, so setting it there would leave the connection writable.
+ connectionConfig.readOnly = readOnly
+
+ console.log(
+ `[onAuthenticate] document="${documentName}" user="${me.displayName ?? id}" ` +
+ `id="${id}" readOnly=${readOnly}`
+ )
+ return {
+ readOnly,
+ clientAppVersion,
+ user: {
+ id,
+ displayName: me.displayName ?? me.userPrincipalName ?? id,
+ color: deterministicColor(id)
+ }
+ }
+ },
+
+ async onConnect({ documentName, requestHeaders }) {
+ const origin = requestHeaders.get('origin') ?? '-'
+ console.log(`[onConnect] document="${documentName}" origin=${origin}`)
+ },
+
+ async onDisconnect({ documentName, clientsCount }) {
+ console.log(`[onDisconnect] document="${documentName}" remaining=${clientsCount}`)
+ if (clientsCount === 0) {
+ // Forget the version baseline once the room empties out so a new
+ // deploy can start fresh without manual restart.
+ appVersionByDocument.delete(documentName)
+ }
+ },
+
+ // Anti-spoof identity stamp: before each inbound awareness update is
+ // applied, overwrite the `user` field on every state in the update with
+ // the authenticated identity from the connection's context.
+ //
+ // Hocuspocus v4 invokes extension hooks with a single payload object. The
+ // positional `(document, states, origin)` signature applies only to the
+ // document-level callback the lib wires up internally (see
+ // hocuspocus-server.cjs ~line 1299). Using positional args here would
+ // silently no-op (states=undefined -> no user found -> return).
+ async beforeHandleAwareness({ states, context, connection }) {
+ const user = context?.user ?? connection?.context?.user
+ if (!user) return
+ const canonical = {
+ id: user.id,
+ name: user.displayName,
+ color: user.color
+ }
+ for (const state of states.values()) {
+ state.user = canonical
+ }
+ }
+})
+
+server.listen().then(
+ () => {
+ console.log(`realtime server listening on :${port}, oc=${opencloudUrl}`)
+ },
+ (err: unknown) => {
+ // Most often the port is already taken. Without this the process died on an
+ // unhandled rejection and a stack trace instead of saying what went wrong.
+ console.error(`realtime server failed to listen on :${port}:`, err)
+ process.exit(1)
+ }
+)
diff --git a/services/realtime/tsconfig.json b/services/realtime/tsconfig.json
new file mode 100644
index 0000000000..cc78812885
--- /dev/null
+++ b/services/realtime/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "extends": "@opencloud-eu/tsconfig",
+ "compilerOptions": {
+ "noEmit": true,
+ "module": "nodenext",
+ "moduleResolution": "nodenext",
+ // Node runs the sources directly via type stripping, so only syntax that
+ // can be erased without emitting code is allowed
+ "erasableSyntaxOnly": true,
+ "allowImportingTsExtensions": true,
+ "lib": ["ESNext"],
+ "types": ["node"]
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/tsconfig.json b/tsconfig.json
index 72761af9f1..7b9a0d3c64 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -2,5 +2,7 @@
"extends": "@opencloud-eu/tsconfig",
"compilerOptions": {
"allowImportingTsExtensions": true
- }
+ },
+ // `services` holds node services with their own tsconfig, checked separately
+ "exclude": ["node_modules", "services"]
}