@lincyaw/workspace 1.0.0-rc.0 → 1.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/package.json +10 -5
- package/src/domain/{revisions.ts → commit-message.ts} +7 -7
- package/src/domain/locks.ts +5 -0
- package/src/domain/paths.ts +2 -2
- package/src/index.ts +7 -2
- package/src/node/authoring-clients.ts +206 -0
- package/src/node/build.ts +4 -4
- package/src/node/compile-session.ts +187 -0
- package/src/node/config.ts +36 -21
- package/src/node/document-assets.ts +30 -0
- package/src/node/document-audio.ts +473 -0
- package/src/node/document-authoring.ts +436 -0
- package/src/node/document-editor.ts +157 -0
- package/src/node/document-history.ts +190 -0
- package/src/node/document-images.ts +241 -0
- package/src/node/document-operations.ts +191 -0
- package/src/node/document-transcription-adoption.ts +96 -0
- package/src/node/document-transcription.ts +471 -0
- package/src/node/document-voice.ts +115 -0
- package/src/node/errors.ts +23 -0
- package/src/node/git.ts +15 -15
- package/src/node/jobs.ts +361 -0
- package/src/node/locks.ts +10 -5
- package/src/node/page-delete.ts +106 -0
- package/src/node/project-files.ts +163 -0
- package/src/node/projects.ts +192 -0
- package/src/node/review/agent-session-reviews.ts +374 -0
- package/src/node/review/git-guard.ts +9 -8
- package/src/node/review/git-review.ts +102 -90
- package/src/node/review/record.ts +77 -0
- package/src/node/review/routes.ts +36 -35
- package/src/node/review/task-reviews.ts +50 -0
- package/src/node/revisions.ts +321 -68
- package/src/node/routes.ts +236 -146
- package/src/node/serve.ts +319 -118
- package/src/node/site.ts +34 -23
- package/src/node/source-editor.ts +332 -0
- package/src/node/speech-settings.ts +21 -0
- package/src/node/templates.ts +27 -0
- package/src/node/workbench-api.ts +176 -0
- package/src/node/writer.ts +389 -0
- package/templates/document/default/lecture.json +75 -0
- package/src/domain/api.ts +0 -102
- package/src/domain/index.ts +0 -6
- package/src/node/documents.ts +0 -185
- package/src/node/files.ts +0 -252
- package/src/node/review/review-record.ts +0 -73
- package/src/node/review/reviews.ts +0 -422
- package/src/node/service.ts +0 -237
- package/src/node/session.ts +0 -123
- package/src/node/source.ts +0 -148
- package/templates/document/default/lecture.md +0 -18
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Presmium's workspace: the site of several documents (`presmium build` at a workspace root) and the local workbench `presmium serve`, with the document templates.
|
|
4
4
|
|
|
5
|
-
Part of [Presmium](https://github.com/Lincyaw/presmium), which
|
|
5
|
+
Part of [Presmium](https://github.com/Lincyaw/presmium), which compiles canonical lecture.json teaching documents into web pages, slides, Jupyter notebooks and narrated playback.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -10,11 +10,11 @@ It comes with `@lincyaw/cli`.
|
|
|
10
10
|
|
|
11
11
|
## Workspaces
|
|
12
12
|
|
|
13
|
-
A workspace has `presmium.workspace.yaml` at its root and each document in a subdirectory with its own `lecture.
|
|
13
|
+
A workspace has `presmium.workspace.yaml` at its root and each document in a subdirectory with its own `lecture.json`:
|
|
14
14
|
|
|
15
15
|
```yaml
|
|
16
16
|
title: Introduction to machine learning
|
|
17
|
-
documents: [week1/lecture.
|
|
17
|
+
documents: [week1/lecture.json, week2/lecture.json]
|
|
18
18
|
site:
|
|
19
19
|
nav:
|
|
20
20
|
- { text: Week 1, link: /week1/ }
|
|
@@ -24,11 +24,11 @@ site:
|
|
|
24
24
|
|
|
25
25
|
## The workbench (preview)
|
|
26
26
|
|
|
27
|
-
`presmium serve` runs the workbench on your machine: the live site, editing the source in the browser, managing documents and the history of changes.
|
|
27
|
+
`presmium serve` runs the workbench on your machine: the live site, editing the source in the browser, managing documents and the history of changes. The routes the workbench (`@lincyaw/workbench`) uses are experimental; the command, the workspace configuration file and the Node services stay.
|
|
28
28
|
|
|
29
29
|
## Documentation
|
|
30
30
|
|
|
31
|
-
- [Author guide](https://github.com/Lincyaw/presmium/blob/main/site/guide/lecture.
|
|
31
|
+
- [Author guide](https://github.com/Lincyaw/presmium/blob/main/site/guide/locales/en/lecture.json): workspaces
|
|
32
32
|
- [Security and the trust model](https://github.com/Lincyaw/presmium/blob/main/SECURITY.md)
|
|
33
33
|
|
|
34
34
|
## License
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lincyaw/workspace",
|
|
3
|
-
"version": "1.0.0-rc.
|
|
3
|
+
"version": "1.0.0-rc.1",
|
|
4
4
|
"description": "Presmium's workspace service (presmium serve): documents, their history, reviews and the workspace site",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -23,7 +23,6 @@
|
|
|
23
23
|
},
|
|
24
24
|
"exports": {
|
|
25
25
|
".": "./src/index.ts",
|
|
26
|
-
"./domain": "./src/domain/index.ts",
|
|
27
26
|
"./package.json": "./package.json"
|
|
28
27
|
},
|
|
29
28
|
"files": [
|
|
@@ -31,9 +30,15 @@
|
|
|
31
30
|
"templates"
|
|
32
31
|
],
|
|
33
32
|
"dependencies": {
|
|
33
|
+
"image-size": "2.0.4",
|
|
34
|
+
"mime-types": "3.0.2",
|
|
34
35
|
"yaml": "^2.9.1",
|
|
35
|
-
"@lincyaw/
|
|
36
|
-
"@lincyaw/
|
|
37
|
-
"@lincyaw/
|
|
36
|
+
"@lincyaw/server": "1.0.0-rc.1",
|
|
37
|
+
"@lincyaw/kernel": "1.0.0-rc.1",
|
|
38
|
+
"@lincyaw/compiler": "1.0.0-rc.1"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/mime-types": "^3.0.1",
|
|
42
|
+
"@lincyaw/mcp": "1.0.0-rc.1"
|
|
38
43
|
}
|
|
39
44
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Member, RevisionOrigin } from '@lincyaw/kernel/contracts'
|
|
1
|
+
import type { Member, RevisionOperation, RevisionOrigin } from '@lincyaw/kernel/contracts'
|
|
2
2
|
|
|
3
3
|
export const ORIGIN_TRAILER = 'Presmium-Origin'
|
|
4
4
|
export const TASK_TRAILER = 'Presmium-Task'
|
|
@@ -7,12 +7,6 @@ const CO_AUTHOR = 'Co-authored-by'
|
|
|
7
7
|
|
|
8
8
|
const ORIGINS = new Set<RevisionOrigin['kind']>(['agent', 'document', 'editor'])
|
|
9
9
|
|
|
10
|
-
export type RevisionOperation =
|
|
11
|
-
| { op: 'create', dir: string }
|
|
12
|
-
| { op: 'delete', dir: string }
|
|
13
|
-
| { op: 'rename', dir: string, to: string }
|
|
14
|
-
| { op: 'copy', dir: string, to: string }
|
|
15
|
-
|
|
16
10
|
export type CommitOrigin = RevisionOrigin & { operation?: RevisionOperation }
|
|
17
11
|
|
|
18
12
|
export function memberAddress(member: Member): string {
|
|
@@ -44,6 +38,12 @@ export function commitMessage(summary: string, body: string, origin: CommitOrigi
|
|
|
44
38
|
return `${head}\n\n${body.trim() ? `${body.trim()}\n\n` : ''}${trailers.join('\n')}\n`
|
|
45
39
|
}
|
|
46
40
|
|
|
41
|
+
/** The summary of a save that came without the author's: the file's name, in the language of the document holding it. */
|
|
42
|
+
export function saveSummary(path: string, lang: string | undefined): string {
|
|
43
|
+
const name = path.split('/').at(-1)!
|
|
44
|
+
return !lang || lang.startsWith('zh') ? `编辑 ${name}` : `Edit ${name}`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
47
|
export function trailers(message: string): Record<string, string[]> {
|
|
48
48
|
const paragraphs = message.trim().split(/\n\s*\n/)
|
|
49
49
|
const last = paragraphs.at(-1) ?? ''
|
package/src/domain/paths.ts
CHANGED
|
@@ -32,7 +32,7 @@ export function leavesRoot(path: string): boolean {
|
|
|
32
32
|
return path === '..' || path.startsWith('../')
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
export function
|
|
35
|
+
export function isWithin(path: string, dir: string): boolean {
|
|
36
36
|
return dir === '' || path === dir || path.startsWith(`${dir}/`)
|
|
37
37
|
}
|
|
38
38
|
|
|
@@ -76,7 +76,7 @@ export function movedUrl(url: string, file: string, was: string, from: string, t
|
|
|
76
76
|
const dirOf = (p: string) => p.split('/').slice(0, -1).join('/')
|
|
77
77
|
const target = joinPath(dirOf(was), decoded)
|
|
78
78
|
if (target === undefined) return undefined
|
|
79
|
-
const moved =
|
|
79
|
+
const moved = isWithin(target, from) ? `${to}${target.slice(from.length)}` : target
|
|
80
80
|
let next = relativePath(dirOf(file), moved)
|
|
81
81
|
if (decoded.endsWith('/') && !next.endsWith('/')) next += '/'
|
|
82
82
|
if (decoded.startsWith('./') && !next.startsWith('.')) next = `./${next}`
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
export { buildWorkspace } from './node/build.ts'
|
|
2
2
|
export type { WorkspaceBuild } from './node/build.ts'
|
|
3
|
-
export { isWorkspaceRoot
|
|
4
|
-
export type { FoundDocument, WorkspaceConfig } from './node/config.ts'
|
|
3
|
+
export { isWorkspaceRoot } from './node/config.ts'
|
|
5
4
|
export { serveWorkspace } from './node/serve.ts'
|
|
6
5
|
export type { ServeOptions, WorkspaceServer } from './node/serve.ts'
|
|
6
|
+
export { createDocumentTranscriptions } from './node/document-transcription.ts'
|
|
7
|
+
export { createTranscriptionAdoption } from './node/document-transcription-adoption.ts'
|
|
8
|
+
export { createDocumentAudioAuthoring } from './node/document-audio.ts'
|
|
9
|
+
export type { DocumentAudioAuthoringOptions, DocumentAudioServices } from './node/document-audio.ts'
|
|
10
|
+
export { createDocumentVoiceAuthoring } from './node/document-voice.ts'
|
|
11
|
+
export type { DocumentVoiceAuthoringOptions, DocumentVoiceServices } from './node/document-voice.ts'
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { randomBytes, randomUUID, createHash } from 'node:crypto'
|
|
2
|
+
import { AUTHORING_CLIENT_ACK_MAX_BYTES, AUTHORING_CLIENT_MAX_CLIENTS, AUTHORING_CLIENT_MAX_REQUESTS, AUTHORING_CLIENT_MAX_REQUEST_CHARS, AUTHORING_CLIENT_MAX_RESPONSE_CHARS, AUTHORING_CLIENT_REQUEST_TIMEOUT_MS, authoringClientResultFits, DOCUMENT_ENTRY, fnv1a64, hasRole, isRecord, jsonResponse, problemResponse, pushStream, sseResponse } from '@lincyaw/kernel'
|
|
3
|
+
import type { AuthoringClientAcknowledgement, AuthoringClientConnection, AuthoringClientDescriptor, AuthoringClientEvent, AuthoringClientLease, AuthoringClientOpen, AuthoringClientReport, AuthoringClientRequest, AuthoringClientRequestResult, AuthoringClients, CanonicalSource, Member, PushStream, Route } from '@lincyaw/kernel'
|
|
4
|
+
import { validateAuthoringClientAcknowledgement, validateAuthoringClientLease, validateAuthoringClientOpen, validateAuthoringClientQuery, validateAuthoringClientReport, validateAuthoringClientRequest, validateAuthoringClientRequestQuery } from '@lincyaw/kernel/node'
|
|
5
|
+
import { readAuthoringDocument } from './document-editor.ts'
|
|
6
|
+
import { catchRequestErrors, RequestError } from './errors.ts'
|
|
7
|
+
import type { SourceEditor } from './source-editor.ts'
|
|
8
|
+
|
|
9
|
+
type Client = { member: string, credential: string, value: AuthoringClientDescriptor, sequence: number, lastSignature?: string, send?(value: AuthoringClientEvent): void, close?(): void }
|
|
10
|
+
type Pending = { member: string, signature: string, request: AuthoringClientRequest, value: AuthoringClientRequestResult, timer?: ReturnType<typeof setTimeout> }
|
|
11
|
+
const sameSource = (a: CanonicalSource, b: CanonicalSource) => a.documentId === b.documentId && a.version === b.version && a.fingerprint === b.fingerprint
|
|
12
|
+
const size = (value: unknown) => JSON.stringify(value, null, 2).length
|
|
13
|
+
const budget = (value: unknown, maximum = AUTHORING_CLIENT_MAX_REQUEST_CHARS) => { const text = JSON.stringify(value); if (text === undefined) throw new RequestError(400, 'client-request-invalid'); if (text.length > maximum) throw new RequestError(413, 'client-request-too-large') }
|
|
14
|
+
const digest = (value: unknown) => createHash('sha256').update(JSON.stringify(value, (_, entry) => isRecord(entry) ? Object.fromEntries(Object.entries(entry).sort(([a], [b]) => a.localeCompare(b))) : entry)).digest('hex')
|
|
15
|
+
const terminal = (pending: Pending) => !['sent', 'executing'].includes(pending.value.state)
|
|
16
|
+
|
|
17
|
+
/** Ephemeral live-client control. It owns no document writer, compilation or durable task ledger. */
|
|
18
|
+
export function createAuthoringClients(editor: SourceEditor) {
|
|
19
|
+
const clients = new Map<string, Client>(), requests = new Map<string, Pending>()
|
|
20
|
+
let closed = false
|
|
21
|
+
const clientKey = (member: string, clientId: string) => JSON.stringify([member, clientId])
|
|
22
|
+
const requestKey = (member: string, document: string, query: { clientId: string, epoch: string, requestId: string }) => JSON.stringify([member, document, query.clientId, query.epoch, query.requestId])
|
|
23
|
+
const authorize = (document: string, member: Member) => {
|
|
24
|
+
if (closed) throw new RequestError(503, 'client-owner-closed')
|
|
25
|
+
if (!hasRole(member, 'editor')) throw new RequestError(403, 'client-permission-denied')
|
|
26
|
+
editor.readableFile({ document, home: false }, document ? `${document}/${DOCUMENT_ENTRY}` : DOCUMENT_ENTRY, member)
|
|
27
|
+
}
|
|
28
|
+
const current = (document: string, member: Member) => {
|
|
29
|
+
authorize(document, member)
|
|
30
|
+
const opened = readAuthoringDocument(editor, { document, member })
|
|
31
|
+
return { documentId: opened.content.id, version: opened.source.version, fingerprint: fnv1a64(opened.source.content) }
|
|
32
|
+
}
|
|
33
|
+
const connection = (document: string, lease: AuthoringClientLease, member: Member) => {
|
|
34
|
+
authorize(document, member)
|
|
35
|
+
const client = clients.get(clientKey(member.id, lease.clientId))
|
|
36
|
+
if (!client || client.value.document !== document || client.value.epoch !== lease.epoch || client.credential !== lease.credential
|
|
37
|
+
|| client.value.connectionId !== lease.connectionId || !client.value.connected) throw new RequestError(409, 'client-connection-changed')
|
|
38
|
+
return client
|
|
39
|
+
}
|
|
40
|
+
const finish = (pending: Pending, state: AuthoringClientRequestResult['state'], code?: string) => {
|
|
41
|
+
if (terminal(pending)) return
|
|
42
|
+
clearTimeout(pending.timer); pending.timer = undefined
|
|
43
|
+
pending.value = { ...pending.value, state, ...code ? { error: { code } } : {} }
|
|
44
|
+
if (state === 'expired') clients.get(clientKey(pending.member, pending.request.clientId))?.send?.({ type: 'cancel', requestId: pending.request.requestId, connectionId: pending.request.expectedClientState.connectionId })
|
|
45
|
+
}
|
|
46
|
+
const disconnect = (client: Client, connectionId: string) => {
|
|
47
|
+
if (client.value.connectionId !== connectionId) return
|
|
48
|
+
client.value = { ...client.value, connected: false }
|
|
49
|
+
client.send = undefined; client.close = undefined
|
|
50
|
+
for (const pending of requests.values()) if (!terminal(pending) && pending.member === client.member && pending.request.clientId === client.value.clientId && pending.request.epoch === client.value.epoch
|
|
51
|
+
&& pending.request.expectedClientState.connectionId === connectionId) finish(pending, 'expired', 'client-disconnected')
|
|
52
|
+
}
|
|
53
|
+
const readResult = (pending: Pending, actual?: CanonicalSource): AuthoringClientRequestResult => {
|
|
54
|
+
const client = clients.get(clientKey(pending.member, pending.request.clientId)), value = pending.value
|
|
55
|
+
return structuredClone({ ...value, current: Boolean(client?.value.connected && client.value.epoch === value.epoch && client.value.connectionId === value.connectionId
|
|
56
|
+
&& value.snapshot && actual && sameSource(actual, value.snapshot.source) && client.value.snapshot.revision === value.snapshot.revision && sameSource(client.value.snapshot.source, value.snapshot.source)) })
|
|
57
|
+
}
|
|
58
|
+
const report = (client: Client, value: AuthoringClientReport, signature: string) => {
|
|
59
|
+
if (value.sequence < client.sequence || value.sequence === client.sequence && client.lastSignature !== signature) throw new RequestError(409, 'client-report-superseded')
|
|
60
|
+
if (value.sequence === client.sequence) return
|
|
61
|
+
if (value.snapshot.revision < client.value.snapshot.revision) throw new RequestError(409, 'client-state-superseded')
|
|
62
|
+
client.sequence = value.sequence; client.lastSignature = signature
|
|
63
|
+
client.value = { ...client.value, snapshot: structuredClone(value.snapshot), observedAt: new Date().toISOString() }
|
|
64
|
+
}
|
|
65
|
+
const service: AuthoringClients = {
|
|
66
|
+
async list(document, query, member) {
|
|
67
|
+
authorize(document, member); budget(query)
|
|
68
|
+
if (!validateAuthoringClientQuery(query)) throw new RequestError(400, 'client-query-invalid')
|
|
69
|
+
const all = [...clients.values()].filter(client => client.member === member.id && client.value.document === document && (!query.clientId || client.value.clientId === query.clientId)
|
|
70
|
+
&& (!query.after || client.value.clientId > query.after)).sort((a, b) => a.value.clientId.localeCompare(b.value.clientId))
|
|
71
|
+
const answer = { document, clients: [] as AuthoringClientDescriptor[], unavailable: ['recording', 'new-comment-restore', 'active-comment-discard', 'timing-drafts', 'recording-drafts', 'review', 'open-document'] }
|
|
72
|
+
for (const client of all) {
|
|
73
|
+
const row = structuredClone(client.value)
|
|
74
|
+
if (size({ ...answer, clients: [...answer.clients, row], nextAfter: row.clientId }) > AUTHORING_CLIENT_MAX_RESPONSE_CHARS) break
|
|
75
|
+
answer.clients.push(row)
|
|
76
|
+
}
|
|
77
|
+
if (all.length && !answer.clients.length) throw new RequestError(413, 'client-state-too-large')
|
|
78
|
+
return { ...answer, ...answer.clients.length < all.length ? { nextAfter: answer.clients.at(-1)!.clientId } : {} }
|
|
79
|
+
},
|
|
80
|
+
async request(document, request, member) {
|
|
81
|
+
const actual = current(document, member); budget(request)
|
|
82
|
+
if (!validateAuthoringClientRequest(request)) throw new RequestError(400, 'client-request-invalid')
|
|
83
|
+
const key = requestKey(member.id, document, request), signature = digest(request), known = requests.get(key)
|
|
84
|
+
if (known) { if (known.signature !== signature) throw new RequestError(409, 'client-request-id-reused'); return readResult(known, actual) }
|
|
85
|
+
const client = clients.get(clientKey(member.id, request.clientId)), state = client?.value
|
|
86
|
+
if (!client || !state?.connected || state.document !== document || state.epoch !== request.epoch) throw new RequestError(409, 'client-unavailable')
|
|
87
|
+
if (state.connectionId !== request.expectedClientState.connectionId || state.snapshot.revision !== request.expectedClientState.revision) throw new RequestError(409, 'client-state-changed')
|
|
88
|
+
if (!sameSource(actual, request.source) || !sameSource(state.snapshot.source, request.source)) throw new RequestError(409, 'client-source-changed')
|
|
89
|
+
if (!state.snapshot.actions.includes(request.action.type)) throw new RequestError(409, 'client-action-unavailable')
|
|
90
|
+
if ([...requests.values()].some(value => !terminal(value) && value.member === member.id && value.request.clientId === request.clientId)) throw new RequestError(409, 'client-busy')
|
|
91
|
+
while (requests.size >= AUTHORING_CLIENT_MAX_REQUESTS) {
|
|
92
|
+
const disposable = [...requests].find(([, value]) => terminal(value))
|
|
93
|
+
if (!disposable) throw new RequestError(429, 'client-request-capacity')
|
|
94
|
+
requests.delete(disposable[0])
|
|
95
|
+
}
|
|
96
|
+
const now = Date.now(), value: AuthoringClientRequestResult = { document, clientId: state.clientId, epoch: state.epoch, connectionId: state.connectionId, requestId: request.requestId, action: request.action.type, source: request.source,
|
|
97
|
+
state: 'sent', admittedAt: new Date(now).toISOString(), expiresAt: new Date(now + AUTHORING_CLIENT_REQUEST_TIMEOUT_MS).toISOString(), verification: 'not-acknowledged', current: false }
|
|
98
|
+
const pending: Pending = { member: member.id, signature, request: structuredClone(request), value }
|
|
99
|
+
pending.timer = setTimeout(() => finish(pending, 'expired', 'client-request-timeout'), AUTHORING_CLIENT_REQUEST_TIMEOUT_MS)
|
|
100
|
+
requests.set(key, pending)
|
|
101
|
+
client.send?.({ type: 'request', request: pending.request, expiresAt: value.expiresAt })
|
|
102
|
+
return readResult(pending, actual)
|
|
103
|
+
},
|
|
104
|
+
async get(document, query, member) {
|
|
105
|
+
authorize(document, member); budget(query)
|
|
106
|
+
if (!validateAuthoringClientRequestQuery(query)) throw new RequestError(400, 'client-request-query-invalid')
|
|
107
|
+
const pending = requests.get(requestKey(member.id, document, query))
|
|
108
|
+
if (!pending) throw new RequestError(404, 'client-request-not-found')
|
|
109
|
+
let actual: CanonicalSource | undefined
|
|
110
|
+
try { actual = current(document, member) } catch { /* An old receipt stays readable if the current source is invalid. */ }
|
|
111
|
+
return readResult(pending, actual)
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
const register = (document: string, value: AuthoringClientOpen, member: Member): AuthoringClientLease => {
|
|
115
|
+
const source = current(document, member)
|
|
116
|
+
// Opening a transport may race an ordinary autosave. Requests still require the exact current source.
|
|
117
|
+
if (source.documentId !== value.snapshot.source.documentId) throw new RequestError(409, 'client-source-changed')
|
|
118
|
+
const key = clientKey(member.id, value.clientId), old = clients.get(key)
|
|
119
|
+
if (!old && clients.size >= AUTHORING_CLIENT_MAX_CLIENTS) {
|
|
120
|
+
const disposable = [...clients].find(([, client]) => !client.value.connected)
|
|
121
|
+
if (!disposable) throw new RequestError(429, 'client-capacity')
|
|
122
|
+
clients.delete(disposable[0])
|
|
123
|
+
}
|
|
124
|
+
old?.close?.()
|
|
125
|
+
const credential = randomBytes(32).toString('base64url'), connectionId = randomUUID()
|
|
126
|
+
clients.set(key, { member: member.id, credential, sequence: -1, value: { document, clientId: value.clientId, epoch: value.epoch, connectionId, connected: false,
|
|
127
|
+
snapshot: structuredClone(value.snapshot), observedAt: new Date().toISOString(), verification: 'reported-state' } })
|
|
128
|
+
return { clientId: value.clientId, epoch: value.epoch, connectionId, credential }
|
|
129
|
+
}
|
|
130
|
+
const open = (document: string, lease: AuthoringClientLease, member: Member, signal: AbortSignal): PushStream<{ event: string, data: AuthoringClientEvent }> => {
|
|
131
|
+
authorize(document, member)
|
|
132
|
+
const client = clients.get(clientKey(member.id, lease.clientId))
|
|
133
|
+
if (!client || client.credential !== lease.credential || client.value.document !== document || client.value.epoch !== lease.epoch) throw new RequestError(409, 'client-connection-changed')
|
|
134
|
+
client.close?.()
|
|
135
|
+
const connectionId = randomUUID()
|
|
136
|
+
client.value = { ...client.value, connected: true, connectionId }
|
|
137
|
+
const stream = pushStream<{ event: string, data: AuthoringClientEvent }>(push => {
|
|
138
|
+
client.send = data => push({ event: 'authoring-client', data })
|
|
139
|
+
return () => disconnect(client, connectionId)
|
|
140
|
+
}, { signal })
|
|
141
|
+
client.close = () => stream.end()
|
|
142
|
+
client.send?.({ type: 'connected', connectionId })
|
|
143
|
+
return stream
|
|
144
|
+
}
|
|
145
|
+
const acknowledge = (document: string, lease: AuthoringClientLease, ack: AuthoringClientAcknowledgement, member: Member) => {
|
|
146
|
+
const client = connection(document, lease, member), pending = requests.get(requestKey(member.id, document, { ...lease, requestId: ack.requestId }))
|
|
147
|
+
if (pending && Date.now() >= Date.parse(pending.value.expiresAt)) finish(pending, 'expired', 'client-request-timeout')
|
|
148
|
+
if (!pending || terminal(pending) || pending.request.expectedClientState.connectionId !== lease.connectionId) throw new RequestError(409, 'client-request-expired')
|
|
149
|
+
if (ack.state === 'executing' && pending.value.state !== 'sent' || ack.state === 'done' && pending.value.state !== 'executing') throw new RequestError(409, 'client-ack-state-invalid')
|
|
150
|
+
if ((ack.state === 'executing' || ack.state === 'done') && !sameSource(ack.snapshot.source, pending.request.source) && !(ack.state === 'done' && pending.request.action.type === 'source.flush' && ack.snapshot.source.documentId === pending.request.source.documentId)) throw new RequestError(409, 'client-source-changed')
|
|
151
|
+
const actual = ack.state === 'executing' || ack.state === 'done' ? current(document, member) : undefined
|
|
152
|
+
if (ack.state === 'executing' && (ack.snapshot.revision !== pending.request.expectedClientState.revision || !actual || !sameSource(actual, pending.request.source))) throw new RequestError(409, 'client-state-changed')
|
|
153
|
+
if (ack.state === 'done' && (!actual || !sameSource(actual, ack.snapshot.source))) throw new RequestError(409, 'client-source-changed')
|
|
154
|
+
const action = pending.request.action
|
|
155
|
+
const family = action.type.startsWith('comment-drafts.') ? 'comment' : action.type.startsWith('component-drafts.') ? 'component' : undefined
|
|
156
|
+
if (ack.result && (!family || ack.state !== 'done' || ack.result.type !== action.type || ack.result.family !== family)) throw new RequestError(400, 'client-result-invalid')
|
|
157
|
+
if (family && ack.state === 'done') {
|
|
158
|
+
const result = ack.result
|
|
159
|
+
const mutation = action.type === 'component-drafts.restore' || action.type === 'component-drafts.discard' || action.type === 'comment-drafts.restore' || action.type === 'comment-drafts.discard'
|
|
160
|
+
if (!result || (mutation ? ack.snapshot.revision <= pending.request.expectedClientState.revision : ack.snapshot.revision !== pending.request.expectedClientState.revision)) throw new RequestError(409, `client-${family}-drafts-changed`)
|
|
161
|
+
if (!authoringClientResultFits({ request: pending.request, connection: client.value, document, snapshot: ack.snapshot }, result)) throw new RequestError(413, `client-${family}-result-too-large`)
|
|
162
|
+
if ((action.type === 'comment-drafts.read' || action.type === 'component-drafts.read') && (result.type !== action.type || result.draft.id !== action.draft.id || result.draft.revision !== action.draft.revision || result.draft.token !== action.draft.token
|
|
163
|
+
|| action.type === 'component-drafts.read' && (result.type !== action.type || result.draft.blockId !== action.draft.blockId || result.draft.stepId !== action.draft.stepId)
|
|
164
|
+
|| result.offset !== action.offset || result.endOffset !== result.offset + result.jsonText.length || result.endOffset > result.totalUtf16
|
|
165
|
+
|| result.jsonText.length > (action.maxChars ?? 2048))) throw new RequestError(400, 'client-result-invalid')
|
|
166
|
+
if ((action.type === 'comment-drafts.inspect' || action.type === 'component-drafts.inspect') && (result.type !== action.type || result.drafts.length > (action.limit ?? 10)
|
|
167
|
+
|| action.cursor && action.cursor.token !== result.token || result.next && result.next.token !== result.token)) throw new RequestError(400, 'client-result-invalid')
|
|
168
|
+
const sameComment = (a: import('@lincyaw/kernel').ClientCommentDraftReference | null, b: import('@lincyaw/kernel').ClientCommentDraftReference | null) => a === null || b === null ? a === b
|
|
169
|
+
: a.id === b.id && a.revision === b.revision && a.token === b.token
|
|
170
|
+
if (action.type === 'comment-drafts.restore' && (result.type !== action.type || !sameComment(result.draft, action.draft) || result.composerId !== action.composerId
|
|
171
|
+
|| !sameComment(result.preservedCurrent, action.expectedCurrent) || result.restored.revision !== 1 || result.restored.id === action.draft.id || result.restored.id === action.expectedCurrent?.id)) throw new RequestError(400, 'client-result-invalid')
|
|
172
|
+
if (action.type === 'comment-drafts.discard' && (result.type !== action.type || !sameComment(result.draft, action.draft) || !result.discarded)) throw new RequestError(400, 'client-result-invalid')
|
|
173
|
+
const sameDraft = (a: import('@lincyaw/kernel').ClientComponentDraftReference | null, b: import('@lincyaw/kernel').ClientComponentDraftReference | null) => a === null || b === null ? a === b
|
|
174
|
+
: a.id === b.id && a.revision === b.revision && a.token === b.token && a.blockId === b.blockId && a.stepId === b.stepId
|
|
175
|
+
if (action.type === 'component-drafts.restore' && (result.type !== action.type || !sameDraft(result.draft, action.draft) || !sameDraft(result.preservedCurrent, action.expectedCurrent)
|
|
176
|
+
|| !result.restored.current || result.restored.storage !== 'durable' || result.restored.status !== 'available' || result.restored.composing
|
|
177
|
+
|| result.restored.draft.id === action.draft.id || result.restored.draft.id === action.expectedCurrent?.id
|
|
178
|
+
|| result.restored.draft.revision !== 1 || result.restored.draft.blockId !== action.draft.blockId || result.restored.draft.stepId !== action.draft.stepId)) throw new RequestError(400, 'client-result-invalid')
|
|
179
|
+
if (action.type === 'component-drafts.discard' && (result.type !== action.type || !sameDraft(result.draft, action.draft) || result.current !== action.current || !result.discarded)) throw new RequestError(400, 'client-result-invalid')
|
|
180
|
+
|
|
181
|
+
}
|
|
182
|
+
report(client, ack, digest(ack))
|
|
183
|
+
pending.value = { ...pending.value, observedAt: new Date().toISOString(), snapshot: structuredClone(ack.snapshot), verification: 'browser-acknowledged', ...ack.error ? { error: structuredClone(ack.error) } : {}, ...ack.result ? { result: structuredClone(ack.result) } : {} }
|
|
184
|
+
if (ack.state === 'executing') pending.value.state = 'executing'
|
|
185
|
+
else finish(pending, ack.state)
|
|
186
|
+
return { accepted: true }
|
|
187
|
+
}
|
|
188
|
+
const post = (kind: 'register' | 'report' | 'ack'): Route => ({ role: 'editor', maxBody: kind === 'ack' ? AUTHORING_CLIENT_ACK_MAX_BYTES : 16384, handle: (request, ctx) => catchRequestErrors(async () => {
|
|
189
|
+
if (request.method !== 'POST') return problemResponse(405, 'POST')
|
|
190
|
+
const value: unknown = await request.json().catch(() => undefined); budget(value, kind === 'ack' ? AUTHORING_CLIENT_MAX_RESPONSE_CHARS : AUTHORING_CLIENT_MAX_REQUEST_CHARS)
|
|
191
|
+
if (kind === 'register') { if (!validateAuthoringClientOpen(value)) throw new RequestError(400, 'client-open-invalid'); return jsonResponse(register(ctx.document, value, ctx.member)) }
|
|
192
|
+
if (!isRecord(value) || Object.keys(value).some(key => !['connection', 'report'].includes(key)) || !validateAuthoringClientLease(value.connection)) throw new RequestError(400, 'client-connection-invalid')
|
|
193
|
+
if (kind === 'ack') { if (!validateAuthoringClientAcknowledgement(value.report)) throw new RequestError(400, 'client-ack-invalid'); return jsonResponse(acknowledge(ctx.document, value.connection, value.report, ctx.member)) }
|
|
194
|
+
if (!validateAuthoringClientReport(value.report)) throw new RequestError(400, 'client-report-invalid')
|
|
195
|
+
report(connection(ctx.document, value.connection, ctx.member), value.report, digest(value.report)); return jsonResponse({ accepted: true })
|
|
196
|
+
}) })
|
|
197
|
+
return { service, routes: {
|
|
198
|
+
'authoring-clients/register': post('register'), 'authoring-clients/report': post('report'), 'authoring-clients/ack': post('ack'),
|
|
199
|
+
'authoring-clients/events': { role: 'editor', handle: (request, ctx) => catchRequestErrors(async () => {
|
|
200
|
+
if (request.method !== 'GET') return problemResponse(405, 'GET')
|
|
201
|
+
const query = new URL(request.url).searchParams, lease = { clientId: query.get('clientId'), epoch: query.get('epoch'), connectionId: query.get('connectionId'), credential: request.headers.get('X-Presmium-Authoring-Client') }
|
|
202
|
+
if (!validateAuthoringClientLease(lease)) throw new RequestError(400, 'client-connection-invalid')
|
|
203
|
+
return sseResponse(open(ctx.document, lease, ctx.member, request.signal), request.signal)
|
|
204
|
+
}) } satisfies Route,
|
|
205
|
+
}, async close() { closed = true; for (const client of clients.values()) client.close?.(); for (const pending of requests.values()) clearTimeout(pending.timer); requests.clear(); clients.clear() } }
|
|
206
|
+
}
|
package/src/node/build.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { Compiler } from '@lincyaw/compiler'
|
|
|
3
3
|
import type { RunStats } from '@lincyaw/compiler'
|
|
4
4
|
import { errorMessage } from '@lincyaw/kernel'
|
|
5
5
|
import type { Finding } from '@lincyaw/kernel'
|
|
6
|
-
import { listDocuments,
|
|
6
|
+
import { listDocuments, readWorkspaceConfig } from './config.ts'
|
|
7
7
|
import { siteContext, siteDocument, siteIR, siteSpec, siteTranslations, webRenderer } from './site.ts'
|
|
8
8
|
|
|
9
9
|
export interface WorkspaceBuild {
|
|
@@ -13,7 +13,7 @@ export interface WorkspaceBuild {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export async function buildWorkspace(root: string, options: { outDir?: string, bundled?: string } = {}): Promise<WorkspaceBuild> {
|
|
16
|
-
const config =
|
|
16
|
+
const config = readWorkspaceConfig(root)
|
|
17
17
|
const found = listDocuments(config)
|
|
18
18
|
const findings: Finding[] = []
|
|
19
19
|
const documents: WorkspaceBuild['documents'] = []
|
|
@@ -22,8 +22,8 @@ export async function buildWorkspace(root: string, options: { outDir?: string, b
|
|
|
22
22
|
const compilation = await new Compiler(join(config.root, document.entry), { bundled: options.bundled, settings: config.settings }).compile({ pruneCache: true })
|
|
23
23
|
const translated = await siteTranslations(compilation)
|
|
24
24
|
findings.push(...compilation.doc.diagnostics, ...translated.findings)
|
|
25
|
-
documents.push({ dir: document.dir, title: document.title, runs: compilation.
|
|
26
|
-
site.documents.push(siteDocument(document, siteIR(compilation), translated.translations))
|
|
25
|
+
documents.push({ dir: document.dir, title: document.title, runs: compilation.runStats })
|
|
26
|
+
site.documents.push(siteDocument(document, siteIR(compilation), translated.translations, compilation))
|
|
27
27
|
}
|
|
28
28
|
const outDir = join(resolve(options.outDir ?? join(config.root, 'dist')), 'web')
|
|
29
29
|
const renderer = await webRenderer(config.root, options.bundled)
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { existsSync, statSync, watch } from 'node:fs'
|
|
2
|
+
import type { FSWatcher } from 'node:fs'
|
|
3
|
+
import { basename, dirname, join } from 'node:path'
|
|
4
|
+
import type { Compilation, Compiler } from '@lincyaw/compiler'
|
|
5
|
+
import { errorMessage } from '@lincyaw/kernel'
|
|
6
|
+
import type { Logger } from '@lincyaw/kernel'
|
|
7
|
+
|
|
8
|
+
/** One document of the workspace server: its compiler, the latest compilation, and recompiling when a file it read changes. */
|
|
9
|
+
export class CompileSession {
|
|
10
|
+
readonly dir: string
|
|
11
|
+
readonly compiler: Compiler
|
|
12
|
+
#latest?: Compilation
|
|
13
|
+
#first?: Promise<Compilation>
|
|
14
|
+
#running: Promise<unknown> = Promise.resolve()
|
|
15
|
+
#queued?: Promise<Compilation>
|
|
16
|
+
readonly #listeners = new Set<(compilation: Compilation, changed: string[]) => void | Promise<void>>()
|
|
17
|
+
readonly #failures = new Set<(error: unknown) => void>()
|
|
18
|
+
#changedFiles = new Set<string>()
|
|
19
|
+
readonly #watchers = new Map<string, FSWatcher>()
|
|
20
|
+
#relevant = new Map<string, Set<string>>()
|
|
21
|
+
#timer?: NodeJS.Timeout
|
|
22
|
+
#closed = false
|
|
23
|
+
#compiling = false
|
|
24
|
+
#version = 0
|
|
25
|
+
#controller?: AbortController
|
|
26
|
+
readonly #observed = new Map<string, number>()
|
|
27
|
+
readonly #log: Logger
|
|
28
|
+
|
|
29
|
+
constructor(dir: string, compiler: Compiler, log: Logger) {
|
|
30
|
+
this.dir = dir
|
|
31
|
+
this.compiler = compiler
|
|
32
|
+
this.#log = log
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
ready(): Promise<Compilation> {
|
|
36
|
+
if (this.#latest) return Promise.resolve(this.#latest)
|
|
37
|
+
if (!this.#first) {
|
|
38
|
+
const first = this.recompile()
|
|
39
|
+
first.catch(() => {
|
|
40
|
+
if (this.#first === first) this.#first = undefined
|
|
41
|
+
})
|
|
42
|
+
this.#first = first
|
|
43
|
+
}
|
|
44
|
+
return this.#first
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get latest(): Compilation | undefined {
|
|
48
|
+
return this.#latest
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
onCompile(listener: (compilation: Compilation, changed: string[]) => void | Promise<void>): () => void {
|
|
52
|
+
this.#listeners.add(listener)
|
|
53
|
+
return () => this.#listeners.delete(listener)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
onFailure(listener: (error: unknown) => void): () => void {
|
|
57
|
+
this.#failures.add(listener)
|
|
58
|
+
return () => { this.#failures.delete(listener) }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
recompile(): Promise<Compilation> {
|
|
62
|
+
if (this.#queued) return this.#queued
|
|
63
|
+
const next = this.#running.then(async () => {
|
|
64
|
+
this.#queued = undefined
|
|
65
|
+
this.#compiling = true
|
|
66
|
+
try {
|
|
67
|
+
let compilation: Compilation | undefined
|
|
68
|
+
const changed = new Set<string>()
|
|
69
|
+
let version: number
|
|
70
|
+
do {
|
|
71
|
+
version = this.#version
|
|
72
|
+
for (const file of this.#changedFiles) changed.add(file)
|
|
73
|
+
this.#changedFiles.clear()
|
|
74
|
+
const started = Date.now()
|
|
75
|
+
this.#log.info(`[compile] document=${JSON.stringify(this.dir || '.')} state=started changed=${changed.size}`)
|
|
76
|
+
const controller = this.#controller = new AbortController()
|
|
77
|
+
try {
|
|
78
|
+
compilation = await this.compiler.compile({ signal: controller.signal })
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (!this.#closed && controller.signal.aborted && version !== this.#version) continue
|
|
81
|
+
throw error
|
|
82
|
+
}
|
|
83
|
+
this.#watch(compilation, started)
|
|
84
|
+
this.#log.info(`[compile] document=${JSON.stringify(this.dir || '.')} state=compiled elapsedMs=${Date.now() - started} sections=${compilation.doc.sections.length} diagnostics=${compilation.doc.diagnostics.length}`)
|
|
85
|
+
} while (!this.#closed && version !== this.#version)
|
|
86
|
+
if (!compilation) throw new Error('the compilation did not complete')
|
|
87
|
+
if (this.#closed) return compilation
|
|
88
|
+
this.#latest = compilation
|
|
89
|
+
for (const listener of this.#listeners) {
|
|
90
|
+
try {
|
|
91
|
+
await listener(compilation, [...changed])
|
|
92
|
+
} catch (error) {
|
|
93
|
+
this.#log.error(`after compiling ${this.dir || '.'}: ${errorMessage(error)}`)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return compilation
|
|
97
|
+
} finally {
|
|
98
|
+
this.#controller = undefined
|
|
99
|
+
this.#compiling = false
|
|
100
|
+
if (!this.#closed && this.#changedFiles.size > 0) this.#schedule()
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
this.#queued = next
|
|
104
|
+
this.#running = next.catch((error: unknown) => {
|
|
105
|
+
if (!this.#closed) {
|
|
106
|
+
this.#log.error(`compiling ${this.dir || '.'} failed: ${errorMessage(error)}`)
|
|
107
|
+
for (const listener of this.#failures) listener(error)
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
return next
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
close(): void {
|
|
114
|
+
this.#closed = true
|
|
115
|
+
this.#controller?.abort()
|
|
116
|
+
clearTimeout(this.#timer)
|
|
117
|
+
for (const watcher of this.#watchers.values()) watcher.close()
|
|
118
|
+
this.#watchers.clear()
|
|
119
|
+
this.#listeners.clear()
|
|
120
|
+
this.#failures.clear()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
#watch(compilation: Compilation, started: number): void {
|
|
124
|
+
if (this.#closed) return
|
|
125
|
+
const files = new Set([...compilation.sourceFiles, ...compilation.inputs.files])
|
|
126
|
+
const next = new Map<string, Set<string>>()
|
|
127
|
+
for (const file of files) {
|
|
128
|
+
let dir = dirname(file)
|
|
129
|
+
let name = basename(file)
|
|
130
|
+
while (!existsSync(dir) && dirname(dir) !== dir) {
|
|
131
|
+
name = basename(dir)
|
|
132
|
+
dir = dirname(dir)
|
|
133
|
+
}
|
|
134
|
+
next.set(dir, (next.get(dir) ?? new Set<string>()).add(name))
|
|
135
|
+
}
|
|
136
|
+
this.#relevant = next
|
|
137
|
+
for (const [dir, watcher] of this.#watchers) {
|
|
138
|
+
if (next.has(dir)) continue
|
|
139
|
+
watcher.close()
|
|
140
|
+
this.#watchers.delete(dir)
|
|
141
|
+
}
|
|
142
|
+
for (const dir of next.keys()) {
|
|
143
|
+
if (this.#watchers.has(dir)) continue
|
|
144
|
+
const failed = (error: unknown) => {
|
|
145
|
+
if (this.#closed) return
|
|
146
|
+
const failure = new Error(`cannot watch ${dir}: ${errorMessage(error)}`)
|
|
147
|
+
this.#log.error(failure.message)
|
|
148
|
+
for (const listener of this.#failures) listener(failure)
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
const watcher = watch(dir, (_event, name) => {
|
|
152
|
+
if (name && this.#relevant.get(dir)?.has(basename(name))) this.#changed(join(dir, name))
|
|
153
|
+
})
|
|
154
|
+
this.#watchers.set(dir, watcher)
|
|
155
|
+
watcher.on('error', (error) => {
|
|
156
|
+
watcher.close()
|
|
157
|
+
if (this.#watchers.get(dir) === watcher) this.#watchers.delete(dir)
|
|
158
|
+
failed(error)
|
|
159
|
+
})
|
|
160
|
+
} catch (error) {
|
|
161
|
+
failed(error)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const modified = [...files].filter((file) => {
|
|
165
|
+
const time = statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? 0
|
|
166
|
+
const changed = time >= started - 20 && time <= Date.now() && this.#observed.get(file) !== time
|
|
167
|
+
this.#observed.set(file, time)
|
|
168
|
+
return changed
|
|
169
|
+
})
|
|
170
|
+
for (const file of this.#observed.keys()) if (!files.has(file)) this.#observed.delete(file)
|
|
171
|
+
for (const file of modified) this.#changed(file)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#changed(file: string): void {
|
|
175
|
+
if (this.#closed) return
|
|
176
|
+
this.#changedFiles.add(file)
|
|
177
|
+
this.#observed.set(file, statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? 0)
|
|
178
|
+
this.#version++
|
|
179
|
+
this.#controller?.abort()
|
|
180
|
+
if (!this.#compiling) this.#schedule()
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
#schedule(): void {
|
|
184
|
+
clearTimeout(this.#timer)
|
|
185
|
+
this.#timer = setTimeout(() => void this.recompile().catch(() => undefined), 10)
|
|
186
|
+
}
|
|
187
|
+
}
|