@peanut-admin/admin 0.1.0-alpha.4 → 0.1.0-alpha.5
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.
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
export const COLLABORATION_ENGINE_NAME = 'yjs' as const
|
|
2
|
+
export const COLLABORATION_ENGINE_VERSION = '13.6.32' as const
|
|
3
|
+
|
|
4
|
+
export type CollaborationCapability = 'read' | 'write'
|
|
5
|
+
export type CollaborationSessionState = 'active' | 'published' | 'closed' | 'expired'
|
|
6
|
+
export type CollaborationConnectionStatus = 'idle' | 'admitting' | 'hydrating' | 'connecting' | 'connected' | 'disconnected' | 'error' | 'disposed'
|
|
7
|
+
export type CollaborationUpdateOrigin = 'local' | 'remote' | 'replay'
|
|
8
|
+
export type CollaborationTransportStatus = 'connecting' | 'connected' | 'disconnected'
|
|
9
|
+
export type CollaborationErrorCode =
|
|
10
|
+
| 'COLLABORATION_INVALID'
|
|
11
|
+
| 'COLLABORATION_NOT_FOUND'
|
|
12
|
+
| 'COLLABORATION_DENIED'
|
|
13
|
+
| 'COLLABORATION_CONFLICT'
|
|
14
|
+
| 'COLLABORATION_LEASE_EXPIRED'
|
|
15
|
+
| 'COLLABORATION_PAYLOAD_TOO_LARGE'
|
|
16
|
+
| 'COLLABORATION_BACKPRESSURE'
|
|
17
|
+
| 'COLLABORATION_PROVIDER_UNAVAILABLE'
|
|
18
|
+
| 'COLLABORATION_INTEGRITY_FAILURE'
|
|
19
|
+
| 'COLLABORATION_INTERNAL_ERROR'
|
|
20
|
+
|
|
21
|
+
export interface CollaborationSession {
|
|
22
|
+
readonly sessionKey: string
|
|
23
|
+
readonly artifactType: string
|
|
24
|
+
readonly artifactKey: string
|
|
25
|
+
readonly engineName: typeof COLLABORATION_ENGINE_NAME
|
|
26
|
+
readonly engineVersion: typeof COLLABORATION_ENGINE_VERSION
|
|
27
|
+
readonly baseRevisionKey: string
|
|
28
|
+
readonly baseRevisionDigest: string
|
|
29
|
+
readonly latestSequence: number
|
|
30
|
+
readonly state: CollaborationSessionState
|
|
31
|
+
readonly expiresAt: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface CollaborationLease {
|
|
35
|
+
readonly leaseKey: string
|
|
36
|
+
readonly clientKey: string
|
|
37
|
+
readonly capability: CollaborationCapability
|
|
38
|
+
readonly expiresAt: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CollaborationTransportAdmission {
|
|
42
|
+
readonly websocketUrl: string
|
|
43
|
+
readonly roomName: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface CollaborationAdmission {
|
|
47
|
+
readonly session: CollaborationSession
|
|
48
|
+
readonly lease: CollaborationLease
|
|
49
|
+
readonly transport: CollaborationTransportAdmission
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface CollaborationUpdateEnvelope {
|
|
53
|
+
readonly updateKey: string
|
|
54
|
+
readonly sequence: number
|
|
55
|
+
readonly engineName: typeof COLLABORATION_ENGINE_NAME
|
|
56
|
+
readonly engineVersion: typeof COLLABORATION_ENGINE_VERSION
|
|
57
|
+
readonly digest: string
|
|
58
|
+
readonly payload: Uint8Array
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface CollaborationSnapshotEnvelope {
|
|
62
|
+
readonly snapshotKey: string
|
|
63
|
+
readonly coveredSequence: number
|
|
64
|
+
readonly engineName: typeof COLLABORATION_ENGINE_NAME
|
|
65
|
+
readonly engineVersion: typeof COLLABORATION_ENGINE_VERSION
|
|
66
|
+
readonly snapshotDigest: string
|
|
67
|
+
readonly stateVectorDigest: string
|
|
68
|
+
readonly snapshot: Uint8Array
|
|
69
|
+
readonly stateVector: Uint8Array
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface CollaborationStatePage {
|
|
73
|
+
readonly snapshot: CollaborationSnapshotEnvelope | null
|
|
74
|
+
readonly updates: readonly CollaborationUpdateEnvelope[]
|
|
75
|
+
readonly latestSequence: number
|
|
76
|
+
readonly nextAfterSequence: number | null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface CollaborationSafeError {
|
|
80
|
+
readonly code: CollaborationErrorCode
|
|
81
|
+
readonly message: string
|
|
82
|
+
readonly requestId: string | null
|
|
83
|
+
readonly status: number
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface CollaborationEngine<TDocument = unknown> {
|
|
87
|
+
readonly document: TDocument
|
|
88
|
+
applyUpdate: (update: Uint8Array, origin?: Exclude<CollaborationUpdateOrigin, 'local'>) => void
|
|
89
|
+
encodeStateVector: () => Uint8Array
|
|
90
|
+
encodeSnapshot: () => Uint8Array
|
|
91
|
+
onUpdate: (listener: (update: Uint8Array, origin: CollaborationUpdateOrigin) => void) => () => void
|
|
92
|
+
dispose: () => void
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface CollaborationTransport {
|
|
96
|
+
connect: (admission: CollaborationTransportAdmission, initialSnapshot: Uint8Array) => void
|
|
97
|
+
disconnect: () => void
|
|
98
|
+
sendUpdate: (update: Uint8Array) => void
|
|
99
|
+
onStatus: (listener: (status: CollaborationTransportStatus) => void) => () => void
|
|
100
|
+
onUpdate: (listener: (update: Uint8Array) => void) => () => void
|
|
101
|
+
dispose: () => void
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface CollaborationHostApi {
|
|
105
|
+
admit: (signal: AbortSignal) => Promise<CollaborationAdmission>
|
|
106
|
+
state: (sessionKey: string, afterSequence: number, signal: AbortSignal) => Promise<CollaborationStatePage>
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface CollaborationRuntimeState {
|
|
110
|
+
readonly status: CollaborationConnectionStatus
|
|
111
|
+
readonly session: CollaborationSession | null
|
|
112
|
+
readonly lease: CollaborationLease | null
|
|
113
|
+
readonly latestSequence: number
|
|
114
|
+
readonly error: CollaborationSafeError | null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const stableKey = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/
|
|
118
|
+
const opaqueKey = /^[a-z][a-z0-9]*_[0-9a-f]{32}$/
|
|
119
|
+
const sha256 = /^[0-9a-f]{64}$/
|
|
120
|
+
const instant = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
|
|
121
|
+
|
|
122
|
+
const printable = (value: string, maximum: number): boolean => value.length >= 1 && value.length <= maximum && /^[\x20-\x7e]+$/.test(value)
|
|
123
|
+
const validInstant = (value: string): boolean => instant.test(value) && Number.isFinite(Date.parse(value))
|
|
124
|
+
const sequence = (value: number): boolean => Number.isSafeInteger(value) && value >= 0
|
|
125
|
+
const bytes = (value: Uint8Array, maximum: number): boolean => value.byteLength >= 1 && value.byteLength <= maximum
|
|
126
|
+
|
|
127
|
+
export const assertCollaborationAdmission = (admission: CollaborationAdmission): void => {
|
|
128
|
+
const { session, lease, transport } = admission
|
|
129
|
+
if (!opaqueKey.test(session.sessionKey) || !stableKey.test(session.artifactType) || session.artifactType.length > 64
|
|
130
|
+
|| !printable(session.artifactKey, 128) || session.engineName !== COLLABORATION_ENGINE_NAME
|
|
131
|
+
|| session.engineVersion !== COLLABORATION_ENGINE_VERSION || !printable(session.baseRevisionKey, 128)
|
|
132
|
+
|| !sha256.test(session.baseRevisionDigest) || !sequence(session.latestSequence) || session.state !== 'active'
|
|
133
|
+
|| !validInstant(session.expiresAt) || !opaqueKey.test(lease.leaseKey) || !printable(lease.clientKey, 128)
|
|
134
|
+
|| (lease.capability !== 'read' && lease.capability !== 'write') || !validInstant(lease.expiresAt)
|
|
135
|
+
|| typeof transport.websocketUrl !== 'string' || !printable(transport.roomName, 128)) {
|
|
136
|
+
throw new Error('COLLABORATION_RESPONSE_INVALID')
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export const assertCollaborationStatePage = (page: CollaborationStatePage, afterSequence: number): void => {
|
|
141
|
+
if (!sequence(page.latestSequence) || page.latestSequence < afterSequence
|
|
142
|
+
|| (page.nextAfterSequence !== null && (!sequence(page.nextAfterSequence) || page.nextAfterSequence <= afterSequence || page.nextAfterSequence > page.latestSequence))) {
|
|
143
|
+
throw new Error('COLLABORATION_RESPONSE_INVALID')
|
|
144
|
+
}
|
|
145
|
+
let cursor = afterSequence
|
|
146
|
+
if (page.snapshot !== null) {
|
|
147
|
+
const snapshot = page.snapshot
|
|
148
|
+
if (!opaqueKey.test(snapshot.snapshotKey) || !sequence(snapshot.coveredSequence) || snapshot.coveredSequence < afterSequence
|
|
149
|
+
|| snapshot.coveredSequence > page.latestSequence || snapshot.engineName !== COLLABORATION_ENGINE_NAME
|
|
150
|
+
|| snapshot.engineVersion !== COLLABORATION_ENGINE_VERSION || !sha256.test(snapshot.snapshotDigest)
|
|
151
|
+
|| !sha256.test(snapshot.stateVectorDigest) || !bytes(snapshot.snapshot, 8_388_608) || !bytes(snapshot.stateVector, 8_388_608)) {
|
|
152
|
+
throw new Error('COLLABORATION_RESPONSE_INVALID')
|
|
153
|
+
}
|
|
154
|
+
cursor = snapshot.coveredSequence
|
|
155
|
+
}
|
|
156
|
+
for (const update of page.updates) {
|
|
157
|
+
if (!opaqueKey.test(update.updateKey) || update.sequence !== cursor + 1 || update.sequence > page.latestSequence
|
|
158
|
+
|| update.engineName !== COLLABORATION_ENGINE_NAME || update.engineVersion !== COLLABORATION_ENGINE_VERSION
|
|
159
|
+
|| !sha256.test(update.digest) || !bytes(update.payload, 262_144)) {
|
|
160
|
+
throw new Error('COLLABORATION_RESPONSE_INVALID')
|
|
161
|
+
}
|
|
162
|
+
cursor = update.sequence
|
|
163
|
+
}
|
|
164
|
+
if (page.nextAfterSequence !== null && page.nextAfterSequence !== cursor) throw new Error('COLLABORATION_RESPONSE_INVALID')
|
|
165
|
+
if (page.nextAfterSequence === null && cursor !== page.latestSequence) throw new Error('COLLABORATION_RESPONSE_INVALID')
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const messages: Readonly<Record<CollaborationErrorCode, string>> = {
|
|
169
|
+
COLLABORATION_INVALID: 'The collaboration request was rejected.',
|
|
170
|
+
COLLABORATION_NOT_FOUND: 'The collaboration session was not found.',
|
|
171
|
+
COLLABORATION_DENIED: 'You do not have access to this collaboration session.',
|
|
172
|
+
COLLABORATION_CONFLICT: 'The collaboration session changed. Reopen it and try again.',
|
|
173
|
+
COLLABORATION_LEASE_EXPIRED: 'The collaboration lease expired. Reconnect to continue.',
|
|
174
|
+
COLLABORATION_PAYLOAD_TOO_LARGE: 'The collaboration update is too large.',
|
|
175
|
+
COLLABORATION_BACKPRESSURE: 'The collaboration session must be saved before more updates can be accepted.',
|
|
176
|
+
COLLABORATION_PROVIDER_UNAVAILABLE: 'The collaboration service is temporarily unavailable.',
|
|
177
|
+
COLLABORATION_INTEGRITY_FAILURE: 'The collaboration update could not be verified.',
|
|
178
|
+
COLLABORATION_INTERNAL_ERROR: 'The collaboration request could not be completed.',
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const statuses: Readonly<Record<CollaborationErrorCode, number>> = {
|
|
182
|
+
COLLABORATION_INVALID: 422,
|
|
183
|
+
COLLABORATION_NOT_FOUND: 404,
|
|
184
|
+
COLLABORATION_DENIED: 403,
|
|
185
|
+
COLLABORATION_CONFLICT: 409,
|
|
186
|
+
COLLABORATION_LEASE_EXPIRED: 409,
|
|
187
|
+
COLLABORATION_PAYLOAD_TOO_LARGE: 413,
|
|
188
|
+
COLLABORATION_BACKPRESSURE: 429,
|
|
189
|
+
COLLABORATION_PROVIDER_UNAVAILABLE: 503,
|
|
190
|
+
COLLABORATION_INTEGRITY_FAILURE: 500,
|
|
191
|
+
COLLABORATION_INTERNAL_ERROR: 500,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export class CollaborationRequestError extends Error {
|
|
195
|
+
readonly safe: CollaborationSafeError
|
|
196
|
+
|
|
197
|
+
constructor(code: CollaborationErrorCode, requestId: string | null = null) {
|
|
198
|
+
super(messages[code])
|
|
199
|
+
this.name = 'CollaborationRequestError'
|
|
200
|
+
const safeRequestId = requestId !== null && /^[A-Za-z0-9._-]{1,128}$/.test(requestId) ? requestId : null
|
|
201
|
+
this.safe = { code, message: messages[code], requestId: safeRequestId, status: statuses[code] }
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export const safeCollaborationError = (error: unknown): CollaborationSafeError => error instanceof CollaborationRequestError
|
|
206
|
+
? error.safe
|
|
207
|
+
: new CollaborationRequestError('COLLABORATION_INTERNAL_ERROR').safe
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import * as Y from 'yjs'
|
|
2
|
+
import type { CollaborationEngine, CollaborationUpdateOrigin } from './contracts'
|
|
3
|
+
|
|
4
|
+
const remoteOrigin = Symbol('peanut.collaboration.remote')
|
|
5
|
+
const replayOrigin = Symbol('peanut.collaboration.replay')
|
|
6
|
+
|
|
7
|
+
export type YjsCollaborationEngine = CollaborationEngine<Y.Doc>
|
|
8
|
+
|
|
9
|
+
export const createYjsCollaborationEngine = (options: { readonly gc?: boolean; readonly guid?: string } = {}): YjsCollaborationEngine => {
|
|
10
|
+
const document = new Y.Doc(options)
|
|
11
|
+
const listeners = new Set<(update: Uint8Array, origin: CollaborationUpdateOrigin) => void>()
|
|
12
|
+
let disposed = false
|
|
13
|
+
const updated = (update: Uint8Array, origin: unknown): void => {
|
|
14
|
+
if (disposed) return
|
|
15
|
+
const kind: CollaborationUpdateOrigin = origin === remoteOrigin ? 'remote' : origin === replayOrigin ? 'replay' : 'local'
|
|
16
|
+
for (const listener of listeners) listener(update.slice(), kind)
|
|
17
|
+
}
|
|
18
|
+
document.on('update', updated)
|
|
19
|
+
return {
|
|
20
|
+
document,
|
|
21
|
+
applyUpdate(update, origin = 'remote') {
|
|
22
|
+
if (disposed) throw new Error('COLLABORATION_ENGINE_DISPOSED')
|
|
23
|
+
Y.applyUpdate(document, update, origin === 'replay' ? replayOrigin : remoteOrigin)
|
|
24
|
+
},
|
|
25
|
+
encodeStateVector() {
|
|
26
|
+
if (disposed) throw new Error('COLLABORATION_ENGINE_DISPOSED')
|
|
27
|
+
return Y.encodeStateVector(document)
|
|
28
|
+
},
|
|
29
|
+
encodeSnapshot() {
|
|
30
|
+
if (disposed) throw new Error('COLLABORATION_ENGINE_DISPOSED')
|
|
31
|
+
return Y.encodeStateAsUpdate(document)
|
|
32
|
+
},
|
|
33
|
+
onUpdate(listener) {
|
|
34
|
+
if (disposed) throw new Error('COLLABORATION_ENGINE_DISPOSED')
|
|
35
|
+
listeners.add(listener)
|
|
36
|
+
return () => { listeners.delete(listener) }
|
|
37
|
+
},
|
|
38
|
+
dispose() {
|
|
39
|
+
if (disposed) return
|
|
40
|
+
disposed = true
|
|
41
|
+
listeners.clear()
|
|
42
|
+
document.off('update', updated)
|
|
43
|
+
document.destroy()
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CollaborationRequestError,
|
|
3
|
+
assertCollaborationAdmission,
|
|
4
|
+
assertCollaborationStatePage,
|
|
5
|
+
safeCollaborationError,
|
|
6
|
+
} from './contracts'
|
|
7
|
+
import type {
|
|
8
|
+
CollaborationEngine,
|
|
9
|
+
CollaborationHostApi,
|
|
10
|
+
CollaborationRuntimeState,
|
|
11
|
+
CollaborationTransport,
|
|
12
|
+
} from './contracts'
|
|
13
|
+
|
|
14
|
+
export interface CollaborationRuntime {
|
|
15
|
+
readonly state: CollaborationRuntimeState
|
|
16
|
+
connect: () => Promise<void>
|
|
17
|
+
reconnect: () => Promise<void>
|
|
18
|
+
disconnect: () => void
|
|
19
|
+
onState: (listener: (state: CollaborationRuntimeState) => void) => () => void
|
|
20
|
+
encodeSnapshot: () => Uint8Array
|
|
21
|
+
encodeStateVector: () => Uint8Array
|
|
22
|
+
dispose: () => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface CollaborationRuntimeOptions<TDocument = unknown> {
|
|
26
|
+
readonly host: CollaborationHostApi
|
|
27
|
+
readonly engine: CollaborationEngine<TDocument>
|
|
28
|
+
readonly transport: CollaborationTransport
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const aborted = (error: unknown): boolean => error instanceof DOMException && error.name === 'AbortError'
|
|
32
|
+
|
|
33
|
+
export const createCollaborationRuntime = <TDocument>(options: CollaborationRuntimeOptions<TDocument>): CollaborationRuntime => {
|
|
34
|
+
const listeners = new Set<(state: CollaborationRuntimeState) => void>()
|
|
35
|
+
let current: CollaborationRuntimeState = { status: 'idle', session: null, lease: null, latestSequence: 0, error: null }
|
|
36
|
+
let controller: AbortController | null = null
|
|
37
|
+
let generation = 0
|
|
38
|
+
let disposed = false
|
|
39
|
+
let establishedSessionKey: string | null = null
|
|
40
|
+
|
|
41
|
+
const publish = (patch: Partial<CollaborationRuntimeState>): void => {
|
|
42
|
+
current = { ...current, ...patch }
|
|
43
|
+
for (const listener of listeners) listener(current)
|
|
44
|
+
}
|
|
45
|
+
const disconnect = (): void => {
|
|
46
|
+
if (disposed) return
|
|
47
|
+
generation += 1
|
|
48
|
+
controller?.abort()
|
|
49
|
+
controller = null
|
|
50
|
+
options.transport.disconnect()
|
|
51
|
+
publish({ status: 'disconnected', lease: null, error: null })
|
|
52
|
+
}
|
|
53
|
+
const hydrate = async (sessionKey: string, firstSequence: number, signal: AbortSignal, run: number): Promise<number> => {
|
|
54
|
+
let cursor = firstSequence
|
|
55
|
+
for (let pages = 0; pages < 1000; pages += 1) {
|
|
56
|
+
const page = await options.host.state(sessionKey, cursor, signal)
|
|
57
|
+
if (run !== generation) return cursor
|
|
58
|
+
assertCollaborationStatePage(page, cursor)
|
|
59
|
+
if (page.snapshot !== null) {
|
|
60
|
+
options.engine.applyUpdate(page.snapshot.snapshot, 'replay')
|
|
61
|
+
cursor = page.snapshot.coveredSequence
|
|
62
|
+
}
|
|
63
|
+
for (const update of page.updates) {
|
|
64
|
+
options.engine.applyUpdate(update.payload, 'replay')
|
|
65
|
+
cursor = update.sequence
|
|
66
|
+
}
|
|
67
|
+
if (page.nextAfterSequence === null) return page.latestSequence
|
|
68
|
+
cursor = page.nextAfterSequence
|
|
69
|
+
}
|
|
70
|
+
throw new CollaborationRequestError('COLLABORATION_INTERNAL_ERROR')
|
|
71
|
+
}
|
|
72
|
+
const connect = async (): Promise<void> => {
|
|
73
|
+
if (disposed) throw new Error('COLLABORATION_RUNTIME_DISPOSED')
|
|
74
|
+
const run = ++generation
|
|
75
|
+
controller?.abort()
|
|
76
|
+
options.transport.disconnect()
|
|
77
|
+
const nextController = new AbortController()
|
|
78
|
+
controller = nextController
|
|
79
|
+
publish({ status: 'admitting', lease: null, error: null })
|
|
80
|
+
try {
|
|
81
|
+
const admission = await options.host.admit(nextController.signal)
|
|
82
|
+
if (run !== generation) return
|
|
83
|
+
assertCollaborationAdmission(admission)
|
|
84
|
+
if (establishedSessionKey !== null && establishedSessionKey !== admission.session.sessionKey) {
|
|
85
|
+
throw new CollaborationRequestError('COLLABORATION_CONFLICT')
|
|
86
|
+
}
|
|
87
|
+
const initialSequence = establishedSessionKey === null ? 0 : current.latestSequence
|
|
88
|
+
publish({ status: 'hydrating', session: admission.session, lease: admission.lease, latestSequence: initialSequence })
|
|
89
|
+
const latestSequence = await hydrate(admission.session.sessionKey, initialSequence, nextController.signal, run)
|
|
90
|
+
if (run !== generation) return
|
|
91
|
+
if (latestSequence !== admission.session.latestSequence) throw new CollaborationRequestError('COLLABORATION_INTEGRITY_FAILURE')
|
|
92
|
+
establishedSessionKey = admission.session.sessionKey
|
|
93
|
+
publish({ status: 'connecting', latestSequence })
|
|
94
|
+
options.transport.connect(admission.transport, options.engine.encodeSnapshot())
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (run !== generation || aborted(error)) return
|
|
97
|
+
options.transport.disconnect()
|
|
98
|
+
publish({ status: 'error', lease: null, error: safeCollaborationError(error) })
|
|
99
|
+
} finally {
|
|
100
|
+
if (controller === nextController) controller = null
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const removeEngineListener = options.engine.onUpdate((update, origin) => {
|
|
105
|
+
if ((current.status === 'connecting' || current.status === 'connected') && origin === 'local') {
|
|
106
|
+
try {
|
|
107
|
+
if (update.byteLength > 262_144) throw new CollaborationRequestError('COLLABORATION_PAYLOAD_TOO_LARGE')
|
|
108
|
+
options.transport.sendUpdate(update)
|
|
109
|
+
} catch (error) {
|
|
110
|
+
options.transport.disconnect()
|
|
111
|
+
publish({ status: 'error', lease: null, error: safeCollaborationError(error) })
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
const removeTransportUpdateListener = options.transport.onUpdate(update => {
|
|
116
|
+
if (current.status !== 'connecting' && current.status !== 'connected') return
|
|
117
|
+
try { options.engine.applyUpdate(update, 'remote') } catch (error) {
|
|
118
|
+
options.transport.disconnect()
|
|
119
|
+
publish({ status: 'error', lease: null, error: safeCollaborationError(error) })
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
const removeTransportStatusListener = options.transport.onStatus(status => {
|
|
123
|
+
if (disposed || current.status === 'disposed' || current.status === 'error') return
|
|
124
|
+
if (status === 'connected') publish({ status: 'connected', error: null })
|
|
125
|
+
else if (status === 'connecting') publish({ status: 'connecting' })
|
|
126
|
+
else publish({ status: 'disconnected', lease: null })
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
get state() { return current },
|
|
131
|
+
connect,
|
|
132
|
+
async reconnect() { disconnect(); await connect() },
|
|
133
|
+
disconnect,
|
|
134
|
+
onState(listener) {
|
|
135
|
+
if (disposed) throw new Error('COLLABORATION_RUNTIME_DISPOSED')
|
|
136
|
+
listeners.add(listener)
|
|
137
|
+
return () => { listeners.delete(listener) }
|
|
138
|
+
},
|
|
139
|
+
encodeSnapshot: () => options.engine.encodeSnapshot(),
|
|
140
|
+
encodeStateVector: () => options.engine.encodeStateVector(),
|
|
141
|
+
dispose() {
|
|
142
|
+
if (disposed) return
|
|
143
|
+
generation += 1
|
|
144
|
+
controller?.abort()
|
|
145
|
+
controller = null
|
|
146
|
+
removeEngineListener()
|
|
147
|
+
removeTransportUpdateListener()
|
|
148
|
+
removeTransportStatusListener()
|
|
149
|
+
options.transport.dispose()
|
|
150
|
+
options.engine.dispose()
|
|
151
|
+
disposed = true
|
|
152
|
+
current = { ...current, status: 'disposed', lease: null, error: null }
|
|
153
|
+
for (const listener of listeners) listener(current)
|
|
154
|
+
listeners.clear()
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import * as Y from 'yjs'
|
|
2
|
+
import { WebsocketProvider } from 'y-websocket'
|
|
3
|
+
import type { CollaborationTransport, CollaborationTransportAdmission, CollaborationTransportStatus } from './contracts'
|
|
4
|
+
|
|
5
|
+
interface ProviderStatusEvent { readonly status: CollaborationTransportStatus }
|
|
6
|
+
interface CollaborationWebsocketProvider {
|
|
7
|
+
connect: () => void
|
|
8
|
+
disconnect: () => void
|
|
9
|
+
destroy: () => void
|
|
10
|
+
on: (event: 'status', listener: (event: ProviderStatusEvent) => void) => void
|
|
11
|
+
off: (event: 'status', listener: (event: ProviderStatusEvent) => void) => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CollaborationWebsocketProviderOptions {
|
|
15
|
+
readonly connect: false
|
|
16
|
+
readonly disableBc: true
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type CollaborationWebsocketProviderFactory = (
|
|
20
|
+
websocketUrl: string,
|
|
21
|
+
roomName: string,
|
|
22
|
+
document: Y.Doc,
|
|
23
|
+
options: CollaborationWebsocketProviderOptions,
|
|
24
|
+
) => CollaborationWebsocketProvider
|
|
25
|
+
|
|
26
|
+
export interface YWebsocketCollaborationTransportOptions {
|
|
27
|
+
readonly hostOrigin?: string
|
|
28
|
+
readonly providerFactory?: CollaborationWebsocketProviderFactory
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const providerFactory: CollaborationWebsocketProviderFactory = (websocketUrl, roomName, document, options) => new WebsocketProvider(
|
|
32
|
+
websocketUrl,
|
|
33
|
+
roomName,
|
|
34
|
+
document,
|
|
35
|
+
options,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
const loopback = (hostname: string): boolean => hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]'
|
|
39
|
+
|
|
40
|
+
const validateAdmission = (admission: CollaborationTransportAdmission, hostOrigin: string): URL => {
|
|
41
|
+
let websocket: URL
|
|
42
|
+
let host: URL
|
|
43
|
+
try {
|
|
44
|
+
websocket = new URL(admission.websocketUrl)
|
|
45
|
+
host = new URL(hostOrigin)
|
|
46
|
+
} catch {
|
|
47
|
+
throw new Error('COLLABORATION_TRANSPORT_INVALID')
|
|
48
|
+
}
|
|
49
|
+
const expectedProtocol = host.protocol === 'https:' ? 'wss:' : host.protocol === 'http:' ? 'ws:' : ''
|
|
50
|
+
if ((websocket.protocol !== 'wss:' && websocket.protocol !== 'ws:') || websocket.protocol !== expectedProtocol
|
|
51
|
+
|| websocket.hostname !== host.hostname || websocket.port !== host.port || websocket.username !== '' || websocket.password !== ''
|
|
52
|
+
|| websocket.search !== '' || websocket.hash !== '' || (websocket.protocol === 'ws:' && !loopback(websocket.hostname))
|
|
53
|
+
|| !/^[a-z0-9][a-z0-9._-]{0,127}$/.test(admission.roomName)) {
|
|
54
|
+
throw new Error('COLLABORATION_TRANSPORT_INVALID')
|
|
55
|
+
}
|
|
56
|
+
return websocket
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const createYWebsocketCollaborationTransport = (options: YWebsocketCollaborationTransportOptions = {}): CollaborationTransport => {
|
|
60
|
+
const statusListeners = new Set<(status: CollaborationTransportStatus) => void>()
|
|
61
|
+
const updateListeners = new Set<(update: Uint8Array) => void>()
|
|
62
|
+
const createProvider = options.providerFactory ?? providerFactory
|
|
63
|
+
let provider: CollaborationWebsocketProvider | null = null
|
|
64
|
+
let document: Y.Doc | null = null
|
|
65
|
+
let statusHandler: ((event: ProviderStatusEvent) => void) | null = null
|
|
66
|
+
let updateHandler: ((update: Uint8Array, origin: unknown) => void) | null = null
|
|
67
|
+
let disposed = false
|
|
68
|
+
|
|
69
|
+
const notifyStatus = (status: CollaborationTransportStatus): void => {
|
|
70
|
+
for (const listener of statusListeners) listener(status)
|
|
71
|
+
}
|
|
72
|
+
const release = (): void => {
|
|
73
|
+
if (provider !== null && statusHandler !== null) provider.off('status', statusHandler)
|
|
74
|
+
if (document !== null && updateHandler !== null) document.off('update', updateHandler)
|
|
75
|
+
provider?.disconnect()
|
|
76
|
+
provider?.destroy()
|
|
77
|
+
document?.destroy()
|
|
78
|
+
provider = null
|
|
79
|
+
document = null
|
|
80
|
+
statusHandler = null
|
|
81
|
+
updateHandler = null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
connect(admission, initialSnapshot) {
|
|
86
|
+
if (disposed) throw new Error('COLLABORATION_TRANSPORT_DISPOSED')
|
|
87
|
+
if (initialSnapshot.byteLength < 1 || initialSnapshot.byteLength > 8_388_608) throw new Error('COLLABORATION_TRANSPORT_INVALID')
|
|
88
|
+
const origin = options.hostOrigin ?? globalThis.location?.origin
|
|
89
|
+
if (origin === undefined) throw new Error('COLLABORATION_TRANSPORT_ORIGIN_REQUIRED')
|
|
90
|
+
const websocket = validateAdmission(admission, origin)
|
|
91
|
+
release()
|
|
92
|
+
const syncDocument = new Y.Doc()
|
|
93
|
+
Y.applyUpdate(syncDocument, initialSnapshot)
|
|
94
|
+
const nextProvider = createProvider(websocket.toString(), admission.roomName, syncDocument, { connect: false, disableBc: true })
|
|
95
|
+
const nextStatusHandler = (event: ProviderStatusEvent): void => {
|
|
96
|
+
if (provider !== nextProvider || !['connecting', 'connected', 'disconnected'].includes(event.status)) return
|
|
97
|
+
if (event.status === 'disconnected') nextProvider.disconnect()
|
|
98
|
+
notifyStatus(event.status)
|
|
99
|
+
}
|
|
100
|
+
const nextUpdateHandler = (update: Uint8Array, updateOrigin: unknown): void => {
|
|
101
|
+
if (provider !== nextProvider || updateOrigin !== nextProvider) return
|
|
102
|
+
for (const listener of updateListeners) listener(update.slice())
|
|
103
|
+
}
|
|
104
|
+
provider = nextProvider
|
|
105
|
+
document = syncDocument
|
|
106
|
+
statusHandler = nextStatusHandler
|
|
107
|
+
updateHandler = nextUpdateHandler
|
|
108
|
+
syncDocument.on('update', nextUpdateHandler)
|
|
109
|
+
nextProvider.on('status', nextStatusHandler)
|
|
110
|
+
notifyStatus('connecting')
|
|
111
|
+
nextProvider.connect()
|
|
112
|
+
},
|
|
113
|
+
disconnect() {
|
|
114
|
+
if (disposed) return
|
|
115
|
+
release()
|
|
116
|
+
notifyStatus('disconnected')
|
|
117
|
+
},
|
|
118
|
+
sendUpdate(update) {
|
|
119
|
+
if (disposed) throw new Error('COLLABORATION_TRANSPORT_DISPOSED')
|
|
120
|
+
if (document === null) throw new Error('COLLABORATION_TRANSPORT_DISCONNECTED')
|
|
121
|
+
if (update.byteLength < 1 || update.byteLength > 262_144) throw new Error('COLLABORATION_TRANSPORT_UPDATE_INVALID')
|
|
122
|
+
Y.applyUpdate(document, update)
|
|
123
|
+
},
|
|
124
|
+
onStatus(listener) {
|
|
125
|
+
if (disposed) throw new Error('COLLABORATION_TRANSPORT_DISPOSED')
|
|
126
|
+
statusListeners.add(listener)
|
|
127
|
+
return () => { statusListeners.delete(listener) }
|
|
128
|
+
},
|
|
129
|
+
onUpdate(listener) {
|
|
130
|
+
if (disposed) throw new Error('COLLABORATION_TRANSPORT_DISPOSED')
|
|
131
|
+
updateListeners.add(listener)
|
|
132
|
+
return () => { updateListeners.delete(listener) }
|
|
133
|
+
},
|
|
134
|
+
dispose() {
|
|
135
|
+
if (disposed) return
|
|
136
|
+
release()
|
|
137
|
+
disposed = true
|
|
138
|
+
statusListeners.clear()
|
|
139
|
+
updateListeners.clear()
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peanut-admin/admin",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.5",
|
|
4
4
|
"description": "Reusable Peanut Admin Web services and module contributions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
|
-
"
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/peanut-opensource/peanut-admin.git",
|
|
10
|
+
"directory": "packages/web"
|
|
11
|
+
},
|
|
12
|
+
"sideEffects": [
|
|
13
|
+
"*.vue"
|
|
14
|
+
],
|
|
8
15
|
"files": [
|
|
9
16
|
"admin-core/src",
|
|
10
17
|
"admin-shell/src",
|
|
@@ -16,6 +23,7 @@
|
|
|
16
23
|
"import-export/src",
|
|
17
24
|
"ops-console/src",
|
|
18
25
|
"integration-security/src",
|
|
26
|
+
"collaboration/src",
|
|
19
27
|
"testing/src",
|
|
20
28
|
"client-core/src",
|
|
21
29
|
"client-nuxt/src",
|
|
@@ -62,6 +70,10 @@
|
|
|
62
70
|
"types": "./integration-security/src/index.ts",
|
|
63
71
|
"import": "./integration-security/src/index.ts"
|
|
64
72
|
},
|
|
73
|
+
"./collaboration": {
|
|
74
|
+
"types": "./collaboration/src/index.ts",
|
|
75
|
+
"import": "./collaboration/src/index.ts"
|
|
76
|
+
},
|
|
65
77
|
"./testing": {
|
|
66
78
|
"development": {
|
|
67
79
|
"types": "./testing/src/index.ts",
|
|
@@ -83,24 +95,57 @@
|
|
|
83
95
|
},
|
|
84
96
|
"typesVersions": {
|
|
85
97
|
"*": {
|
|
86
|
-
"core": [
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
"
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
"
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
"
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
"
|
|
99
|
-
|
|
98
|
+
"core": [
|
|
99
|
+
"admin-core/src/index.ts"
|
|
100
|
+
],
|
|
101
|
+
"shell": [
|
|
102
|
+
"admin-shell/src/index.ts"
|
|
103
|
+
],
|
|
104
|
+
"settings": [
|
|
105
|
+
"settings/src/index.ts"
|
|
106
|
+
],
|
|
107
|
+
"reference-codes": [
|
|
108
|
+
"reference-codes/src/index.ts"
|
|
109
|
+
],
|
|
110
|
+
"file-media": [
|
|
111
|
+
"file-media/src/index.ts"
|
|
112
|
+
],
|
|
113
|
+
"task-job": [
|
|
114
|
+
"task-job/src/index.ts"
|
|
115
|
+
],
|
|
116
|
+
"notification-sms": [
|
|
117
|
+
"notification-sms/src/index.ts"
|
|
118
|
+
],
|
|
119
|
+
"import-export": [
|
|
120
|
+
"import-export/src/index.ts"
|
|
121
|
+
],
|
|
122
|
+
"ops-console": [
|
|
123
|
+
"ops-console/src/index.ts"
|
|
124
|
+
],
|
|
125
|
+
"integration-security": [
|
|
126
|
+
"integration-security/src/index.ts"
|
|
127
|
+
],
|
|
128
|
+
"collaboration": [
|
|
129
|
+
"collaboration/src/index.ts"
|
|
130
|
+
],
|
|
131
|
+
"testing": [
|
|
132
|
+
"testing/src/index.ts"
|
|
133
|
+
],
|
|
134
|
+
"client": [
|
|
135
|
+
"client-core/src/index.ts"
|
|
136
|
+
],
|
|
137
|
+
"client/nuxt": [
|
|
138
|
+
"client-nuxt/src/index.ts"
|
|
139
|
+
],
|
|
140
|
+
"client/uniapp": [
|
|
141
|
+
"client-uniapp/src/index.ts"
|
|
142
|
+
]
|
|
100
143
|
}
|
|
101
144
|
},
|
|
102
145
|
"dependencies": {
|
|
103
|
-
"openapi-fetch": "0.17.0"
|
|
146
|
+
"openapi-fetch": "0.17.0",
|
|
147
|
+
"y-websocket": "3.1.0",
|
|
148
|
+
"yjs": "13.6.32"
|
|
104
149
|
},
|
|
105
150
|
"devDependencies": {
|
|
106
151
|
"@vue/test-utils": "2.4.11",
|
|
@@ -128,7 +173,7 @@
|
|
|
128
173
|
}
|
|
129
174
|
},
|
|
130
175
|
"scripts": {
|
|
131
|
-
"test": "vitest run admin-core/tests admin-shell/tests file-media/tests import-export/tests integration-security/tests notification-sms/tests ops-console/tests reference-codes/tests settings/tests testing/tests client-core/tests client-nuxt/tests client-uniapp/tests",
|
|
132
|
-
"typecheck": "tsc --noEmit -p admin-core/tsconfig.json && tsc --noEmit -p admin-shell/tsconfig.json && vue-tsc --noEmit -p file-media/tsconfig.json && vue-tsc --noEmit -p import-export/tsconfig.json && vue-tsc --noEmit -p integration-security/tsconfig.json && vue-tsc --noEmit -p notification-sms/tsconfig.json && vue-tsc --noEmit -p ops-console/tsconfig.json && vue-tsc --noEmit -p reference-codes/tsconfig.json && vue-tsc --noEmit -p settings/tsconfig.json && vue-tsc --noEmit -p task-job/tsconfig.json && tsc --noEmit -p testing/tsconfig.json && tsc --noEmit -p client-core/tsconfig.json && tsc --noEmit -p client-nuxt/tsconfig.json && tsc --noEmit -p client-uniapp/tsconfig.json"
|
|
176
|
+
"test": "vitest run admin-core/tests admin-shell/tests collaboration/tests file-media/tests import-export/tests integration-security/tests notification-sms/tests ops-console/tests reference-codes/tests settings/tests testing/tests client-core/tests client-nuxt/tests client-uniapp/tests",
|
|
177
|
+
"typecheck": "tsc --noEmit -p admin-core/tsconfig.json && tsc --noEmit -p admin-shell/tsconfig.json && tsc --noEmit -p collaboration/tsconfig.json && vue-tsc --noEmit -p file-media/tsconfig.json && vue-tsc --noEmit -p import-export/tsconfig.json && vue-tsc --noEmit -p integration-security/tsconfig.json && vue-tsc --noEmit -p notification-sms/tsconfig.json && vue-tsc --noEmit -p ops-console/tsconfig.json && vue-tsc --noEmit -p reference-codes/tsconfig.json && vue-tsc --noEmit -p settings/tsconfig.json && vue-tsc --noEmit -p task-job/tsconfig.json && tsc --noEmit -p testing/tsconfig.json && tsc --noEmit -p client-core/tsconfig.json && tsc --noEmit -p client-nuxt/tsconfig.json && tsc --noEmit -p client-uniapp/tsconfig.json"
|
|
133
178
|
}
|
|
134
|
-
}
|
|
179
|
+
}
|