@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,329 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import { DOCUMENT_ENTRY, fnv1a64, hasRole, isRecord, jsonResponse, problemResponse, PRODUCTION_AUDIO_AUTHORING, pushStream, SPEECH, sseResponse } from '@lincyaw/kernel'
|
|
4
|
+
import type { ChangesAnswer, CommitAnswer, CompileJob, DocumentsAnswer, JobsAnswer, Member, PresmiumNodePlugin, RequestContext, Route, SavedAnswer, ServerContribution, SourceTransactionAnswer, SourceTransactionChange } from '@lincyaw/kernel'
|
|
5
|
+
import { hasBearerToken } from '@lincyaw/server'
|
|
6
|
+
import { saveSummary } from '../domain/commit-message.ts'
|
|
7
|
+
import { documentSummaries, listDocuments } from './config.ts'
|
|
8
|
+
import type { WorkspaceConfig } from './config.ts'
|
|
9
|
+
import { documentsDiff } from './document-operations.ts'
|
|
10
|
+
import type { DocumentOperations, DocumentsChange } from './document-operations.ts'
|
|
11
|
+
import { catchRequestErrors, RequestError } from './errors.ts'
|
|
12
|
+
import { lockedError } from './locks.ts'
|
|
13
|
+
import type { DocumentLocks } from './locks.ts'
|
|
14
|
+
import type { Revisions } from './revisions.ts'
|
|
15
|
+
import { reviewRoutes } from './review/routes.ts'
|
|
16
|
+
import type { AgentSessionReviews } from './review/agent-session-reviews.ts'
|
|
17
|
+
import { MAX_SOURCE, MAX_SOURCE_CHANGES, validateDocumentSource } from './source-editor.ts'
|
|
18
|
+
import type { SourceEditor } from './source-editor.ts'
|
|
19
|
+
import { listTemplates } from './templates.ts'
|
|
20
|
+
import type { Jobs } from './jobs.ts'
|
|
21
|
+
import type { WorkbenchApi } from './workbench-api.ts'
|
|
22
|
+
import { documentEditorRoutes } from './document-editor.ts'
|
|
23
|
+
import type { DocumentEditor } from './document-editor.ts'
|
|
24
|
+
import { createDocumentAudioAuthoring } from './document-audio.ts'
|
|
25
|
+
import { createDocumentImageAuthoring } from './document-images.ts'
|
|
26
|
+
import { createDocumentTranscriptions, documentTranscriptionRoutes } from './document-transcription.ts'
|
|
27
|
+
import type { DocumentTranscriptions, ProductionAudioAuthoring, SpeechService } from '@lincyaw/kernel'
|
|
28
|
+
import { documentDirOf } from './writer.ts'
|
|
29
|
+
import { createDocumentHistory } from './document-history.ts'
|
|
30
|
+
|
|
31
|
+
const { version } = createRequire(import.meta.url)('../../package.json') as { version: string }
|
|
32
|
+
|
|
33
|
+
export const WORKSPACE_ID = 'presmium.workspace'
|
|
34
|
+
|
|
35
|
+
/** Host-level author routes and compiled workspace routes retain the same launcher authorization. */
|
|
36
|
+
export function authorizeWorkspaceRoutes(routes: Record<string, Route>, launcherToken?: string): Record<string, Route> {
|
|
37
|
+
if (!launcherToken) return routes
|
|
38
|
+
return Object.fromEntries(Object.entries(routes).map(([key, route]) => [key, { ...route, handle: (request, ctx) => hasBearerToken(request, launcherToken) ? route.handle(request, ctx) : Promise.resolve(problemResponse(401, 'the workspace service\'s routes need its token: Authorization: Bearer <PRESMIUM_SERVICE_TOKEN>')) } satisfies Route]))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The workspace's routes; the history, the document operations and the reviews are there only in a git repository. */
|
|
42
|
+
export interface WorkspaceRoutesOptions {
|
|
43
|
+
config: WorkspaceConfig
|
|
44
|
+
editor: SourceEditor
|
|
45
|
+
documentEditor: DocumentEditor
|
|
46
|
+
locks: DocumentLocks
|
|
47
|
+
revisions?: Revisions
|
|
48
|
+
operations?: DocumentOperations
|
|
49
|
+
reviews?: AgentSessionReviews
|
|
50
|
+
documentsChanged(change: DocumentsChange): Promise<void> | void
|
|
51
|
+
workbench?: WorkbenchApi
|
|
52
|
+
jobs?: Jobs
|
|
53
|
+
launcherToken?: string
|
|
54
|
+
audio?: ProductionAudioAuthoring
|
|
55
|
+
speech?: SpeechService
|
|
56
|
+
/** Supplied by a workspace host so remounting contributions does not replace or cancel tasks. */
|
|
57
|
+
transcriptions?: DocumentTranscriptions
|
|
58
|
+
transcriptionAdoptionRoutes?: Record<string, Route>
|
|
59
|
+
audioAuthoringRoutes?: Record<string, Route>
|
|
60
|
+
imageAuthoringRoutes?: Record<string, Route>
|
|
61
|
+
historyRoutes?: Record<string, Route>
|
|
62
|
+
authoringClientRoutes?: Record<string, Route>
|
|
63
|
+
projectRoutes?: Record<string, Route>
|
|
64
|
+
speechSettings?: Route
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function toRendererIds(body: unknown): string[] | undefined {
|
|
68
|
+
if (!isRecord(body) || !Array.isArray(body.to)) return undefined
|
|
69
|
+
const ids = body.to.filter((id): id is string => typeof id === 'string' && /^[\w.-]+$/.test(id))
|
|
70
|
+
return ids.length > 0 ? ids : undefined
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const NO_GIT = 'the workspace is not a git repository: run git init in it to use the document operations and the history'
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
function toChosenPaths(body: unknown, uncommitted: string[]): string[] {
|
|
77
|
+
const asked = isRecord(body) && Array.isArray(body.paths) ? body.paths.filter((path): path is string => typeof path === 'string') : undefined
|
|
78
|
+
return asked ? uncommitted.filter(path => asked.includes(path)) : uncommitted
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function toSaveMessage(value: unknown): string {
|
|
82
|
+
return typeof value === 'string' ? [...(value.trim().split(/\r?\n/)[0] ?? '').trim()].slice(0, 200).join('') : ''
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
export function workspaceRoutes(options: WorkspaceRoutesOptions): ServerContribution {
|
|
87
|
+
const { config, editor, locks, revisions, operations, reviews, workbench } = options
|
|
88
|
+
const listed = () => listDocuments(config).map(doc => doc.dir)
|
|
89
|
+
const scopeOf = (ctx: RequestContext) => editor.scope(ctx.document)
|
|
90
|
+
const uncommitted = async (ctx: RequestContext) => (revisions ? await revisions.uncommittedFiles(editor.roots(scopeOf(ctx))) : [])
|
|
91
|
+
const withChosenChanges = async (request: Request, ctx: RequestContext, holder: string, work: (revisions: Revisions, paths: string[], member: Member, body: Record<string, unknown>) => Promise<Response>) => {
|
|
92
|
+
if (request.method !== 'POST') return problemResponse(405, 'POST')
|
|
93
|
+
if (!revisions) return problemResponse(409, NO_GIT)
|
|
94
|
+
const body: unknown = await request.json().catch(() => ({}))
|
|
95
|
+
const paths = toChosenPaths(body, (await uncommitted(ctx)).map(file => file.path))
|
|
96
|
+
if (paths.length === 0) return problemResponse(409, 'no changes to take: every file is as committed')
|
|
97
|
+
const claimed = locks.claimAll(paths.map(path => documentDirOf(config, path) ?? ''), holder)
|
|
98
|
+
if ('holder' in claimed) throw lockedError(claimed.document, claimed.holder)
|
|
99
|
+
try {
|
|
100
|
+
return await work(revisions, paths, ctx.member, isRecord(body) ? body : {})
|
|
101
|
+
} finally {
|
|
102
|
+
claimed.release()
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const ownedHistory = options.historyRoutes ? undefined : createDocumentHistory({ config, editor, locks, revisions, changed: (before, after) => options.documentsChanged(documentsDiff(before, after)), reverted: (target, revision) => reviews?.reverted(target, revision) })
|
|
106
|
+
const ownedTranscriptions = options.transcriptions ? undefined : createDocumentTranscriptions({ root: config.root, editor, audio: options.audio, speech: options.speech })
|
|
107
|
+
const transcriptions = options.transcriptions ?? ownedTranscriptions!
|
|
108
|
+
const ownedAudio = options.audioAuthoringRoutes ? undefined : createDocumentAudioAuthoring({ root: config.root, editor, revisions, audio: options.audio, reviewRequired: config.settings['presmium.assistant']?.review === 'required' })
|
|
109
|
+
const routes: Record<string, Route> = {
|
|
110
|
+
...options.speechSettings ? { 'speech-settings': options.speechSettings } : {},
|
|
111
|
+
...documentEditorRoutes({ root: config.root, editor, documentEditor: options.documentEditor, workbench }),
|
|
112
|
+
...(options.audioAuthoringRoutes ?? ownedAudio!.routes),
|
|
113
|
+
...(options.imageAuthoringRoutes ?? createDocumentImageAuthoring({ root: config.root, editor, revisions, reviewRequired: config.settings['presmium.assistant']?.review === 'required' }).routes),
|
|
114
|
+
...documentTranscriptionRoutes(transcriptions),
|
|
115
|
+
...options.transcriptionAdoptionRoutes,
|
|
116
|
+
documents: {
|
|
117
|
+
role: 'reader',
|
|
118
|
+
handle: (request, ctx) => catchRequestErrors(async () => {
|
|
119
|
+
if (request.method === 'GET') {
|
|
120
|
+
const status = revisions ? await revisions.status(editor.roots(scopeOf(ctx))) : {}
|
|
121
|
+
return jsonResponse({ title: config.title, documents: documentSummaries(config), templates: listTemplates(config), git: Boolean(revisions), ...status, single: config.singleDeck } satisfies DocumentsAnswer)
|
|
122
|
+
}
|
|
123
|
+
if (request.method !== 'POST') return problemResponse(405, 'GET or POST')
|
|
124
|
+
return problemResponse(409, 'project-plan-required')
|
|
125
|
+
}),
|
|
126
|
+
},
|
|
127
|
+
...(options.historyRoutes ?? ownedHistory!.routes),
|
|
128
|
+
...options.authoringClientRoutes,
|
|
129
|
+
...options.projectRoutes,
|
|
130
|
+
'files': {
|
|
131
|
+
role: 'editor',
|
|
132
|
+
handle: (request, ctx) => catchRequestErrors(async () => {
|
|
133
|
+
if (request.method !== 'GET') return problemResponse(405, 'GET')
|
|
134
|
+
return jsonResponse(editor.files(scopeOf(ctx), ctx.member))
|
|
135
|
+
}),
|
|
136
|
+
},
|
|
137
|
+
'source': {
|
|
138
|
+
role: 'editor',
|
|
139
|
+
maxBody: MAX_SOURCE * 3,
|
|
140
|
+
handle: (request, ctx) => catchRequestErrors(async () => {
|
|
141
|
+
const scope = scopeOf(ctx)
|
|
142
|
+
if (request.method === 'GET') return jsonResponse(editor.read(scope, new URL(request.url).searchParams.get('path') ?? '', ctx.member))
|
|
143
|
+
if (request.method !== 'POST') return problemResponse(405, 'GET or POST')
|
|
144
|
+
const body: unknown = await request.json().catch(() => undefined)
|
|
145
|
+
if (!isRecord(body) || typeof body.path !== 'string' || typeof body.content !== 'string' || typeof body.base !== 'string') return problemResponse(400, '{ path, content, base: <the version opened>, message? }')
|
|
146
|
+
const message = toSaveMessage(body.message)
|
|
147
|
+
const commit = revisions && (async (path: string, lang: string | undefined) => (await revisions.ignores(path) ? undefined : revisions.commit({ files: [path], message: message || saveSummary(path, lang), origin: { kind: 'editor', member: ctx.member } })))
|
|
148
|
+
return jsonResponse(await editor.save(scope, body.path, body.content, body.base, ctx.member, commit) satisfies SavedAnswer)
|
|
149
|
+
}),
|
|
150
|
+
},
|
|
151
|
+
'source/create': {
|
|
152
|
+
role: 'editor',
|
|
153
|
+
maxBody: 8192,
|
|
154
|
+
handle: (request, ctx) => catchRequestErrors(async () => {
|
|
155
|
+
if (request.method !== 'POST') return problemResponse(405, 'POST')
|
|
156
|
+
const body: unknown = await request.json().catch(() => undefined)
|
|
157
|
+
if (!isRecord(body) || typeof body.path !== 'string') return problemResponse(400, '{ path: <new text file> }')
|
|
158
|
+
return jsonResponse(await editor.create(scopeOf(ctx), body.path, ctx.member), 201)
|
|
159
|
+
}),
|
|
160
|
+
},
|
|
161
|
+
'source/transaction': {
|
|
162
|
+
role: 'editor',
|
|
163
|
+
maxBody: MAX_SOURCE * 3 + 8192,
|
|
164
|
+
handle: (request, ctx) => catchRequestErrors(async () => {
|
|
165
|
+
if (request.method !== 'POST') return problemResponse(405, 'POST')
|
|
166
|
+
const body: unknown = await request.json().catch(() => undefined)
|
|
167
|
+
if (!isRecord(body) || !Array.isArray(body.changes) || body.changes.length === 0 || body.changes.length > MAX_SOURCE_CHANGES || !body.changes.every(change => isRecord(change) && typeof change.path === 'string' && typeof change.content === 'string' && (change.base === null || (typeof change.base === 'string' && change.base.length > 0 && change.base.length <= 200)))) return problemResponse(400, '{ changes: [{ path, content, base: <the version opened, or null for a new file> }], message? }')
|
|
168
|
+
const changes = body.changes as SourceTransactionChange[]
|
|
169
|
+
const message = toSaveMessage(body.message)
|
|
170
|
+
const history = revisions ? {
|
|
171
|
+
run: <T>(work: () => Promise<T>) => revisions.transaction(work),
|
|
172
|
+
commit: async (paths: string[], lang: string | undefined) => {
|
|
173
|
+
const ignored = (await Promise.all(paths.map(async path => await revisions.ignores(path) ? path : undefined))).filter((path): path is string => path !== undefined)
|
|
174
|
+
if (ignored.length) throw new RequestError(409, `a source transaction cannot record ignored files: ${ignored.join(', ')}`, { ignored })
|
|
175
|
+
return revisions.commitWhileLocked({ paths, summary: message || saveSummary(paths[0]!, lang), body: '', origin: { kind: 'editor', member: ctx.member } })
|
|
176
|
+
},
|
|
177
|
+
} : undefined
|
|
178
|
+
return jsonResponse(await editor.transaction(scopeOf(ctx), changes, ctx.member, history) satisfies SourceTransactionAnswer)
|
|
179
|
+
}),
|
|
180
|
+
},
|
|
181
|
+
'changes': {
|
|
182
|
+
role: 'editor',
|
|
183
|
+
handle: (request, ctx) => catchRequestErrors(async () => {
|
|
184
|
+
if (request.method !== 'GET') return problemResponse(405, 'GET')
|
|
185
|
+
return jsonResponse({ files: await uncommitted(ctx) } satisfies ChangesAnswer)
|
|
186
|
+
}),
|
|
187
|
+
},
|
|
188
|
+
'changes/commit': {
|
|
189
|
+
role: 'editor',
|
|
190
|
+
handle: (request, ctx) => catchRequestErrors(() => withChosenChanges(request, ctx, 'editor:commit', async (history, paths, member, body) => {
|
|
191
|
+
const message = typeof body.message === 'string' ? body.message.trim().slice(0, 4000) : ''
|
|
192
|
+
if (!message) return problemResponse(400, '{ message: what the change is, paths? }')
|
|
193
|
+
for (const path of paths) {
|
|
194
|
+
if (!path.endsWith(`/${DOCUMENT_ENTRY}`) && path !== DOCUMENT_ENTRY) continue
|
|
195
|
+
const source = editor.read(scopeOf(ctx), path, member)
|
|
196
|
+
validateDocumentSource(path, source.content)
|
|
197
|
+
}
|
|
198
|
+
const revision = await history.commit({ files: paths, message, origin: { kind: 'editor', member } })
|
|
199
|
+
return jsonResponse({ ...revision ? { revision } : {}, paths } satisfies CommitAnswer)
|
|
200
|
+
})),
|
|
201
|
+
},
|
|
202
|
+
...workbench ? workbenchRoutes(workbench) : {},
|
|
203
|
+
...options.jobs ? jobRoutes(options.jobs, editor) : {},
|
|
204
|
+
...reviews ? reviewRoutes(reviews) : {},
|
|
205
|
+
'changes/discard': {
|
|
206
|
+
role: 'editor',
|
|
207
|
+
handle: (request, ctx) => catchRequestErrors(() => withChosenChanges(request, ctx, 'editor:discard', async (history, paths) => {
|
|
208
|
+
const before = listed()
|
|
209
|
+
await history.discard(paths)
|
|
210
|
+
await options.documentsChanged(documentsDiff(before, listed()))
|
|
211
|
+
return jsonResponse({ paths } satisfies CommitAnswer)
|
|
212
|
+
})),
|
|
213
|
+
},
|
|
214
|
+
}
|
|
215
|
+
return { routes: authorizeWorkspaceRoutes(routes, options.launcherToken), close: async () => { await Promise.all([ownedTranscriptions?.close(), ownedAudio?.close(), ownedHistory?.close()]) } }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function workbenchRoutes(workbench: WorkbenchApi): Record<string, Route> {
|
|
219
|
+
return {
|
|
220
|
+
'voice-capabilities': {
|
|
221
|
+
role: 'editor',
|
|
222
|
+
handle: (request, ctx) => catchRequestErrors(async () => {
|
|
223
|
+
if (request.method !== 'GET') return problemResponse(405, 'GET')
|
|
224
|
+
return jsonResponse(await workbench.voiceCapabilities(ctx.document, ctx.member, request.signal))
|
|
225
|
+
}),
|
|
226
|
+
},
|
|
227
|
+
'voice-catalog': {
|
|
228
|
+
role: 'editor',
|
|
229
|
+
handle: (request, ctx) => catchRequestErrors(async () => {
|
|
230
|
+
if (request.method !== 'GET') return problemResponse(405, 'GET')
|
|
231
|
+
const params = new URL(request.url).searchParams
|
|
232
|
+
return jsonResponse(await workbench.voiceCatalog(ctx.document, ctx.member, params.get('engine') || undefined, params.get('lang') || undefined, request.signal))
|
|
233
|
+
}),
|
|
234
|
+
},
|
|
235
|
+
'ir': {
|
|
236
|
+
role: 'editor',
|
|
237
|
+
handle: (request, ctx) => catchRequestErrors(async () => {
|
|
238
|
+
if (request.method !== 'GET') return problemResponse(405, 'GET')
|
|
239
|
+
const ir = await workbench.ir(ctx.document)
|
|
240
|
+
const capabilities = createHash('sha256').update(JSON.stringify([ir.sourceCapabilities ?? {}, ir.sourceFingerprints ?? {}, ir.authoring ?? {}])).digest('hex').slice(0, 8)
|
|
241
|
+
const etag = `"${ir.revision}-${capabilities}"`
|
|
242
|
+
if (request.headers.get('if-none-match') === etag) return new Response(null, { status: 304, headers: { ETag: etag, 'Cache-Control': 'no-store' } })
|
|
243
|
+
const response = jsonResponse(ir)
|
|
244
|
+
response.headers.set('ETag', etag)
|
|
245
|
+
response.headers.set('Cache-Control', 'no-store')
|
|
246
|
+
return response
|
|
247
|
+
}),
|
|
248
|
+
},
|
|
249
|
+
'ir/events': {
|
|
250
|
+
role: 'editor',
|
|
251
|
+
handle: async (request, ctx) => (request.method === 'GET' ? sseResponse(workbench.events(ctx.document, request.signal), request.signal) : problemResponse(405, 'GET')),
|
|
252
|
+
},
|
|
253
|
+
'preview': {
|
|
254
|
+
role: 'editor',
|
|
255
|
+
handle: (request, ctx) => catchRequestErrors(async () => {
|
|
256
|
+
if (request.method !== 'GET') return problemResponse(405, 'GET')
|
|
257
|
+
const params = new URL(request.url).searchParams
|
|
258
|
+
const renderer = params.get('view') ?? undefined
|
|
259
|
+
if (renderer !== undefined && renderer !== 'web' && renderer !== 'slides') return problemResponse(400, 'view must be web or slides')
|
|
260
|
+
return jsonResponse(await workbench.preview(ctx.document, renderer, params.get('prefetch') === '1'))
|
|
261
|
+
}),
|
|
262
|
+
},
|
|
263
|
+
'build': {
|
|
264
|
+
role: 'editor',
|
|
265
|
+
handle: (request, ctx) => catchRequestErrors(async () => {
|
|
266
|
+
if (request.method !== 'POST') return problemResponse(405, 'POST')
|
|
267
|
+
return jsonResponse(await workbench.build(ctx.document, toRendererIds(await request.json().catch(() => undefined))))
|
|
268
|
+
}),
|
|
269
|
+
},
|
|
270
|
+
'check': {
|
|
271
|
+
role: 'editor',
|
|
272
|
+
handle: (request, ctx) => catchRequestErrors(async () => (request.method === 'POST' ? jsonResponse(await workbench.check(ctx.document)) : problemResponse(405, 'POST'))),
|
|
273
|
+
},
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The document's builds and exports (`jobs`): `GET` what it can be made into and its jobs, `POST { target }` starts one;
|
|
279
|
+
* `jobs/cancel` (`POST { id }`); `jobs/events`, each change of the document's jobs as it happens (SSE, event `job`).
|
|
280
|
+
*/
|
|
281
|
+
function jobRoutes(jobs: Jobs, editor: SourceEditor): Record<string, Route> {
|
|
282
|
+
const own = (document: string, id: unknown) => {
|
|
283
|
+
const job = typeof id === 'string' ? jobs.get(id) : undefined
|
|
284
|
+
return job?.document === document ? job : undefined
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
'jobs': {
|
|
288
|
+
role: 'editor',
|
|
289
|
+
handle: (request, ctx) => catchRequestErrors(async () => {
|
|
290
|
+
if (request.method === 'GET') return jsonResponse({ targets: await jobs.targets(ctx.document), jobs: jobs.list(ctx.document) } satisfies JobsAnswer)
|
|
291
|
+
if (request.method !== 'POST') return problemResponse(405, 'GET, POST')
|
|
292
|
+
const body: unknown = await request.json().catch(() => undefined)
|
|
293
|
+
if (!isRecord(body) || typeof body.target !== 'string') return problemResponse(400, '{ target: a renderer or an export of the document }')
|
|
294
|
+
if (!isRecord(body.source) || typeof body.source.version !== 'string' || !body.source.version || body.source.version.length > 128 || typeof body.source.fingerprint !== 'string' || !body.source.fingerprint || body.source.fingerprint.length > 128) return problemResponse(400, 'job-source-required')
|
|
295
|
+
const source = editor.read(editor.scope(ctx.document), ctx.document ? `${ctx.document}/${DOCUMENT_ENTRY}` : DOCUMENT_ENTRY, ctx.member)
|
|
296
|
+
if (source.version !== body.source.version || fnv1a64(source.content) !== body.source.fingerprint) throw new RequestError(409, 'job-source-changed')
|
|
297
|
+
return jsonResponse(await jobs.start(ctx.document, body.target, { version: body.source.version, fingerprint: body.source.fingerprint }))
|
|
298
|
+
}),
|
|
299
|
+
},
|
|
300
|
+
'jobs/cancel': {
|
|
301
|
+
role: 'editor',
|
|
302
|
+
handle: (request, ctx) => catchRequestErrors(async () => {
|
|
303
|
+
if (request.method !== 'POST') return problemResponse(405, 'POST')
|
|
304
|
+
const body: unknown = await request.json().catch(() => undefined)
|
|
305
|
+
const job = own(ctx.document, isRecord(body) ? body.id : undefined)
|
|
306
|
+
return job ? jsonResponse(jobs.cancel(job.id)) : problemResponse(404, 'no such job of the document')
|
|
307
|
+
}),
|
|
308
|
+
},
|
|
309
|
+
'jobs/events': {
|
|
310
|
+
role: 'editor',
|
|
311
|
+
handle: async (request, ctx) => {
|
|
312
|
+
if (request.method !== 'GET') return problemResponse(405, 'GET')
|
|
313
|
+
const events = pushStream<{ event: string, data: CompileJob }>(push => jobs.subscribe((job) => {
|
|
314
|
+
if (job.document === ctx.document) push({ event: 'job', data: job })
|
|
315
|
+
}), { signal: request.signal })
|
|
316
|
+
return sseResponse(events, request.signal)
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function workspaceRoutesPlugin(options: WorkspaceRoutesOptions, borrowedRoutes?: Record<string, Route>): PresmiumNodePlugin {
|
|
323
|
+
return {
|
|
324
|
+
id: WORKSPACE_ID,
|
|
325
|
+
version,
|
|
326
|
+
kernel: '^1.0.0',
|
|
327
|
+
server: ctx => borrowedRoutes ? { routes: borrowedRoutes } : workspaceRoutes({ ...options, audio: ctx.service(PRODUCTION_AUDIO_AUTHORING), speech: ctx.service(SPEECH) }),
|
|
328
|
+
}
|
|
329
|
+
}
|