experimental-a2 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +11 -0
- package/CHANGELOG.md +36 -0
- package/README.md +29 -0
- package/dist/actor-client.d.ts +46 -0
- package/dist/actor-client.d.ts.map +1 -0
- package/dist/actor-client.js +54 -0
- package/dist/actor-client.js.map +1 -0
- package/dist/actor-react.d.ts +54 -0
- package/dist/actor-react.d.ts.map +1 -0
- package/dist/actor-react.js +79 -0
- package/dist/actor-react.js.map +1 -0
- package/dist/actor-shared-BACubf4x.d.ts +136 -0
- package/dist/actor-shared-BACubf4x.d.ts.map +1 -0
- package/dist/actor-shared-DI7J5upy.js +127 -0
- package/dist/actor-shared-DI7J5upy.js.map +1 -0
- package/dist/actor.browser.d.ts +1 -0
- package/dist/actor.browser.js +13 -0
- package/dist/actor.browser.js.map +1 -0
- package/dist/actor.d.ts +176 -0
- package/dist/actor.d.ts.map +1 -0
- package/dist/actor.js +437 -0
- package/dist/actor.js.map +1 -0
- package/dist/ai-server.d.ts +2 -2
- package/dist/ai-server.js +2 -2
- package/dist/ai.d.ts +2 -2
- package/dist/client.d.ts +1 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +4 -4
- package/dist/client.js.map +1 -1
- package/dist/{errors-BQuJpe82.js → errors-DCk6ch5n.js} +16 -2
- package/dist/{errors-BQuJpe82.js.map → errors-DCk6ch5n.js.map} +1 -1
- package/dist/{idempotent-replay-DuqEkYA7.js → idempotent-replay-DVOlyYbx.js} +2 -2
- package/dist/{idempotent-replay-DuqEkYA7.js.map → idempotent-replay-DVOlyYbx.js.map} +1 -1
- package/dist/index.d.ts +16 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/react.d.ts +1 -1
- package/dist/{contract-jIfaR085.d.ts → reducer-DJKWm3cp.d.ts} +39 -39
- package/dist/reducer-DJKWm3cp.d.ts.map +1 -0
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +2 -2
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +1 -1
- package/dist/{server-B2XNevQA.js → server-CBET-jSz.js} +6 -6
- package/dist/server-CBET-jSz.js.map +1 -0
- package/dist/{server-DjPhHnbI.d.ts → server-CKY3_lbw.d.ts} +3 -3
- package/dist/{server-DjPhHnbI.d.ts.map → server-CKY3_lbw.d.ts.map} +1 -1
- package/dist/server.d.ts +3 -3
- package/dist/server.js +1 -1
- package/dist/{store-RJO35BMj.d.ts → store-DGHeBtIQ.d.ts} +2 -2
- package/dist/{store-RJO35BMj.d.ts.map → store-DGHeBtIQ.d.ts.map} +1 -1
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.js +2 -2
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.js +2 -2
- package/dist/{store-redis-core-DT01r4GZ.js → store-redis-core-z-ykbyMg.js} +3 -3
- package/dist/{store-redis-core-DT01r4GZ.js.map → store-redis-core-z-ykbyMg.js.map} +1 -1
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +2 -2
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.js +2 -2
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.js +2 -2
- package/dist/{wire-B6te_wns.js → wire--yji6mO3.js} +2 -2
- package/dist/{wire-B6te_wns.js.map → wire--yji6mO3.js.map} +1 -1
- package/docs/actors/01-introduction.mdx +189 -0
- package/docs/actors/02-concurrency.mdx +154 -0
- package/docs/actors/03-timers.mdx +120 -0
- package/docs/actors/04-routes.mdx +352 -0
- package/docs/actors/meta.ts +1 -0
- package/docs/concepts/meta.ts +1 -0
- package/docs/guides/07-examples.mdx +56 -0
- package/docs/guides/meta.ts +1 -0
- package/docs/index.mdx +16 -0
- package/docs/reference/02-errors.mdx +33 -0
- package/docs/reference/meta.ts +1 -0
- package/examples/README.md +15 -0
- package/examples/playground/AGENTS.md +11 -0
- package/examples/playground/DEPLOY.md +106 -0
- package/examples/playground/README.md +19 -0
- package/examples/playground/activity-feed.test.ts +10 -0
- package/examples/playground/app/agent/[agentId]/agent-client.tsx +376 -0
- package/examples/playground/app/agent/[agentId]/page.tsx +29 -0
- package/examples/playground/app/agent/events/route.ts +4 -0
- package/examples/playground/app/agent/model.ts +3 -0
- package/examples/playground/app/agent/new-agent-session.tsx +98 -0
- package/examples/playground/app/agent/page.tsx +25 -0
- package/examples/playground/app/agent/scheduler/route.ts +5 -0
- package/examples/playground/app/agent/server.ts +153 -0
- package/examples/playground/app/agent/session.ts +13 -0
- package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +682 -0
- package/examples/playground/app/canvas/[canvasId]/canvas-replay.test.ts +68 -0
- package/examples/playground/app/canvas/[canvasId]/canvas-replay.ts +19 -0
- package/examples/playground/app/canvas/[canvasId]/page.tsx +22 -0
- package/examples/playground/app/canvas/[canvasId]/session.ts +19 -0
- package/examples/playground/app/canvas/events/route.ts +13 -0
- package/examples/playground/app/canvas/model.ts +94 -0
- package/examples/playground/app/canvas/open-canvas.tsx +40 -0
- package/examples/playground/app/canvas/page.tsx +20 -0
- package/examples/playground/app/canvas/server.ts +9 -0
- package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +118 -0
- package/examples/playground/app/chat/[chatId]/agent-stream-drawer.tsx +316 -0
- package/examples/playground/app/chat/[chatId]/chat-client.tsx +922 -0
- package/examples/playground/app/chat/[chatId]/chat-view.test.ts +152 -0
- package/examples/playground/app/chat/[chatId]/chat-view.ts +101 -0
- package/examples/playground/app/chat/[chatId]/composer.test.ts +44 -0
- package/examples/playground/app/chat/[chatId]/composer.ts +30 -0
- package/examples/playground/app/chat/[chatId]/page.tsx +30 -0
- package/examples/playground/app/chat/[chatId]/session.ts +7 -0
- package/examples/playground/app/chat/events/route.ts +7 -0
- package/examples/playground/app/chat/model.test.ts +155 -0
- package/examples/playground/app/chat/model.ts +310 -0
- package/examples/playground/app/chat/new-conversation.tsx +16 -0
- package/examples/playground/app/chat/page.tsx +25 -0
- package/examples/playground/app/chat/scheduler/route.ts +5 -0
- package/examples/playground/app/chat/server.ts +184 -0
- package/examples/playground/app/components/activity-feed.tsx +54 -0
- package/examples/playground/app/components/connection-pill.tsx +29 -0
- package/examples/playground/app/counter/counter-client.tsx +72 -0
- package/examples/playground/app/counter/events/route.ts +4 -0
- package/examples/playground/app/counter/model.test.ts +36 -0
- package/examples/playground/app/counter/model.ts +31 -0
- package/examples/playground/app/counter/page.tsx +24 -0
- package/examples/playground/app/counter/server.ts +9 -0
- package/examples/playground/app/counter/session.ts +13 -0
- package/examples/playground/app/documents/[documentId]/code-editor.tsx +80 -0
- package/examples/playground/app/documents/[documentId]/document-client.tsx +525 -0
- package/examples/playground/app/documents/[documentId]/page.tsx +23 -0
- package/examples/playground/app/documents/[documentId]/session.ts +7 -0
- package/examples/playground/app/documents/events/route.ts +4 -0
- package/examples/playground/app/documents/model.ts +55 -0
- package/examples/playground/app/documents/open-document.tsx +40 -0
- package/examples/playground/app/documents/page.tsx +22 -0
- package/examples/playground/app/documents/server.ts +9 -0
- package/examples/playground/app/globals.css +2078 -0
- package/examples/playground/app/layout.tsx +44 -0
- package/examples/playground/app/orders/[orderId]/order-client.tsx +140 -0
- package/examples/playground/app/orders/[orderId]/page.tsx +29 -0
- package/examples/playground/app/orders/[orderId]/session.ts +11 -0
- package/examples/playground/app/orders/create/route.ts +30 -0
- package/examples/playground/app/orders/events/route.ts +4 -0
- package/examples/playground/app/orders/model.ts +79 -0
- package/examples/playground/app/orders/new-order-form.tsx +98 -0
- package/examples/playground/app/orders/page.tsx +22 -0
- package/examples/playground/app/orders/scheduler/route.ts +5 -0
- package/examples/playground/app/orders/server.ts +50 -0
- package/examples/playground/app/page.tsx +111 -0
- package/examples/playground/app/recovery/[recoveryId]/page.tsx +31 -0
- package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +144 -0
- package/examples/playground/app/recovery/[recoveryId]/session.ts +7 -0
- package/examples/playground/app/recovery/events/route.ts +3 -0
- package/examples/playground/app/recovery/model.ts +55 -0
- package/examples/playground/app/recovery/new-recovery-session.tsx +20 -0
- package/examples/playground/app/recovery/page.tsx +22 -0
- package/examples/playground/app/recovery/scheduler/route.ts +7 -0
- package/examples/playground/app/recovery/server.ts +53 -0
- package/examples/playground/app/recovery/start/route.ts +41 -0
- package/examples/playground/app/vault/[vaultId]/route.ts +19 -0
- package/examples/playground/app/vault/page.tsx +12 -0
- package/examples/playground/app/vault/server.ts +9 -0
- package/examples/playground/app/vault/vault-client.tsx +124 -0
- package/examples/playground/app/vault/vault.test.ts +147 -0
- package/examples/playground/app/vault/vault.ts +119 -0
- package/examples/playground/css.d.ts +4 -0
- package/examples/playground/lib/store.ts +15 -0
- package/examples/playground/next-env.d.ts +5 -0
- package/examples/playground/next.config.ts +10 -0
- package/examples/playground/package.json +46 -0
- package/examples/playground/tsconfig.json +37 -0
- package/examples/playground/vercel.json +40 -0
- package/package.json +11 -2
- package/src/actor-client.ts +132 -0
- package/src/actor-react.ts +143 -0
- package/src/actor-shared.ts +356 -0
- package/src/actor.browser.ts +12 -0
- package/src/actor.ts +914 -0
- package/src/client.ts +9 -1
- package/src/errors.ts +15 -0
- package/src/index.ts +1 -1
- package/src/server.ts +13 -3
- package/dist/contract-jIfaR085.d.ts.map +0 -1
- package/dist/server-B2XNevQA.js.map +0 -1
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import type { CSSProperties, ChangeEvent, ReactNode } from 'react'
|
|
3
|
+
import {
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
useSyncExternalStore,
|
|
9
|
+
} from 'react'
|
|
10
|
+
import * as Y from 'yjs'
|
|
11
|
+
import {
|
|
12
|
+
Awareness,
|
|
13
|
+
applyAwarenessUpdate,
|
|
14
|
+
encodeAwarenessUpdate,
|
|
15
|
+
} from 'y-protocols/awareness'
|
|
16
|
+
import { ConnectionPill } from '@/app/components/connection-pill'
|
|
17
|
+
import type { DocumentActor, DocumentState } from '../model'
|
|
18
|
+
import { CodeMirrorEditor } from './code-editor'
|
|
19
|
+
import { SessionProvider, useSession } from './session'
|
|
20
|
+
|
|
21
|
+
const EDIT_DEBOUNCE_MS = 120
|
|
22
|
+
const A2_REMOTE_ORIGIN = Symbol('a2-remote')
|
|
23
|
+
const ACTOR_KEY = 'a2-example-document-actor'
|
|
24
|
+
const ADJECTIVES = ['Bright', 'Calm', 'Curious', 'Kind', 'Swift'] as const
|
|
25
|
+
const ANIMALS = ['Fox', 'Heron', 'Otter', 'Panda', 'Wren'] as const
|
|
26
|
+
const COLORS = ['teal', 'amber', 'blue', 'violet', 'rose'] as const
|
|
27
|
+
const COLOR_HEX: Record<DocumentActor['color'], string> = {
|
|
28
|
+
teal: '#16836a',
|
|
29
|
+
amber: '#bd7917',
|
|
30
|
+
blue: '#3874c8',
|
|
31
|
+
violet: '#7956b6',
|
|
32
|
+
rose: '#bd5269',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type SyncState = 'saved' | 'saving' | 'error'
|
|
36
|
+
|
|
37
|
+
function randomItem<T>(values: readonly T[], value: number): T {
|
|
38
|
+
return values[value % values.length]!
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isActor(value: unknown): value is DocumentActor {
|
|
42
|
+
if (!value || typeof value !== 'object') return false
|
|
43
|
+
const actor = value as Record<string, unknown>
|
|
44
|
+
return (
|
|
45
|
+
typeof actor['id'] === 'string' &&
|
|
46
|
+
typeof actor['name'] === 'string' &&
|
|
47
|
+
typeof actor['color'] === 'string' &&
|
|
48
|
+
COLORS.includes(actor['color'] as DocumentActor['color'])
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** One roll per page load, shared by the session wrapper and the
|
|
53
|
+
* toolbar. The id is the stable per-tab identity (contributor history
|
|
54
|
+
* stays one person); name and color re-roll on every refresh. */
|
|
55
|
+
let loadedActor: DocumentActor | null = null
|
|
56
|
+
|
|
57
|
+
function loadActor(): DocumentActor {
|
|
58
|
+
if (loadedActor) return loadedActor
|
|
59
|
+
let id: string | null = null
|
|
60
|
+
try {
|
|
61
|
+
const stored = sessionStorage.getItem(ACTOR_KEY)
|
|
62
|
+
if (stored) {
|
|
63
|
+
const parsed: unknown = JSON.parse(stored)
|
|
64
|
+
if (isActor(parsed)) id = parsed.id
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
// Storage may be unavailable; a tab-local identity still works.
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const random = new Uint32Array(3)
|
|
71
|
+
crypto.getRandomValues(random)
|
|
72
|
+
const actor: DocumentActor = {
|
|
73
|
+
id: id ?? crypto.randomUUID(),
|
|
74
|
+
name: `${randomItem(ADJECTIVES, random[0]!)} ${randomItem(ANIMALS, random[1]!)}`,
|
|
75
|
+
color: randomItem(COLORS, random[2]!),
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
sessionStorage.setItem(ACTOR_KEY, JSON.stringify(actor))
|
|
79
|
+
} catch {
|
|
80
|
+
// The in-memory identity is enough for this visit.
|
|
81
|
+
}
|
|
82
|
+
loadedActor = actor
|
|
83
|
+
return actor
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function updateToBase64(update: Uint8Array): string {
|
|
87
|
+
let binary = ''
|
|
88
|
+
const chunkSize = 0x8000
|
|
89
|
+
for (let start = 0; start < update.length; start += chunkSize) {
|
|
90
|
+
binary += String.fromCharCode(...update.subarray(start, start + chunkSize))
|
|
91
|
+
}
|
|
92
|
+
return btoa(binary)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function updateFromBase64(encoded: string): Uint8Array {
|
|
96
|
+
const binary = atob(encoded)
|
|
97
|
+
const update = new Uint8Array(binary.length)
|
|
98
|
+
for (let i = 0; i < binary.length; i += 1) {
|
|
99
|
+
update[i] = binary.charCodeAt(i)
|
|
100
|
+
}
|
|
101
|
+
return update
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function createDocument(updates: readonly string[]): Y.Doc {
|
|
105
|
+
const doc = new Y.Doc()
|
|
106
|
+
for (const update of updates) {
|
|
107
|
+
Y.applyUpdate(doc, updateFromBase64(update), A2_REMOTE_ORIGIN)
|
|
108
|
+
}
|
|
109
|
+
return doc
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Translate the controlled title input into a minimal Y.Text splice. */
|
|
113
|
+
function replaceText(text: Y.Text, next: string): void {
|
|
114
|
+
const current = text.toString()
|
|
115
|
+
if (current === next) return
|
|
116
|
+
|
|
117
|
+
let prefix = 0
|
|
118
|
+
while (
|
|
119
|
+
prefix < current.length &&
|
|
120
|
+
prefix < next.length &&
|
|
121
|
+
current[prefix] === next[prefix]
|
|
122
|
+
) {
|
|
123
|
+
prefix += 1
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let suffix = 0
|
|
127
|
+
while (
|
|
128
|
+
suffix < current.length - prefix &&
|
|
129
|
+
suffix < next.length - prefix &&
|
|
130
|
+
current[current.length - 1 - suffix] === next[next.length - 1 - suffix]
|
|
131
|
+
) {
|
|
132
|
+
suffix += 1
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const deleteCount = current.length - prefix - suffix
|
|
136
|
+
const insertion = next.slice(prefix, next.length - suffix)
|
|
137
|
+
if (deleteCount > 0) text.delete(prefix, deleteCount)
|
|
138
|
+
if (insertion) text.insert(prefix, insertion)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function useYText(doc: Y.Doc, name: string): string {
|
|
142
|
+
const [text] = useState(() => doc.getText(name))
|
|
143
|
+
const subscribe = useCallback(
|
|
144
|
+
(notify: () => void) => {
|
|
145
|
+
const observer = (): void => notify()
|
|
146
|
+
text.observe(observer)
|
|
147
|
+
return () => text.unobserve(observer)
|
|
148
|
+
},
|
|
149
|
+
[text],
|
|
150
|
+
)
|
|
151
|
+
const snapshot = useCallback(() => text.toString(), [text])
|
|
152
|
+
return useSyncExternalStore(subscribe, snapshot, snapshot)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function DocumentSession({
|
|
156
|
+
documentId,
|
|
157
|
+
initialState,
|
|
158
|
+
initialIndex,
|
|
159
|
+
}: {
|
|
160
|
+
documentId: string
|
|
161
|
+
initialState: DocumentState
|
|
162
|
+
initialIndex: number
|
|
163
|
+
}): ReactNode {
|
|
164
|
+
// Minted before the provider connects, so the session carries its
|
|
165
|
+
// participant id from the first frame. The rendered UI keeps its
|
|
166
|
+
// own post-mount actor state; loadActor reads the same storage key,
|
|
167
|
+
// so both are one identity.
|
|
168
|
+
const [identity] = useState(() =>
|
|
169
|
+
typeof window === 'undefined' ? null : loadActor(),
|
|
170
|
+
)
|
|
171
|
+
return (
|
|
172
|
+
<SessionProvider
|
|
173
|
+
sessionId={documentId}
|
|
174
|
+
api="/documents/events"
|
|
175
|
+
initialState={initialState}
|
|
176
|
+
initialIndex={initialIndex}
|
|
177
|
+
{...(identity ? { participant: identity.id } : {})}
|
|
178
|
+
>
|
|
179
|
+
<DocumentClient documentId={documentId} />
|
|
180
|
+
</SessionProvider>
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function DocumentClient({
|
|
185
|
+
documentId,
|
|
186
|
+
}: {
|
|
187
|
+
documentId: string
|
|
188
|
+
}): ReactNode {
|
|
189
|
+
const { state, push, events, index, connection, presence, setPresence } =
|
|
190
|
+
useSession()
|
|
191
|
+
const [doc] = useState(() => createDocument(state.updates))
|
|
192
|
+
const title = useYText(doc, 'title')
|
|
193
|
+
const body = useYText(doc, 'body')
|
|
194
|
+
const [actor, setActor] = useState<DocumentActor | null>(null)
|
|
195
|
+
const [syncState, setSyncState] = useState<SyncState>('saved')
|
|
196
|
+
const [error, setError] = useState<string | null>(null)
|
|
197
|
+
const [copied, setCopied] = useState(false)
|
|
198
|
+
const appliedUpdates = useRef(new Set(state.updates))
|
|
199
|
+
const pendingUpdates = useRef<Uint8Array[]>([])
|
|
200
|
+
const flushing = useRef(false)
|
|
201
|
+
const flushTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
202
|
+
const actorRef = useRef(actor)
|
|
203
|
+
const pushRef = useRef(push)
|
|
204
|
+
const flushRef = useRef<() => Promise<void>>(async () => undefined)
|
|
205
|
+
const queueUpdateRef = useRef<(update: Uint8Array) => void>(() => undefined)
|
|
206
|
+
|
|
207
|
+
actorRef.current = actor
|
|
208
|
+
pushRef.current = push
|
|
209
|
+
|
|
210
|
+
useEffect(() => setActor(loadActor()), [])
|
|
211
|
+
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
for (const update of state.updates) {
|
|
214
|
+
if (appliedUpdates.current.has(update)) continue
|
|
215
|
+
Y.applyUpdate(doc, updateFromBase64(update), A2_REMOTE_ORIGIN)
|
|
216
|
+
appliedUpdates.current.add(update)
|
|
217
|
+
}
|
|
218
|
+
}, [doc, state.updates])
|
|
219
|
+
|
|
220
|
+
// ── awareness × presence ── Yjs's second protocol rides A2's second
|
|
221
|
+
// plane. The Awareness instance is the local API (yCollab renders
|
|
222
|
+
// remote selections from it); A2 presence is its only transport.
|
|
223
|
+
const [awareness] = useState(() => new Awareness(doc))
|
|
224
|
+
useEffect(() => () => awareness.destroy(), [awareness])
|
|
225
|
+
|
|
226
|
+
// The cursor label y-codemirror shows: LWW state, so a rename here
|
|
227
|
+
// repaints the caret in every tab. Blur empties the chair: a blurred
|
|
228
|
+
// window or hidden tab clears the whole local state, so peers drop
|
|
229
|
+
// the caret immediately instead of waiting out the protocol's 30s
|
|
230
|
+
// timeout; focus re-seats the identity, and the next editor update
|
|
231
|
+
// re-adds the caret.
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
if (!actor) return
|
|
234
|
+
const hex = COLOR_HEX[actor.color]
|
|
235
|
+
// setLocalState, not setLocalStateField: the field setter
|
|
236
|
+
// silently no-ops while the state is null, and blur nulls it —
|
|
237
|
+
// this must revive the seat from nothing.
|
|
238
|
+
const seat = (): void => {
|
|
239
|
+
awareness.setLocalState({
|
|
240
|
+
...awareness.getLocalState(),
|
|
241
|
+
user: {
|
|
242
|
+
name: actor.name.trim() || 'Anonymous editor',
|
|
243
|
+
color: hex,
|
|
244
|
+
colorLight: `${hex}33`,
|
|
245
|
+
},
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
const clear = (): void => awareness.setLocalState(null)
|
|
249
|
+
const onVisibility = (): void => {
|
|
250
|
+
if (document.visibilityState === 'hidden') clear()
|
|
251
|
+
else seat()
|
|
252
|
+
}
|
|
253
|
+
seat()
|
|
254
|
+
window.addEventListener('blur', clear)
|
|
255
|
+
window.addEventListener('focus', seat)
|
|
256
|
+
window.addEventListener('beforeunload', clear)
|
|
257
|
+
document.addEventListener('visibilitychange', onVisibility)
|
|
258
|
+
return () => {
|
|
259
|
+
window.removeEventListener('blur', clear)
|
|
260
|
+
window.removeEventListener('focus', seat)
|
|
261
|
+
window.removeEventListener('beforeunload', clear)
|
|
262
|
+
document.removeEventListener('visibilitychange', onVisibility)
|
|
263
|
+
}
|
|
264
|
+
}, [awareness, actor])
|
|
265
|
+
|
|
266
|
+
// Outbound: any local awareness change (selection moved, field set,
|
|
267
|
+
// the protocol's periodic keep-alive) publishes the encoded update as
|
|
268
|
+
// presence — the client's send throttle coalesces caret storms, and
|
|
269
|
+
// every set refreshes the A2 TTL alongside awareness's own timeout.
|
|
270
|
+
useEffect(() => {
|
|
271
|
+
if (!actor) return
|
|
272
|
+
const publish = (_changes: unknown, origin: unknown): void => {
|
|
273
|
+
if (origin === A2_REMOTE_ORIGIN) return
|
|
274
|
+
setPresence({
|
|
275
|
+
awareness: updateToBase64(
|
|
276
|
+
encodeAwarenessUpdate(awareness, [doc.clientID]),
|
|
277
|
+
),
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
awareness.on('update', publish)
|
|
281
|
+
return () => awareness.off('update', publish)
|
|
282
|
+
}, [awareness, doc, actor, setPresence])
|
|
283
|
+
|
|
284
|
+
// Inbound: foreign participants' values feed the same instance.
|
|
285
|
+
// applyAwarenessUpdate dedupes by the protocol's own clocks, so
|
|
286
|
+
// re-applying an unchanged value is a no-op; vanished peers expire
|
|
287
|
+
// via awareness's 30s timeout.
|
|
288
|
+
useEffect(() => {
|
|
289
|
+
for (const [participant, fields] of Object.entries(presence)) {
|
|
290
|
+
if (participant === actor?.id) continue
|
|
291
|
+
const encoded = fields.awareness?.value
|
|
292
|
+
if (encoded) {
|
|
293
|
+
applyAwarenessUpdate(
|
|
294
|
+
awareness,
|
|
295
|
+
updateFromBase64(encoded),
|
|
296
|
+
A2_REMOTE_ORIGIN,
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}, [presence, awareness, actor])
|
|
301
|
+
|
|
302
|
+
useEffect(
|
|
303
|
+
() => () => {
|
|
304
|
+
if (flushTimer.current) clearTimeout(flushTimer.current)
|
|
305
|
+
},
|
|
306
|
+
[],
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
flushRef.current = async (): Promise<void> => {
|
|
310
|
+
const currentActor = actorRef.current
|
|
311
|
+
if (
|
|
312
|
+
flushing.current ||
|
|
313
|
+
!currentActor ||
|
|
314
|
+
pendingUpdates.current.length === 0
|
|
315
|
+
) {
|
|
316
|
+
return
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (flushTimer.current) clearTimeout(flushTimer.current)
|
|
320
|
+
flushTimer.current = null
|
|
321
|
+
const batch = pendingUpdates.current.splice(0)
|
|
322
|
+
const update = Y.mergeUpdates(batch)
|
|
323
|
+
const eventActor = {
|
|
324
|
+
...currentActor,
|
|
325
|
+
name: currentActor.name.trim() || 'Anonymous editor',
|
|
326
|
+
}
|
|
327
|
+
flushing.current = true
|
|
328
|
+
setSyncState('saving')
|
|
329
|
+
setError(null)
|
|
330
|
+
|
|
331
|
+
try {
|
|
332
|
+
await pushRef.current({
|
|
333
|
+
type: 'document.updated',
|
|
334
|
+
payload: { update: updateToBase64(update), actor: eventActor },
|
|
335
|
+
})
|
|
336
|
+
flushing.current = false
|
|
337
|
+
if (pendingUpdates.current.length > 0) {
|
|
338
|
+
void flushRef.current()
|
|
339
|
+
} else {
|
|
340
|
+
setSyncState('saved')
|
|
341
|
+
}
|
|
342
|
+
} catch (err) {
|
|
343
|
+
// Keep the failed CRDT bytes. Retry merges them with any newer
|
|
344
|
+
// edits, so dependencies are never sent out of order.
|
|
345
|
+
pendingUpdates.current.unshift(update)
|
|
346
|
+
flushing.current = false
|
|
347
|
+
setSyncState('error')
|
|
348
|
+
setError(err instanceof Error ? err.message : String(err))
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const queueUpdate = (update: Uint8Array): void => {
|
|
353
|
+
pendingUpdates.current.push(update)
|
|
354
|
+
setSyncState('saving')
|
|
355
|
+
if (flushTimer.current) clearTimeout(flushTimer.current)
|
|
356
|
+
flushTimer.current = setTimeout(
|
|
357
|
+
() => void flushRef.current(),
|
|
358
|
+
EDIT_DEBOUNCE_MS,
|
|
359
|
+
)
|
|
360
|
+
}
|
|
361
|
+
queueUpdateRef.current = queueUpdate
|
|
362
|
+
|
|
363
|
+
useEffect(() => {
|
|
364
|
+
const publishLocalUpdate = (update: Uint8Array, origin: unknown): void => {
|
|
365
|
+
if (origin !== A2_REMOTE_ORIGIN) queueUpdateRef.current(update)
|
|
366
|
+
}
|
|
367
|
+
doc.on('update', publishLocalUpdate)
|
|
368
|
+
return () => doc.off('update', publishLocalUpdate)
|
|
369
|
+
}, [doc])
|
|
370
|
+
|
|
371
|
+
const editTitle = (next: string): void => {
|
|
372
|
+
if (!actor) return
|
|
373
|
+
doc.transact(() => replaceText(doc.getText('title'), next), actor.id)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const rename = (event: ChangeEvent<HTMLInputElement>): void => {
|
|
377
|
+
const name = event.target.value.slice(0, 40)
|
|
378
|
+
setActor((current) => (current ? { ...current, name } : current))
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const saveActor = (): void => {
|
|
382
|
+
if (!actor) return
|
|
383
|
+
const normalized = actor.name.trim() || 'Anonymous editor'
|
|
384
|
+
const next = { ...actor, name: normalized }
|
|
385
|
+
setActor(next)
|
|
386
|
+
try {
|
|
387
|
+
sessionStorage.setItem(ACTOR_KEY, JSON.stringify(next))
|
|
388
|
+
} catch {
|
|
389
|
+
// Keep the identity in memory when storage is unavailable.
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const copyLink = async (): Promise<void> => {
|
|
394
|
+
try {
|
|
395
|
+
await navigator.clipboard.writeText(window.location.href)
|
|
396
|
+
setCopied(true)
|
|
397
|
+
setTimeout(() => setCopied(false), 1500)
|
|
398
|
+
} catch (err) {
|
|
399
|
+
setError(err instanceof Error ? err.message : String(err))
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const collaborators = state.contributors.toReversed()
|
|
404
|
+
|
|
405
|
+
return (
|
|
406
|
+
<article className="document-page">
|
|
407
|
+
<header className="session-header document-header">
|
|
408
|
+
<div>
|
|
409
|
+
<p className="eyebrow">Multiplayer document</p>
|
|
410
|
+
<h1>
|
|
411
|
+
<code>{documentId}</code>
|
|
412
|
+
</h1>
|
|
413
|
+
</div>
|
|
414
|
+
<ConnectionPill connection={connection} />
|
|
415
|
+
<button
|
|
416
|
+
className="secondary compact"
|
|
417
|
+
type="button"
|
|
418
|
+
onClick={() => void copyLink()}
|
|
419
|
+
>
|
|
420
|
+
{copied ? 'Copied!' : 'Copy link'}
|
|
421
|
+
</button>
|
|
422
|
+
</header>
|
|
423
|
+
|
|
424
|
+
<div className="editor-toolbar">
|
|
425
|
+
<label className="identity-field">
|
|
426
|
+
You are
|
|
427
|
+
<input
|
|
428
|
+
value={actor?.name ?? ''}
|
|
429
|
+
onChange={rename}
|
|
430
|
+
onBlur={saveActor}
|
|
431
|
+
disabled={!actor}
|
|
432
|
+
placeholder="Joining…"
|
|
433
|
+
/>
|
|
434
|
+
</label>
|
|
435
|
+
<div className="collaborators" aria-label="Document contributors">
|
|
436
|
+
{collaborators.length === 0 ? (
|
|
437
|
+
<span className="hint">No durable edits yet</span>
|
|
438
|
+
) : (
|
|
439
|
+
collaborators.map((contributor) => (
|
|
440
|
+
<span className="collaborator" key={contributor.id}>
|
|
441
|
+
<span
|
|
442
|
+
className="collaborator-dot"
|
|
443
|
+
style={
|
|
444
|
+
{
|
|
445
|
+
'--collaborator-color': COLOR_HEX[contributor.color],
|
|
446
|
+
} as CSSProperties
|
|
447
|
+
}
|
|
448
|
+
/>
|
|
449
|
+
{contributor.id === actor?.id ? actor.name : contributor.name}
|
|
450
|
+
{contributor.id === actor?.id ? ' (you)' : ''}
|
|
451
|
+
</span>
|
|
452
|
+
))
|
|
453
|
+
)}
|
|
454
|
+
</div>
|
|
455
|
+
</div>
|
|
456
|
+
|
|
457
|
+
<section className="document-editor" aria-label="Collaborative editor">
|
|
458
|
+
<input
|
|
459
|
+
className="document-title"
|
|
460
|
+
aria-label="Document title"
|
|
461
|
+
value={title}
|
|
462
|
+
onChange={(event) => editTitle(event.target.value)}
|
|
463
|
+
placeholder="Untitled document"
|
|
464
|
+
disabled={!actor}
|
|
465
|
+
/>
|
|
466
|
+
<CodeMirrorEditor doc={doc} awareness={awareness} disabled={!actor} />
|
|
467
|
+
<footer className="editor-status" aria-live="polite">
|
|
468
|
+
<span className={`sync-state ${syncState}`}>
|
|
469
|
+
{syncState === 'saved'
|
|
470
|
+
? `Saved through log position ${index}`
|
|
471
|
+
: syncState === 'saving'
|
|
472
|
+
? 'Saving…'
|
|
473
|
+
: 'Local edits not saved'}
|
|
474
|
+
</span>
|
|
475
|
+
<span>{body.length.toLocaleString()} characters</span>
|
|
476
|
+
{syncState === 'error' ? (
|
|
477
|
+
<button
|
|
478
|
+
className="secondary compact"
|
|
479
|
+
type="button"
|
|
480
|
+
onClick={() => void flushRef.current()}
|
|
481
|
+
>
|
|
482
|
+
Retry
|
|
483
|
+
</button>
|
|
484
|
+
) : null}
|
|
485
|
+
</footer>
|
|
486
|
+
</section>
|
|
487
|
+
{error ? <p className="error">{error}</p> : null}
|
|
488
|
+
|
|
489
|
+
<section>
|
|
490
|
+
<h2>Durable update log</h2>
|
|
491
|
+
<p className="hint">
|
|
492
|
+
{state.updates.length} update{' '}
|
|
493
|
+
{state.updates.length === 1 ? 'batch' : 'batches'} folded into this
|
|
494
|
+
document.
|
|
495
|
+
</p>
|
|
496
|
+
{events.length === 0 ? (
|
|
497
|
+
<p className="hint">
|
|
498
|
+
No new updates observed since this tab connected. Start typing to
|
|
499
|
+
append one.
|
|
500
|
+
</p>
|
|
501
|
+
) : (
|
|
502
|
+
<ol className="feed document-feed">
|
|
503
|
+
{events.map((event) => (
|
|
504
|
+
<li key={event.id}>
|
|
505
|
+
<span className="idx">#{event.index}</span>
|
|
506
|
+
<span className="type">{event.payload.actor.name}</span>
|
|
507
|
+
<span className="payload">
|
|
508
|
+
Yjs update ·{' '}
|
|
509
|
+
{Math.ceil((event.payload.update.length * 3) / 4)} bytes
|
|
510
|
+
</span>
|
|
511
|
+
<time dateTime={event.createdAt.toISOString()}>
|
|
512
|
+
{event.createdAt.toLocaleTimeString()}
|
|
513
|
+
</time>
|
|
514
|
+
</li>
|
|
515
|
+
))}
|
|
516
|
+
</ol>
|
|
517
|
+
)}
|
|
518
|
+
<p className="frontier">
|
|
519
|
+
A2 orders and replays opaque CRDT bytes; Yjs materializes the same
|
|
520
|
+
title and body in every browser.
|
|
521
|
+
</p>
|
|
522
|
+
</section>
|
|
523
|
+
</article>
|
|
524
|
+
)
|
|
525
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { documentsReducer } from '../model'
|
|
3
|
+
import { documentsServer } from '../server'
|
|
4
|
+
import { DocumentSession } from './document-client'
|
|
5
|
+
|
|
6
|
+
export default async function DocumentPage({
|
|
7
|
+
params,
|
|
8
|
+
}: {
|
|
9
|
+
params: Promise<{ documentId: string }>
|
|
10
|
+
}): Promise<ReactNode> {
|
|
11
|
+
const { documentId } = await params
|
|
12
|
+
const { state, index } = await documentsServer
|
|
13
|
+
.session(documentId)
|
|
14
|
+
.state(documentsReducer)
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<DocumentSession
|
|
18
|
+
documentId={documentId}
|
|
19
|
+
initialState={state}
|
|
20
|
+
initialIndex={index}
|
|
21
|
+
/>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import * as a2 from 'experimental-a2'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
|
|
4
|
+
const paletteSchema = z.enum(['teal', 'amber', 'blue', 'violet', 'rose'])
|
|
5
|
+
|
|
6
|
+
export const documentActorSchema = z.object({
|
|
7
|
+
id: z.string().min(1),
|
|
8
|
+
name: z.string().min(1).max(40),
|
|
9
|
+
color: paletteSchema,
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
export type DocumentActor = z.output<typeof documentActorSchema>
|
|
13
|
+
|
|
14
|
+
export const documents = a2.contract({
|
|
15
|
+
name: 'documents',
|
|
16
|
+
events: {
|
|
17
|
+
'document.updated': z.object({
|
|
18
|
+
update: z.string().min(1),
|
|
19
|
+
actor: documentActorSchema,
|
|
20
|
+
}),
|
|
21
|
+
},
|
|
22
|
+
presence: {
|
|
23
|
+
awareness: z.string().min(1),
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const documentState = z.object({
|
|
28
|
+
updates: z.array(z.string()),
|
|
29
|
+
contributors: z.array(documentActorSchema),
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
export type DocumentState = z.output<typeof documentState>
|
|
33
|
+
|
|
34
|
+
export const documentsReducer = documents
|
|
35
|
+
.reducer({
|
|
36
|
+
name: 'document-updates',
|
|
37
|
+
initialState: { updates: [], contributors: [] },
|
|
38
|
+
stateSchema: documentState,
|
|
39
|
+
})
|
|
40
|
+
.fold((state, event) => {
|
|
41
|
+
switch (event.type) {
|
|
42
|
+
case 'document.updated':
|
|
43
|
+
return {
|
|
44
|
+
updates: [...state.updates, event.payload.update],
|
|
45
|
+
contributors: [
|
|
46
|
+
...state.contributors.filter(
|
|
47
|
+
(actor) => actor.id !== event.payload.actor.id,
|
|
48
|
+
),
|
|
49
|
+
event.payload.actor,
|
|
50
|
+
],
|
|
51
|
+
}
|
|
52
|
+
default:
|
|
53
|
+
return state
|
|
54
|
+
}
|
|
55
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import type { FormEvent, ReactNode } from 'react'
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { useRouter } from 'next/navigation'
|
|
5
|
+
|
|
6
|
+
export function OpenDocument(): ReactNode {
|
|
7
|
+
const router = useRouter()
|
|
8
|
+
const [documentId, setDocumentId] = useState('')
|
|
9
|
+
|
|
10
|
+
const open = (event: FormEvent): void => {
|
|
11
|
+
event.preventDefault()
|
|
12
|
+
const id = documentId.trim()
|
|
13
|
+
if (id) router.push(`/documents/${encodeURIComponent(id)}`)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<form className="document-picker" onSubmit={open}>
|
|
18
|
+
<button
|
|
19
|
+
type="button"
|
|
20
|
+
onClick={() =>
|
|
21
|
+
router.push(`/documents/${crypto.randomUUID().slice(0, 8)}`)
|
|
22
|
+
}
|
|
23
|
+
>
|
|
24
|
+
New document
|
|
25
|
+
</button>
|
|
26
|
+
<span className="hint">or</span>
|
|
27
|
+
<label>
|
|
28
|
+
Open a document ID
|
|
29
|
+
<input
|
|
30
|
+
value={documentId}
|
|
31
|
+
onChange={(event) => setDocumentId(event.target.value)}
|
|
32
|
+
placeholder="roadmap"
|
|
33
|
+
/>
|
|
34
|
+
</label>
|
|
35
|
+
<button className="secondary" type="submit" disabled={!documentId.trim()}>
|
|
36
|
+
Open
|
|
37
|
+
</button>
|
|
38
|
+
</form>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { OpenDocument } from './open-document'
|
|
3
|
+
|
|
4
|
+
export default function DocumentsPage(): ReactNode {
|
|
5
|
+
return (
|
|
6
|
+
<article>
|
|
7
|
+
<h1>A multiplayer document, backed by the log</h1>
|
|
8
|
+
<p className="lede">
|
|
9
|
+
CodeMirror binds directly to a shared Y.Text. Every edit becomes a
|
|
10
|
+
compact Yjs update in an A2 session: open the document URL in another
|
|
11
|
+
tab, type in both, and watch the CRDT merge both editors over the same
|
|
12
|
+
live event stream used by the other demos.
|
|
13
|
+
</p>
|
|
14
|
+
<OpenDocument />
|
|
15
|
+
<p className="hint">
|
|
16
|
+
A2 provides durable ordering, optimistic pushes, and reconnecting SSE.
|
|
17
|
+
Yjs provides the conflict-free text model. Presence is deliberately not
|
|
18
|
+
persisted: the names below mean contributors, not who is online.
|
|
19
|
+
</p>
|
|
20
|
+
</article>
|
|
21
|
+
)
|
|
22
|
+
}
|