diff --git a/dev/docker/hocuspocus/Dockerfile b/dev/docker/hocuspocus/Dockerfile new file mode 100644 index 0000000000..fa79dea68d --- /dev/null +++ b/dev/docker/hocuspocus/Dockerfile @@ -0,0 +1,10 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY package.json ./ +RUN npm install --omit=dev --no-audit --no-fund --loglevel=error + +COPY server.js ./ + +CMD ["node", "server.js"] diff --git a/dev/docker/hocuspocus/package.json b/dev/docker/hocuspocus/package.json new file mode 100644 index 0000000000..9685d8e1fc --- /dev/null +++ b/dev/docker/hocuspocus/package.json @@ -0,0 +1,12 @@ +{ + "name": "opencloud-hocuspocus-dev", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "@hocuspocus/server": "^4.1.0" + } +} diff --git a/dev/docker/hocuspocus/server.js b/dev/docker/hocuspocus/server.js new file mode 100644 index 0000000000..856ad64a45 --- /dev/null +++ b/dev/docker/hocuspocus/server.js @@ -0,0 +1,268 @@ +import { Server } from '@hocuspocus/server' + +const port = parseInt(process.env.PORT ?? '1234', 10) +const opencloudUrl = (process.env.OPENCLOUD_URL ?? 'https://host.docker.internal:9200').replace( + /\/$/, + '' +) +const devFakeToken = process.env.DEV_FAKE_TOKEN ?? '' + +// 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() + +function deterministicColor(seed) { + 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) { + 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() +} + +// 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 now 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) { + 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 AND the file's +// current native etag. Returns `{ canWrite, etag }` on success; `null` when +// OC denies access entirely (401/403/404). +// +// Two parallel calls: +// - Graph /permissions for the effective action set (top-level +// @libre.graph.permissions.actions.allowedValues, which is the merged +// PermissionSet that backs WebDAV's oc:permissions). +// - WebDAV HEAD for the native eTag (Graph's /items endpoint is share-jail- +// only and 400s on personal drives; WebDAV works uniformly). +async function probeFileAccess(token, documentName) { + const { driveId, itemId } = parseDocumentId(documentName) + const permsUrl = + `${opencloudUrl}/graph/v1beta1/drives/${encodeURIComponent(driveId)}` + + `/items/${encodeURIComponent(itemId)}/permissions` + const davUrl = `${opencloudUrl}/remote.php/dav/spaces/${encodeURIComponent(itemId)}` + const headers = { Authorization: `Bearer ${token}` } + + const [permsRes, headRes] = await Promise.all([ + fetch(permsUrl, { headers }), + fetch(davUrl, { method: 'HEAD', headers }) + ]) + + if ([permsRes.status, headRes.status].some((s) => s === 401 || s === 403 || s === 404)) { + return null + } + if (!permsRes.ok) { + const detail = await permsRes.text().catch(() => '') + throw new Error(`graph permissions returned ${permsRes.status}: ${detail.slice(0, 200)}`) + } + if (!headRes.ok) { + const detail = await headRes.text().catch(() => '') + throw new Error(`webdav HEAD returned ${headRes.status}: ${detail.slice(0, 200)}`) + } + + const permsBody = await permsRes.json() + const allowed = Array.isArray(permsBody?.['@libre.graph.permissions.actions.allowedValues']) + ? permsBody['@libre.graph.permissions.actions.allowedValues'] + : [] + const canWrite = allowed.some((a) => WRITE_ACTION.test(a)) + + // WebDAV emits the strong validator under `ETag` (and sometimes `OC-ETag` + // for OC-specific extensions). Strip surrounding quotes for consistency + // with the etag the wrapper sees from `props.resource.etag`. + const rawEtag = headRes.headers.get('etag') || headRes.headers.get('oc-etag') || '' + const etag = rawEtag.replace(/^"(.*)"$/, '$1') + + return { canWrite, etag } +} + +const META_KEY = '_oc_meta' + +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 CollaborativeWrapper.onProviderSynced). + + async onAuthenticate({ token, documentName, requestParameters }) { + if (!token) { + throw new Error('missing token') + } + + // App-version gate. First connect to a documentName sets the baseline, + // subsequent connects with a different appVersion are rejected so old + // clients can't poison the room. Empty client appVersion is permitted + // (back-compat for the integration test harness using a raw provider). + const clientAppVersion = requestParameters.get('appVersion') ?? '' + const baselineAppVersion = appVersionByDocument.get(documentName) + if (clientAppVersion && baselineAppVersion && clientAppVersion !== baselineAppVersion) { + throw new Error( + `app version mismatch for document="${documentName}": ` + + `client=${clientAppVersion} room=${baselineAppVersion}, please reload` + ) + } + if (clientAppVersion && !baselineAppVersion) { + appVersionByDocument.set(documentName, clientAppVersion) + } + + // Dev shortcut for integration tests: any token matching DEV_FAKE_TOKEN + // returns a synthetic identity. ACL check is skipped (tests use random + // documentNames that don't exist in OC). Disabled when DEV_FAKE_TOKEN is + // unset. Tests can pass `devEtag` to drive the stale-state detection + // path without touching real OC. + if (devFakeToken && token === devFakeToken) { + const id = 'dev-fake-user' + const nativeEtag = requestParameters.get('devEtag') ?? '' + console.log(`[onAuthenticate] dev-fake document="${documentName}" nativeEtag="${nativeEtag}"`) + return { + nativeEtag, + user: { + id, + displayName: 'Dev Fake User', + color: deterministicColor(id) + } + } + } + + const me = await validateTokenAgainstOpenCloud(token) + const id = me.id ?? me.userPrincipalName ?? 'unknown' + + // ACL + native etag probe via Graph: enforces access AND captures the + // current native etag so onLoadDocument can detect a stale persisted + // Y.Doc snapshot (Hocuspocus persistence vs external file write). + const access = await probeFileAccess(token, documentName) + if (access === null) { + throw new Error(`access denied for document="${documentName}"`) + } + const readOnly = !access.canWrite + + console.log( + `[onAuthenticate] document="${documentName}" user="${me.displayName ?? id}" ` + + `id="${id}" readOnly=${readOnly} nativeEtag="${access.etag}"` + ) + return { + readOnly, + nativeEtag: access.etag, + clientAppVersion, + user: { + id, + displayName: me.displayName ?? me.userPrincipalName ?? id, + color: deterministicColor(id) + } + } + }, + + // Stale-state detection — DISABLED. + // + // This hook fires once when a doc is loaded into memory. It used to do + // useful work when we shipped the SQLite extension: at cold load it + // compared the persisted `_oc_meta.etag` against the live native etag + // and flagged drift so the wrapper would rehydrate. Without persistence + // the doc is always freshly created at load time, `_oc_meta` is empty, + // and the wrapper's etag mirror runs strictly AFTER this hook — so the + // comparison can never fire. The equivalent check now lives in + // CollaborativeWrapper.onProviderSynced (runs on the client, sees the + // CRDT-synced `_oc_meta.etag` from whichever peer joined first). + // + // Kept commented out as a reference: if persistence is reintroduced + // (extension-sqlite, redis, etc.), uncomment to get the server-side + // cold-load probe back. + // + // async onLoadDocument({ document, context }) { + // const meta = document.getMap(META_KEY) + // const persistedEtag = meta.get('etag') + // const nativeEtag = context?.nativeEtag + // const persistedAppVersion = meta.get('appVersion') + // const clientAppVersion = context?.clientAppVersion + // + // const etagDrift = !!persistedEtag && !!nativeEtag && persistedEtag !== nativeEtag + // const versionDrift = + // !!persistedAppVersion && !!clientAppVersion && persistedAppVersion !== clientAppVersion + // + // if (!etagDrift && !versionDrift) return + // + // const reasons = [] + // if (etagDrift) reasons.push(`etag(${persistedEtag}→${nativeEtag})`) + // if (versionDrift) reasons.push(`appVersion(${persistedAppVersion}→${clientAppVersion})`) + // console.log( + // `[onLoadDocument] stale state document="${document.name}" ` + + // `${reasons.join(' ')} → marked for rehydrate` + // ) + // document.transact(() => { + // meta.set('isStale', true) + // if (nativeEtag) meta.set('nativeEtag', nativeEtag) + // }) + // }, + + async onConnect({ documentName, requestHeaders }) { + console.log(`[onConnect] document="${documentName}" origin=${requestHeaders.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(`hocuspocus v4 listening on :${port}, oc=${opencloudUrl}`) +}) diff --git a/dev/docker/opencloud.web.config.json b/dev/docker/opencloud.web.config.json index a83f88cf5b..ed78764d38 100644 --- a/dev/docker/opencloud.web.config.json +++ b/dev/docker/opencloud.web.config.json @@ -6,7 +6,6 @@ }, "apps": [ "files", - "text-editor", "pdf-viewer", "search", "external", @@ -21,5 +20,12 @@ "contacts", "rclone-crypt", "office-settings" + ], + "external_apps": [ + { + "id": "text-editor", + "path": "web-app-text-editor", + "config": { "realtimeUrl": "wss://host.docker.internal:9200/realtime" } + } ] } diff --git a/dev/docker/opencloud/proxy.yaml b/dev/docker/opencloud/proxy.yaml index 543e108350..b4a99792cd 100644 --- a/dev/docker/opencloud/proxy.yaml +++ b/dev/docker/opencloud/proxy.yaml @@ -4,6 +4,18 @@ additional_policies: - name: default routes: + # Realtime collab sidecar. The hocuspocus container terminates plain + # HTTP on :1234; OC's reverse proxy upgrades the incoming WebSocket + # request and forwards it on. Pattern is borrowed from the + # opencloud-music sidecar setup. `unprotected: true` because + # hocuspocus does its own bearer-token validation against OC's + # Graph API (see dev/docker/hocuspocus/server.js + # `validateTokenAgainstOpenCloud`) and must see the Authorization + # header the client sent verbatim, not whatever OC's proxy would + # substitute. + - endpoint: /realtime + backend: http://hocuspocus:1234 + unprotected: true - endpoint: /caldav/ backend: http://host.docker.internal:5232 remote_user_header: X-Remote-User diff --git a/docker-compose.yml b/docker-compose.yml index dab21ecbc0..df81b53aeb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -403,11 +403,36 @@ services: depends_on: - stalwart + hocuspocus: + build: ./dev/docker/hocuspocus + extra_hosts: + - host.docker.internal:${DOCKER_HOST:-host-gateway} + environment: + PORT: '1234' + OPENCLOUD_URL: https://host.docker.internal:9200 + # Dev only: self-signed cert from OC's Traefik + NODE_TLS_REJECT_UNAUTHORIZED: '0' + # Dev only: allows the integration tests to bypass real OIDC tokens. + # Remove for prod. + DEV_FAKE_TOKEN: dev-integration-token + volumes: + # Dev override: edit server.js without rebuild; remove for prod + - ./dev/docker/hocuspocus/server.js:/app/server.js:ro + networks: + # On the traefik network so OC (which is also on it) can reach + # hocuspocus by service name. Browser-facing routing is no longer + # 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: opencloud-federated-config: stalwart-data: + hocuspocus-data: networks: traefik: diff --git a/packages/web-app-text-editor/package.json b/packages/web-app-text-editor/package.json index a76188aeeb..13ae0a3775 100644 --- a/packages/web-app-text-editor/package.json +++ b/packages/web-app-text-editor/package.json @@ -4,6 +4,14 @@ "private": true, "description": "OpenCloud Web simple text editor", "license": "AGPL-3.0", + "dependencies": { + "@hocuspocus/provider": "^4.0.0", + "@tiptap/core": "^3.20.4", + "@tiptap/extension-collaboration": "^3.20.4", + "@tiptap/vue-3": "^3.20.4", + "y-protocols": "^1.0.7", + "yjs": "^13.6.0" + }, "devDependencies": { "@opencloud-eu/web-test-helpers": "workspace:*" }, diff --git a/packages/web-app-text-editor/src/App.vue b/packages/web-app-text-editor/src/App.vue index dcd13ea36d..804b26fc9b 100644 --- a/packages/web-app-text-editor/src/App.vue +++ b/packages/web-app-text-editor/src/App.vue @@ -1,48 +1,54 @@ diff --git a/packages/web-app-text-editor/src/TextEditorBinding.vue b/packages/web-app-text-editor/src/TextEditorBinding.vue new file mode 100644 index 0000000000..940982cabe --- /dev/null +++ b/packages/web-app-text-editor/src/TextEditorBinding.vue @@ -0,0 +1,64 @@ + + + diff --git a/packages/web-app-text-editor/src/adapters/textEditorAdapter.ts b/packages/web-app-text-editor/src/adapters/textEditorAdapter.ts new file mode 100644 index 0000000000..9eb1e52256 --- /dev/null +++ b/packages/web-app-text-editor/src/adapters/textEditorAdapter.ts @@ -0,0 +1,85 @@ +import { Editor } from '@tiptap/core' +import { Collaboration } from '@tiptap/extension-collaboration' +import type * as Y from 'yjs' +import type { Editor as TiptapVueEditor } from '@tiptap/vue-3' +import type { CollaborativeAdapter } from '@opencloud-eu/web-pkg' +import type { ContentTypeStrategy } from '@opencloud-eu/web-pkg/editor' + +// CollaborativeWrapper binds editor state to a named Y.XmlFragment. We use +// the default field name `'default'`; the editor component (TextEditorBinding, +// which calls useTextEditor with the same ydoc + default fragment) must +// match. +const FRAGMENT = 'default' + +/** + * Bridges the existing `web-pkg/editor` strategy contract to the + * `CollaborativeAdapter` contract the `CollaborativeWrapper` expects. + * + * Each strategy already knows how to convert between its native string + * format (markdown / HTML / plain text / tiptap-json) and a Tiptap editor + * state. The adapter wraps that with the bit of plumbing the wrapper + * needs: hydration through a headless editor that materialises into the + * shared Y.Doc, serialisation that prefers the live editor when bound + * (via the `getAdapterContext()` channel) and only spawns a headless + * fallback when no UI is mounted, and `hasContent` / `reset` against the + * `'default'` Y.XmlFragment that `Collaboration` writes to. + * + * Must be called from a Vue setup context — the strategy reference comes + * from `useContentStrategy()` which in turn calls `useGettext()`. + */ +export function makeTextEditorAdapter(strategy: ContentTypeStrategy): CollaborativeAdapter { + function makeHeadlessEditor(ydoc: Y.Doc): Editor { + const detached = document.createElement('div') + return new Editor({ + element: detached, + extensions: [ + ...strategy.extensions(), + Collaboration.configure({ document: ydoc, field: FRAGMENT }) + ] + }) + } + + function setContentOptions(): Record { + const opts: Record = { emitUpdate: false } + if (strategy.editorContentType) { + opts.contentType = strategy.editorContentType() + } + return opts + } + + return { + hydrate(ydoc, content) { + if (!content) return + const editor = makeHeadlessEditor(ydoc) + try { + editor.commands.setContent( + strategy.deserialize(content) as Parameters[0], + setContentOptions() + ) + } finally { + editor.destroy() + } + }, + + serialize(ydoc, context) { + const live = (context as { editor?: TiptapVueEditor } | undefined)?.editor + if (live) return strategy.serialize(live) + const editor = makeHeadlessEditor(ydoc) + try { + return strategy.serialize(editor as unknown as TiptapVueEditor) + } finally { + editor.destroy() + } + }, + + hasContent(ydoc) { + return ydoc.getXmlFragment(FRAGMENT).length > 0 + }, + + reset(ydoc) { + const frag = ydoc.getXmlFragment(FRAGMENT) + if (frag.length === 0) return + ydoc.transact(() => frag.delete(0, frag.length), 'reset') + } + } +} 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..cecab92be2 100644 --- a/packages/web-app-text-editor/tests/unit/app.spec.ts +++ b/packages/web-app-text-editor/tests/unit/app.spec.ts @@ -1,10 +1,47 @@ import { PartialComponentProps, defaultPlugins, mount } from '@opencloud-eu/web-test-helpers' import { mock } from 'vitest-mock-extended' +import { defineComponent, h } from 'vue' import type { Resource } from '@opencloud-eu/web-client' import App from '../../src/App.vue' -vi.mock('@opencloud-eu/web-pkg') -vi.mock('@opencloud-eu/web-pkg/editor') +// Stub CollaborativeWrapper so the test doesn't have to mount a real +// HocuspocusProvider / Y.Doc chain. The stub just renders a div with the +// stable class the prior version of this test asserted against — App.vue +// itself is now a thin shell, so it's enough to verify it mounts the +// wrapper with the correct adapter / editor / realtime contract. +vi.mock('@opencloud-eu/web-pkg', async () => { + return { + CollaborativeWrapper: defineComponent({ + name: 'CollaborativeWrapperStub', + props: [ + 'resource', + 'currentContent', + 'isReadOnly', + 'adapter', + 'editor', + 'appVersion', + 'realtimeUrl' + ], + setup() { + return () => h('div', { class: 'oc-text-editor' }) + } + }) + } +}) + +vi.mock('@opencloud-eu/web-pkg/editor', () => { + return { + useContentStrategy: () => ({ + resolveStrategy: () => ({ + editorContentType: () => 'markdown', + extensions: (): unknown[] => [], + editorActionGroups: (): unknown[] => [], + serialize: () => '', + deserialize: (s: string) => s + }) + }) + } +}) describe('Text editor app', () => { it('shows the editor', () => { diff --git a/packages/web-pkg/package.json b/packages/web-pkg/package.json index 355be1a13a..fb94773eda 100644 --- a/packages/web-pkg/package.json +++ b/packages/web-pkg/package.json @@ -49,11 +49,13 @@ "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.20.4", + "@tiptap/extension-collaboration": "^3.20.4", "@tiptap/extension-document": "^3.20.4", "@tiptap/extension-hard-break": "^3.20.4", "@tiptap/extension-image": "^3.20.4", @@ -72,6 +74,7 @@ "@tiptap/starter-kit": "^3.20.4", "@tiptap/suggestion": "^3.20.4", "@tiptap/vue-3": "^3.20.4", + "@tiptap/y-tiptap": "^3.0.0", "@uppy/core": "^5.2.0", "@uppy/tus": "^5.1.1", "@uppy/utils": "^7.2.0", @@ -92,16 +95,20 @@ "password-sheriff": "^2.0.0", "pinia": "^3.0.4", "qs": "^6.15.0", + "semver": "^7.8.0", "uuid": "^14.0.0", "vue-concurrency": "^5.0.3", "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 4702fdb624..36b7a1f74d 100644 --- a/packages/web-pkg/src/components/AppTemplates/AppWrapper.vue +++ b/packages/web-pkg/src/components/AppTemplates/AppWrapper.vue @@ -469,23 +469,71 @@ const saveFileTask = useTask(function* () { serverContent.value = newContent currentETag.value = putFileContentsResponse.etag resourcesStore.upsertResource(putFileContentsResponse) + // Keep our local `resource` ref in sync with the fresh etag so any + // downstream watcher on `props.resource.etag` (CollaborativeWrapper's + // meta-mirror, for one) actually fires. `upsertResource` only touches + // the store; the local ref is the one passed down via slotAttrs. + resource.value = { ...unref(resource), etag: putFileContentsResponse.etag } } catch (e) { + // 409 / 412 — `previousEntityTag` didn't match what the server has. + // Usually means another peer in a collaborative session saved this + // file just before us. Our editor's content (Y.Doc-synced) already + // includes the peer's edits, so simply refetching the file to grab + // the fresh etag and retrying the save lets us keep going without + // bothering the user. We only fall through to the conflict popup + // when the refetch / retry path itself fails. + if (e.statusCode === 412 || e.statusCode === 409) { + 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 + } + + // Server has a different content (typical collab case: peer + // saved, our Y.Doc has peer's edits + our own additions). Retry + // the PUT with the fresh etag — that publishes our combined + // state. Cross-app or external writers will be overwritten + // here; collaborating editors of the same file in different + // apps remains a known footgun documented in + // REALTIME_COLLAB_MIGRATION.md. + const retry = yield putFileContents(currentFileContext, { + content: newContent as string, + 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. + } + 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) @@ -755,6 +803,20 @@ const slotAttrs = computed(() => ({ 'onUpdate:currentContent': (value: unknown) => { currentContent.value = value }, + // Optional companion to update:currentContent — collab-aware wrappers + // emit this when a peer save just landed, so we can sync `serverContent` + // to the freshly-on-disk state without a refetch. Non-collab editors + // never emit it and the binding is a no-op. + 'onUpdate:serverContent': (value: unknown) => { + serverContent.value = value + }, + // Companion to update:serverContent — collab wrappers also publish the + // peer-saved etag so our next PUT's `If-Match` is current and we skip the + // 412 → refetch → retry recovery path entirely. + 'onUpdate:etag': (value: unknown) => { + if (typeof value !== 'string' || currentETag.value === value) return + currentETag.value = value + }, 'onRegister:onDeleteResourceCallback': (value: () => void) => { appOnDeleteResourceCallback = value diff --git a/packages/web-pkg/src/components/Collaborative/CollaborativeWrapper.vue b/packages/web-pkg/src/components/Collaborative/CollaborativeWrapper.vue new file mode 100644 index 0000000000..0ea95e89eb --- /dev/null +++ b/packages/web-pkg/src/components/Collaborative/CollaborativeWrapper.vue @@ -0,0 +1,612 @@ + + + diff --git a/packages/web-pkg/src/components/Collaborative/index.ts b/packages/web-pkg/src/components/Collaborative/index.ts new file mode 100644 index 0000000000..2d73d772d5 --- /dev/null +++ b/packages/web-pkg/src/components/Collaborative/index.ts @@ -0,0 +1,2 @@ +export { default as CollaborativeWrapper } from './CollaborativeWrapper.vue' +export type { CollaborativeAdapter } from './types' diff --git a/packages/web-pkg/src/components/Collaborative/types.ts b/packages/web-pkg/src/components/Collaborative/types.ts new file mode 100644 index 0000000000..4b82aabfe9 --- /dev/null +++ b/packages/web-pkg/src/components/Collaborative/types.ts @@ -0,0 +1,56 @@ +import type * as Y from 'yjs' + +/** + * App-specific adapter between the native file format and the shared Y.Doc. + * The wrapper itself stays generic: it handles realtime sync, the etag loop, + * and lifecycle. Adapters describe how to move bytes in and out of the doc. + * + * Implementations must be deterministic — given the same Y.Doc state, + * `serialize` must always return the same content. + */ +export interface CollaborativeAdapter { + /** + * Populate an empty Y.Doc from the native file content. Called once per + * document by the elected hydrating client; 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. + * + * `context` is an opaque object the bound editor component publishes via + * `defineExpose({ getAdapterContext() })`. The wrapper passes it through + * untyped because adapters may need wildly different shapes — a Tiptap + * adapter wants the live `Editor` instance to call `getMarkdown()` on + * directly (avoiding a per-keystroke headless-editor spawn), a CodeMirror + * adapter just reads `Y.Text.toString()` and needs nothing. Adapters cast + * `context` to their expected shape and treat absence as "fall back to + * Y.Doc-only serialization". + * + * `context` is `undefined` when no editor is bound (e.g. during + * stale-recovery on a peer that holds the doc but never mounted a UI) + * or when the bound editor doesn't expose `getAdapterContext`. + */ + serialize(ydoc: Y.Doc, context?: unknown): string | Promise + + /** + * Returns true if the adapter has populated the doc with app data. + * Used to detect "doc is empty, needs hydration" without the wrapper + * knowing the adapter's shared-type layout. + */ + hasContent(ydoc: Y.Doc): boolean + + /** + * Wipe the adapter's shared content so `hasContent` returns false again. + * The wrapper calls this when the sidecar signals a stale persisted Y.Doc + * (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 wrapper falls back to forcing a full reload. + */ + reset?(ydoc: Y.Doc): void +} diff --git a/packages/web-pkg/src/components/index.ts b/packages/web-pkg/src/components/index.ts index 2dfb1eeaf6..cb9f96501c 100644 --- a/packages/web-pkg/src/components/index.ts +++ b/packages/web-pkg/src/components/index.ts @@ -1,5 +1,6 @@ export * from './AppBar' export * from './AppTemplates' +export * from './Collaborative' export * from './ContextActions' export * from './FilesList' export * from './Filters' diff --git a/packages/web-pkg/src/editor/composables/strategies/html.ts b/packages/web-pkg/src/editor/composables/strategies/html.ts index a68732695b..0e82c4ae3d 100644 --- a/packages/web-pkg/src/editor/composables/strategies/html.ts +++ b/packages/web-pkg/src/editor/composables/strategies/html.ts @@ -39,7 +39,8 @@ export const useStrategyHtml = (editorState: TextEditorState): ContentTypeStrate const extensions = (): Extension[] => { return [ - StarterKit.configure({ link: false }), + // See markdown strategy for why `undoRedo: false`. + StarterKit.configure({ link: false, undoRedo: false }), Link.configure({ openOnClick: true, autolink: true, diff --git a/packages/web-pkg/src/editor/composables/strategies/markdown.ts b/packages/web-pkg/src/editor/composables/strategies/markdown.ts index 955e74819e..0e2fdd5549 100644 --- a/packages/web-pkg/src/editor/composables/strategies/markdown.ts +++ b/packages/web-pkg/src/editor/composables/strategies/markdown.ts @@ -29,7 +29,12 @@ export const useStrategyMarkdown = (editorState: TextEditorState): ContentTypeSt const extensions = (): Extension[] => { return [ - StarterKit.configure({ link: false }), + // `undoRedo: false` — when the host wires a Y.Doc through this + // editor instance (collab mode), the `Collaboration` extension brings + // yUndoPlugin from @tiptap/y-tiptap which is the collab-aware undo + // manager. Tiptap warns + double-stacks history if both run together. + // Read-only callers don't exercise undo so the flag is harmless there. + StarterKit.configure({ link: false, undoRedo: false }), Markdown, Link.configure({ openOnClick: true, diff --git a/packages/web-pkg/src/editor/composables/strategies/tiptapJson.ts b/packages/web-pkg/src/editor/composables/strategies/tiptapJson.ts index 29bfd255eb..a7bdae8d28 100644 --- a/packages/web-pkg/src/editor/composables/strategies/tiptapJson.ts +++ b/packages/web-pkg/src/editor/composables/strategies/tiptapJson.ts @@ -37,7 +37,8 @@ export const useStrategyTiptapJson = (editorState: TextEditorState): ContentType const extensions = (): Extension[] => { return [ - StarterKit.configure({ link: false }), + // See markdown strategy for why `undoRedo: false`. + StarterKit.configure({ link: false, undoRedo: false }), Link.configure({ openOnClick: true, autolink: true, diff --git a/packages/web-pkg/src/editor/composables/useTextEditor.ts b/packages/web-pkg/src/editor/composables/useTextEditor.ts index 47c0503753..5e2f8fac89 100644 --- a/packages/web-pkg/src/editor/composables/useTextEditor.ts +++ b/packages/web-pkg/src/editor/composables/useTextEditor.ts @@ -1,12 +1,51 @@ import { ref, computed, onBeforeUnmount, watch, unref, onMounted, 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 { TextEditorOptions, TextEditorInstance, TextEditorState } from '../types' import { SlashCommands } from '../extensions' import { useContentStrategy } from './useContentStrategy' +// 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 state: TextEditorState = { @@ -16,11 +55,30 @@ export function useTextEditor(options: TextEditorOptions): TextEditorInstance { const contentType = ref(options.contentType) const readonly = ref(options.readonly ?? false) const strategy = resolveStrategy(options.contentType, state) + const collabFragment = options.ydocFragment ?? 'default' // Debounce onUpdate to avoid firing on every keystroke while typing. let debounceTimer: ReturnType | null = null const extensions = strategy.extensions() + 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. The + // strategies already disable `StarterKit.undoRedo` so yUndoPlugin can + // take over without conflict. + 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 = strategy.editorActionGroups() const hasSlashCommandItems = resolvedGroups.some((group) => @@ -44,7 +102,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 } @@ -52,6 +117,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) }) @@ -135,9 +203,12 @@ export function useTextEditor(options: TextEditorOptions): TextEditorInstance { onMounted(() => { editor.value?.on('selectionUpdate', triggerEditorUpdate) editor.value?.on('transaction', triggerEditorUpdate) - if (!unref(readonly)) { - focus() - } + // Auto-focus on mount used to live here — moved to the consumer. + // The composable's job is to build an Editor; deciding when (or + // whether) to put the cursor in it is UX policy and belongs with the + // caller. All current consumers either rely on the user clicking in + // (text-editor) or render read-only previews (app-store description, + // files list/space headers) and never wanted auto-focus anyway. }) onBeforeUnmount(() => { diff --git a/packages/web-pkg/src/editor/index.ts b/packages/web-pkg/src/editor/index.ts index 2aa1782b42..6102c07623 100644 --- a/packages/web-pkg/src/editor/index.ts +++ b/packages/web-pkg/src/editor/index.ts @@ -1,5 +1,7 @@ export type { ContentType, TextEditorOptions, TextEditorInstance } from './types' export { useTextEditor } from './composables/useTextEditor' +export { useContentStrategy } from './composables/useContentStrategy' +export type { ContentTypeStrategy } from './composables/strategies/types' 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 0a56cdd33a..a80e989712 100644 --- a/packages/web-pkg/src/editor/styles/content.css +++ b/packages/web-pkg/src/editor/styles/content.css @@ -1 +1,2 @@ @import './text-editor.css'; +@import './collab-cursor.css'; diff --git a/packages/web-pkg/src/editor/types.ts b/packages/web-pkg/src/editor/types.ts index 38c341d354..74bbfb9b19 100644 --- a/packages/web-pkg/src/editor/types.ts +++ b/packages/web-pkg/src/editor/types.ts @@ -1,5 +1,7 @@ import type { ShallowRef, Ref, ComputedRef } from 'vue' import { Editor } from '@tiptap/vue-3' +import type * as Y from 'yjs' +import type { Awareness } from 'y-protocols/awareness' import { EditorActionGroup } from './composables' export type ContentType = 'plain-text' | 'markdown' | 'html' | 'tiptap-json' @@ -13,6 +15,26 @@ export interface TextEditorOptions { onUpdate?: (content: string) => void onRequestLinkUrl?: (currentUrl?: string) => Promise onRequestImageUrl?: () => Promise + /** + * 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`. The undo manager comes from `yUndoPlugin` via + * `@tiptap/y-tiptap` — `StarterKit`'s built-in `undoRedo` is already + * disabled in every strategy to avoid conflict. + */ + ydoc?: Y.Doc + /** + * Y.XmlFragment field name inside the Y.Doc. Matches the + * `CollaborativeWrapper` adapter convention. Defaults to `'default'`. + */ + ydocFragment?: string + /** + * Awareness instance from the same room as `ydoc`. When set (collab + * mode), the editor renders remote peer cursors via `yCursorPlugin` + * from `@tiptap/y-tiptap`. Ignored when `ydoc` is not also set. + */ + awareness?: Awareness } export interface TextEditorState { diff --git a/packages/web-pkg/tests/unit/components/Collaborative/CollaborativeWrapper.spec.ts b/packages/web-pkg/tests/unit/components/Collaborative/CollaborativeWrapper.spec.ts new file mode 100644 index 0000000000..51554c892e --- /dev/null +++ b/packages/web-pkg/tests/unit/components/Collaborative/CollaborativeWrapper.spec.ts @@ -0,0 +1,350 @@ +// Unit coverage for the wrapper that lives in web-pkg and is shared by +// the codemirror / tiptap / text-editor apps. The wrapper carries the +// non-trivial branching (collab vs local) and a handful of side effects +// (debounced emit, 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), +// and useAuthStore / useConfigStore so we don't have to bring in pinia. +// A tiny inline adapter mimics the Y.Text-on-'content' shape that +// web-app-codemirror's real adapter uses; the wrapper only sees the +// CollaborativeAdapter interface and doesn't care which app produced it. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { defineComponent, h, nextTick } from 'vue' +import * as Y from 'yjs' +import { Awareness } from 'y-protocols/awareness' +import type { Resource } from '@opencloud-eu/web-client' + +import CollaborativeWrapper from '../../../../src/components/Collaborative/CollaborativeWrapper.vue' +import type { CollaborativeAdapter } from '../../../../src/components/Collaborative/types' +import { defaultPlugins } 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 + destroy = vi.fn() + 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 } +}) + +vi.mock('../../../../src/composables', () => ({ + useAuthStore: () => ({ accessToken: 'test-token' }), + useConfigStore: () => ({ serverUrl: 'https://oc.test' }) +})) + +// Match the codemirror adapter's shape (Y.Text on 'content'). The wrapper +// is adapter-agnostic, so any concrete adapter exercising hydrate / +// serialize / hasContent / reset works for these tests. +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 + ydoc.transact(() => { + yText.delete(0, yText.length) + }, 'reset') + } +} + +const DummyEditor = defineComponent({ + name: 'DummyEditor', + props: ['ydoc', 'awareness', 'provider', 'isReadOnly'], + setup() { + return () => h('div', { class: 'dummy-editor' }) + } +}) + +function makeResource(overrides: Partial = {}): Resource { + return { + id: 'storage$space!item-1', + etag: 'etag-initial', + ...overrides + } as Resource +} + +function mountWrapper(overrides: Record = {}) { + return mount(CollaborativeWrapper, { + props: { + resource: makeResource(), + currentContent: '', + adapter: testAdapter, + editor: DummyEditor, + appVersion: '1.2.3', + realtimeUrl: null, + ...overrides + }, + global: { + plugins: defaultPlugins() + } + }) +} + +beforeEach(() => { + providerInstances.length = 0 +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('CollaborativeWrapper — local mode (no realtimeUrl)', () => { + it('reports status "local" and does not construct a HocuspocusProvider', async () => { + mountWrapper({ currentContent: 'hello' }) + await flushPromises() + expect(providerInstances).toHaveLength(0) + }) + + it('hydrates the Y.Doc from currentContent (election degenerates to "we win")', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + const wrapper = mountWrapper({ 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() + + const ydocAny = (wrapper.vm as unknown as { ydoc: Y.Doc | null }).ydoc + expect(ydocAny).toBeTruthy() + expect(ydocAny!.getText('content').toString()).toBe('hello local') + }) + + it('mounts the editor component with a real Awareness instance', async () => { + const wrapper = mountWrapper({ currentContent: 'x' }) + await flushPromises() + const editor = wrapper.findComponent(DummyEditor) + expect(editor.exists()).toBe(true) + expect(editor.props('awareness')).toBeInstanceOf(Awareness) + expect(editor.props('provider')).toBeNull() + }) +}) + +describe('CollaborativeWrapper — collab mode (realtimeUrl set)', () => { + it('constructs a HocuspocusProvider with the appVersion query param appended', async () => { + mountWrapper({ + realtimeUrl: '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 wrapper = mountWrapper({ + realtimeUrl: 'wss://example.test/realtime', + currentContent: 'should-only-land-after-sync' + }) + await flushPromises() + vi.advanceTimersByTime(500) + await flushPromises() + + const ydocAny = (wrapper.vm as unknown as { ydoc: Y.Doc }).ydoc + expect(ydocAny.getText('content').toString()).toBe('') + + providerInstances[0].triggerSynced() + vi.advanceTimersByTime(200) + await flushPromises() + expect(ydocAny.getText('content').toString()).toBe('should-only-land-after-sync') + }) + + it('surfaces an auth failure as a lifecycle error and locks the editor read-only', async () => { + const wrapper = mountWrapper({ realtimeUrl: 'wss://example.test/realtime' }) + await flushPromises() + providerInstances[0].triggerAuthFailed('token expired') + await nextTick() + const editor = wrapper.findComponent(DummyEditor) + expect(editor.props('isReadOnly')).toBe(true) + }) +}) + +describe('CollaborativeWrapper — update:currentContent emission', () => { + it('emits debounced after a user-origin Y.Doc update', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + const wrapper = mountWrapper({ currentContent: 'seed' }) + await flushPromises() + vi.advanceTimersByTime(200) + await flushPromises() + + const ydoc = (wrapper.vm as unknown as { ydoc: Y.Doc }).ydoc + // Clear any emit produced by hydration (hydration uses an internal + // origin so this should be a no-op, but we're isolating the + // post-hydrate signal explicitly). + ;(wrapper.emitted()['update:currentContent'] ?? []).length = 0 + + ydoc.getText('content').insert(4, ' edit') // no origin = user-typed + + // Nothing emitted within the debounce window yet. + vi.advanceTimersByTime(100) + await flushPromises() + expect(wrapper.emitted('update:currentContent') ?? []).toHaveLength(0) + + // 300ms after the last edit, the debounced serialize fires. + vi.advanceTimersByTime(300) + await flushPromises() + const emits = wrapper.emitted('update:currentContent') ?? [] + expect(emits.length).toBeGreaterThanOrEqual(1) + expect(emits[emits.length - 1][0]).toBe('seed edit') + }) + + it('does NOT emit for internal-origin transactions (hydrate / reset / recovery)', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + const wrapper = mountWrapper({ currentContent: 'seed' }) + await flushPromises() + vi.advanceTimersByTime(200) // let hydration run + await flushPromises() + vi.advanceTimersByTime(400) // past the debounce window + await flushPromises() + + // After hydration the wrapper may have emitted once with the + // post-hydrate serialization — that's a debounce artefact, not the + // internal-origin transaction itself. The contract we're verifying: + // a fresh internal-origin transact() does NOT schedule a NEW emit. + const before = (wrapper.emitted('update:currentContent') ?? []).length + + const ydoc = (wrapper.vm as unknown as { ydoc: Y.Doc }).ydoc + ydoc.transact(() => { + ydoc.getText('content').insert(0, 'internal-') + }, 'hydrate') + + vi.advanceTimersByTime(400) + await flushPromises() + const after = (wrapper.emitted('update:currentContent') ?? []).length + expect(after).toBe(before) + }) +}) + +describe('CollaborativeWrapper — etag mirror', () => { + it('writes a new resource.etag into _oc_meta.etag', async () => { + const wrapper = mountWrapper({ currentContent: 'x', resource: makeResource({ etag: 'a' }) }) + await flushPromises() + const ydoc = (wrapper.vm as unknown as { ydoc: Y.Doc }).ydoc + const meta = ydoc.getMap('_oc_meta') + + await wrapper.setProps({ resource: makeResource({ etag: 'b' }) }) + await flushPromises() + expect(meta.get('etag')).toBe('b') + expect(meta.get('lastSavedAt')).toBeTypeOf('number') + }) + + // Regression: `setProps({ resource })` with a new resource OBJECT whose + // `id` is unchanged must NOT tear down and rebuild the Y.Doc. The earlier + // implementation used `watchEffect((onCleanup) => { unref(documentName); ... })`, + // which Vue re-ran on every tracked prop access — including `props.resource` + // mutations from AppWrapper's post-save `resourcesStore.upsertResource`. + // Every save would have rebuilt the Y.Doc, losing in-flight peer edits. + // The current implementation gates rebuilds on a `sessionKey` computed + // (documentName + realtimeUrl), so an identity-preserving resource update + // is a no-op for the watch. + it('regression: does not rebuild Y.Doc when resource prop changes without identity change', async () => { + const wrapper = mountWrapper({ currentContent: 'x', resource: makeResource({ etag: 'a' }) }) + await flushPromises() + const ydocBefore = (wrapper.vm as unknown as { ydoc: Y.Doc }).ydoc + expect(ydocBefore).toBeTruthy() + expect(ydocBefore.isDestroyed).toBe(false) + + // Same id, different etag — simulates AppWrapper bouncing `resource` after + // a successful save. + await wrapper.setProps({ resource: makeResource({ etag: 'b' }) }) + await flushPromises() + const ydocAfter = (wrapper.vm as unknown as { ydoc: Y.Doc }).ydoc + expect(ydocAfter).toBe(ydocBefore) + expect(ydocBefore.isDestroyed).toBe(false) + }) + + it('does nothing when the etag is unchanged', async () => { + const wrapper = mountWrapper({ currentContent: 'x', resource: makeResource({ etag: 'a' }) }) + await flushPromises() + const ydoc = (wrapper.vm as unknown as { ydoc: Y.Doc }).ydoc + const meta = ydoc.getMap('_oc_meta') + // Initial etag may have been seeded by onProviderSynced. + const initialMeta = meta.get('etag') + + await wrapper.setProps({ resource: makeResource({ etag: 'a' }) }) + await flushPromises() + expect(meta.get('etag')).toBe(initialMeta) + }) +}) + +describe('CollaborativeWrapper — cleanup', () => { + it('destroys provider, awareness, and doc on unmount (collab mode)', async () => { + const wrapper = mountWrapper({ realtimeUrl: 'wss://example.test/realtime' }) + await flushPromises() + const prov = providerInstances[0] + const ydoc = (wrapper.vm as unknown as { ydoc: Y.Doc }).ydoc + expect(ydoc.isDestroyed).toBe(false) + + wrapper.unmount() + expect(prov.destroy).toHaveBeenCalledOnce() + expect(ydoc.isDestroyed).toBe(true) + }) + + it('destroys awareness and doc on unmount (local mode)', async () => { + const wrapper = mountWrapper({ currentContent: 'x' }) + await flushPromises() + const ydoc = (wrapper.vm as unknown as { ydoc: Y.Doc }).ydoc + expect(ydoc.isDestroyed).toBe(false) + + wrapper.unmount() + expect(ydoc.isDestroyed).toBe(true) + expect(providerInstances).toHaveLength(0) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4cc75410c..b6a555e5ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -770,6 +770,9 @@ importers: packages/web-app-text-editor: dependencies: + '@hocuspocus/provider': + specifier: ^4.0.0 + version: 4.3.0(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31) '@opencloud-eu/design-system': specifier: workspace:^ version: link:../design-system @@ -779,12 +782,27 @@ importers: '@opencloud-eu/web-pkg': specifier: workspace:* version: link:../web-pkg + '@tiptap/core': + specifier: ^3.20.4 + version: 3.27.1(@tiptap/pm@3.27.1) + '@tiptap/extension-collaboration': + specifier: ^3.20.4 + version: 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(@tiptap/y-tiptap@3.0.5(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31))(yjs@13.6.31) + '@tiptap/vue-3': + specifier: ^3.20.4 + version: 3.27.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(vue@3.5.39(typescript@6.0.3)) vue-concurrency: specifier: 5.0.3 version: 5.0.3(vue@3.5.39(typescript@6.0.3)) vue3-gettext: specifier: ^4.0.0-beta.1 version: 4.0.1(vue@3.5.39(typescript@6.0.3)) + y-protocols: + specifier: ^1.0.7 + version: 1.0.7(yjs@13.6.31) + yjs: + specifier: ^13.6.0 + version: 13.6.31 devDependencies: '@opencloud-eu/web-test-helpers': specifier: workspace:* @@ -860,6 +878,9 @@ importers: '@casl/vue': specifier: ^3.0.0 version: 3.0.0(@casl/ability@7.0.0)(vue@3.5.39(typescript@6.0.3)) + '@hocuspocus/provider': + specifier: ^4.0.0 + version: 4.3.0(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31) '@microsoft/fetch-event-source': specifier: ^2.0.1 version: 2.0.1 @@ -875,6 +896,9 @@ importers: '@tiptap/core': specifier: ^3.20.4 version: 3.27.1(@tiptap/pm@3.27.1) + '@tiptap/extension-collaboration': + specifier: ^3.20.4 + version: 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(@tiptap/y-tiptap@3.0.5(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31))(yjs@13.6.31) '@tiptap/extension-document': specifier: ^3.20.4 version: 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1)) @@ -929,6 +953,9 @@ importers: '@tiptap/vue-3': specifier: ^3.20.4 version: 3.27.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(vue@3.5.39(typescript@6.0.3)) + '@tiptap/y-tiptap': + specifier: ^3.0.0 + version: 3.0.5(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31) '@uppy/core': specifier: ^5.2.0 version: 5.2.0 @@ -989,6 +1016,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 @@ -1001,6 +1031,12 @@ importers: vue3-gettext: specifier: 4.0.1 version: 4.0.1(vue@3.5.39(typescript@6.0.3)) + y-protocols: + specifier: ^1.0.7 + version: 1.0.7(yjs@13.6.31) + yjs: + specifier: ^13.6.0 + version: 13.6.31 zod: specifier: ^4.3.6 version: 4.4.3 @@ -1014,6 +1050,9 @@ importers: '@types/node': specifier: ^25.5.0 version: 25.9.4 + '@types/semver': + specifier: ^7.7.0 + version: 7.7.1 '@vitest/web-worker': specifier: ^4.1.2 version: 4.1.9(vitest@4.1.9) @@ -1624,6 +1663,15 @@ packages: '@fyears/rclone-crypt@0.0.7': resolution: {integrity: sha512-WHdoBtiKUdgNoyjJz//2cpldBpp9FjkHv4weUDIGdUZW6oMQq/hDiDjz3ICKuK5i5kXuVdYmaoruVpnRcHtwMw==} + '@hocuspocus/common@4.3.0': + resolution: {integrity: sha512-8USsMvso01aMKOSSyvb8oTAlfES/nBM+Y74XjW5Fy5ovmOaVpoRRBrktIrEVwydrp8Cr6C66ivc0orcW/3P+Kg==} + + '@hocuspocus/provider@4.3.0': + resolution: {integrity: sha512-eS5dECLnJDgELI2AfZNvr5jS0B6IWFoo7eAUgwwOzy1bf7KdPagXAYCtSWqSXmLMwjABlubZJQg8FVrTefU0cA==} + peerDependencies: + y-protocols: ^1.0.6 + yjs: ^13.6.8 + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -1669,6 +1717,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==} @@ -2300,6 +2351,14 @@ packages: peerDependencies: '@tiptap/core': 3.27.1 + '@tiptap/extension-collaboration@3.27.1': + resolution: {integrity: sha512-Da7WeKNIaLsbcHBWlgexMgm5ygoA1mhRroFND1vweLNsWIPxvyjci7jrq/uDN1tSnpqMlJsdyW0tXzbVGYTpMw==} + peerDependencies: + '@tiptap/core': 3.27.1 + '@tiptap/pm': 3.27.1 + '@tiptap/y-tiptap': ^3.0.5 + yjs: ^13 + '@tiptap/extension-document@3.27.1': resolution: {integrity: sha512-8FbBTkfnRP4iVaoj+2h3iWa+H0eGDD3yTyVCwrmue/sQTkqUNUoSuAZa3GDG4Sd41xdPwTJxl9nUWGgM1qDCnw==} peerDependencies: @@ -2459,6 +2518,16 @@ packages: '@tiptap/pm': 3.27.1 vue: ^3.0.0 + '@tiptap/y-tiptap@3.0.5': + resolution: {integrity: sha512-WoK5z3jMW+9nzumcWAxEDRCSC7yQmdq4NN0157MaxQBl9dGWwjxJx3+11mb+WAqR37oDeAMKMmSNy+/hm2XGlA==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + prosemirror-model: ^1.7.1 + prosemirror-state: ^1.2.3 + prosemirror-view: ^1.9.10 + y-protocols: ^1.0.1 + yjs: ^13.5.38 + '@transloadit/prettier-bytes@0.3.5': resolution: {integrity: sha512-xF4A3d/ZyX2LJWeQZREZQw+qFX4TGQ8bGVP97OLRt6sPO6T0TNHBFTuRHOJh7RNmYOBmQ9MHxpolD9bXihpuVA==} @@ -2538,6 +2607,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==} @@ -3923,6 +3995,9 @@ packages: peerDependencies: ws: '*' + isomorphic.js@0.2.5: + resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} + istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -4004,6 +4079,11 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lib0@0.2.117: + resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==} + engines: {node: '>=16'} + hasBin: true + license-checker-rseidelsohn@4.4.2: resolution: {integrity: sha512-Sf8WaJhd2vELvCne+frS9AXqnY/vv591s2/nZcJDwTnoNgltG4mAmoenffVb8L2YPRYbxARLyrHJBC38AVfpuA==} engines: {node: '>=18', npm: '>=8'} @@ -5486,11 +5566,21 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + y-protocols@1.0.7: + resolution: {integrity: sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + yjs: ^13.0.0 + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} hasBin: true + yjs@13.6.31: + resolution: {integrity: sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + yn@3.1.1: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} @@ -5965,6 +6055,18 @@ snapshots: pkcs7-padding: 0.1.1 rfc4648: 1.5.4 + '@hocuspocus/common@4.3.0': + dependencies: + lib0: 0.2.117 + + '@hocuspocus/provider@4.3.0(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31)': + dependencies: + '@hocuspocus/common': 4.3.0 + '@lifeomic/attempt': 3.1.0 + lib0: 0.2.117 + y-protocols: 1.0.7(yjs@13.6.31) + yjs: 13.6.31 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -6015,6 +6117,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)(typescript@6.0.3)(vue-tsc@3.3.6(typescript@6.0.3))': @@ -6518,6 +6622,13 @@ snapshots: dependencies: '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1) + '@tiptap/extension-collaboration@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(@tiptap/y-tiptap@3.0.5(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31))(yjs@13.6.31)': + dependencies: + '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1) + '@tiptap/pm': 3.27.1 + '@tiptap/y-tiptap': 3.0.5(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31) + yjs: 13.6.31 + '@tiptap/extension-document@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))': dependencies: '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1) @@ -6692,6 +6803,15 @@ snapshots: '@tiptap/extension-bubble-menu': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1) '@tiptap/extension-floating-menu': 3.27.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1) + '@tiptap/y-tiptap@3.0.5(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31)': + dependencies: + lib0: 0.2.117 + prosemirror-model: 1.25.9 + prosemirror-state: 1.4.4 + prosemirror-view: 1.41.9 + y-protocols: 1.0.7(yjs@13.6.31) + yjs: 13.6.31 + '@transloadit/prettier-bytes@0.3.5': {} '@tsconfig/node10@1.0.12': {} @@ -6765,6 +6885,8 @@ snapshots: '@types/retry@0.12.2': {} + '@types/semver@7.7.1': {} + '@types/trusted-types@2.0.7': optional: true @@ -8315,6 +8437,8 @@ snapshots: dependencies: ws: 8.18.0 + isomorphic.js@0.2.5: {} + istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -8390,6 +8514,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lib0@0.2.117: + dependencies: + isomorphic.js: 0.2.5 + license-checker-rseidelsohn@4.4.2: dependencies: chalk: 4.1.2 @@ -9974,8 +10102,17 @@ snapshots: xtend@4.0.2: {} + y-protocols@1.0.7(yjs@13.6.31): + dependencies: + lib0: 0.2.117 + yjs: 13.6.31 + yaml@2.9.0: {} + yjs@13.6.31: + dependencies: + lib0: 0.2.117 + yn@3.1.1: {} yocto-queue@0.1.0: {}