@lincyaw/web 1.0.0-rc.0 → 1.0.0-rc.2
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/README.md +33 -4
- package/package.json +20 -19
- package/src/app/base.css +117 -0
- package/src/app/block-playback.ts +81 -0
- package/src/app/block-report.ts +104 -0
- package/src/app/document-modules.ts +30 -0
- package/src/app/env.d.ts +12 -0
- package/src/app/main.ts +198 -0
- package/src/app/page-app.ts +166 -0
- package/src/app/router.ts +109 -0
- package/src/app/server.ts +32 -0
- package/src/app/site/home.tsx +117 -0
- package/src/app/site/layout.tsx +92 -0
- package/src/app/site/search.tsx +22 -0
- package/src/app/site.css +1450 -0
- package/src/client/document.css +172 -0
- package/src/client/host.ts +22 -94
- package/src/client/preview-frame.ts +152 -0
- package/src/client/runtime.ts +159 -0
- package/src/domain/app-config.ts +91 -0
- package/src/domain/config.ts +17 -9
- package/src/domain/dev-updates.ts +12 -0
- package/src/domain/index.ts +17 -7
- package/src/domain/landing.ts +83 -0
- package/src/domain/links.ts +23 -18
- package/src/domain/messages.ts +74 -63
- package/src/domain/pages.ts +23 -41
- package/src/domain/themes.ts +59 -0
- package/src/domain/workspace-home.ts +12 -0
- package/src/node/client-modules.ts +92 -0
- package/src/node/document-bundle.ts +227 -0
- package/src/node/landing-template.ts +34 -0
- package/src/node/landing.ts +82 -0
- package/src/node/offline.ts +34 -11
- package/src/node/publish.ts +83 -15
- package/src/node/renderer.ts +78 -117
- package/src/node/site-assets.ts +17 -0
- package/src/node/site-build.ts +304 -0
- package/src/node/site-files.ts +87 -0
- package/src/node/site-model.ts +337 -0
- package/src/node/site-page.ts +82 -0
- package/src/node/site-server.ts +822 -0
- package/src/node/slides.ts +135 -0
- package/src/node/viewer-app.ts +504 -0
- package/src/node/viewer-build.ts +38 -0
- package/custom-nav-controls.vue +0 -17
- package/src/client/env.d.ts +0 -1
- package/src/client/home.ts +0 -182
- package/src/client/index.ts +0 -4
- package/src/client/page.ts +0 -38
- package/src/client/preview.ts +0 -71
- package/src/client/style.css +0 -427
- package/src/client/theme.ts +0 -322
- package/src/node/addons.ts +0 -76
- package/src/node/site.ts +0 -349
- package/src/node/sites.ts +0 -531
- package/src/node/vite.ts +0 -166
- package/src/node/vitepress.ts +0 -72
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { createServer } from 'node:http'
|
|
3
|
+
import type { AddressInfo } from 'node:net'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { PREVIEW_APPEARANCE_PARAM, slideShape } from '@lincyaw/kernel'
|
|
6
|
+
import type { IRDocument, Renderer, SlideShape } from '@lincyaw/kernel'
|
|
7
|
+
import send from 'send'
|
|
8
|
+
import { PRINTED_ATTRIBUTE, printQuery } from '../domain/index.ts'
|
|
9
|
+
import type { SlidesPrintQuery } from '../domain/index.ts'
|
|
10
|
+
import { devDocument } from './renderer.ts'
|
|
11
|
+
import { buildSlidesSite } from './site-build.ts'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The slides form: the viewer app's slides page (its presentation, one slide at a time on a canvas of a fixed size), built
|
|
15
|
+
* as the web form is, and served by the same dev server. The exports print it: the page lays every slide out at the
|
|
16
|
+
* canvas's size (`?print`), and Chromium prints it to a PDF or takes a picture of each slide.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const BUILD_DIR = '.presmium/slides/build'
|
|
20
|
+
const PRINT_TIMEOUT = 120_000
|
|
21
|
+
// The pictures are taken at twice the canvas's size, sharp on a projector.
|
|
22
|
+
const PICTURE_SCALE = 2
|
|
23
|
+
|
|
24
|
+
export const slidesRenderer: Renderer = {
|
|
25
|
+
id: 'slides',
|
|
26
|
+
site: true,
|
|
27
|
+
support: () => 'full',
|
|
28
|
+
async build(doc, ctx) {
|
|
29
|
+
await buildSlidesSite(doc, { ...ctx, workDir: join(ctx.deckDir, BUILD_DIR) }, ctx.nodeVitePlugins ?? [])
|
|
30
|
+
return { outDir: ctx.outDir, skipped: [] }
|
|
31
|
+
},
|
|
32
|
+
dev: (doc, ctx) => devDocument(doc, ctx, 'slides'),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface PrintingPage {
|
|
36
|
+
goto(url: string, options: { waitUntil: 'load', timeout: number }): Promise<unknown>
|
|
37
|
+
waitForSelector(selector: string, options: { timeout: number }): Promise<unknown>
|
|
38
|
+
pdf(options: { path: string, width: string, height: string, printBackground: boolean }): Promise<unknown>
|
|
39
|
+
$$(selector: string): Promise<{ screenshot(options: { path: string }): Promise<unknown> }[]>
|
|
40
|
+
evaluate<T>(run: () => T): Promise<T>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** What printing uses of Playwright's Chromium (`playwright-chromium`), which the caller loads. */
|
|
44
|
+
export interface PrintingChromium {
|
|
45
|
+
launch(): Promise<{
|
|
46
|
+
newPage(options: { viewport: SlideShape, deviceScaleFactor: number, reducedMotion: 'reduce', serviceWorkers: 'block' }): Promise<PrintingPage>
|
|
47
|
+
close(): Promise<void>
|
|
48
|
+
}>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface PrintOptions extends SlidesPrintQuery {
|
|
52
|
+
/** The slides as `slidesRenderer` built them, for the root path. */
|
|
53
|
+
dir: string
|
|
54
|
+
chromium: PrintingChromium
|
|
55
|
+
/** One PDF file, or a picture of each page in a directory. */
|
|
56
|
+
format: 'pdf' | 'png'
|
|
57
|
+
/** The PDF file, or the directory of the pictures. */
|
|
58
|
+
output: string
|
|
59
|
+
dark?: boolean
|
|
60
|
+
/** How long the page may take to be ready, in milliseconds. */
|
|
61
|
+
timeout?: number
|
|
62
|
+
signal?: AbortSignal
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A printed page: a slide (by number from 1) at a step, and its picture (`<slide>.png`, `<slide>-<step + 1>.png` with `steps`). */
|
|
66
|
+
export interface PrintedPage {
|
|
67
|
+
section: string
|
|
68
|
+
slide: number
|
|
69
|
+
step: number
|
|
70
|
+
file?: string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface Printed {
|
|
74
|
+
shape: SlideShape
|
|
75
|
+
pages: PrintedPage[]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Serves the built slides on a loopback port, through `send` (Express's): types and validators as HTTP defines them. */
|
|
79
|
+
async function serveBuilt(dir: string): Promise<{ url: string, close(): Promise<void> }> {
|
|
80
|
+
const server = createServer((req, res) => {
|
|
81
|
+
send(req, new URL(req.url ?? '/', 'http://localhost').pathname, { root: dir, dotfiles: 'allow' })
|
|
82
|
+
.on('error', (error: { status?: number }) => res.writeHead(error.status ?? 500).end())
|
|
83
|
+
.pipe(res)
|
|
84
|
+
})
|
|
85
|
+
await new Promise<void>(done => server.listen(0, '127.0.0.1', done))
|
|
86
|
+
return {
|
|
87
|
+
url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
|
|
88
|
+
close: () => new Promise((done) => {
|
|
89
|
+
server.close(() => done())
|
|
90
|
+
server.closeAllConnections()
|
|
91
|
+
}),
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Prints the built slides: every slide (or `slides`) at its last step, or each of its steps with `steps`. */
|
|
96
|
+
export async function printSlides(options: PrintOptions): Promise<Printed> {
|
|
97
|
+
const shape = slideShape(JSON.parse(await readFile(join(options.dir, 'ir.json'), 'utf8')) as IRDocument)
|
|
98
|
+
const timeout = options.timeout ?? PRINT_TIMEOUT
|
|
99
|
+
const server = await serveBuilt(options.dir)
|
|
100
|
+
try {
|
|
101
|
+
const browser = await options.chromium.launch()
|
|
102
|
+
const stop = () => void browser.close()
|
|
103
|
+
options.signal?.addEventListener('abort', stop, { once: true })
|
|
104
|
+
try {
|
|
105
|
+
options.signal?.throwIfAborted()
|
|
106
|
+
const page = await browser.newPage({ viewport: shape, deviceScaleFactor: options.format === 'png' ? PICTURE_SCALE : 1, reducedMotion: 'reduce', serviceWorkers: 'block' })
|
|
107
|
+
const query = `${printQuery(options)}&${PREVIEW_APPEARANCE_PARAM}=${options.dark ? 'dark' : 'light'}`
|
|
108
|
+
await page.goto(`${server.url}/${query}`, { waitUntil: 'load', timeout })
|
|
109
|
+
await page.waitForSelector(`html[${PRINTED_ATTRIBUTE}]`, { timeout })
|
|
110
|
+
options.signal?.throwIfAborted()
|
|
111
|
+
const pages: PrintedPage[] = await page.evaluate(() => [...document.querySelectorAll<HTMLElement>('.pm-slides-print__page > .pm-slide')]
|
|
112
|
+
.map(slide => ({ section: slide.dataset.section!, slide: Number(slide.dataset.slide), step: Number(slide.dataset.step) })))
|
|
113
|
+
if (options.format === 'pdf') {
|
|
114
|
+
await page.pdf({ path: options.output, width: `${shape.width}px`, height: `${shape.height}px`, printBackground: true })
|
|
115
|
+
return { shape, pages }
|
|
116
|
+
}
|
|
117
|
+
const elements = await page.$$('.pm-slides-print__page')
|
|
118
|
+
for (const [i, element] of elements.entries()) {
|
|
119
|
+
options.signal?.throwIfAborted()
|
|
120
|
+
const printed = pages[i]!
|
|
121
|
+
printed.file = `${printed.slide}${options.steps ? `-${printed.step + 1}` : ''}.png`
|
|
122
|
+
await element.screenshot({ path: join(options.output, printed.file) })
|
|
123
|
+
}
|
|
124
|
+
return { shape, pages }
|
|
125
|
+
} catch (error) {
|
|
126
|
+
options.signal?.throwIfAborted()
|
|
127
|
+
throw error
|
|
128
|
+
} finally {
|
|
129
|
+
options.signal?.removeEventListener('abort', stop)
|
|
130
|
+
await browser.close()
|
|
131
|
+
}
|
|
132
|
+
} finally {
|
|
133
|
+
await server.close()
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { createRequire } from 'node:module'
|
|
4
|
+
import { homedir } from 'node:os'
|
|
5
|
+
import { dirname, extname, join, relative, sep } from 'node:path'
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
7
|
+
import { isRecord } from '@lincyaw/kernel'
|
|
8
|
+
import { findInstalledPackage, FONT_LICENSE, fontLicense } from '@lincyaw/kernel/node'
|
|
9
|
+
import type { PresmiumNodePlugin } from '@lincyaw/kernel'
|
|
10
|
+
import { collectBrowserModules, emptyClientModules } from './client-modules.ts'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The viewer app, prebuilt once for a set of plugin packages (as a PDF viewer is for every PDF): the web form's runtime
|
|
14
|
+
* with every browser module of those plugins, lazily loaded, and the modules a document's own bundle may import (React,
|
|
15
|
+
* Presmium's packages, the plugins' public subpaths) as entries of their own, which an import map names. It shows every
|
|
16
|
+
* page of the web form: in the workbench, in a dev server and in a built site, whose pages it also renders on the
|
|
17
|
+
* server first (`buildViewerServer`). A build is kept under the cache by a key of what it was built from, and found
|
|
18
|
+
* again by any later session.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const WEB_DIR = fileURLToPath(new URL('../..', import.meta.url))
|
|
22
|
+
const APP_MAIN = fileURLToPath(new URL('../app/main.ts', import.meta.url))
|
|
23
|
+
const APP_SERVER = fileURLToPath(new URL('../app/server.ts', import.meta.url))
|
|
24
|
+
const DOCUMENT_MODULES = fileURLToPath(new URL('../app/document-modules.ts', import.meta.url))
|
|
25
|
+
const REGISTRY = 'virtual:presmium-app/registry'
|
|
26
|
+
const DOCUMENT_MODULE = '\0presmium-document-module:'
|
|
27
|
+
export const APP_MANIFEST = 'manifest.json'
|
|
28
|
+
// Vite's own manifest of the build: the entry's file and its styles and imports.
|
|
29
|
+
const BUILD_MANIFEST = '.vite/manifest.json'
|
|
30
|
+
const SERVER_DIR = 'server'
|
|
31
|
+
const SERVER_ENTRY = 'server.mjs'
|
|
32
|
+
const NOT_SOURCE = new Set(['node_modules', 'test', 'dist', '.presmium', '.git'])
|
|
33
|
+
const OWN_PACKAGES = ['@lincyaw/host', '@lincyaw/kernel', '@lincyaw/viewer', '@lincyaw/design']
|
|
34
|
+
// The subpaths of Presmium's packages that run in the browser (`./node` of the kernel does not).
|
|
35
|
+
const OWN_SHARED: Record<string, string[]> = {
|
|
36
|
+
'@lincyaw/host': ['.'],
|
|
37
|
+
'@lincyaw/kernel': ['.', './contracts', './ir', './reading'],
|
|
38
|
+
'@lincyaw/viewer': ['.', './domain'],
|
|
39
|
+
'@lincyaw/design': ['.'],
|
|
40
|
+
}
|
|
41
|
+
// A plugin package's exports that are not for a document's code (`architecture.md` section 3: the Node entry).
|
|
42
|
+
const NOT_SHARED = new Set(['./presmium', './package.json'])
|
|
43
|
+
// The files a bundler loads as modules, by their extension.
|
|
44
|
+
const MODULE_FILE = new Set(['.js', '.mjs', '.cjs', '.jsx', '.ts', '.mts', '.cts', '.tsx'])
|
|
45
|
+
const SHARED = 'virtual:presmium-app/shared:'
|
|
46
|
+
// Builds kept unused this long are removed, the newest few always kept.
|
|
47
|
+
const KEEP_MS = 7 * 24 * 3600_000
|
|
48
|
+
const KEEP_NEWEST = 3
|
|
49
|
+
|
|
50
|
+
/** What a build's plugin hooks may ask of the bundler (Rolldown's plugin context). */
|
|
51
|
+
interface BuildContext {
|
|
52
|
+
resolve(source: string, importer?: string): Promise<{ id: string, external?: boolean | 'absolute' | 'relative' } | null>
|
|
53
|
+
load(options: { id: string, resolveDependencies?: boolean }): Promise<{ importedIds: string[], exports: string[] }>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface PluginViteConfig { plugins?: unknown[] }
|
|
57
|
+
|
|
58
|
+
/** A plugin package as the viewer app holds it: its browser modules, its public subpaths, its document modules. */
|
|
59
|
+
export interface AppPlugin {
|
|
60
|
+
name: string
|
|
61
|
+
/** Its package's own name, which reports name it by. */
|
|
62
|
+
packageName: string
|
|
63
|
+
dir: string
|
|
64
|
+
fingerprint: string
|
|
65
|
+
components: Record<string, string>
|
|
66
|
+
plugins: string[]
|
|
67
|
+
viewers: string[]
|
|
68
|
+
/** Its `vite.config.ts`, whose Vite plugins the app is built with, as the dev servers are. */
|
|
69
|
+
vite: string[]
|
|
70
|
+
restartable: boolean
|
|
71
|
+
/** Its exports that a document's own code may import, by specifier, with their files. */
|
|
72
|
+
shared: Record<string, string>
|
|
73
|
+
documentModules: string[]
|
|
74
|
+
/** Whether the viewer app can show its documents: the Vite plugins of its Node entry, if any, only give document modules. */
|
|
75
|
+
supported: boolean
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface ViewerAppManifest {
|
|
79
|
+
key: string
|
|
80
|
+
core: string
|
|
81
|
+
plugins: Record<string, string>
|
|
82
|
+
/** The import map's entries: specifier to the file below the app's directory. */
|
|
83
|
+
shared: Record<string, string>
|
|
84
|
+
documentModules: string[]
|
|
85
|
+
builtAt: string
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface ViewerApp {
|
|
89
|
+
dir: string
|
|
90
|
+
manifest: ViewerAppManifest
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** What a page loads to run the app: its script, with the styles and the modules it imports, below the app's directory. */
|
|
94
|
+
export interface AppEntry {
|
|
95
|
+
script: string
|
|
96
|
+
css: string[]
|
|
97
|
+
preload: string[]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface BuildManifestChunk {
|
|
101
|
+
file: string
|
|
102
|
+
isEntry?: boolean
|
|
103
|
+
css?: string[]
|
|
104
|
+
imports?: string[]
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The app's entry as Vite's build manifest gives it: its file, its styles and the chunks it imports at once. */
|
|
108
|
+
export function appEntry(app: ViewerApp): AppEntry {
|
|
109
|
+
const chunks = JSON.parse(readFileSync(join(app.dir, BUILD_MANIFEST), 'utf8')) as Record<string, BuildManifestChunk>
|
|
110
|
+
const main = Object.entries(chunks).find(([source, chunk]) => chunk.isEntry && source.endsWith('main.ts'))?.[1]
|
|
111
|
+
if (!main) throw new Error(`the viewer app at ${app.dir} has no entry in its build manifest`)
|
|
112
|
+
const preload = new Set<string>()
|
|
113
|
+
const css = new Set(main.css ?? [])
|
|
114
|
+
const walk = (keys: readonly string[] = []) => {
|
|
115
|
+
for (const key of keys) {
|
|
116
|
+
const chunk = chunks[key]
|
|
117
|
+
if (!chunk || preload.has(chunk.file)) continue
|
|
118
|
+
preload.add(chunk.file)
|
|
119
|
+
for (const file of chunk.css ?? []) css.add(file)
|
|
120
|
+
walk(chunk.imports)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
walk(main.imports)
|
|
124
|
+
return { script: main.file, css: [...css], preload: [...preload] }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Where viewer apps are kept: the user's cache, shared by every workspace and session. */
|
|
128
|
+
export function viewerAppCache(env: NodeJS.ProcessEnv = process.env): string {
|
|
129
|
+
return join(env.XDG_CACHE_HOME || join(homedir(), '.cache'), 'presmium', 'viewer-app')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function readPackageManifest(dir: string): Record<string, unknown> {
|
|
133
|
+
try {
|
|
134
|
+
const value: unknown = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
|
|
135
|
+
return isRecord(value) ? value : {}
|
|
136
|
+
} catch {
|
|
137
|
+
return {}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** What a directory of sources is now: its files' paths, sizes and times, or a package's name and version under node_modules. */
|
|
142
|
+
export function fingerprint(dir: string): string {
|
|
143
|
+
const real = realpathSync(dir)
|
|
144
|
+
const hash = createHash('sha256')
|
|
145
|
+
if (real.split(sep).includes('node_modules')) {
|
|
146
|
+
const { name, version } = readPackageManifest(real)
|
|
147
|
+
return hash.update(`${String(name)}@${String(version)}`).digest('hex').slice(0, 16)
|
|
148
|
+
}
|
|
149
|
+
const walk = (at: string) => {
|
|
150
|
+
for (const entry of readdirSync(at, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
151
|
+
if (NOT_SOURCE.has(entry.name)) continue
|
|
152
|
+
const path = join(at, entry.name)
|
|
153
|
+
if (entry.isDirectory()) walk(path)
|
|
154
|
+
else if (entry.isFile()) {
|
|
155
|
+
const { size, mtimeMs } = statSync(path)
|
|
156
|
+
hash.update(`${relative(real, path)}:${size}:${Math.trunc(mtimeMs)}\n`)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
walk(real)
|
|
161
|
+
return hash.digest('hex').slice(0, 16)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Presmium's packages the app runs, where the web renderer finds them (the design system through the host). */
|
|
165
|
+
function ownPackages(): Map<string, string> {
|
|
166
|
+
const found = new Map<string, string>()
|
|
167
|
+
for (const name of OWN_PACKAGES) {
|
|
168
|
+
const dir = findInstalledPackage(name, WEB_DIR) ?? [...found.values()].map(from => findInstalledPackage(name, from)).find(Boolean)
|
|
169
|
+
if (!dir) throw new Error(`${name} is not installed beside @lincyaw/web`)
|
|
170
|
+
found.set(name, dir)
|
|
171
|
+
}
|
|
172
|
+
return found
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The sources of the app itself and of Presmium's packages it runs, with the versions of the tools that build it. */
|
|
176
|
+
export function coreFingerprint(): string {
|
|
177
|
+
const require = createRequire(join(WEB_DIR, 'package.json'))
|
|
178
|
+
const versions = ['vite', 'react', 'react-dom'].map(name => `${name}@${String((require(`${name}/package.json`) as { version: string }).version)}`)
|
|
179
|
+
const dirs = [WEB_DIR, ...ownPackages().values()]
|
|
180
|
+
return createHash('sha256').update([...versions, ...dirs.map(fingerprint)].join('\n')).digest('hex').slice(0, 16)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function exportTarget(value: unknown): string | undefined {
|
|
184
|
+
if (typeof value === 'string') return value
|
|
185
|
+
if (!isRecord(value)) return undefined
|
|
186
|
+
return exportTarget(value.browser) ?? exportTarget(value.import) ?? exportTarget(value.default)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The node entry of a plugin package (its `./presmium` export), loaded. */
|
|
190
|
+
async function nodeEntry(dir: string, exports: Record<string, unknown>): Promise<PresmiumNodePlugin | undefined> {
|
|
191
|
+
const file = exportTarget(exports['./presmium'])
|
|
192
|
+
if (!file) return undefined
|
|
193
|
+
return (await import(pathToFileURL(join(dir, file)).href) as { default?: PresmiumNodePlugin }).default
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The modules of a package's exports, by specifier, with their files: `subpaths` when given, else every one but the Node
|
|
198
|
+
* entry. Pattern subpaths (`./*`) name no module by themselves, and only module files are taken.
|
|
199
|
+
*/
|
|
200
|
+
function sharedExports(name: string, dir: string, exports: Record<string, unknown>, subpaths?: string[]): Record<string, string> {
|
|
201
|
+
const shared: Record<string, string> = {}
|
|
202
|
+
for (const [subpath, value] of Object.entries(exports)) {
|
|
203
|
+
const target = exportTarget(value)
|
|
204
|
+
if (subpaths ? !subpaths.includes(subpath) : NOT_SHARED.has(subpath) || subpath.includes('*')) continue
|
|
205
|
+
if (!target || !MODULE_FILE.has(extname(target))) continue
|
|
206
|
+
shared[subpath === '.' ? name : `${name}/${subpath.slice(2)}`] = join(dir, target)
|
|
207
|
+
}
|
|
208
|
+
return shared
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** A plugin package's browser modules and public subpaths, and its node entry's document modules. */
|
|
212
|
+
export async function appPlugin(name: string, dir: string): Promise<AppPlugin> {
|
|
213
|
+
const manifest = readPackageManifest(dir)
|
|
214
|
+
const modules = emptyClientModules()
|
|
215
|
+
collectBrowserModules(dir, name, modules, true)
|
|
216
|
+
const exports = isRecord(manifest.exports) ? manifest.exports : {}
|
|
217
|
+
// A document names a plugin as its configuration does (a path, maybe); its code imports it by its package's name.
|
|
218
|
+
const shared = sharedExports(typeof manifest.name === 'string' ? manifest.name : name, dir, exports)
|
|
219
|
+
let documentModules: string[] = []
|
|
220
|
+
let supported = true
|
|
221
|
+
try {
|
|
222
|
+
const node = await nodeEntry(dir, exports)
|
|
223
|
+
documentModules = Object.keys(node?.documentModules?.({ deckDir: dir, workspaceRoot: dir, config: {} }) ?? {})
|
|
224
|
+
} catch {
|
|
225
|
+
supported = false
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
name,
|
|
229
|
+
packageName: typeof manifest.name === 'string' ? manifest.name : name,
|
|
230
|
+
dir,
|
|
231
|
+
fingerprint: fingerprint(dir),
|
|
232
|
+
components: Object.fromEntries(modules.components),
|
|
233
|
+
plugins: modules.plugins,
|
|
234
|
+
viewers: modules.viewers.map(viewer => viewer.file),
|
|
235
|
+
vite: modules.vite,
|
|
236
|
+
restartable: modules.restartable,
|
|
237
|
+
shared,
|
|
238
|
+
documentModules,
|
|
239
|
+
supported,
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const keyOf = (core: string, plugins: readonly AppPlugin[]) =>
|
|
244
|
+
createHash('sha256').update([core, ...plugins.map(p => `${p.name}:${p.fingerprint}`).sort()].join('\n')).digest('hex').slice(0, 16)
|
|
245
|
+
|
|
246
|
+
/** The newest app under `cacheDir` built with every one of `plugins` as they are now, if there is one. */
|
|
247
|
+
export function findViewerApp(cacheDir: string, core: string, plugins: readonly AppPlugin[]): ViewerApp | undefined {
|
|
248
|
+
const seed = process.env.PRESMIUM_VIEWER_SEED
|
|
249
|
+
const roots = [...new Set([cacheDir, ...seed ? [seed] : []])]
|
|
250
|
+
let found: ViewerApp | undefined
|
|
251
|
+
for (const root of roots) for (const entry of existsSync(root) ? readdirSync(root) : []) {
|
|
252
|
+
const file = join(root, entry, APP_MANIFEST)
|
|
253
|
+
if (entry.startsWith('.') || !existsSync(file)) continue
|
|
254
|
+
try {
|
|
255
|
+
const manifest = JSON.parse(readFileSync(file, 'utf8')) as ViewerAppManifest
|
|
256
|
+
if (manifest.core !== core || !plugins.every(p => manifest.plugins[p.name] === p.fingerprint)) continue
|
|
257
|
+
if (!found || manifest.builtAt > found.manifest.builtAt) found = { dir: join(root, entry), manifest }
|
|
258
|
+
} catch {}
|
|
259
|
+
}
|
|
260
|
+
// Images can ship a read-only build. Materialize it in this user's ordinary
|
|
261
|
+
// cache so every existing asset route and later source rebuild keeps working.
|
|
262
|
+
// A seed with changed core or plugin sources was rejected above.
|
|
263
|
+
if (found && dirname(found.dir) !== cacheDir) {
|
|
264
|
+
const target = join(cacheDir, found.manifest.key)
|
|
265
|
+
if (!/^[a-f0-9]{16}$/.test(found.manifest.key)) return undefined
|
|
266
|
+
if (!existsSync(join(target, APP_MANIFEST))) {
|
|
267
|
+
mkdirSync(cacheDir, { recursive: true })
|
|
268
|
+
const temporary = join(cacheDir, `.build-seed-${found.manifest.key}-${process.pid}-${Date.now()}`)
|
|
269
|
+
try {
|
|
270
|
+
cpSync(found.dir, temporary, { recursive: true })
|
|
271
|
+
try { renameSync(temporary, target) }
|
|
272
|
+
catch (error) { if (!existsSync(join(target, APP_MANIFEST))) throw error }
|
|
273
|
+
} finally { rmSync(temporary, { recursive: true, force: true }) }
|
|
274
|
+
}
|
|
275
|
+
found = { dir: target, manifest: found.manifest }
|
|
276
|
+
}
|
|
277
|
+
// Marked as used, so that the cleaning of another session keeps it.
|
|
278
|
+
if (found) {
|
|
279
|
+
const now = new Date()
|
|
280
|
+
try { utimesSync(join(found.dir, APP_MANIFEST), now, now) } catch {}
|
|
281
|
+
}
|
|
282
|
+
return found
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Removes the apps no session used for a week, keeping the newest. */
|
|
286
|
+
function cleanViewerApps(cacheDir: string, keep: string): void {
|
|
287
|
+
const builds = readdirSync(cacheDir).flatMap((entry) => {
|
|
288
|
+
const dir = join(cacheDir, entry)
|
|
289
|
+
try {
|
|
290
|
+
return [{ dir, used: statSync(join(dir, APP_MANIFEST)).mtimeMs }]
|
|
291
|
+
} catch {
|
|
292
|
+
// A build another process left unfinished a day ago.
|
|
293
|
+
try {
|
|
294
|
+
return entry.startsWith('.build-') && Date.now() - statSync(dir).mtimeMs > 24 * 3600_000 ? [{ dir, used: 0 }] : []
|
|
295
|
+
} catch {
|
|
296
|
+
return []
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}).sort((a, b) => b.used - a.used)
|
|
300
|
+
for (const [i, build] of builds.entries()) {
|
|
301
|
+
if (build.dir !== keep && (build.used === 0 || (i >= KEEP_NEWEST && Date.now() - build.used > KEEP_MS))) rmSync(build.dir, { recursive: true, force: true })
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** The browser modules of each plugin, lazily loaded; on the server, the components' names only (they render once mounted). */
|
|
306
|
+
function registrySource(plugins: readonly AppPlugin[], ssr = false): string {
|
|
307
|
+
const load = (file: string) => (ssr ? 'never' : `() => import(${JSON.stringify(file)})`)
|
|
308
|
+
const entries = plugins.map(p => ` ${JSON.stringify(p.name)}: {
|
|
309
|
+
name: ${JSON.stringify(p.packageName)},
|
|
310
|
+
components: {${Object.entries(p.components).map(([name, file]) => `\n ${JSON.stringify(name)}: ${load(file)},`).join('')}
|
|
311
|
+
},
|
|
312
|
+
plugins: [${ssr ? '' : p.plugins.map(load).join(', ')}],
|
|
313
|
+
viewers: [${ssr ? '' : p.viewers.map(load).join(', ')}],
|
|
314
|
+
restartable: ${p.restartable},
|
|
315
|
+
},`)
|
|
316
|
+
return `${ssr ? 'const never = () => new Promise(() => {})\n' : ''}export const packages = {\n${entries.join('\n')}\n}\n`
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Leaves out the bundler's notes on "use client" (Remotion's modules start with it; it means nothing here). */
|
|
320
|
+
export function quietDirectives(_level: string, log: { code?: string, message?: string }): false | undefined {
|
|
321
|
+
return log.code === 'MODULE_LEVEL_DIRECTIVE' || /module level directive/i.test(log.message ?? '') ? false : undefined
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const sharedName = (specifier: string) => `shared/${specifier.replace(/^@/, '').replace(/[^\w.-]+/g, '_')}`
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The modules the app's import map names, by specifier, with their sources: React by its name, resolved as every other import
|
|
328
|
+
* of it; Presmium's packages and the plugins' public subpaths by their files. A document's bundle imports these by name.
|
|
329
|
+
*/
|
|
330
|
+
export function sharedSpecifiers(plugins: readonly AppPlugin[]): Record<string, string> {
|
|
331
|
+
const specifiers: Record<string, string> = { react: 'react', 'react/jsx-runtime': 'react/jsx-runtime', 'react/jsx-dev-runtime': 'react/jsx-dev-runtime', 'react-dom': 'react-dom', 'react-dom/client': 'react-dom/client' }
|
|
332
|
+
for (const [name, dir] of ownPackages()) {
|
|
333
|
+
const { exports } = readPackageManifest(dir)
|
|
334
|
+
Object.assign(specifiers, sharedExports(name, dir, isRecord(exports) ? exports : {}, OWN_SHARED[name]))
|
|
335
|
+
}
|
|
336
|
+
for (const plugin of plugins) Object.assign(specifiers, plugin.shared)
|
|
337
|
+
return specifiers
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Builds the viewer app for `plugins` into `cacheDir`, unless one is there already, and returns it. */
|
|
341
|
+
export async function buildViewerApp(cacheDir: string, plugins: readonly AppPlugin[]): Promise<ViewerApp> {
|
|
342
|
+
const core = coreFingerprint()
|
|
343
|
+
const known = findViewerApp(cacheDir, core, plugins)
|
|
344
|
+
if (known) return known
|
|
345
|
+
const key = keyOf(core, plugins)
|
|
346
|
+
const out = join(cacheDir, key)
|
|
347
|
+
const work = join(cacheDir, `.build-${key}-${process.pid}`)
|
|
348
|
+
rmSync(work, { recursive: true, force: true })
|
|
349
|
+
mkdirSync(work, { recursive: true })
|
|
350
|
+
// Generated shared entries resolve the same React packages as the web renderer.
|
|
351
|
+
mkdirSync(join(work, 'node_modules'), { recursive: true })
|
|
352
|
+
for (const name of ['react', 'react-dom']) {
|
|
353
|
+
const dir = findInstalledPackage(name, WEB_DIR)
|
|
354
|
+
if (!dir) throw new Error(`${name} is not installed beside @lincyaw/web`)
|
|
355
|
+
symlinkSync(dir, join(work, 'node_modules', name), 'junction')
|
|
356
|
+
}
|
|
357
|
+
writeFileSync(join(work, 'main.ts'), `import ${JSON.stringify(APP_MAIN)}\n`)
|
|
358
|
+
const specifiers = sharedSpecifiers(plugins)
|
|
359
|
+
const input: Record<string, string> = { main: join(work, 'main.ts') }
|
|
360
|
+
const shared: Record<string, string> = {}
|
|
361
|
+
for (const specifier of Object.keys(specifiers)) {
|
|
362
|
+
const name = sharedName(specifier)
|
|
363
|
+
input[name] = `${SHARED}${specifier}`
|
|
364
|
+
shared[specifier] = `${name}.js`
|
|
365
|
+
}
|
|
366
|
+
const documentModules = [...new Set(plugins.flatMap(p => p.documentModules))]
|
|
367
|
+
const viteConfigs = await Promise.all(plugins.flatMap(p => p.vite).map(async file => (await import(pathToFileURL(file).href) as { default?: PluginViteConfig }).default))
|
|
368
|
+
const importer = join(work, 'main.ts')
|
|
369
|
+
/**
|
|
370
|
+
* The module the import map names for `specifier`: all the exports of its source, as the bundler finds them (its
|
|
371
|
+
* default export too when it has one).
|
|
372
|
+
*/
|
|
373
|
+
const sharedModule = async (ctx: BuildContext, specifier: string): Promise<string> => {
|
|
374
|
+
const resolved = await ctx.resolve(specifiers[specifier]!, importer)
|
|
375
|
+
if (!resolved || resolved.external) throw new Error(`${specifier} cannot be resolved for the viewer app`)
|
|
376
|
+
const source = JSON.stringify(resolved.id)
|
|
377
|
+
if (specifier === 'react' || specifier.startsWith('react/') || specifier === 'react-dom' || specifier.startsWith('react-dom/')) {
|
|
378
|
+
const require = createRequire(join(WEB_DIR, 'package.json'))
|
|
379
|
+
const names = Object.keys(require(specifier) as Record<string, unknown>).filter(name => /^[A-Za-z_$][\w$]*$/.test(name) && name !== 'default')
|
|
380
|
+
return `import runtime from ${source}\nexport const { ${names.join(', ')} } = runtime\nexport default runtime\n`
|
|
381
|
+
}
|
|
382
|
+
const { exports } = await ctx.load({ id: resolved.id })
|
|
383
|
+
return `export * from ${source}\n${exports.includes('default') ? `export { default } from ${source}\n` : ''}`
|
|
384
|
+
}
|
|
385
|
+
const { build } = await import('vite')
|
|
386
|
+
await build({
|
|
387
|
+
configFile: false,
|
|
388
|
+
root: work,
|
|
389
|
+
base: './',
|
|
390
|
+
logLevel: 'warn',
|
|
391
|
+
mode: 'production',
|
|
392
|
+
esbuild: { jsx: 'automatic' },
|
|
393
|
+
plugins: [
|
|
394
|
+
{
|
|
395
|
+
name: 'presmium:viewer-app',
|
|
396
|
+
enforce: 'pre' as const,
|
|
397
|
+
resolveId(id: string) {
|
|
398
|
+
if (id === REGISTRY) return `\0${REGISTRY}`
|
|
399
|
+
if (id.startsWith(SHARED)) return `\0${id}`
|
|
400
|
+
if (documentModules.includes(id)) return `${DOCUMENT_MODULE}${id}`
|
|
401
|
+
return undefined
|
|
402
|
+
},
|
|
403
|
+
onLog: quietDirectives,
|
|
404
|
+
async load(id: string) {
|
|
405
|
+
if (id.startsWith(`\0${SHARED}`)) return await sharedModule(this, id.slice(SHARED.length + 1))
|
|
406
|
+
if (id === `\0${REGISTRY}`) return registrySource(plugins)
|
|
407
|
+
if (id.startsWith(DOCUMENT_MODULE)) return `import { documentModule } from ${JSON.stringify(DOCUMENT_MODULES)}\nexport default documentModule(${JSON.stringify(id.slice(DOCUMENT_MODULE.length))})\n`
|
|
408
|
+
return undefined
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
...viteConfigs.flatMap(config => config?.plugins ?? []) as never[],
|
|
412
|
+
],
|
|
413
|
+
resolve: { dedupe: ['react', 'react-dom'] },
|
|
414
|
+
build: {
|
|
415
|
+
outDir: join(work, 'out'),
|
|
416
|
+
emptyOutDir: true,
|
|
417
|
+
target: 'es2022',
|
|
418
|
+
manifest: true,
|
|
419
|
+
reportCompressedSize: false,
|
|
420
|
+
chunkSizeWarningLimit: 100_000,
|
|
421
|
+
rollupOptions: {
|
|
422
|
+
input,
|
|
423
|
+
preserveEntrySignatures: 'exports-only',
|
|
424
|
+
output: {
|
|
425
|
+
entryFileNames: chunk => (chunk.name.startsWith('shared/') ? '[name].js' : 'assets/[name]-[hash].js'),
|
|
426
|
+
chunkFileNames: 'assets/[name]-[hash].js',
|
|
427
|
+
assetFileNames: 'assets/[name]-[hash][extname]',
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
})
|
|
432
|
+
const manifest: ViewerAppManifest = { key, core, plugins: Object.fromEntries(plugins.map(p => [p.name, p.fingerprint])), shared, documentModules, builtAt: new Date().toISOString() }
|
|
433
|
+
writeFileSync(join(work, 'out', APP_MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`)
|
|
434
|
+
// The license of the fonts the app's styles bring, beside them wherever the app goes.
|
|
435
|
+
mkdirSync(dirname(join(work, 'out', FONT_LICENSE)), { recursive: true })
|
|
436
|
+
writeFileSync(join(work, 'out', FONT_LICENSE), fontLicense(import.meta.url))
|
|
437
|
+
// Another session may have built the same app meanwhile: its build stays, since its pages may be loading from it.
|
|
438
|
+
if (!existsSync(join(out, APP_MANIFEST))) {
|
|
439
|
+
rmSync(out, { recursive: true, force: true })
|
|
440
|
+
renameSync(join(work, 'out'), out)
|
|
441
|
+
}
|
|
442
|
+
rmSync(work, { recursive: true, force: true })
|
|
443
|
+
cleanViewerApps(cacheDir, out)
|
|
444
|
+
return { dir: out, manifest: JSON.parse(readFileSync(join(out, APP_MANIFEST), 'utf8')) as ViewerAppManifest }
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* The app's server-side render (`app/server.ts`) for the same plugins, beside the app: a module that renders a built
|
|
449
|
+
* site's pages to HTML. The plugins' components are only names there, so nothing of theirs is bundled; built the first
|
|
450
|
+
* time a site is built with this app.
|
|
451
|
+
*/
|
|
452
|
+
export async function buildViewerServer(app: ViewerApp, plugins: readonly AppPlugin[]): Promise<string> {
|
|
453
|
+
const file = join(app.dir, SERVER_DIR, SERVER_ENTRY)
|
|
454
|
+
if (existsSync(file)) return file
|
|
455
|
+
const work = join(app.dir, `.${SERVER_DIR}-${process.pid}`)
|
|
456
|
+
rmSync(work, { recursive: true, force: true })
|
|
457
|
+
mkdirSync(work, { recursive: true })
|
|
458
|
+
const documentModules = [...new Set(plugins.flatMap(p => p.documentModules))]
|
|
459
|
+
const viteConfigs = await Promise.all(plugins.flatMap(p => p.vite).map(async config => (await import(pathToFileURL(config).href) as { default?: PluginViteConfig }).default))
|
|
460
|
+
const { build } = await import('vite')
|
|
461
|
+
await build({
|
|
462
|
+
configFile: false,
|
|
463
|
+
root: WEB_DIR,
|
|
464
|
+
logLevel: 'warn',
|
|
465
|
+
mode: 'production',
|
|
466
|
+
esbuild: { jsx: 'automatic' },
|
|
467
|
+
plugins: [
|
|
468
|
+
{
|
|
469
|
+
name: 'presmium:viewer-server',
|
|
470
|
+
enforce: 'pre' as const,
|
|
471
|
+
resolveId(id: string) {
|
|
472
|
+
if (id === REGISTRY) return `\0${REGISTRY}`
|
|
473
|
+
if (documentModules.includes(id)) return `${DOCUMENT_MODULE}${id}`
|
|
474
|
+
return undefined
|
|
475
|
+
},
|
|
476
|
+
onLog: quietDirectives,
|
|
477
|
+
load(id: string) {
|
|
478
|
+
if (id === `\0${REGISTRY}`) return registrySource(plugins, true)
|
|
479
|
+
if (id.startsWith(DOCUMENT_MODULE)) return `import { documentModule } from ${JSON.stringify(DOCUMENT_MODULES)}\nexport default documentModule(${JSON.stringify(id.slice(DOCUMENT_MODULE.length))})\n`
|
|
480
|
+
return undefined
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
...viteConfigs.flatMap(config => config?.plugins ?? []) as never[],
|
|
484
|
+
],
|
|
485
|
+
resolve: { dedupe: ['react', 'react-dom'] },
|
|
486
|
+
ssr: { noExternal: true, target: 'node' },
|
|
487
|
+
build: {
|
|
488
|
+
ssr: APP_SERVER,
|
|
489
|
+
outDir: work,
|
|
490
|
+
emptyOutDir: true,
|
|
491
|
+
target: 'node22',
|
|
492
|
+
reportCompressedSize: false,
|
|
493
|
+
chunkSizeWarningLimit: 100_000,
|
|
494
|
+
rollupOptions: { output: { format: 'es', entryFileNames: SERVER_ENTRY, chunkFileNames: 'chunks/[name]-[hash].mjs' } },
|
|
495
|
+
},
|
|
496
|
+
})
|
|
497
|
+
// Another build of the same site may have written it meanwhile.
|
|
498
|
+
if (!existsSync(file)) {
|
|
499
|
+
rmSync(join(app.dir, SERVER_DIR), { recursive: true, force: true })
|
|
500
|
+
renameSync(work, join(app.dir, SERVER_DIR))
|
|
501
|
+
}
|
|
502
|
+
rmSync(work, { recursive: true, force: true })
|
|
503
|
+
return file
|
|
504
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { errorMessage } from '@lincyaw/kernel'
|
|
2
|
+
import { buildDocumentBundle } from './document-bundle.ts'
|
|
3
|
+
import type { DocumentBundleInput } from './document-bundle.ts'
|
|
4
|
+
import { buildViewerApp } from './viewer-app.ts'
|
|
5
|
+
import type { AppPlugin } from './viewer-app.ts'
|
|
6
|
+
|
|
7
|
+
/** The process that builds the viewer app or a document's bundle, apart from the workspace service, which stays responsive. */
|
|
8
|
+
|
|
9
|
+
// Its dependencies' deprecation notes are not the author's to act on.
|
|
10
|
+
process.noDeprecation = true
|
|
11
|
+
|
|
12
|
+
export type ViewerBuildRequest =
|
|
13
|
+
| { kind: 'app', cacheDir: string, plugins: AppPlugin[] }
|
|
14
|
+
| { kind: 'document', input: DocumentBundleInput }
|
|
15
|
+
|
|
16
|
+
// Kept for the next build a while, then gone (a build holds much memory).
|
|
17
|
+
const IDLE_MS = 30_000
|
|
18
|
+
let running = 0
|
|
19
|
+
let idle: ReturnType<typeof setTimeout> | undefined
|
|
20
|
+
const rest = () => {
|
|
21
|
+
clearTimeout(idle)
|
|
22
|
+
if (running === 0) idle = setTimeout(() => process.exit(0), IDLE_MS)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
process.on('message', ({ id, ...request }: ViewerBuildRequest & { id: number }) => {
|
|
26
|
+
running++
|
|
27
|
+
clearTimeout(idle)
|
|
28
|
+
const building = request.kind === 'app' ? buildViewerApp(request.cacheDir, request.plugins) : buildDocumentBundle(request.input)
|
|
29
|
+
building.then(
|
|
30
|
+
result => process.send!({ id, ok: true, result }),
|
|
31
|
+
(error: unknown) => process.send!({ id, ok: false, error: errorMessage(error) }),
|
|
32
|
+
).finally(() => {
|
|
33
|
+
running--
|
|
34
|
+
rest()
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
process.on('disconnect', () => process.exit(0))
|
|
38
|
+
rest()
|