experimental-a2 0.7.0 → 0.8.1

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.
Files changed (150) hide show
  1. package/AGENTS.md +11 -0
  2. package/CHANGELOG.md +27 -0
  3. package/README.md +29 -0
  4. package/dist/ai-server.d.ts +1 -1
  5. package/dist/ai-server.d.ts.map +1 -1
  6. package/dist/ai-server.js +13 -11
  7. package/dist/ai-server.js.map +1 -1
  8. package/dist/ai.d.ts +1 -1
  9. package/dist/index.d.ts +1 -1
  10. package/dist/scheduler-qstash.d.ts +2 -2
  11. package/dist/scheduler-qstash.js +1 -1
  12. package/dist/scheduler-vercel.d.ts +2 -2
  13. package/dist/scheduler-vercel.js +1 -1
  14. package/dist/{server-286j79Mt.js → server-B2XNevQA.js} +123 -53
  15. package/dist/server-B2XNevQA.js.map +1 -0
  16. package/dist/{server-DgXmORIq.d.ts → server-DjPhHnbI.d.ts} +7 -4
  17. package/dist/server-DjPhHnbI.d.ts.map +1 -0
  18. package/dist/server.d.ts +3 -3
  19. package/dist/server.js +1 -1
  20. package/dist/store-N8PXxDAS.js.map +1 -1
  21. package/dist/{store-flRz1OWh.d.ts → store-RJO35BMj.d.ts} +25 -8
  22. package/dist/store-RJO35BMj.d.ts.map +1 -0
  23. package/dist/store-memory.d.ts +1 -1
  24. package/dist/store-memory.d.ts.map +1 -1
  25. package/dist/store-memory.js +79 -19
  26. package/dist/store-memory.js.map +1 -1
  27. package/dist/store-postgres.d.ts +1 -1
  28. package/dist/store-postgres.d.ts.map +1 -1
  29. package/dist/store-postgres.js +230 -101
  30. package/dist/store-postgres.js.map +1 -1
  31. package/dist/{store-redis-core-DEYO8Ryv.js → store-redis-core-DT01r4GZ.js} +167 -29
  32. package/dist/store-redis-core-DT01r4GZ.js.map +1 -0
  33. package/dist/store-redis-http.d.ts +1 -1
  34. package/dist/store-redis-http.js +2 -2
  35. package/dist/store-redis-http.js.map +1 -1
  36. package/dist/store-redis.d.ts +1 -1
  37. package/dist/store-redis.js +2 -2
  38. package/dist/store-redis.js.map +1 -1
  39. package/dist/store-sqlite.d.ts +1 -1
  40. package/dist/store-sqlite.d.ts.map +1 -1
  41. package/dist/store-sqlite.js +103 -19
  42. package/dist/store-sqlite.js.map +1 -1
  43. package/docs/concepts/02-handlers.mdx +4 -0
  44. package/docs/concepts/04-state.mdx +57 -9
  45. package/docs/guides/06-ai-agents.mdx +2 -1
  46. package/docs/guides/07-examples.mdx +56 -0
  47. package/docs/index.mdx +13 -0
  48. package/docs/reference/01-api.mdx +39 -16
  49. package/examples/README.md +15 -0
  50. package/examples/playground/AGENTS.md +11 -0
  51. package/examples/playground/DEPLOY.md +106 -0
  52. package/examples/playground/README.md +19 -0
  53. package/examples/playground/activity-feed.test.ts +10 -0
  54. package/examples/playground/app/agent/[agentId]/agent-client.tsx +376 -0
  55. package/examples/playground/app/agent/[agentId]/page.tsx +29 -0
  56. package/examples/playground/app/agent/events/route.ts +4 -0
  57. package/examples/playground/app/agent/model.ts +3 -0
  58. package/examples/playground/app/agent/new-agent-session.tsx +98 -0
  59. package/examples/playground/app/agent/page.tsx +25 -0
  60. package/examples/playground/app/agent/scheduler/route.ts +5 -0
  61. package/examples/playground/app/agent/server.ts +153 -0
  62. package/examples/playground/app/agent/session.ts +13 -0
  63. package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +682 -0
  64. package/examples/playground/app/canvas/[canvasId]/canvas-replay.test.ts +68 -0
  65. package/examples/playground/app/canvas/[canvasId]/canvas-replay.ts +19 -0
  66. package/examples/playground/app/canvas/[canvasId]/page.tsx +22 -0
  67. package/examples/playground/app/canvas/[canvasId]/session.ts +19 -0
  68. package/examples/playground/app/canvas/events/route.ts +13 -0
  69. package/examples/playground/app/canvas/model.ts +94 -0
  70. package/examples/playground/app/canvas/open-canvas.tsx +40 -0
  71. package/examples/playground/app/canvas/page.tsx +20 -0
  72. package/examples/playground/app/canvas/server.ts +9 -0
  73. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +118 -0
  74. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.tsx +316 -0
  75. package/examples/playground/app/chat/[chatId]/chat-client.tsx +922 -0
  76. package/examples/playground/app/chat/[chatId]/chat-view.test.ts +152 -0
  77. package/examples/playground/app/chat/[chatId]/chat-view.ts +101 -0
  78. package/examples/playground/app/chat/[chatId]/composer.test.ts +44 -0
  79. package/examples/playground/app/chat/[chatId]/composer.ts +30 -0
  80. package/examples/playground/app/chat/[chatId]/page.tsx +30 -0
  81. package/examples/playground/app/chat/[chatId]/session.ts +7 -0
  82. package/examples/playground/app/chat/events/route.ts +7 -0
  83. package/examples/playground/app/chat/model.test.ts +155 -0
  84. package/examples/playground/app/chat/model.ts +310 -0
  85. package/examples/playground/app/chat/new-conversation.tsx +16 -0
  86. package/examples/playground/app/chat/page.tsx +25 -0
  87. package/examples/playground/app/chat/scheduler/route.ts +5 -0
  88. package/examples/playground/app/chat/server.ts +184 -0
  89. package/examples/playground/app/components/activity-feed.tsx +54 -0
  90. package/examples/playground/app/components/connection-pill.tsx +29 -0
  91. package/examples/playground/app/counter/counter-client.tsx +72 -0
  92. package/examples/playground/app/counter/events/route.ts +4 -0
  93. package/examples/playground/app/counter/model.test.ts +36 -0
  94. package/examples/playground/app/counter/model.ts +31 -0
  95. package/examples/playground/app/counter/page.tsx +24 -0
  96. package/examples/playground/app/counter/server.ts +9 -0
  97. package/examples/playground/app/counter/session.ts +13 -0
  98. package/examples/playground/app/documents/[documentId]/code-editor.tsx +80 -0
  99. package/examples/playground/app/documents/[documentId]/document-client.tsx +525 -0
  100. package/examples/playground/app/documents/[documentId]/page.tsx +23 -0
  101. package/examples/playground/app/documents/[documentId]/session.ts +7 -0
  102. package/examples/playground/app/documents/events/route.ts +4 -0
  103. package/examples/playground/app/documents/model.ts +55 -0
  104. package/examples/playground/app/documents/open-document.tsx +40 -0
  105. package/examples/playground/app/documents/page.tsx +22 -0
  106. package/examples/playground/app/documents/server.ts +9 -0
  107. package/examples/playground/app/globals.css +2078 -0
  108. package/examples/playground/app/layout.tsx +44 -0
  109. package/examples/playground/app/orders/[orderId]/order-client.tsx +140 -0
  110. package/examples/playground/app/orders/[orderId]/page.tsx +29 -0
  111. package/examples/playground/app/orders/[orderId]/session.ts +11 -0
  112. package/examples/playground/app/orders/create/route.ts +30 -0
  113. package/examples/playground/app/orders/events/route.ts +4 -0
  114. package/examples/playground/app/orders/model.ts +79 -0
  115. package/examples/playground/app/orders/new-order-form.tsx +98 -0
  116. package/examples/playground/app/orders/page.tsx +22 -0
  117. package/examples/playground/app/orders/scheduler/route.ts +5 -0
  118. package/examples/playground/app/orders/server.ts +50 -0
  119. package/examples/playground/app/page.tsx +102 -0
  120. package/examples/playground/app/recovery/[recoveryId]/page.tsx +31 -0
  121. package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +144 -0
  122. package/examples/playground/app/recovery/[recoveryId]/session.ts +7 -0
  123. package/examples/playground/app/recovery/events/route.ts +3 -0
  124. package/examples/playground/app/recovery/model.ts +55 -0
  125. package/examples/playground/app/recovery/new-recovery-session.tsx +20 -0
  126. package/examples/playground/app/recovery/page.tsx +22 -0
  127. package/examples/playground/app/recovery/scheduler/route.ts +7 -0
  128. package/examples/playground/app/recovery/server.ts +53 -0
  129. package/examples/playground/app/recovery/start/route.ts +41 -0
  130. package/examples/playground/css.d.ts +4 -0
  131. package/examples/playground/lib/store.ts +15 -0
  132. package/examples/playground/next-env.d.ts +5 -0
  133. package/examples/playground/next.config.ts +10 -0
  134. package/examples/playground/package.json +46 -0
  135. package/examples/playground/tsconfig.json +37 -0
  136. package/examples/playground/vercel.json +40 -0
  137. package/package.json +5 -2
  138. package/src/ai-server.ts +27 -10
  139. package/src/server.ts +242 -87
  140. package/src/store-memory.ts +138 -20
  141. package/src/store-postgres.ts +355 -138
  142. package/src/store-redis-core.ts +201 -27
  143. package/src/store-redis-http.ts +1 -1
  144. package/src/store-redis.ts +1 -1
  145. package/src/store-sqlite.ts +191 -34
  146. package/src/store.ts +27 -9
  147. package/dist/server-286j79Mt.js.map +0 -1
  148. package/dist/server-DgXmORIq.d.ts.map +0 -1
  149. package/dist/store-flRz1OWh.d.ts.map +0 -1
  150. package/dist/store-redis-core-DEYO8Ryv.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,7 @@
1
+ 'use client'
2
+ import { createReact } from 'experimental-a2/react'
3
+ import { documentsReducer } from '../model'
4
+
5
+ export const { SessionProvider, useSession } = createReact({
6
+ reducer: documentsReducer,
7
+ })
@@ -0,0 +1,4 @@
1
+ import { documentsServer } from '../server'
2
+
3
+ export const GET = documentsServer.fetch
4
+ export const POST = documentsServer.fetch
@@ -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
+ }
@@ -0,0 +1,9 @@
1
+ import 'server-only'
2
+ import { createServer } from 'experimental-a2/server'
3
+ import { store } from '@/lib/store'
4
+ import { documents } from './model'
5
+
6
+ export const documentsServer = createServer({
7
+ ...(store ? { store } : {}),
8
+ contract: documents,
9
+ })