@lincyaw/workspace 0.0.0-stage → 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/LICENSE +202 -0
- package/README.md +35 -2
- package/package.json +41 -3
- package/src/domain/commit-message.ts +80 -0
- package/src/domain/locks.ts +5 -0
- package/src/domain/paths.ts +98 -0
- package/src/index.ts +11 -0
- package/src/node/authoring-clients.ts +206 -0
- package/src/node/build.ts +42 -0
- package/src/node/compile-session.ts +187 -0
- package/src/node/config.ts +141 -0
- 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 +68 -0
- package/src/node/jobs.ts +361 -0
- package/src/node/locks.ts +46 -0
- 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 +104 -0
- package/src/node/review/git-review.ts +428 -0
- package/src/node/review/record.ts +77 -0
- package/src/node/review/routes.ts +73 -0
- package/src/node/review/task-reviews.ts +50 -0
- package/src/node/revisions.ts +599 -0
- package/src/node/routes.ts +329 -0
- package/src/node/serve.ts +503 -0
- package/src/node/site.ts +132 -0
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { join, resolve } from 'node:path'
|
|
2
|
+
import { Compiler } from '@lincyaw/compiler'
|
|
3
|
+
import type { RunStats } from '@lincyaw/compiler'
|
|
4
|
+
import { errorMessage } from '@lincyaw/kernel'
|
|
5
|
+
import type { Finding } from '@lincyaw/kernel'
|
|
6
|
+
import { listDocuments, readWorkspaceConfig } from './config.ts'
|
|
7
|
+
import { siteContext, siteDocument, siteIR, siteSpec, siteTranslations, webRenderer } from './site.ts'
|
|
8
|
+
|
|
9
|
+
export interface WorkspaceBuild {
|
|
10
|
+
outDir: string
|
|
11
|
+
documents: { dir: string, title: string, runs: RunStats }[]
|
|
12
|
+
findings: Finding[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function buildWorkspace(root: string, options: { outDir?: string, bundled?: string } = {}): Promise<WorkspaceBuild> {
|
|
16
|
+
const config = readWorkspaceConfig(root)
|
|
17
|
+
const found = listDocuments(config)
|
|
18
|
+
const findings: Finding[] = []
|
|
19
|
+
const documents: WorkspaceBuild['documents'] = []
|
|
20
|
+
const site = siteSpec(config, [])
|
|
21
|
+
for (const document of found) {
|
|
22
|
+
const compilation = await new Compiler(join(config.root, document.entry), { bundled: options.bundled, settings: config.settings }).compile({ pruneCache: true })
|
|
23
|
+
const translated = await siteTranslations(compilation)
|
|
24
|
+
findings.push(...compilation.doc.diagnostics, ...translated.findings)
|
|
25
|
+
documents.push({ dir: document.dir, title: document.title, runs: compilation.runStats })
|
|
26
|
+
site.documents.push(siteDocument(document, siteIR(compilation), translated.translations, compilation))
|
|
27
|
+
}
|
|
28
|
+
const outDir = join(resolve(options.outDir ?? join(config.root, 'dist')), 'web')
|
|
29
|
+
const renderer = await webRenderer(config.root, options.bundled)
|
|
30
|
+
const ctx = siteContext({ root: config.root, outDir, workDir: join(config.root, '.presmium', 'site'), base: siteBase(config.site.base), findings })
|
|
31
|
+
try {
|
|
32
|
+
await renderer.buildSite!(site, ctx)
|
|
33
|
+
} catch (error) {
|
|
34
|
+
findings.push({ checkId: 'presmium.render', severity: 'error', message: `the workspace site failed: ${errorMessage(error)}` })
|
|
35
|
+
}
|
|
36
|
+
return { outDir, documents, findings }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function siteBase(value: unknown): string {
|
|
40
|
+
const path = typeof value === 'string' ? value.trim().replace(/^\/+|\/+$/g, '') : ''
|
|
41
|
+
return path ? `/${path}/` : '/'
|
|
42
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
|
|
2
|
+
import { join, resolve } from 'node:path'
|
|
3
|
+
import { WORKSPACE_CONFIG } from '@lincyaw/compiler'
|
|
4
|
+
import type { OperatorSettings } from '@lincyaw/compiler'
|
|
5
|
+
import { APPROVAL_KINDS, DOCUMENT_ENTRY, errorMessage, isRecord } from '@lincyaw/kernel'
|
|
6
|
+
import type { ApprovalKind, DocumentSummary } from '@lincyaw/kernel'
|
|
7
|
+
import type { ApprovalPolicy } from '@lincyaw/server'
|
|
8
|
+
import { parse as parseYaml } from 'yaml'
|
|
9
|
+
import { matchesPattern, patternSegments } from '../domain/paths.ts'
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_DOCUMENTS = [`*/${DOCUMENT_ENTRY}`]
|
|
12
|
+
|
|
13
|
+
const SKIPPED = new Set(['node_modules', '.git', '.presmium', 'dist'])
|
|
14
|
+
|
|
15
|
+
const POLICIES = new Set<ApprovalPolicy>(['off', 'ask', 'allow'])
|
|
16
|
+
|
|
17
|
+
export interface WorkspaceConfig {
|
|
18
|
+
root: string
|
|
19
|
+
title: string
|
|
20
|
+
documents: string[]
|
|
21
|
+
singleDeck: boolean
|
|
22
|
+
site: Record<string, unknown>
|
|
23
|
+
approvals: Partial<Record<ApprovalKind, ApprovalPolicy>>
|
|
24
|
+
templates?: string
|
|
25
|
+
settings: OperatorSettings
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function strings(value: unknown): string[] {
|
|
29
|
+
return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string' && v.trim() !== '').map(v => v.trim()) : typeof value === 'string' && value.trim() ? [value.trim()] : []
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function readWorkspaceConfig(root: string): WorkspaceConfig {
|
|
33
|
+
const dir = realpathSync(resolve(root))
|
|
34
|
+
const file = join(dir, WORKSPACE_CONFIG)
|
|
35
|
+
let data: Record<string, unknown> = {}
|
|
36
|
+
if (existsSync(file)) {
|
|
37
|
+
try {
|
|
38
|
+
const parsed: unknown = parseYaml(readFileSync(file, 'utf8'))
|
|
39
|
+
data = isRecord(parsed) ? parsed : {}
|
|
40
|
+
} catch (error) {
|
|
41
|
+
throw new Error(`${file}: ${errorMessage(error)}`)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const declared = strings(data.documents)
|
|
45
|
+
const singleDeck = declared.length === 0 && existsSync(join(dir, DOCUMENT_ENTRY))
|
|
46
|
+
const approvals = isRecord(data.approvals) ? data.approvals : {}
|
|
47
|
+
const settings = isRecord(data.settings) ? data.settings : {}
|
|
48
|
+
const renderers = isRecord(data.renderers) ? data.renderers : {}
|
|
49
|
+
for (const [path, value] of [['renderers.lecture', renderers.lecture], ['settings.presmium.lecture', settings['presmium.lecture']]] as const) {
|
|
50
|
+
if (isRecord(value) && Object.hasOwn(value, 'script')) throw new Error(`${file}: ${path}.script: External lecture scripts are not supported. Author narration in each document's lecture.json narrations.`)
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
root: dir,
|
|
54
|
+
title: typeof data.title === 'string' && data.title.trim() ? data.title.trim() : dir.split('/').at(-1) ?? 'Presmium',
|
|
55
|
+
documents: singleDeck ? [DOCUMENT_ENTRY] : declared.length > 0 ? declared : DEFAULT_DOCUMENTS,
|
|
56
|
+
singleDeck,
|
|
57
|
+
site: isRecord(data.site) ? data.site : {},
|
|
58
|
+
approvals: Object.fromEntries(APPROVAL_KINDS.flatMap(kind => (POLICIES.has(approvals[kind] as ApprovalPolicy) ? [[kind, approvals[kind] as ApprovalPolicy]] : []))),
|
|
59
|
+
...typeof data.templates === 'string' && data.templates.trim() ? { templates: resolve(dir, data.templates.trim()) } : {},
|
|
60
|
+
settings: Object.fromEntries(Object.entries(settings).map(([id, value]) => [id, isRecord(value) ? value : {}])),
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function isWorkspaceRoot(dir: string): boolean {
|
|
65
|
+
if (!existsSync(join(dir, WORKSPACE_CONFIG))) return false
|
|
66
|
+
try {
|
|
67
|
+
return !readWorkspaceConfig(dir).singleDeck
|
|
68
|
+
} catch {
|
|
69
|
+
return true
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function entries(config: WorkspaceConfig): string[] {
|
|
74
|
+
const found: string[] = []
|
|
75
|
+
for (const pattern of config.documents) {
|
|
76
|
+
const parts = patternSegments(pattern)
|
|
77
|
+
const walk = (dir: string, rel: string, i: number): string[] => {
|
|
78
|
+
const part = parts[i]!
|
|
79
|
+
const last = i === parts.length - 1
|
|
80
|
+
const names = typeof part === 'string'
|
|
81
|
+
? [part]
|
|
82
|
+
: (() => {
|
|
83
|
+
try {
|
|
84
|
+
return readdirSync(dir).filter(name => !SKIPPED.has(name) && !name.startsWith('.') && part.test(name)).sort()
|
|
85
|
+
} catch {
|
|
86
|
+
return []
|
|
87
|
+
}
|
|
88
|
+
})()
|
|
89
|
+
return names.flatMap((name) => {
|
|
90
|
+
const path = join(dir, name)
|
|
91
|
+
const next = rel ? `${rel}/${name}` : name
|
|
92
|
+
if (last) return name === DOCUMENT_ENTRY && existsSync(path) && statSync(path).isFile() ? [next] : []
|
|
93
|
+
return existsSync(path) && statSync(path).isDirectory() ? walk(path, next, i + 1) : []
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
for (const entry of walk(config.root, '', 0)) if (!found.includes(entry)) found.push(entry)
|
|
97
|
+
}
|
|
98
|
+
return found
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface DocumentEntry {
|
|
102
|
+
dir: string
|
|
103
|
+
entry: string
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface FoundDocument extends DocumentSummary, DocumentEntry {}
|
|
107
|
+
|
|
108
|
+
/** The configured documents that exist on disk, without reading their content. */
|
|
109
|
+
export function listDocumentEntries(config: WorkspaceConfig): DocumentEntry[] {
|
|
110
|
+
return entries(config).map(entry => ({ dir: entry.split('/').slice(0, -1).join('/'), entry }))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function readDocumentSummary(config: WorkspaceConfig, { dir, entry }: DocumentEntry): FoundDocument {
|
|
114
|
+
let headmatter: Record<string, unknown> = {}
|
|
115
|
+
try {
|
|
116
|
+
const parsed: unknown = JSON.parse(readFileSync(join(config.root, entry), 'utf8'))
|
|
117
|
+
headmatter = isRecord(parsed) && isRecord(parsed.metadata) ? parsed.metadata : {}
|
|
118
|
+
} catch {
|
|
119
|
+
}
|
|
120
|
+
const tags = strings(headmatter.tags)
|
|
121
|
+
return {
|
|
122
|
+
dir,
|
|
123
|
+
entry,
|
|
124
|
+
title: typeof headmatter.title === 'string' && headmatter.title.trim() ? headmatter.title.trim() : dir || config.title,
|
|
125
|
+
...typeof headmatter.description === 'string' && headmatter.description.trim() ? { description: headmatter.description.trim() } : {},
|
|
126
|
+
...typeof headmatter.lang === 'string' && headmatter.lang.trim() ? { lang: headmatter.lang.trim() } : {},
|
|
127
|
+
...tags.length > 0 ? { tags } : {},
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function listDocuments(config: WorkspaceConfig): FoundDocument[] {
|
|
132
|
+
return listDocumentEntries(config).map(entry => readDocumentSummary(config, entry))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function documentSummaries(config: WorkspaceConfig): DocumentSummary[] {
|
|
136
|
+
return listDocuments(config).map(({ entry: _entry, ...document }) => document)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function isDocumentDir(config: WorkspaceConfig, dir: string): boolean {
|
|
140
|
+
return config.documents.some(pattern => matchesPattern(pattern, dir ? `${dir}/${DOCUMENT_ENTRY}` : DOCUMENT_ENTRY))
|
|
141
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createReadStream, realpathSync } from 'node:fs'
|
|
2
|
+
import { basename, isAbsolute, relative, resolve, sep } from 'node:path'
|
|
3
|
+
import { Readable } from 'node:stream'
|
|
4
|
+
import { lookup } from 'mime-types'
|
|
5
|
+
import type { TeachingDocument } from '@lincyaw/kernel'
|
|
6
|
+
import { RequestError } from './errors.ts'
|
|
7
|
+
|
|
8
|
+
/** Read only a file registered by the current canonical document, without compiling it. */
|
|
9
|
+
export function documentAssetResponse(root: string, directory: string, document: TeachingDocument, id: string, readableFile: (path: string) => string, signal?: AbortSignal): Response {
|
|
10
|
+
const asset = document.assets[id]
|
|
11
|
+
if (!asset) throw new RequestError(404, 'document-asset-missing')
|
|
12
|
+
if (!asset.location || /^(?:[a-z][a-z\d+.-]*:|\/)/i.test(asset.location)) throw new RequestError(400, 'document-asset-not-local')
|
|
13
|
+
let location = asset.location.replace(/[?#].*$/s, '')
|
|
14
|
+
try { location = decodeURIComponent(location) } catch { throw new RequestError(400, 'document-asset-path') }
|
|
15
|
+
if (!location || isAbsolute(location) || location.includes('\\') || location.includes('\0')) throw new RequestError(400, 'document-asset-path')
|
|
16
|
+
const workspace = realpathSync(root), target = resolve(workspace, directory, location)
|
|
17
|
+
const path = relative(workspace, target)
|
|
18
|
+
if (!path || path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path)) throw new RequestError(403, 'document-asset-outside-workspace')
|
|
19
|
+
const file = readableFile(path.split(sep).join('/'))
|
|
20
|
+
const type = lookup(file) || 'application/octet-stream'
|
|
21
|
+
const inline = /^(?:image\/(?:png|jpeg|gif|webp|avif|svg\+xml|bmp|x-icon)|audio\/|video\/)/i.test(type)
|
|
22
|
+
return new Response(Readable.toWeb(createReadStream(file, { signal })), { headers: {
|
|
23
|
+
'content-type': type,
|
|
24
|
+
'cache-control': 'private, no-store',
|
|
25
|
+
'content-disposition': `${inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(basename(file))}`,
|
|
26
|
+
'x-content-type-options': 'nosniff',
|
|
27
|
+
'cross-origin-resource-policy': 'same-origin',
|
|
28
|
+
'content-security-policy': "sandbox; default-src 'none'; style-src 'unsafe-inline'",
|
|
29
|
+
} })
|
|
30
|
+
}
|