@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.
@@ -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?: HeadersInit
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: 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 validBaseUrl = (baseUrl: string): URL => {
134
- let url: URL
135
- try {
136
- url = new URL(baseUrl)
137
- } catch {
138
- throw new ClientRequestError('path', 'CLIENT_BASE_URL_INVALID', 'The client base URL is invalid.')
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
- !['http:', 'https:'].includes(url.protocol)
143
- || url.username !== ''
144
- || url.password !== ''
145
- || url.search !== ''
146
- || url.hash !== ''
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 new ClientRequestError('path', 'CLIENT_BASE_URL_INVALID', 'The client base URL is invalid.')
179
+ throw invalidBaseUrl()
149
180
  }
150
181
 
151
- return url
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
- const root = new URL(base.origin)
159
- root.pathname = basePath
160
- return new URL(path, root).toString()
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 requestHeaders = (headers: HeadersInit | undefined): Headers => {
207
- const result = new Headers(headers)
208
- // Headers is case-insensitive and delete removes all values for this name.
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: 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
@@ -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?: HeadersInit
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: Headers): Record<string, string> => {
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,4 @@
1
+ export * from './contracts'
2
+ export * from './engine'
3
+ export * from './runtime'
4
+ export * from './transport'
@@ -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",
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
- "sideEffects": ["*.vue"],
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": ["admin-core/src/index.ts"],
87
- "shell": ["admin-shell/src/index.ts"],
88
- "settings": ["settings/src/index.ts"],
89
- "reference-codes": ["reference-codes/src/index.ts"],
90
- "file-media": ["file-media/src/index.ts"],
91
- "task-job": ["task-job/src/index.ts"],
92
- "notification-sms": ["notification-sms/src/index.ts"],
93
- "import-export": ["import-export/src/index.ts"],
94
- "ops-console": ["ops-console/src/index.ts"],
95
- "integration-security": ["integration-security/src/index.ts"],
96
- "testing": ["testing/src/index.ts"],
97
- "client": ["client-core/src/index.ts"],
98
- "client/nuxt": ["client-nuxt/src/index.ts"],
99
- "client/uniapp": ["client-uniapp/src/index.ts"]
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
- "pinia": "4.0.2",
106
- "vue-router": "5.2.0"
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
- "vue": "^3.5.39"
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
+ }