@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.
Files changed (53) hide show
  1. package/README.md +5 -5
  2. package/package.json +10 -5
  3. package/src/domain/{revisions.ts → commit-message.ts} +7 -7
  4. package/src/domain/locks.ts +5 -0
  5. package/src/domain/paths.ts +2 -2
  6. package/src/index.ts +7 -2
  7. package/src/node/authoring-clients.ts +206 -0
  8. package/src/node/build.ts +4 -4
  9. package/src/node/compile-session.ts +187 -0
  10. package/src/node/config.ts +36 -21
  11. package/src/node/document-assets.ts +30 -0
  12. package/src/node/document-audio.ts +473 -0
  13. package/src/node/document-authoring.ts +436 -0
  14. package/src/node/document-editor.ts +157 -0
  15. package/src/node/document-history.ts +190 -0
  16. package/src/node/document-images.ts +241 -0
  17. package/src/node/document-operations.ts +191 -0
  18. package/src/node/document-transcription-adoption.ts +96 -0
  19. package/src/node/document-transcription.ts +471 -0
  20. package/src/node/document-voice.ts +115 -0
  21. package/src/node/errors.ts +23 -0
  22. package/src/node/git.ts +15 -15
  23. package/src/node/jobs.ts +361 -0
  24. package/src/node/locks.ts +10 -5
  25. package/src/node/page-delete.ts +106 -0
  26. package/src/node/project-files.ts +163 -0
  27. package/src/node/projects.ts +192 -0
  28. package/src/node/review/agent-session-reviews.ts +374 -0
  29. package/src/node/review/git-guard.ts +9 -8
  30. package/src/node/review/git-review.ts +102 -90
  31. package/src/node/review/record.ts +77 -0
  32. package/src/node/review/routes.ts +36 -35
  33. package/src/node/review/task-reviews.ts +50 -0
  34. package/src/node/revisions.ts +321 -68
  35. package/src/node/routes.ts +236 -146
  36. package/src/node/serve.ts +319 -118
  37. package/src/node/site.ts +34 -23
  38. package/src/node/source-editor.ts +332 -0
  39. package/src/node/speech-settings.ts +21 -0
  40. package/src/node/templates.ts +27 -0
  41. package/src/node/workbench-api.ts +176 -0
  42. package/src/node/writer.ts +389 -0
  43. package/templates/document/default/lecture.json +75 -0
  44. package/src/domain/api.ts +0 -102
  45. package/src/domain/index.ts +0 -6
  46. package/src/node/documents.ts +0 -185
  47. package/src/node/files.ts +0 -252
  48. package/src/node/review/review-record.ts +0 -73
  49. package/src/node/review/reviews.ts +0 -422
  50. package/src/node/service.ts +0 -237
  51. package/src/node/session.ts +0 -123
  52. package/src/node/source.ts +0 -148
  53. 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 turns lectures written in MyST Markdown into web pages, slides, Jupyter notebooks and narrated lectures.
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.md`:
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.md, week2/lecture.md]
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. Its browser side (`@lincyaw/workspace/client`, `@lincyaw/workspace/domain` and the routes behind it) is experimental and will be replaced by a new workbench; the command, the workspace configuration file and the Node services stay.
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.en.md): workspaces
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.0",
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/compiler": "1.0.0-rc.0",
36
- "@lincyaw/server": "1.0.0-rc.0",
37
- "@lincyaw/kernel": "1.0.0-rc.0"
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) ?? ''
@@ -0,0 +1,5 @@
1
+ export type LockKind = 'agent' | 'revert' | 'operation'
2
+
3
+ export function lockKind(holder: string): LockKind {
4
+ return holder.startsWith('agent:') ? 'agent' : holder.endsWith(':revert') ? 'revert' : 'operation'
5
+ }
@@ -32,7 +32,7 @@ export function leavesRoot(path: string): boolean {
32
32
  return path === '..' || path.startsWith('../')
33
33
  }
34
34
 
35
- export function within(path: string, dir: string): boolean {
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 = within(target, from) ? `${to}${target.slice(from.length)}` : target
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, listDocuments, readWorkspace } from './node/config.ts'
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, readWorkspace } from './config.ts'
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 = readWorkspace(root)
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.runs })
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
+ }