@peanut-admin/admin 0.1.0-alpha.3 → 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.
- package/client-core/src/index.ts +113 -23
- package/client-nuxt/src/index.ts +11 -3
- package/client-uniapp/src/index.ts +2 -2
- package/collaboration/src/contracts.ts +207 -0
- package/collaboration/src/engine.ts +46 -0
- package/collaboration/src/index.ts +4 -0
- package/collaboration/src/runtime.ts +157 -0
- package/collaboration/src/transport.ts +142 -0
- package/package.json +78 -23
package/client-core/src/index.ts
CHANGED
|
@@ -7,11 +7,27 @@ export type ClientRequestMethod =
|
|
|
7
7
|
| 'POST'
|
|
8
8
|
| 'PUT'
|
|
9
9
|
|
|
10
|
+
export interface ClientHeaderSource {
|
|
11
|
+
forEach: (callback: (value: string, key: string) => void) => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type ClientRequestHeaders =
|
|
15
|
+
| Readonly<Record<string, string>>
|
|
16
|
+
| ReadonlyArray<readonly [string, string]>
|
|
17
|
+
| ClientHeaderSource
|
|
18
|
+
|
|
19
|
+
export interface ClientHeaders extends ClientHeaderSource {
|
|
20
|
+
delete: (name: string) => void
|
|
21
|
+
get: (name: string) => string | null
|
|
22
|
+
has: (name: string) => boolean
|
|
23
|
+
set: (name: string, value: string) => void
|
|
24
|
+
}
|
|
25
|
+
|
|
10
26
|
export interface ClientRequest<TData = unknown> {
|
|
11
27
|
readonly path: string
|
|
12
28
|
readonly method?: ClientRequestMethod
|
|
13
29
|
readonly data?: TData
|
|
14
|
-
readonly headers?:
|
|
30
|
+
readonly headers?: ClientRequestHeaders
|
|
15
31
|
readonly auth?: boolean
|
|
16
32
|
}
|
|
17
33
|
|
|
@@ -19,7 +35,7 @@ export interface ClientTransportRequest<TData = unknown> {
|
|
|
19
35
|
readonly path: string
|
|
20
36
|
readonly method: ClientRequestMethod
|
|
21
37
|
readonly data?: TData
|
|
22
|
-
readonly headers:
|
|
38
|
+
readonly headers: ClientHeaders
|
|
23
39
|
}
|
|
24
40
|
|
|
25
41
|
export type ClientTransport = (request: ClientTransportRequest) => Promise<unknown>
|
|
@@ -96,6 +112,9 @@ const pathControlCharacters = /[\u0000-\u001f\u007f]/
|
|
|
96
112
|
const controlCharacters = /[\u0000-\u001f\u007f]/g
|
|
97
113
|
const encodedUnsafePathSegment = /%(?:2e|2f|5c)/i
|
|
98
114
|
const absolutePath = /^[A-Za-z][A-Za-z0-9+.-]*:/
|
|
115
|
+
const httpBaseUrl = /^(https?):\/\/([^/?#]+)(\/[^?#]*)?$/i
|
|
116
|
+
const validHeaderName = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/
|
|
117
|
+
const invalidHeaderValue = /[\u0000\r\n]/
|
|
99
118
|
const safeCode = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/
|
|
100
119
|
const defaultUnauthorizedCode = 'CLIENT_UNAUTHORIZED'
|
|
101
120
|
const defaultBusinessCode = 'CLIENT_BUSINESS_ERROR'
|
|
@@ -130,34 +149,49 @@ const assertClientPath = (path: string): void => {
|
|
|
130
149
|
}
|
|
131
150
|
}
|
|
132
151
|
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
152
|
+
const invalidBaseUrl = (): ClientRequestError => (
|
|
153
|
+
new ClientRequestError('path', 'CLIENT_BASE_URL_INVALID', 'The client base URL is invalid.')
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
const validBaseUrl = (baseUrl: string): { origin: string; pathname: string } => {
|
|
157
|
+
if (
|
|
158
|
+
typeof baseUrl !== 'string'
|
|
159
|
+
|| baseUrl.trim() !== baseUrl
|
|
160
|
+
|| pathControlCharacters.test(baseUrl)
|
|
161
|
+
|| baseUrl.includes('\\')
|
|
162
|
+
) {
|
|
163
|
+
throw invalidBaseUrl()
|
|
139
164
|
}
|
|
140
165
|
|
|
166
|
+
const match = httpBaseUrl.exec(baseUrl)
|
|
167
|
+
const protocol = match?.[1]
|
|
168
|
+
const authority = match?.[2]
|
|
169
|
+
const pathname = match?.[3] ?? '/'
|
|
141
170
|
if (
|
|
142
|
-
|
|
143
|
-
||
|
|
144
|
-
||
|
|
145
|
-
||
|
|
146
|
-
||
|
|
171
|
+
protocol === undefined
|
|
172
|
+
|| authority === undefined
|
|
173
|
+
|| authority === ''
|
|
174
|
+
|| authority.includes('@')
|
|
175
|
+
|| /\s/.test(authority)
|
|
176
|
+
|| encodedUnsafePathSegment.test(pathname)
|
|
177
|
+
|| pathname.split('/').some(segment => segment === '.' || segment === '..')
|
|
147
178
|
) {
|
|
148
|
-
throw
|
|
179
|
+
throw invalidBaseUrl()
|
|
149
180
|
}
|
|
150
181
|
|
|
151
|
-
return
|
|
182
|
+
return {
|
|
183
|
+
origin: `${protocol.toLowerCase()}://${authority}`,
|
|
184
|
+
pathname,
|
|
185
|
+
}
|
|
152
186
|
}
|
|
153
187
|
|
|
154
188
|
export const resolveClientUrl = (baseUrl: string, path: string): string => {
|
|
155
189
|
assertClientPath(path)
|
|
156
190
|
const base = validBaseUrl(baseUrl)
|
|
157
191
|
const basePath = base.pathname.endsWith('/') ? base.pathname : `${base.pathname}/`
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
192
|
+
return path.startsWith('/')
|
|
193
|
+
? `${base.origin}${path}`
|
|
194
|
+
: `${base.origin}${basePath}${path}`
|
|
161
195
|
}
|
|
162
196
|
|
|
163
197
|
const safeMessage = (value: unknown, fallback: string): string => {
|
|
@@ -203,9 +237,66 @@ const normalizedDecodedResult = (value: ClientDecodeResult): ClientDecodeResult
|
|
|
203
237
|
}
|
|
204
238
|
}
|
|
205
239
|
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
240
|
+
const isHeaderSource = (value: unknown): value is ClientHeaderSource => (
|
|
241
|
+
typeof value === 'object'
|
|
242
|
+
&& value !== null
|
|
243
|
+
&& typeof (value as { forEach?: unknown }).forEach === 'function'
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
class PortableClientHeaders implements ClientHeaders {
|
|
247
|
+
private readonly values = new Map<string, string>()
|
|
248
|
+
|
|
249
|
+
constructor(headers?: ClientRequestHeaders) {
|
|
250
|
+
if (headers === undefined) return
|
|
251
|
+
|
|
252
|
+
if (Array.isArray(headers)) {
|
|
253
|
+
for (const entry of headers) {
|
|
254
|
+
if (!Array.isArray(entry) || entry.length !== 2) throw new TypeError('invalid header entry')
|
|
255
|
+
this.set(entry[0], entry[1])
|
|
256
|
+
}
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (isHeaderSource(headers)) {
|
|
261
|
+
headers.forEach((value, key) => this.set(key, value))
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (typeof headers === 'object' && headers !== null) {
|
|
266
|
+
for (const [key, value] of Object.entries(headers)) this.set(key, value)
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
throw new TypeError('invalid headers')
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
delete(name: string): void {
|
|
274
|
+
this.values.delete(name.toLowerCase())
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
get(name: string): string | null {
|
|
278
|
+
return this.values.get(name.toLowerCase()) ?? null
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
has(name: string): boolean {
|
|
282
|
+
return this.values.has(name.toLowerCase())
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
set(name: string, value: string): void {
|
|
286
|
+
if (!validHeaderName.test(name) || typeof value !== 'string' || invalidHeaderValue.test(value)) {
|
|
287
|
+
throw new TypeError('invalid header')
|
|
288
|
+
}
|
|
289
|
+
this.values.set(name.toLowerCase(), value.trim())
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
forEach(callback: (value: string, key: string) => void): void {
|
|
293
|
+
this.values.forEach((value, key) => callback(value, key))
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const requestHeaders = (headers: ClientRequestHeaders | undefined): ClientHeaders => {
|
|
298
|
+
const result = new PortableClientHeaders(headers)
|
|
299
|
+
// Header names are normalized, so delete removes every caller spelling.
|
|
209
300
|
result.delete('Authorization')
|
|
210
301
|
return result
|
|
211
302
|
}
|
|
@@ -251,7 +342,7 @@ export const createClient = (options: ClientOptions): Client => {
|
|
|
251
342
|
}
|
|
252
343
|
|
|
253
344
|
const method = methodOf(input.method)
|
|
254
|
-
let headers:
|
|
345
|
+
let headers: ClientHeaders
|
|
255
346
|
try {
|
|
256
347
|
headers = requestHeaders(input.headers)
|
|
257
348
|
} catch {
|
|
@@ -322,4 +413,3 @@ export const createClient = (options: ClientOptions): Client => {
|
|
|
322
413
|
export type ClientResult<TData = unknown> = ClientDecodeResult<TData>
|
|
323
414
|
export type ClientRequestResult<TData = unknown> = Promise<TData>
|
|
324
415
|
export type ClientHook = (error: ClientRequestError) => void | Promise<void>
|
|
325
|
-
export type ClientRequestHeaders = HeadersInit
|
package/client-nuxt/src/index.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { resolveClientUrl } from '@peanut-admin/admin/client'
|
|
2
|
-
import type { ClientTransport, ClientTransportRequest } from '@peanut-admin/admin/client'
|
|
2
|
+
import type { ClientHeaders, ClientTransport, ClientTransportRequest } from '@peanut-admin/admin/client'
|
|
3
3
|
|
|
4
4
|
export interface NuxtClientFetchOptions {
|
|
5
5
|
readonly method?: string
|
|
6
6
|
readonly query?: unknown
|
|
7
7
|
readonly body?: unknown
|
|
8
|
-
readonly headers?:
|
|
8
|
+
readonly headers?: Record<string, string>
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
export type NuxtClientFetch = (
|
|
@@ -20,6 +20,14 @@ export interface NuxtClientTransportOptions {
|
|
|
20
20
|
|
|
21
21
|
const isQueryMethod = (method: string): boolean => method === 'GET' || method === 'DELETE'
|
|
22
22
|
|
|
23
|
+
const headersRecord = (headers: ClientHeaders): Record<string, string> => {
|
|
24
|
+
const result: Record<string, string> = {}
|
|
25
|
+
headers.forEach((value, key) => {
|
|
26
|
+
result[key] = value
|
|
27
|
+
})
|
|
28
|
+
return result
|
|
29
|
+
}
|
|
30
|
+
|
|
23
31
|
export const createNuxtClientTransport = (
|
|
24
32
|
options: NuxtClientTransportOptions,
|
|
25
33
|
): ClientTransport => {
|
|
@@ -28,7 +36,7 @@ export const createNuxtClientTransport = (
|
|
|
28
36
|
const url = resolveClientUrl(options.baseUrl, request.path)
|
|
29
37
|
const fetchOptions: NuxtClientFetchOptions = {
|
|
30
38
|
method,
|
|
31
|
-
headers: request.headers,
|
|
39
|
+
headers: headersRecord(request.headers),
|
|
32
40
|
...(request.data !== undefined
|
|
33
41
|
? isQueryMethod(method)
|
|
34
42
|
? { query: request.data }
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { resolveClientUrl } from '@peanut-admin/admin/client'
|
|
2
|
-
import type { ClientTransport, ClientTransportRequest } from '@peanut-admin/admin/client'
|
|
2
|
+
import type { ClientHeaders, ClientTransport, ClientTransportRequest } from '@peanut-admin/admin/client'
|
|
3
3
|
|
|
4
4
|
export interface UniAppClientResponse {
|
|
5
5
|
readonly data: unknown
|
|
@@ -23,7 +23,7 @@ export interface UniAppClientTransportOptions {
|
|
|
23
23
|
readonly request: UniAppClientRequest
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
const headersRecord = (headers:
|
|
26
|
+
const headersRecord = (headers: ClientHeaders): Record<string, string> => {
|
|
27
27
|
const result: Record<string, string> = {}
|
|
28
28
|
headers.forEach((value, key) => {
|
|
29
29
|
result[key] = value
|
|
@@ -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,42 +95,85 @@
|
|
|
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
|
-
"element-plus": "2.14.3",
|
|
104
146
|
"openapi-fetch": "0.17.0",
|
|
105
|
-
"
|
|
106
|
-
"
|
|
147
|
+
"y-websocket": "3.1.0",
|
|
148
|
+
"yjs": "13.6.32"
|
|
107
149
|
},
|
|
108
150
|
"devDependencies": {
|
|
109
151
|
"@vue/test-utils": "2.4.11",
|
|
152
|
+
"element-plus": "2.14.3",
|
|
110
153
|
"happy-dom": "20.10.6",
|
|
154
|
+
"pinia": "4.0.2",
|
|
111
155
|
"typescript": "5.9.3",
|
|
112
156
|
"vite": "8.1.4",
|
|
113
157
|
"vitest": "4.1.10",
|
|
114
158
|
"vue": "3.5.39",
|
|
159
|
+
"vue-router": "5.2.0",
|
|
115
160
|
"vue-tsc": "3.3.7"
|
|
116
161
|
},
|
|
117
162
|
"peerDependencies": {
|
|
118
|
-
"
|
|
163
|
+
"element-plus": "^2.14.3",
|
|
164
|
+
"pinia": ">=2.0.23 <5",
|
|
165
|
+
"vue": "^3.4.21"
|
|
166
|
+
},
|
|
167
|
+
"peerDependenciesMeta": {
|
|
168
|
+
"element-plus": {
|
|
169
|
+
"optional": true
|
|
170
|
+
},
|
|
171
|
+
"pinia": {
|
|
172
|
+
"optional": true
|
|
173
|
+
}
|
|
119
174
|
},
|
|
120
175
|
"scripts": {
|
|
121
|
-
"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",
|
|
122
|
-
"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"
|
|
123
178
|
}
|
|
124
|
-
}
|
|
179
|
+
}
|