@lincyaw/workspace 1.0.0-rc.0 → 1.0.0-rc.10
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 +71 -9
- 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.10",
|
|
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/compiler": "1.0.0-rc.
|
|
36
|
-
"@lincyaw/
|
|
37
|
-
"@lincyaw/
|
|
36
|
+
"@lincyaw/compiler": "1.0.0-rc.10",
|
|
37
|
+
"@lincyaw/kernel": "1.0.0-rc.10",
|
|
38
|
+
"@lincyaw/server": "1.0.0-rc.10"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/mime-types": "^3.0.1",
|
|
42
|
+
"@lincyaw/mcp": "1.0.0-rc.10"
|
|
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
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
1
|
+
import { cpSync, existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { isAbsolute, join, resolve } from 'node:path'
|
|
3
|
+
import { build, Compiler } from '@lincyaw/compiler'
|
|
4
|
+
import type { Compilation, RunStats } from '@lincyaw/compiler'
|
|
5
|
+
import { errorMessage, isRecord } from '@lincyaw/kernel'
|
|
5
6
|
import type { Finding } from '@lincyaw/kernel'
|
|
6
|
-
import { listDocuments,
|
|
7
|
+
import { listDocuments, readWorkspaceConfig } from './config.ts'
|
|
7
8
|
import { siteContext, siteDocument, siteIR, siteSpec, siteTranslations, webRenderer } from './site.ts'
|
|
8
9
|
|
|
9
10
|
export interface WorkspaceBuild {
|
|
@@ -13,29 +14,90 @@ export interface WorkspaceBuild {
|
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
export async function buildWorkspace(root: string, options: { outDir?: string, bundled?: string } = {}): Promise<WorkspaceBuild> {
|
|
16
|
-
const config =
|
|
17
|
+
const config = readWorkspaceConfig(root)
|
|
17
18
|
const found = listDocuments(config)
|
|
18
19
|
const findings: Finding[] = []
|
|
19
20
|
const documents: WorkspaceBuild['documents'] = []
|
|
20
21
|
const site = siteSpec(config, [])
|
|
22
|
+
const base = siteBase(config.site.base)
|
|
23
|
+
const forms: { dir: string, compilation: Compilation, to: string[] }[] = []
|
|
21
24
|
for (const document of found) {
|
|
22
25
|
const compilation = await new Compiler(join(config.root, document.entry), { bundled: options.bundled, settings: config.settings }).compile({ pruneCache: true })
|
|
26
|
+
const to = presentations(compilation, `${base}${urlPath(document.dir)}/`, siteLook(config.site, config.root))
|
|
27
|
+
if (to.length > 0) forms.push({ dir: document.dir, compilation, to })
|
|
23
28
|
const translated = await siteTranslations(compilation)
|
|
24
29
|
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))
|
|
30
|
+
documents.push({ dir: document.dir, title: document.title, runs: compilation.runStats })
|
|
31
|
+
site.documents.push(siteDocument(document, siteIR(compilation), translated.translations, compilation))
|
|
27
32
|
}
|
|
28
33
|
const outDir = join(resolve(options.outDir ?? join(config.root, 'dist')), 'web')
|
|
29
34
|
const renderer = await webRenderer(config.root, options.bundled)
|
|
30
|
-
const ctx = siteContext({ root: config.root, outDir, workDir: join(config.root, '.presmium', 'site'), base
|
|
35
|
+
const ctx = siteContext({ root: config.root, outDir, workDir: join(config.root, '.presmium', 'site'), base, findings })
|
|
31
36
|
try {
|
|
32
37
|
await renderer.buildSite!(site, ctx)
|
|
33
38
|
} catch (error) {
|
|
34
39
|
findings.push({ checkId: 'presmium.render', severity: 'error', message: `the workspace site failed: ${errorMessage(error)}` })
|
|
40
|
+
return { outDir, documents, findings }
|
|
41
|
+
}
|
|
42
|
+
// A document's presentation and its narrated lecture are built beside its pages: `<dir>/slides/` and `<dir>/lecture/`.
|
|
43
|
+
for (const { dir, compilation, to } of forms) {
|
|
44
|
+
const work = join(config.root, '.presmium', 'forms', dir)
|
|
45
|
+
rmSync(work, { recursive: true, force: true })
|
|
46
|
+
try {
|
|
47
|
+
const report = await build(compilation, { outDir: work, to })
|
|
48
|
+
findings.push(...report.findings)
|
|
49
|
+
for (const form of to) if (existsSync(join(work, form))) cpSync(join(work, form), join(outDir, dir, form), { recursive: true })
|
|
50
|
+
if (to.includes('slides')) shareSlidesApp(join(outDir, dir, 'slides'), `${base}${urlPath(dir)}/slides/`, outDir, base)
|
|
51
|
+
} catch (error) {
|
|
52
|
+
findings.push({ checkId: 'presmium.render', severity: 'error', message: `document ${dir}: its ${to.join(' and ')} failed: ${errorMessage(error)}` })
|
|
53
|
+
}
|
|
35
54
|
}
|
|
36
55
|
return { outDir, documents, findings }
|
|
37
56
|
}
|
|
38
57
|
|
|
58
|
+
const PRESENTATIONS = ['slides', 'lecture']
|
|
59
|
+
|
|
60
|
+
/** The site's theme and its parameters, for its presentations to look like its pages; a theme directory made absolute. */
|
|
61
|
+
function siteLook(site: Record<string, unknown>, root: string): Record<string, unknown> {
|
|
62
|
+
const theme = typeof site.theme === 'string' && (site.theme.startsWith('.') || isAbsolute(site.theme)) ? resolve(root, site.theme) : site.theme
|
|
63
|
+
return { ...theme !== undefined ? { theme } : {}, ...isRecord(site.params) ? { params: site.params } : {} }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const urlPath = (dir: string) => dir.split('/').map(encodeURIComponent).join('/')
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Every document's slides run the same viewer app: it moves to the site's `_app/<key>/`, beside the web pages' own,
|
|
70
|
+
* so a reader downloads it once for all the presentations of the site, and the slides' page points there.
|
|
71
|
+
*/
|
|
72
|
+
function shareSlidesApp(slides: string, slidesBase: string, outDir: string, base: string): void {
|
|
73
|
+
const app = join(slides, '_app')
|
|
74
|
+
if (!existsSync(app)) return
|
|
75
|
+
for (const key of readdirSync(app)) if (!existsSync(join(outDir, '_app', key))) cpSync(join(app, key), join(outDir, '_app', key), { recursive: true })
|
|
76
|
+
rmSync(app, { recursive: true, force: true })
|
|
77
|
+
const page = join(slides, 'index.html')
|
|
78
|
+
writeFileSync(page, readFileSync(page, 'utf8').replaceAll(`${slidesBase}_app/`, `${base}_app/`))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The presentation forms a document asks for (`metadata.renderers.slides` or `lecture`), set to be served below its
|
|
83
|
+
* pages at `prefix`: the slides at `slides/`, and the web pages and the slides linked to each other.
|
|
84
|
+
*/
|
|
85
|
+
function presentations(compilation: Compilation, prefix: string, look: Record<string, unknown>): string[] {
|
|
86
|
+
const { renderers } = compilation.doc.config
|
|
87
|
+
const to = PRESENTATIONS.filter(form => renderers[form] !== undefined)
|
|
88
|
+
if (to.length === 0) return to
|
|
89
|
+
if (to.includes('slides')) renderers.slides = { ...look, ...isRecord(renderers.slides) ? renderers.slides : {}, base: `${prefix}slides/` }
|
|
90
|
+
const settings = compilation.doc.config.presmium ??= {}
|
|
91
|
+
const web = isRecord(settings['presmium.web']) ? settings['presmium.web'] : {}
|
|
92
|
+
settings['presmium.web'] = { ...web, webUrl: prefix, ...to.includes('slides') ? { slidesUrl: `${prefix}slides/` } : {} }
|
|
93
|
+
// The slides find the lecture where it is built, without probing `slides/lecture/` first.
|
|
94
|
+
if (to.includes('lecture')) {
|
|
95
|
+
const lecture = isRecord(settings['presmium.lecture']) ? settings['presmium.lecture'] : {}
|
|
96
|
+
settings['presmium.lecture'] = { url: `${prefix}lecture/`, ...lecture }
|
|
97
|
+
}
|
|
98
|
+
return to
|
|
99
|
+
}
|
|
100
|
+
|
|
39
101
|
function siteBase(value: unknown): string {
|
|
40
102
|
const path = typeof value === 'string' ? value.trim().replace(/^\/+|\/+$/g, '') : ''
|
|
41
103
|
return path ? `/${path}/` : '/'
|