@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,332 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { lstatSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { WORKSPACE_CONFIG } from '@lincyaw/compiler'
|
|
5
|
+
import { DOCUMENT_ENTRY, validateDocument } from '@lincyaw/kernel'
|
|
6
|
+
import type { ConflictAnswer, DocumentFileChange, FilesAnswer, Member, Revision, SavedAnswer, SourceAnswer, SourceFile, SourceTransactionAnswer, SourceTransactionChange } from '@lincyaw/kernel'
|
|
7
|
+
import { isWithin } from '../domain/paths.ts'
|
|
8
|
+
import { listDocumentEntries, readDocumentSummary } from './config.ts'
|
|
9
|
+
import type { DocumentEntry, WorkspaceConfig } from './config.ts'
|
|
10
|
+
import { RequestError } from './errors.ts'
|
|
11
|
+
import { lockedError } from './locks.ts'
|
|
12
|
+
import type { DocumentLocks } from './locks.ts'
|
|
13
|
+
import { documentDirOf, FilesError, writeChanges } from './writer.ts'
|
|
14
|
+
import type { DocumentWriter } from './writer.ts'
|
|
15
|
+
|
|
16
|
+
export const MAX_SOURCE = 2 << 20
|
|
17
|
+
export const MAX_SOURCE_CHANGES = 16
|
|
18
|
+
const MAX_FILES = 2000
|
|
19
|
+
const HIDDEN_FROM_EDITOR = new Set(['node_modules', 'dist', '.git', '.presmium'])
|
|
20
|
+
const BINARY = /\.(?:png|jpe?g|gif|webp|avif|ico|bmp|pdf|wasm|zip|gz|tgz|bz2|xz|7z|woff2?|ttf|otf|eot|mp3|mp4|m4a|wav|ogg|oga|webm|mov|avi|mkv|bin|so|dylib|dll|exe|pyc|xlsx?|docx?|pptx?|parquet|npy|npz|pkl|db|sqlite)$/i
|
|
21
|
+
|
|
22
|
+
export function validateDocumentSource(path: string, content: string, before?: Uint8Array): void {
|
|
23
|
+
if (path !== DOCUMENT_ENTRY && !path.endsWith(`/${DOCUMENT_ENTRY}`)) return
|
|
24
|
+
let value: unknown
|
|
25
|
+
try { value = JSON.parse(content.replace(/^\uFEFF/, '')) }
|
|
26
|
+
catch { throw new RequestError(422, 'document-invalid', { issues: [{ path: '', code: 'json-syntax', message: 'The document is not valid JSON.', severity: 'error' }] }) }
|
|
27
|
+
const checked = validateDocument(value)
|
|
28
|
+
if (!checked.document || checked.issues.some(issue => issue.severity === 'error')) throw new RequestError(422, 'document-invalid', { issues: checked.issues })
|
|
29
|
+
if (before) {
|
|
30
|
+
let previous: unknown
|
|
31
|
+
try { previous = JSON.parse(Buffer.from(before).toString('utf8').replace(/^\uFEFF/, '')) } catch {}
|
|
32
|
+
if (previous && typeof previous === 'object' && 'id' in previous && typeof previous.id === 'string' && previous.id !== checked.document.id) throw new RequestError(422, 'document-identity-changed')
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function versionOf(bytes: Uint8Array | string): string {
|
|
37
|
+
return createHash('sha256').update(bytes).digest('hex').slice(0, 20)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SourceEditorOptions {
|
|
41
|
+
config: WorkspaceConfig
|
|
42
|
+
writer: DocumentWriter
|
|
43
|
+
locks?: DocumentLocks
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface Scope {
|
|
47
|
+
document: string
|
|
48
|
+
home: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface SourceTransactionHistory {
|
|
52
|
+
run<T>(work: () => Promise<T>): Promise<T>
|
|
53
|
+
commit(paths: string[], lang: string | undefined): Promise<Revision | undefined>
|
|
54
|
+
}
|
|
55
|
+
interface TransactionFile { path: string, content: string | Uint8Array, base: string | null }
|
|
56
|
+
|
|
57
|
+
/** The source files the browser's editor lists, opens, creates and saves, as far as the member and the page's document may. */
|
|
58
|
+
export class SourceEditor {
|
|
59
|
+
readonly #config: WorkspaceConfig
|
|
60
|
+
readonly #writer: DocumentWriter
|
|
61
|
+
readonly #locks?: DocumentLocks
|
|
62
|
+
|
|
63
|
+
constructor(options: SourceEditorOptions) {
|
|
64
|
+
this.#config = options.config
|
|
65
|
+
this.#writer = options.writer
|
|
66
|
+
this.#locks = options.locks
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
scope(document: string): Scope {
|
|
70
|
+
return { document, home: document === '' && !this.#config.singleDeck }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
roots(scope: Scope): string[] {
|
|
74
|
+
if (!scope.home) return [scope.document]
|
|
75
|
+
return ['']
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
canEdit(scope: Scope, path: string, member: Member): boolean {
|
|
79
|
+
return this.#canEdit(scope, path, member)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#canEdit(scope: Scope, path: string, member: Member, documents?: readonly DocumentEntry[]): boolean {
|
|
83
|
+
if (path === WORKSPACE_CONFIG) return (scope.home || (this.#config.singleDeck && scope.document === '')) && member.roles.includes('owner')
|
|
84
|
+
if (scope.home && member.roles.includes('owner')) return true
|
|
85
|
+
const dir = documentDirOf(this.#config, path)
|
|
86
|
+
if (dir === undefined) return scope.home && member.roles.includes('owner')
|
|
87
|
+
return scope.home ? (documents ?? listDocumentEntries(this.#config)).some(doc => doc.dir === dir) : dir === scope.document
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
files(scope: Scope, member: Member): FilesAnswer {
|
|
91
|
+
const files: SourceFile[] = []
|
|
92
|
+
const documents = scope.home ? listDocumentEntries(this.#config) : undefined
|
|
93
|
+
let truncated = false
|
|
94
|
+
const walk = (dir: string, owner: string | undefined) => {
|
|
95
|
+
let names: string[]
|
|
96
|
+
try {
|
|
97
|
+
names = readdirSync(join(this.#config.root, dir)).sort()
|
|
98
|
+
} catch {
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
for (const name of names) {
|
|
102
|
+
if (HIDDEN_FROM_EDITOR.has(name) || name.startsWith('.')) continue
|
|
103
|
+
const path = dir ? `${dir}/${name}` : name
|
|
104
|
+
const found = lstatSync(join(this.#config.root, path), { throwIfNoEntry: false })
|
|
105
|
+
if (!found || found.isSymbolicLink()) continue
|
|
106
|
+
if (found.isDirectory() && (scope.home || documentDirOf(this.#config, `${path}/x`) === owner)) {
|
|
107
|
+
walk(path, owner)
|
|
108
|
+
} else if (found.isFile() && this.#canEdit(scope, path, member, documents)) {
|
|
109
|
+
if (files.length >= MAX_FILES) {
|
|
110
|
+
truncated = true
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
files.push({ path, ...BINARY.test(name) ? { binary: true as const } : {} })
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
walk(scope.home ? '' : scope.document, scope.home ? undefined : scope.document)
|
|
118
|
+
return { document: scope.document, home: scope.home, ...documents ? { documents: documents.map(doc => doc.dir) } : {}, files, ...truncated ? { truncated: true as const } : {} }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
#documentHolding(path: string, documents: readonly DocumentEntry[]): DocumentEntry | undefined {
|
|
122
|
+
let found: DocumentEntry | undefined
|
|
123
|
+
for (const doc of documents) if (isWithin(path, doc.dir) && (!found || doc.dir.length > found.dir.length)) found = doc
|
|
124
|
+
return found
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Checks path and document policy without requiring a file to exist. */
|
|
128
|
+
assertEditable(scope: Scope, path: string, member: Member): void {
|
|
129
|
+
this.#assertEditable(scope, path, member)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
#assertEditable(scope: Scope, path: string, member: Member, documents?: readonly DocumentEntry[]): void {
|
|
133
|
+
if (typeof path !== 'string' || !path || path.startsWith('/') || path.includes('\\') || path.includes('\0') || path.split('/').some(part => part === '' || part === '.' || part === '..' || HIDDEN_FROM_EDITOR.has(part))) throw new RequestError(400, 'workspace-path-invalid', { path: String(path).slice(0, 500) })
|
|
134
|
+
if (!this.#canEdit(scope, path, member, documents)) throw new RequestError(403, 'workspace-file-forbidden', { path })
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A regular file under the same path and document policy as source editing. */
|
|
138
|
+
readableFile(scope: Scope, path: string, member: Member): string {
|
|
139
|
+
return this.#readableFile(scope, path, member)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
#readableFile(scope: Scope, path: string, member: Member, documents?: readonly DocumentEntry[]): string {
|
|
143
|
+
this.#assertEditable(scope, path, member, documents)
|
|
144
|
+
const file = join(this.#config.root, path)
|
|
145
|
+
let real: string
|
|
146
|
+
try {
|
|
147
|
+
real = realpathSync(file)
|
|
148
|
+
} catch {
|
|
149
|
+
throw new RequestError(404, 'workspace-file-missing', { path })
|
|
150
|
+
}
|
|
151
|
+
if (real !== file || !lstatSync(file).isFile()) throw new RequestError(403, 'workspace-file-irregular', { path })
|
|
152
|
+
return file
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
#readBytes(scope: Scope, path: string, member: Member, documents?: readonly DocumentEntry[]): Buffer {
|
|
156
|
+
const file = this.#readableFile(scope, path, member, documents)
|
|
157
|
+
const bytes = readFileSync(file)
|
|
158
|
+
if (bytes.byteLength > MAX_SOURCE) throw new RequestError(413, `${path} is larger than the editor opens (${MAX_SOURCE} bytes)`)
|
|
159
|
+
return bytes
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
read(scope: Scope, path: string, member: Member): SourceAnswer {
|
|
163
|
+
const bytes = this.#readBytes(scope, path, member)
|
|
164
|
+
if (BINARY.test(path) || bytes.includes(0)) throw new RequestError(415, `${path} is not a text file`)
|
|
165
|
+
let content: string
|
|
166
|
+
try {
|
|
167
|
+
content = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes)
|
|
168
|
+
} catch {
|
|
169
|
+
throw new RequestError(415, `${path} is not UTF-8 text`)
|
|
170
|
+
}
|
|
171
|
+
return { path, content, version: versionOf(bytes) }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async create(scope: Scope, path: string, member: Member): Promise<SourceAnswer> {
|
|
175
|
+
const documents = listDocumentEntries(this.#config)
|
|
176
|
+
this.#assertEditable(scope, path, member, documents)
|
|
177
|
+
if (path === DOCUMENT_ENTRY || path.endsWith(`/${DOCUMENT_ENTRY}`)) throw new RequestError(422, 'create-a-document-with-valid-content')
|
|
178
|
+
if (BINARY.test(path)) throw new RequestError(415, `${path} is not a text file`)
|
|
179
|
+
const document = this.#documentHolding(path, documents)?.dir
|
|
180
|
+
const scopeDir = document ?? ''
|
|
181
|
+
const release = this.#locks ? this.#locks.claim(scopeDir, 'editor:create') : () => {}
|
|
182
|
+
if (!release) throw lockedError(scopeDir, this.#locks!.holder(scopeDir)!)
|
|
183
|
+
try {
|
|
184
|
+
await this.#writer.create(path, Buffer.alloc(0), document === undefined)
|
|
185
|
+
return { path, content: '', version: versionOf('') }
|
|
186
|
+
} catch (error) {
|
|
187
|
+
if ((error as NodeJS.ErrnoException).code === 'EEXIST') throw new RequestError(409, `${path} already exists`, { exists: true })
|
|
188
|
+
if (error instanceof FilesError) throw new RequestError(403, error.message)
|
|
189
|
+
throw error
|
|
190
|
+
} finally {
|
|
191
|
+
release()
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Saves related sources as one authoring change; any failed write or commit restores all preimages. */
|
|
196
|
+
async transaction(scope: Scope, changes: SourceTransactionChange[], member: Member, history?: SourceTransactionHistory): Promise<SourceTransactionAnswer> {
|
|
197
|
+
return this.#transaction(scope, changes, member, history)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Checks the same scope, size, CAS and candidate rules without writing or committing. */
|
|
201
|
+
async planTransaction(scope: Scope, changes: SourceTransactionChange[], member: Member): Promise<SourceTransactionAnswer> {
|
|
202
|
+
return this.#transaction(scope, changes, member, undefined, undefined, true)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** A validated authoring document and one new original attachment share CAS, rollback and history. */
|
|
206
|
+
async transactionWithBinary(scope: Scope, document: SourceTransactionChange, attachment: { path: string, content: Uint8Array }, member: Member, history?: SourceTransactionHistory): Promise<SourceTransactionAnswer> {
|
|
207
|
+
if (document.path !== DOCUMENT_ENTRY && !document.path.endsWith(`/${DOCUMENT_ENTRY}`) || document.base === null) throw new RequestError(400, 'document-transaction-required')
|
|
208
|
+
if (!attachment.content.byteLength || attachment.content.byteLength > 16 << 20) throw new RequestError(413, 'production.asset-size')
|
|
209
|
+
return this.#transaction(scope, [document, { ...attachment, content: new Uint8Array(attachment.content), base: null }], member, history, attachment.path)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async #transaction(scope: Scope, changes: TransactionFile[], member: Member, history?: SourceTransactionHistory, binaryPath?: string, dryRun = false): Promise<SourceTransactionAnswer> {
|
|
213
|
+
if (changes.length === 0 || changes.length > MAX_SOURCE_CHANGES) throw new RequestError(400, `a source transaction takes 1 to ${MAX_SOURCE_CHANGES} files`)
|
|
214
|
+
const paths = new Set<string>()
|
|
215
|
+
const known = listDocumentEntries(this.#config)
|
|
216
|
+
let bytes = 0
|
|
217
|
+
const documents = changes.map(change => {
|
|
218
|
+
this.#assertEditable(scope, change.path, member, known)
|
|
219
|
+
if (paths.has(change.path)) throw new RequestError(400, `a source path occurs twice: ${change.path}`)
|
|
220
|
+
paths.add(change.path)
|
|
221
|
+
if (change.path !== binaryPath) {
|
|
222
|
+
if (typeof change.content !== 'string' || BINARY.test(change.path) || change.content.includes('\0')) throw new RequestError(415, `${change.path} is not a text file`)
|
|
223
|
+
bytes += Buffer.byteLength(change.content, 'utf8')
|
|
224
|
+
}
|
|
225
|
+
return this.#documentHolding(change.path, known)
|
|
226
|
+
})
|
|
227
|
+
if (bytes > MAX_SOURCE) throw new RequestError(413, `the transaction is larger than the editor saves (${MAX_SOURCE} bytes)`)
|
|
228
|
+
for (const path of paths) if ([...paths].some(other => path !== other && path.startsWith(`${other}/`))) throw new RequestError(400, `a source file cannot also be a parent directory: ${path}`)
|
|
229
|
+
const document = documents[0]
|
|
230
|
+
if (!document || documents.some(found => found?.dir !== document.dir)) throw new RequestError(400, 'a source transaction must stay in one document')
|
|
231
|
+
const scopeDir = document.dir
|
|
232
|
+
const release = this.#locks ? this.#locks.claim(scopeDir, 'editor:transaction') : () => {}
|
|
233
|
+
if (!release) throw lockedError(scopeDir, this.#locks!.holder(scopeDir)!)
|
|
234
|
+
const work = async (): Promise<SourceTransactionAnswer> => {
|
|
235
|
+
// Read every preimage before creating directories or writing any file.
|
|
236
|
+
const snapshots = changes.map(change => {
|
|
237
|
+
const written = typeof change.content === 'string' ? Buffer.from(change.content, 'utf8') : Buffer.from(change.content)
|
|
238
|
+
if (change.base !== null) {
|
|
239
|
+
const before = this.#readBytes(scope, change.path, member, known)
|
|
240
|
+
const version = versionOf(before)
|
|
241
|
+
if (version !== change.base) throw new RequestError(409, `${change.path} changed since it was opened`, { conflict: { path: change.path, version, content: before.toString('utf8') } })
|
|
242
|
+
if (BINARY.test(change.path) || before.includes(0)) throw new RequestError(415, `${change.path} is not a text file`)
|
|
243
|
+
try { new TextDecoder('utf-8', { fatal: true }).decode(before) } catch { throw new RequestError(415, `${change.path} is not UTF-8 text`) }
|
|
244
|
+
const executable = (lstatSync(join(this.#config.root, change.path)).mode & 0o111) !== 0
|
|
245
|
+
return { path: change.path, before, written, executable }
|
|
246
|
+
}
|
|
247
|
+
const file = join(this.#config.root, change.path)
|
|
248
|
+
let parent = this.#config.root
|
|
249
|
+
for (const part of change.path.split('/').slice(0, -1)) {
|
|
250
|
+
parent = join(parent, part)
|
|
251
|
+
const found = lstatSync(parent, { throwIfNoEntry: false })
|
|
252
|
+
if (found && (!found.isDirectory() || realpathSync(parent) !== parent)) throw new RequestError(403, `${change.path} has no regular workspace parent`)
|
|
253
|
+
if (!found) break
|
|
254
|
+
}
|
|
255
|
+
if (lstatSync(file, { throwIfNoEntry: false })) throw new RequestError(409, `${change.path} already exists`, { exists: true })
|
|
256
|
+
return { path: change.path, before: undefined, written, executable: false }
|
|
257
|
+
})
|
|
258
|
+
for (const snapshot of snapshots) if (snapshot.path !== binaryPath) validateDocumentSource(snapshot.path, snapshot.written.toString('utf8'), snapshot.before)
|
|
259
|
+
if (dryRun) return { versions: snapshots.map(snapshot => ({ path: snapshot.path, version: versionOf(snapshot.written) })) }
|
|
260
|
+
const created: string[] = []
|
|
261
|
+
const edited = snapshots.filter(snapshot => snapshot.before && !snapshot.written.equals(snapshot.before))
|
|
262
|
+
const touched = snapshots.filter(snapshot => !snapshot.before || !snapshot.written.equals(snapshot.before)).map(snapshot => snapshot.path)
|
|
263
|
+
const lang = history && touched.length ? readDocumentSummary(this.#config, document).lang : undefined
|
|
264
|
+
const write = (next: DocumentFileChange[]) => this.#writer.write(next)
|
|
265
|
+
try {
|
|
266
|
+
// Exclusive creation prevents replacing a file created outside serve after validation.
|
|
267
|
+
for (const snapshot of snapshots) if (!snapshot.before) {
|
|
268
|
+
await this.#writer.create(snapshot.path, snapshot.written)
|
|
269
|
+
created.push(snapshot.path)
|
|
270
|
+
}
|
|
271
|
+
if (edited.length) await write(edited.map(snapshot => ({ path: snapshot.path, content: snapshot.written, ...snapshot.executable ? { executable: true } : {} })))
|
|
272
|
+
const revision = history && touched.length ? await history.commit(touched, lang) : undefined
|
|
273
|
+
return { versions: snapshots.map(snapshot => ({ path: snapshot.path, version: versionOf(snapshot.written) })), ...revision ? { revision } : {} }
|
|
274
|
+
} catch (error) {
|
|
275
|
+
try {
|
|
276
|
+
await write([
|
|
277
|
+
...snapshots.flatMap(snapshot => snapshot.before ? [{ path: snapshot.path, content: snapshot.before, ...snapshot.executable ? { executable: true } : {} }] : []),
|
|
278
|
+
...created.map(path => ({ path, remove: true as const })),
|
|
279
|
+
])
|
|
280
|
+
} catch (rollback) {
|
|
281
|
+
throw new Error(`source transaction failed (${error instanceof Error ? error.message : String(error)}); restoring sources also failed (${rollback instanceof Error ? rollback.message : String(rollback)})`)
|
|
282
|
+
}
|
|
283
|
+
throw error
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
return await (history ? history.run(work) : work())
|
|
288
|
+
} finally {
|
|
289
|
+
release()
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Writes the text over the version the editor opened. `commit` makes a change a revision while the document's lock
|
|
295
|
+
* is still held; when it fails, the file is put back as it was and the save fails.
|
|
296
|
+
*/
|
|
297
|
+
async save(scope: Scope, path: string, content: string, base: string, member: Member, commit?: (path: string, lang: string | undefined) => Promise<Revision | undefined>): Promise<SavedAnswer> {
|
|
298
|
+
const documents = listDocumentEntries(this.#config)
|
|
299
|
+
const document = this.#documentHolding(path, documents)
|
|
300
|
+
const outside = document === undefined
|
|
301
|
+
const scopeDir = document?.dir ?? ''
|
|
302
|
+
const release = this.#locks ? this.#locks.claim(scopeDir, 'editor:save') : () => {}
|
|
303
|
+
if (!release) throw lockedError(scopeDir, this.#locks!.holder(scopeDir)!)
|
|
304
|
+
try {
|
|
305
|
+
const bytes = this.#readBytes(scope, path, member, documents)
|
|
306
|
+
const now = versionOf(bytes)
|
|
307
|
+
if (now !== base) {
|
|
308
|
+
const conflict: Omit<ConflictAnswer, 'error'> = { conflict: { version: now, content: bytes.toString('utf8') } }
|
|
309
|
+
throw new RequestError(409, `${path} changed since it was opened`, conflict)
|
|
310
|
+
}
|
|
311
|
+
const written = Buffer.from(content, 'utf8')
|
|
312
|
+
validateDocumentSource(path, content, bytes)
|
|
313
|
+
if (written.byteLength > MAX_SOURCE) throw new RequestError(413, `the text is larger than the editor saves (${MAX_SOURCE} bytes)`)
|
|
314
|
+
const change: DocumentFileChange = { path, content: written, ...(lstatSync(join(this.#config.root, path)).mode & 0o111) !== 0 ? { executable: true } : {} }
|
|
315
|
+
const lang = commit && !written.equals(bytes) && document ? readDocumentSummary(this.#config, document).lang : undefined
|
|
316
|
+
const write = async (next: DocumentFileChange) => (outside ? writeChanges(this.#config, [next], true) : await this.#writer.write([next]))
|
|
317
|
+
await write(change)
|
|
318
|
+
let revision: Revision | undefined
|
|
319
|
+
if (commit && !written.equals(bytes)) {
|
|
320
|
+
try {
|
|
321
|
+
revision = await commit(path, lang)
|
|
322
|
+
} catch (error) {
|
|
323
|
+
await write({ ...change, content: bytes })
|
|
324
|
+
throw error
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return { version: versionOf(written), ...revision ? { revision } : {} }
|
|
328
|
+
} finally {
|
|
329
|
+
release()
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { isRecord, jsonResponse, problemResponse } from '@lincyaw/kernel'
|
|
2
|
+
import type { Route, SpeechSettingsChange } from '@lincyaw/kernel'
|
|
3
|
+
import { OperatorSpeechSettings, SpeechSettingsError } from '@lincyaw/kernel/node'
|
|
4
|
+
|
|
5
|
+
/** Global operator route: works before opening a document and never returns credential values. */
|
|
6
|
+
export function speechSettingsRoute(directory: string, fallback: (name: string) => string | undefined): Route {
|
|
7
|
+
const settings = new OperatorSpeechSettings(directory, fallback)
|
|
8
|
+
return { role: 'owner', maxBody: 16384, handle: async request => {
|
|
9
|
+
try {
|
|
10
|
+
if (request.method === 'GET') return jsonResponse(settings.read(), 200, { 'cache-control': 'no-store' })
|
|
11
|
+
if (request.method !== 'POST') return problemResponse(405, 'GET or POST')
|
|
12
|
+
const body: unknown = await request.json().catch(() => undefined)
|
|
13
|
+
if (!isRecord(body) || typeof body.revision !== 'string' || !['save', 'clear'].includes(String(body.action)) || Object.keys(body).some(key => !['action', 'revision', 'apiKey', 'appId'].includes(key))
|
|
14
|
+
|| body.action === 'save' && (body.apiKey !== undefined && typeof body.apiKey !== 'string' || body.appId !== undefined && typeof body.appId !== 'string')) return problemResponse(400, 'speech-settings-invalid')
|
|
15
|
+
return jsonResponse(await settings.change(body as unknown as SpeechSettingsChange), 200, { 'cache-control': 'no-store' })
|
|
16
|
+
} catch (error) {
|
|
17
|
+
const code = error instanceof SpeechSettingsError ? error.code : 'speech-settings-unavailable'
|
|
18
|
+
return problemResponse(code === 'speech-settings-invalid' ? 400 : code === 'speech-settings-conflict' || code === 'speech-settings-busy' ? 409 : 503, code)
|
|
19
|
+
}
|
|
20
|
+
} }
|
|
21
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readdirSync, statSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import type { WorkspaceConfig } from './config.ts'
|
|
5
|
+
|
|
6
|
+
const BUILTIN_TEMPLATES = fileURLToPath(new URL('../../templates/document', import.meta.url))
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_TEMPLATE = 'default'
|
|
9
|
+
|
|
10
|
+
function templatesDir(config: WorkspaceConfig): string {
|
|
11
|
+
return config.templates ?? BUILTIN_TEMPLATES
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function listTemplates(config: WorkspaceConfig): string[] {
|
|
15
|
+
const dir = templatesDir(config)
|
|
16
|
+
try {
|
|
17
|
+
return readdirSync(dir).filter(name => statSync(join(dir, name)).isDirectory()).sort()
|
|
18
|
+
} catch {
|
|
19
|
+
return []
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function findTemplate(config: WorkspaceConfig, name: string): string | undefined {
|
|
24
|
+
if (!name || name.includes('/') || name.includes('\\') || name.startsWith('.')) return undefined
|
|
25
|
+
const dir = join(templatesDir(config), name)
|
|
26
|
+
return existsSync(dir) && lstatSync(dir, { throwIfNoEntry: false })?.isDirectory() ? dir : undefined
|
|
27
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { relative, sep } from 'node:path'
|
|
2
|
+
import { relativeIR } from '@lincyaw/compiler'
|
|
3
|
+
import type { Compilation } from '@lincyaw/compiler'
|
|
4
|
+
import { errorMessage, PREVIEW_REVISION, PreviewPreparing, pushStream } from '@lincyaw/kernel'
|
|
5
|
+
import { irRevision } from '@lincyaw/kernel/node'
|
|
6
|
+
import type { DocumentVoiceAuthoring, DocumentVoiceCapabilities, Finding, IrAnswer, IRDocument, IrEvent, Logger, Member, PreviewAnswer, PreviewRenderer, VoiceCatalogAnswer } from '@lincyaw/kernel'
|
|
7
|
+
import type { DiagnosticStore, ServerHost } from '@lincyaw/server'
|
|
8
|
+
import type { CompileSession } from './compile-session.ts'
|
|
9
|
+
import { RequestError } from './errors.ts'
|
|
10
|
+
|
|
11
|
+
// The workbench asks again this soon when the viewer app or the document's code is still being built.
|
|
12
|
+
const PREPARING_RETRY_MS = 1000
|
|
13
|
+
|
|
14
|
+
export interface BuildAnswer {
|
|
15
|
+
outputs: { renderer: string, outDir: string }[]
|
|
16
|
+
findings: Finding[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CheckAnswer {
|
|
20
|
+
findings: Finding[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** What the workbench's routes ask of the workspace server: a document's IR and its changes, its previews, builds and checks. */
|
|
24
|
+
export interface WorkbenchApi {
|
|
25
|
+
ir(document: string): Promise<IrAnswer>
|
|
26
|
+
/** Observe already-requested compilations without starting one for source editing. */
|
|
27
|
+
onCompiled(document: string, listener: (event: IrEvent) => void): () => void
|
|
28
|
+
voiceCatalog(document: string, member: Member, engine?: string, lang?: string, signal?: AbortSignal): Promise<VoiceCatalogAnswer>
|
|
29
|
+
voiceCapabilities(document: string, member: Member, signal?: AbortSignal): Promise<DocumentVoiceCapabilities>
|
|
30
|
+
events(document: string, signal: AbortSignal): AsyncIterable<{ event: string, data: IrEvent }>
|
|
31
|
+
/**
|
|
32
|
+
* The document's preview addresses: the viewer app's page of the document, and the same page showing its slides.
|
|
33
|
+
* `prefetch`: the workbench prepares the document ahead of the author's click, answered at once. A build still running
|
|
34
|
+
* after the wait is answered with 503 `preview-preparing` and `Retry-After`.
|
|
35
|
+
*/
|
|
36
|
+
preview(document: string, renderer?: PreviewRenderer, prefetch?: boolean): Promise<PreviewAnswer>
|
|
37
|
+
build(document: string, to?: string[]): Promise<BuildAnswer>
|
|
38
|
+
check(document: string): Promise<CheckAnswer>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function withIrRevision(doc: IRDocument, revision: string): IRDocument {
|
|
42
|
+
return { ...doc, meta: { ...doc.meta, [PREVIEW_REVISION]: revision } }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function workspacePath(root: string, file: string): string {
|
|
46
|
+
return relative(root, file).split(sep).join('/')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function urlPath(dir: string): string {
|
|
50
|
+
return dir.split('/').map(encodeURIComponent).join('/')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface WorkbenchBackendOptions {
|
|
54
|
+
root: string
|
|
55
|
+
compileSessionOf(dir: string): CompileSession
|
|
56
|
+
serverHost: ServerHost
|
|
57
|
+
voices: DocumentVoiceAuthoring
|
|
58
|
+
diagnostics?: DiagnosticStore
|
|
59
|
+
/** The origin browsers reach the service at; `basePath` is the site's path on it (default `/`). */
|
|
60
|
+
url(): string
|
|
61
|
+
basePath?: string
|
|
62
|
+
preview?: { origins: string[] }
|
|
63
|
+
log: Logger
|
|
64
|
+
/** Starts the web dev server of a document ahead of its first request. */
|
|
65
|
+
warmSite?(dir: string): Promise<void>
|
|
66
|
+
/** The path of the document's page in the viewer app, once it can be shown there (prepared when not; `wait`: a while). */
|
|
67
|
+
viewerPage?(dir: string, wait: boolean): Promise<string | undefined>
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The workbench's API with what the workspace server tells it: each compilation, the documents removed, closing. */
|
|
71
|
+
export interface WorkbenchBackend extends WorkbenchApi {
|
|
72
|
+
compiled(dir: string, compilation: Compilation, changed: string[]): void
|
|
73
|
+
/** The document as the site shows it, stamped with its IR revision when pages embed previews. */
|
|
74
|
+
stampPreviewRevision(compilation: Compilation, doc: IRDocument): IRDocument
|
|
75
|
+
documentsRemoved(dirs: string[]): Promise<void>
|
|
76
|
+
close(): Promise<void>
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createWorkbenchBackend(options: WorkbenchBackendOptions): WorkbenchBackend {
|
|
80
|
+
const { root, log } = options
|
|
81
|
+
const irRevisions = new WeakMap<Compilation, string>()
|
|
82
|
+
const relativeDocs = new WeakMap<Compilation, IRDocument>()
|
|
83
|
+
const irRevisionOf = (compilation: Compilation) => {
|
|
84
|
+
let known = irRevisions.get(compilation)
|
|
85
|
+
if (!known) irRevisions.set(compilation, known = irRevision(compilation.doc))
|
|
86
|
+
return known
|
|
87
|
+
}
|
|
88
|
+
const listeners = new Map<string, Set<(event: IrEvent) => void>>()
|
|
89
|
+
let previewRequest = 0
|
|
90
|
+
|
|
91
|
+
const eventOf = (dir: string, compilation: Compilation, changed: string[]): IrEvent => {
|
|
92
|
+
const count = (severity: Finding['severity']) => compilation.doc.diagnostics.filter(finding => finding.severity === severity).length
|
|
93
|
+
return { document: dir, revision: irRevisionOf(compilation), diagnostics: { errors: count('error'), warnings: count('warning') }, files: changed.map(file => workspacePath(root, file)) }
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
onCompiled(document, listener) {
|
|
97
|
+
const own = listeners.get(document) ?? new Set()
|
|
98
|
+
listeners.set(document, own); own.add(listener)
|
|
99
|
+
return () => { own.delete(listener); if (!own.size) listeners.delete(document) }
|
|
100
|
+
},
|
|
101
|
+
voiceCapabilities: (document, member, signal) => options.voices.capabilities(document, member, signal),
|
|
102
|
+
voiceCatalog: (document, member, engine, lang, signal) => options.voices.catalog(document, { ...engine ? { engine } : {}, ...lang ? { lang } : {} }, member, signal),
|
|
103
|
+
async ir(document) {
|
|
104
|
+
const compilation = await options.compileSessionOf(document).ready()
|
|
105
|
+
let doc = relativeDocs.get(compilation)
|
|
106
|
+
if (!doc) relativeDocs.set(compilation, doc = relativeIR(compilation.doc, root, root))
|
|
107
|
+
return { document, revision: irRevisionOf(compilation), doc, diagnostics: doc.diagnostics, sourceCapabilities: compilation.sourceCapabilities,
|
|
108
|
+
...compilation.sourceFingerprints ? { sourceFingerprints: Object.fromEntries(Object.entries(compilation.sourceFingerprints).map(([file, value]) => [workspacePath(root, file), value])) } : {} }
|
|
109
|
+
},
|
|
110
|
+
events(document, signal) {
|
|
111
|
+
const session = options.compileSessionOf(document)
|
|
112
|
+
return pushStream((push) => {
|
|
113
|
+
const own = listeners.get(document) ?? new Set()
|
|
114
|
+
listeners.set(document, own)
|
|
115
|
+
const listener = (event: IrEvent) => push({ event: 'revision', data: event })
|
|
116
|
+
own.add(listener)
|
|
117
|
+
if (session.latest) listener(eventOf(document, session.latest, []))
|
|
118
|
+
else void session.ready().catch(() => undefined)
|
|
119
|
+
return () => own.delete(listener)
|
|
120
|
+
}, { signal })
|
|
121
|
+
},
|
|
122
|
+
async preview(document, renderer, prefetch = false) {
|
|
123
|
+
const request = ++previewRequest
|
|
124
|
+
const started = Date.now()
|
|
125
|
+
const label = `[preview ${request}] document=${JSON.stringify(document || '.')} renderer=${renderer ?? 'all'} prefetch=${prefetch}`
|
|
126
|
+
let phase = 'viewer'
|
|
127
|
+
let outcome = 'failed'
|
|
128
|
+
log.info(`${label} state=started`)
|
|
129
|
+
try {
|
|
130
|
+
const base = options.url()
|
|
131
|
+
// The viewer app's page once it has what the document needs (an opened document waits for it a while), else the
|
|
132
|
+
// document's page on the site.
|
|
133
|
+
const page = await options.viewerPage?.(document, !prefetch).catch((error: unknown) => {
|
|
134
|
+
if (error instanceof PreviewPreparing) {
|
|
135
|
+
outcome = 'preparing'
|
|
136
|
+
throw new RequestError(503, 'preview-preparing', { retryAfterMs: PREPARING_RETRY_MS }, { 'Retry-After': String(PREPARING_RETRY_MS / 1000) })
|
|
137
|
+
}
|
|
138
|
+
log.warn(`the viewer app cannot show ${document || '.'}: ${errorMessage(error)}`)
|
|
139
|
+
if (!prefetch) throw error
|
|
140
|
+
return undefined
|
|
141
|
+
})
|
|
142
|
+
const web = page ? `${base}${page}` : `${base}${options.basePath ?? '/'}${document ? `${urlPath(document)}/` : ''}`
|
|
143
|
+
log.info(`${label} phase=viewer source=${page ? 'app' : 'site'} elapsedMs=${Date.now() - started}`)
|
|
144
|
+
phase = 'compilation'
|
|
145
|
+
if (prefetch && !page) void options.warmSite?.(document).catch(() => undefined)
|
|
146
|
+
const compilation = await options.compileSessionOf(document).ready()
|
|
147
|
+
const configured = Object.keys(compilation.doc.config.renderers)
|
|
148
|
+
const slides = configured.length === 0 || configured.includes('slides')
|
|
149
|
+
outcome = 'ready'
|
|
150
|
+
return { document, web, ...slides ? { slides: `${web}${web.includes('?') ? '&' : '?'}form=slides` } : {} }
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (outcome !== 'preparing') log.warn(`${label} phase=${phase} error=${errorMessage(error)}`)
|
|
153
|
+
throw error
|
|
154
|
+
} finally {
|
|
155
|
+
log.info(`${label} state=${outcome} phase=${phase} elapsedMs=${Date.now() - started}`)
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
async build(document, to) {
|
|
159
|
+
return await options.serverHost.build(document, to ? { to } : {})
|
|
160
|
+
},
|
|
161
|
+
async check(document) {
|
|
162
|
+
return { findings: await options.serverHost.check(document) }
|
|
163
|
+
},
|
|
164
|
+
compiled(dir, compilation, changed) {
|
|
165
|
+
const event = eventOf(dir, compilation, changed)
|
|
166
|
+
for (const listener of listeners.get(dir) ?? []) listener(event)
|
|
167
|
+
},
|
|
168
|
+
stampPreviewRevision(compilation, doc) {
|
|
169
|
+
return options.preview ? withIrRevision(doc, irRevisionOf(compilation)) : doc
|
|
170
|
+
},
|
|
171
|
+
async documentsRemoved(dirs) {
|
|
172
|
+
for (const dir of dirs) options.diagnostics?.forget(dir)
|
|
173
|
+
},
|
|
174
|
+
async close() {},
|
|
175
|
+
}
|
|
176
|
+
}
|