@lincyaw/workspace 0.0.0-stage → 1.0.0-rc.0

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.
@@ -0,0 +1,302 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
3
+ import { homedir, tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { buildView, Compiler, pluginLogger, runBlock, workspacePlugins } from '@lincyaw/compiler'
6
+ import type { Compilation, CompilerOptions } from '@lincyaw/compiler'
7
+ import { CHANGE_REVIEW, DOCUMENT_FILES, DOCUMENT_LOCKS, errorMessage, REVISIONS } from '@lincyaw/kernel'
8
+ import type { Finding, IRDocument, Logger, ServiceToken, SiteSpec } from '@lincyaw/kernel'
9
+ import { servedDocument, startServer } from '@lincyaw/server'
10
+ import type { PresmiumServer, ServedDocument, ServedPlugin, ServerHost } from '@lincyaw/server'
11
+ import { WORKSPACE_ID } from '../domain/api.ts'
12
+ import { listDocuments, readWorkspace } from './config.ts'
13
+ import { workspaceService } from './service.ts'
14
+ import type { Service } from './service.ts'
15
+ import { Documents, RequestError } from './documents.ts'
16
+ import type { DocumentsChange } from './documents.ts'
17
+ import { localWriter } from './files.ts'
18
+ import type { DocumentWriter } from './files.ts'
19
+ import { Git } from './git.ts'
20
+ import { DocumentLocks } from './locks.ts'
21
+ import { Reviews } from './review/reviews.ts'
22
+ import { Revisions } from './revisions.ts'
23
+ import { workspacePlugin } from './routes.ts'
24
+ import type { WorkspaceRoutes } from './routes.ts'
25
+ import { DocumentSession } from './session.ts'
26
+ import { siteContext, siteDocument, siteIR, siteSpec, untranslated, webRenderer } from './site.ts'
27
+
28
+ export interface ServeOptions {
29
+ dir: string
30
+ host?: string
31
+ port?: number
32
+ operatorDir?: string
33
+ bundled?: string
34
+ check?(compilation: Compilation): Promise<Finding[]>
35
+ previewOrigins?: string[]
36
+ env?: NodeJS.ProcessEnv
37
+ approvalTimeoutMs?: number
38
+ log?: Logger
39
+ }
40
+
41
+ export interface WorkspaceServer {
42
+ url: string
43
+ root: string
44
+ mcp: { url: string, stateFile: string }
45
+ close(): Promise<void>
46
+ }
47
+
48
+ const DISK_LOOK_MS = 1000
49
+
50
+ function stateDir(root: string, env: NodeJS.ProcessEnv): string {
51
+ const base = env.XDG_CACHE_HOME || join(homedir(), '.cache')
52
+ const dir = join(base, 'presmium', 'serve', createHash('sha256').update(root).digest('hex').slice(0, 16))
53
+ mkdirSync(dir, { recursive: true })
54
+ return dir
55
+ }
56
+
57
+ export async function serveWorkspace(options: ServeOptions): Promise<WorkspaceServer> {
58
+ const env = options.env ?? process.env
59
+ const log = options.log ?? pluginLogger('presmium serve')
60
+ const config = readWorkspace(options.dir)
61
+ const { root } = config
62
+ const docs = () => listDocuments(config)
63
+ const sessions = new Map<string, DocumentSession>()
64
+ const locks = new DocumentLocks()
65
+ const check = options.check ?? (compilation => compilation.check())
66
+ const token = env.PRESMIUM_SERVICE_TOKEN || undefined
67
+ const preview = { origins: options.previewOrigins ?? [] }
68
+
69
+ let services: [ServiceToken<unknown>, unknown][] = []
70
+ let hostServices: [ServiceToken<unknown>, unknown][] = []
71
+ let site: SiteBackend | undefined
72
+ let server: PresmiumServer | undefined
73
+ let service: Service | undefined
74
+
75
+ const sessionOf = (dir: string): DocumentSession => {
76
+ const known = sessions.get(dir)
77
+ if (known) return known
78
+ const document = docs().find(doc => doc.dir === dir)
79
+ if (!document) throw new RequestError(404, `no document ${JSON.stringify(dir)}`)
80
+ const session = new DocumentSession(dir, new Compiler(join(root, document.entry), { ...pluginOptions(), execution: { env: 'serve' } }), log)
81
+ session.onCompile(async (compilation) => {
82
+ for (const finding of untranslated(compilation, root)) {
83
+ if (!compilation.doc.diagnostics.some(f => f.message === finding.message)) compilation.doc.diagnostics.push(finding)
84
+ }
85
+ })
86
+ session.onCompile((compilation, changed) => {
87
+ service?.compiled(dir, compilation, changed)
88
+ void Promise.resolve(site?.update(dir, compilation)).catch((error: unknown) => log.warn(`the site did not take ${dir}: ${errorMessage(error)}`))
89
+ })
90
+ sessions.set(dir, session)
91
+ return session
92
+ }
93
+
94
+ const pluginOptions = (): CompilerOptions => ({
95
+ workspaceRoot: root,
96
+ settings: config.settings,
97
+ services: [...services, ...hostServices],
98
+ bundled: options.bundled,
99
+ ...options.operatorDir ? { operatorDir: options.operatorDir } : {},
100
+ secret: name => env[name],
101
+ })
102
+
103
+ const spec: SiteSpec = siteSpec(config, docs().map(doc => siteDocument(doc)))
104
+ const refreshSpec = (): DocumentsChange => {
105
+ const now = docs()
106
+ const before = spec.documents.map(doc => doc.dir)
107
+ spec.documents.splice(0, spec.documents.length, ...now.map(doc => siteDocument(doc)))
108
+ return { removed: before.filter(dir => !now.some(doc => doc.dir === dir)), added: now.map(doc => doc.dir).filter(dir => !before.includes(dir)) }
109
+ }
110
+
111
+ const documentsChanged = async (change: DocumentsChange): Promise<void> => {
112
+ for (const dir of change.removed) {
113
+ sessions.get(dir)?.close()
114
+ sessions.delete(dir)
115
+ }
116
+ const listed = refreshSpec()
117
+ const removed = [...new Set([...change.removed, ...listed.removed])]
118
+ for (const dir of new Set([...removed, ...change.added, ...listed.added])) await server?.unmount(dir)
119
+ await site?.documentsChanged(removed)
120
+ await service?.removed(removed)
121
+ }
122
+
123
+ let lookedAt = 0
124
+ const lookAtDisk = (): void => {
125
+ if (Date.now() - lookedAt < DISK_LOOK_MS) return
126
+ lookedAt = Date.now()
127
+ const now = docs().map(doc => doc.dir)
128
+ const known = spec.documents.map(doc => doc.dir)
129
+ const change = { removed: known.filter(dir => !now.includes(dir)), added: now.filter(dir => !known.includes(dir)) }
130
+ if (change.removed.length || change.added.length) void documentsChanged(change).catch((error: unknown) => log.warn(`the documents on disk changed, but the site did not follow: ${errorMessage(error)}`))
131
+ }
132
+
133
+ const workspaceOf = (): { revisions?: Revisions, reviews?: Reviews, writer: DocumentWriter, plugin: ServedPlugin } => {
134
+ const writer = localWriter(config)
135
+ const git = Git.isRepository(root) ? new Git(root) : undefined
136
+ if (!git) log.warn(`${root} is not a git repository: editing, the document operations and the history are not available (run git init)`)
137
+ const revisions = git ? new Revisions(git, { config, writer }) : undefined
138
+ const reviews = revisions ? new Reviews({ config, revisions, locks, files: writer, log }) : undefined
139
+ const documents = new Documents({ config, git: git!, revisions: revisions!, writer, changed: documentsChanged, locks })
140
+ const routes: WorkspaceRoutes = { documents, ...revisions ? { revisions } : {}, locks, documentsChanged, ...service ? { service } : {}, ...token ? { token } : {}, ...reviews ? { reviews } : {} }
141
+ return { ...revisions ? { revisions } : {}, ...reviews ? { reviews } : {}, writer, plugin: { id: WORKSPACE_ID, plugin: workspacePlugin(routes), config: {} } }
142
+ }
143
+ let workspace: ReturnType<typeof workspaceOf> | undefined
144
+ const plugin = (): ServedPlugin => workspace!.plugin
145
+
146
+ let started!: () => void
147
+ const ready = new Promise<void>(resolve => started = resolve)
148
+
149
+ const serverHost: ServerHost = {
150
+ async documents() {
151
+ return docs().map(({ entry: _entry, ...doc }) => doc)
152
+ },
153
+ async compile(document) {
154
+ await ready
155
+ const { doc } = await sessionOf(document).ready()
156
+ return { doc, diagnostics: doc.diagnostics }
157
+ },
158
+ async check(document) {
159
+ await ready
160
+ const compilation = await sessionOf(document).ready()
161
+ return [...compilation.doc.diagnostics, ...await check(compilation)]
162
+ },
163
+ async build(document, buildOptions = {}) {
164
+ await ready
165
+ const compilation = await sessionOf(document).ready()
166
+ const outDir = join(stateDir(root, env), 'builds', encodeURIComponent(document) || '_')
167
+ const report = await buildView(compilation, { to: buildOptions.to, outDir })
168
+ return { outputs: report.outputs.map(({ renderer, outDir: written }) => ({ renderer, outDir: written })), findings: report.findings }
169
+ },
170
+ async run(document, target, signal) {
171
+ await ready
172
+ return runBlock(await sessionOf(document).ready(), target, signal)
173
+ },
174
+ }
175
+
176
+ let operators: ReturnType<typeof workspacePlugins> | undefined
177
+ let home: string | undefined
178
+ const operatorsOf = async () => {
179
+ await ready
180
+ home ??= mkdtempSync(join(tmpdir(), 'presmium-home-'))
181
+ return operators ??= workspacePlugins(root, pluginOptions())
182
+ }
183
+ const homeDocument = async (): Promise<ServedDocument> => {
184
+ const loaded = await operatorsOf()
185
+ for (const problem of loaded.problems) log.warn(`the home page: ${problem.message}`)
186
+ const own = (token: ServiceToken<unknown>) => [...services, ...hostServices].find(([known]) => known.id === token.id)?.[1]
187
+ return {
188
+ deckDir: home!,
189
+ workspaceRoot: home!,
190
+ plugins: [plugin(), ...loaded.plugins.flatMap(({ plugin: node }) => (node ? [{ id: node.id, plugin: node, config: {} }] : []))],
191
+ service: <T>(token: ServiceToken<T>) => (own(token) ?? loaded.service(token)) as T | undefined,
192
+ settings: config.settings,
193
+ ...options.operatorDir ? { operatorDir: options.operatorDir } : {},
194
+ home: true,
195
+ }
196
+ }
197
+
198
+ const stateFile = join(stateDir(root, env), 'server.json')
199
+ server = await startServer({
200
+ mode: 'serve',
201
+ host: options.host,
202
+ port: options.port,
203
+ stateFile,
204
+ forward: () => {
205
+ lookAtDisk()
206
+ return site?.forward()
207
+ },
208
+ settings: config.settings,
209
+ approvals: config.approvals,
210
+ ...options.operatorDir ? { operatorDir: options.operatorDir } : {},
211
+ serverHost,
212
+ env,
213
+ ...options.approvalTimeoutMs ? { approvalTimeoutMs: options.approvalTimeoutMs } : {},
214
+ log,
215
+ async document(dir) {
216
+ await ready
217
+ if (dir === '' && !config.single) return await homeDocument()
218
+ if (!docs().some(doc => doc.dir === dir)) return undefined
219
+ const compilation = await sessionOf(dir).ready()
220
+ const served = servedDocument(compilation)
221
+ const reviews = workspace?.reviews
222
+ const service = <T>(token: ServiceToken<T>) => served.service(token) ?? (reviews && token.id === CHANGE_REVIEW.id ? reviews.service as T : undefined)
223
+ return { ...served, service, plugins: [plugin(), ...served.plugins] }
224
+ },
225
+ })
226
+ const running = server
227
+ services = running.services
228
+ service = workspaceService({ root, sessionOf, serverHost, url: () => running.url, preview, stateDir: stateDir(root, env), log })
229
+ workspace = workspaceOf()
230
+ hostServices = [
231
+ ...workspace.revisions ? [[REVISIONS, workspace.revisions] as [ServiceToken<unknown>, unknown]] : [],
232
+ [DOCUMENT_FILES, workspace.writer],
233
+ [DOCUMENT_LOCKS, locks],
234
+ ]
235
+ await workspace.reviews?.recover(docs().map(doc => doc.dir))
236
+
237
+ started()
238
+ if (config.single) for (const { dir } of docs()) void sessionOf(dir).ready().catch(() => undefined)
239
+
240
+ const compileFull = async (dir: string): Promise<IRDocument> => {
241
+ const compilation = await sessionOf(dir).ready()
242
+ return service!.siteDoc(compilation, siteIR(compilation))
243
+ }
244
+ try {
245
+ site = await localSite({ root, spec, bundled: options.bundled, state: stateDir(root, env), compile: compileFull, siteDoc: (compilation, doc) => service!.siteDoc(compilation, doc), preview, log })
246
+ } catch (error) {
247
+ await service?.close()
248
+ await running.close()
249
+ if (home) rmSync(home, { recursive: true, force: true })
250
+ throw error
251
+ }
252
+
253
+ return {
254
+ url: running.url,
255
+ root,
256
+ mcp: { url: `${running.url}/mcp`, stateFile },
257
+ async close() {
258
+ workspace?.reviews?.close()
259
+ for (const session of sessions.values()) session.close()
260
+ await service?.close()
261
+ await site?.close()
262
+ await running.close()
263
+ if (home) rmSync(home, { recursive: true, force: true })
264
+ },
265
+ }
266
+ }
267
+
268
+ interface SiteBackend {
269
+ forward(): string | undefined
270
+ update(dir: string, compilation: Compilation): Promise<void> | void
271
+ documentsChanged(removed: string[]): Promise<void>
272
+ close(): Promise<void>
273
+ }
274
+
275
+ interface LocalSiteOptions {
276
+ root: string
277
+ spec: SiteSpec
278
+ bundled?: string
279
+ state: string
280
+ compile(dir: string): Promise<IRDocument>
281
+ siteDoc(compilation: Compilation, doc: IRDocument): IRDocument
282
+ preview: { origins: string[] }
283
+ log: Logger
284
+ }
285
+
286
+ async function localSite(options: LocalSiteOptions): Promise<SiteBackend> {
287
+ const { root, spec, log } = options
288
+ const renderer = await webRenderer(root, options.bundled)
289
+ const findings: Finding[] = []
290
+ const work = join(options.state, 'site', 'full')
291
+ const ctx = siteContext({ root, outDir: work, workDir: work, findings })
292
+ ctx.preview = options.preview
293
+ const dev = await renderer.devSite!(spec, ctx, options.compile)
294
+ const origin = new URL(dev.url).origin
295
+ log.info(`the workspace site's dev router is at ${origin} (this machine only; pages reach it through serve)`)
296
+ return {
297
+ forward: () => origin,
298
+ update: (dir, compilation) => dev.update(dir, options.siteDoc(compilation, siteIR(compilation))).catch((error: unknown) => log.warn(`the site did not take ${dir}: ${errorMessage(error)}`)),
299
+ documentsChanged: async () => {},
300
+ close: () => dev.close(),
301
+ }
302
+ }
@@ -0,0 +1,237 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { createServer } from 'node:net'
3
+ import type { AddressInfo } from 'node:net'
4
+ import { join, relative, sep } from 'node:path'
5
+ import { dev, relativeIR } from '@lincyaw/compiler'
6
+ import type { Compilation } from '@lincyaw/compiler'
7
+ import { errorMessage, PREVIEW_REVISION } from '@lincyaw/kernel'
8
+ import type { Finding, IRDocument, Logger, ProxySpec } from '@lincyaw/kernel'
9
+ import type { ServerHost } from '@lincyaw/server'
10
+ import { WORKSPACE_ID } from '../domain/api.ts'
11
+ import type { DocumentSession } from './session.ts'
12
+
13
+ export interface IrAnswer {
14
+ document: string
15
+ revision: string
16
+ doc: IRDocument
17
+ diagnostics: Finding[]
18
+ }
19
+
20
+ export interface IrEvent {
21
+ document: string
22
+ revision: string
23
+ diagnostics: { errors: number, warnings: number }
24
+ files: string[]
25
+ }
26
+
27
+ export interface PreviewAnswer {
28
+ document: string
29
+ web: string
30
+ slides?: string
31
+ }
32
+
33
+ export interface BuildAnswer {
34
+ outputs: { renderer: string, outDir: string }[]
35
+ findings: Finding[]
36
+ }
37
+
38
+ export interface CheckAnswer {
39
+ findings: Finding[]
40
+ }
41
+
42
+ export interface WorkspaceService {
43
+ ir(document: string): Promise<IrAnswer>
44
+ events(document: string, signal: AbortSignal): AsyncIterable<{ event: string, data: IrEvent }>
45
+ preview(document: string): Promise<PreviewAnswer>
46
+ build(document: string, to?: string[]): Promise<BuildAnswer>
47
+ check(document: string): Promise<CheckAnswer>
48
+ proxies: ProxySpec[]
49
+ }
50
+
51
+ export function irRevision(doc: IRDocument): string {
52
+ return createHash('sha256').update(JSON.stringify(doc)).digest('hex').slice(0, 16)
53
+ }
54
+
55
+ export function withRevision(doc: IRDocument, revision: string): IRDocument {
56
+ return { ...doc, meta: { ...doc.meta, [PREVIEW_REVISION]: revision } }
57
+ }
58
+
59
+ function workspacePath(root: string, file: string): string {
60
+ return relative(root, file).split(sep).join('/')
61
+ }
62
+
63
+ function freePort(): Promise<number> {
64
+ return new Promise((resolve, reject) => {
65
+ const server = createServer()
66
+ server.once('error', reject)
67
+ server.listen(0, '127.0.0.1', () => {
68
+ const { port } = server.address() as AddressInfo
69
+ server.close(() => resolve(port))
70
+ })
71
+ })
72
+ }
73
+
74
+ function urlPath(dir: string): string {
75
+ return dir.split('/').map(encodeURIComponent).join('/')
76
+ }
77
+
78
+ function slidesPath(dir: string): string {
79
+ return `slides/${dir ? `${urlPath(dir)}/` : ''}`
80
+ }
81
+
82
+ export interface ServiceOptions {
83
+ root: string
84
+ sessionOf(dir: string): DocumentSession
85
+ serverHost: ServerHost
86
+ url(): string
87
+ preview?: { origins: string[] }
88
+ stateDir: string
89
+ log: Logger
90
+ }
91
+
92
+ export interface Service extends WorkspaceService {
93
+ compiled(dir: string, compilation: Compilation, changed: string[]): void
94
+ siteDoc(compilation: Compilation, doc: IRDocument): IRDocument
95
+ removed(dirs: string[]): Promise<void>
96
+ close(): Promise<void>
97
+ }
98
+
99
+ export function workspaceService(options: ServiceOptions): Service {
100
+ const { root, log } = options
101
+ const revisions = new WeakMap<Compilation, string>()
102
+ const revisionOf = (compilation: Compilation) => {
103
+ let known = revisions.get(compilation)
104
+ if (!known) revisions.set(compilation, known = irRevision(compilation.doc))
105
+ return known
106
+ }
107
+ const listeners = new Map<string, Set<(event: IrEvent) => void>>()
108
+ const slides = new Map<string, Promise<{ update(compilation: Compilation): Promise<void>, close(): Promise<void> } | undefined>>()
109
+ const proxies: ProxySpec[] = []
110
+ let closed = false
111
+
112
+ const eventOf = (dir: string, compilation: Compilation, changed: string[]): IrEvent => {
113
+ const count = (severity: Finding['severity']) => compilation.doc.diagnostics.filter(finding => finding.severity === severity).length
114
+ return { document: dir, revision: revisionOf(compilation), diagnostics: { errors: count('error'), warnings: count('warning') }, files: changed.map(file => workspacePath(root, file)) }
115
+ }
116
+ const stamped = (compilation: Compilation): Compilation => (options.preview ? { ...compilation, doc: withRevision(compilation.doc, revisionOf(compilation)) } : compilation)
117
+
118
+ const slidesOf = (dir: string) => {
119
+ let known = slides.get(dir)
120
+ if (!known) {
121
+ known = (async () => {
122
+ const session = options.sessionOf(dir)
123
+ const compilation = await session.ready()
124
+ const configured = Object.keys(compilation.doc.config.renderers)
125
+ if (configured.length > 0 && !configured.includes('slides')) return undefined
126
+ const path = slidesPath(dir)
127
+ const started = await dev(session.compiler, {
128
+ outDir: join(options.stateDir, 'preview', encodeURIComponent(dir) || '_'),
129
+ to: ['slides'],
130
+ compilation: stamped(compilation),
131
+ base: `/__presmium/${WORKSPACE_ID}/${path}`,
132
+ port: await freePort(),
133
+ ...options.preview ? { preview: { origins: options.preview.origins } } : {},
134
+ onCompile(_compilation, findings) {
135
+ for (const finding of findings.filter(f => f.checkId === 'presmium.render')) log.warn(`the slides' preview of ${dir || '.'}: ${finding.message}`)
136
+ },
137
+ })
138
+ const server = started.servers[0]
139
+ if (!server || closed) {
140
+ await started.close()
141
+ return undefined
142
+ }
143
+ const target = new URL(server.url)
144
+ proxies.push({ path: path.replace(/\/$/, ''), target: `${target.origin}${target.pathname.replace(/\/$/, '')}` })
145
+ proxies.sort((a, b) => b.path.length - a.path.length)
146
+ return started
147
+ })()
148
+ known.catch((error: unknown) => {
149
+ log.warn(`the slides' preview of ${dir || '.'} did not start: ${errorMessage(error)}`)
150
+ slides.delete(dir)
151
+ })
152
+ slides.set(dir, known)
153
+ }
154
+ return known
155
+ }
156
+
157
+ const stop = async (dir: string) => {
158
+ const known = slides.get(dir)
159
+ slides.delete(dir)
160
+ const path = slidesPath(dir).replace(/\/$/, '')
161
+ const at = proxies.findIndex(proxy => proxy.path === path)
162
+ if (at >= 0) proxies.splice(at, 1)
163
+ await (await known?.catch(() => undefined))?.close()
164
+ }
165
+
166
+ return {
167
+ proxies,
168
+ async ir(document) {
169
+ const compilation = await options.sessionOf(document).ready()
170
+ const doc = relativeIR(compilation.doc, root, root)
171
+ return { document, revision: revisionOf(compilation), doc, diagnostics: doc.diagnostics }
172
+ },
173
+ events(document, signal) {
174
+ const waiting: IrEvent[] = []
175
+ let wake: (() => void) | undefined
176
+ const listener = (event: IrEvent) => {
177
+ waiting.push(event)
178
+ wake?.()
179
+ }
180
+ const own = listeners.get(document) ?? new Set()
181
+ listeners.set(document, own.add(listener))
182
+ const done = () => own.delete(listener)
183
+ signal.addEventListener('abort', () => {
184
+ done()
185
+ wake?.()
186
+ }, { once: true })
187
+ const session = options.sessionOf(document)
188
+ if (session.latest) waiting.push(eventOf(document, session.latest, []))
189
+ else void session.ready().catch(() => undefined)
190
+ return {
191
+ async* [Symbol.asyncIterator]() {
192
+ try {
193
+ while (!signal.aborted) {
194
+ const next = waiting.shift()
195
+ if (next) {
196
+ yield { event: 'revision', data: next }
197
+ continue
198
+ }
199
+ await new Promise<void>(resolve => wake = resolve)
200
+ wake = undefined
201
+ }
202
+ } finally {
203
+ done()
204
+ }
205
+ },
206
+ }
207
+ },
208
+ async preview(document) {
209
+ const base = options.url()
210
+ const web = `${base}/${document ? `${urlPath(document)}/` : ''}`
211
+ const own = await slidesOf(document).catch(() => undefined)
212
+ return { document, web, ...own ? { slides: `${base}/__presmium/${WORKSPACE_ID}/${slidesPath(document)}` } : {} }
213
+ },
214
+ async build(document, to) {
215
+ return await options.serverHost.build(document, to ? { to } : {})
216
+ },
217
+ async check(document) {
218
+ return { findings: await options.serverHost.check(document) }
219
+ },
220
+ compiled(dir, compilation, changed) {
221
+ const event = eventOf(dir, compilation, changed)
222
+ for (const listener of listeners.get(dir) ?? []) listener(event)
223
+ const known = slides.get(dir)
224
+ if (known) void known.then(started => started?.update(stamped(compilation))).catch((error: unknown) => log.warn(`the slides' preview of ${dir || '.'} did not update: ${errorMessage(error)}`))
225
+ },
226
+ siteDoc(compilation, doc) {
227
+ return options.preview ? withRevision(doc, revisionOf(compilation)) : doc
228
+ },
229
+ async removed(dirs) {
230
+ for (const dir of dirs) await stop(dir)
231
+ },
232
+ async close() {
233
+ closed = true
234
+ for (const dir of [...slides.keys()]) await stop(dir)
235
+ },
236
+ }
237
+ }
@@ -0,0 +1,123 @@
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
+ export class DocumentSession {
9
+ readonly dir: string
10
+ readonly compiler: Compiler
11
+ #latest?: Compilation
12
+ #first?: Promise<Compilation>
13
+ #running: Promise<unknown> = Promise.resolve()
14
+ #queued?: Promise<Compilation>
15
+ readonly #listeners = new Set<(compilation: Compilation, changed: string[]) => void | Promise<void>>()
16
+ #changedFiles = new Set<string>()
17
+ readonly #watchers = new Map<string, FSWatcher>()
18
+ #relevant = new Map<string, Set<string>>()
19
+ #timer?: NodeJS.Timeout
20
+ #closed = false
21
+ readonly #log: Logger
22
+
23
+ constructor(dir: string, compiler: Compiler, log: Logger) {
24
+ this.dir = dir
25
+ this.compiler = compiler
26
+ this.#log = log
27
+ }
28
+
29
+ ready(): Promise<Compilation> {
30
+ if (this.#latest) return Promise.resolve(this.#latest)
31
+ if (!this.#first) {
32
+ const first = this.recompile()
33
+ first.catch(() => {
34
+ if (this.#first === first) this.#first = undefined
35
+ })
36
+ this.#first = first
37
+ }
38
+ return this.#first
39
+ }
40
+
41
+ get latest(): Compilation | undefined {
42
+ return this.#latest
43
+ }
44
+
45
+ onCompile(listener: (compilation: Compilation, changed: string[]) => void | Promise<void>): () => void {
46
+ this.#listeners.add(listener)
47
+ return () => this.#listeners.delete(listener)
48
+ }
49
+
50
+ recompile(): Promise<Compilation> {
51
+ if (this.#queued) return this.#queued
52
+ const next = this.#running.then(async () => {
53
+ this.#queued = undefined
54
+ const started = Date.now()
55
+ const changed = [...this.#changedFiles]
56
+ this.#changedFiles.clear()
57
+ const compilation = await this.compiler.compile()
58
+ this.#latest = compilation
59
+ for (const listener of this.#listeners) {
60
+ try {
61
+ await listener(compilation, changed)
62
+ } catch (error) {
63
+ this.#log.error(`after compiling ${this.dir || '.'}: ${errorMessage(error)}`)
64
+ }
65
+ }
66
+ this.#watch(compilation, started)
67
+ return compilation
68
+ })
69
+ this.#queued = next
70
+ this.#running = next.catch((error: unknown) => this.#log.error(`compiling ${this.dir || '.'} failed: ${errorMessage(error)}`))
71
+ return next
72
+ }
73
+
74
+ close(): void {
75
+ this.#closed = true
76
+ clearTimeout(this.#timer)
77
+ for (const watcher of this.#watchers.values()) watcher.close()
78
+ this.#watchers.clear()
79
+ this.#listeners.clear()
80
+ }
81
+
82
+ #watch(compilation: Compilation, started: number): void {
83
+ if (this.#closed) return
84
+ const files = new Set([...compilation.files, ...compilation.dependencies.files])
85
+ const next = new Map<string, Set<string>>()
86
+ for (const file of files) {
87
+ let dir = dirname(file)
88
+ let name = basename(file)
89
+ while (!existsSync(dir) && dirname(dir) !== dir) {
90
+ name = basename(dir)
91
+ dir = dirname(dir)
92
+ }
93
+ next.set(dir, (next.get(dir) ?? new Set<string>()).add(name))
94
+ }
95
+ this.#relevant = next
96
+ for (const [dir, watcher] of this.#watchers) {
97
+ if (next.has(dir)) continue
98
+ watcher.close()
99
+ this.#watchers.delete(dir)
100
+ }
101
+ for (const dir of next.keys()) {
102
+ if (this.#watchers.has(dir)) continue
103
+ try {
104
+ this.#watchers.set(dir, watch(dir, (_event, name) => {
105
+ if (name && this.#relevant.get(dir)?.has(basename(name))) this.#changed(join(dir, name))
106
+ }))
107
+ } catch {
108
+ }
109
+ }
110
+ const modified = [...files].filter((file) => {
111
+ const time = statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? 0
112
+ return time >= started - 20 && time <= Date.now()
113
+ })
114
+ for (const file of modified) this.#changed(file)
115
+ }
116
+
117
+ #changed(file: string): void {
118
+ if (this.#closed) return
119
+ this.#changedFiles.add(file)
120
+ clearTimeout(this.#timer)
121
+ this.#timer = setTimeout(() => void this.recompile().catch(() => undefined), 50)
122
+ }
123
+ }