refactor(tooling): [MOD-DEV-01] own dev server lifecycle and report port failures

This commit is contained in:
Harvey Zhao committed 2026-09-14 11:29:46 +08:00
1 parent 401d4cdd65
commit 772d79a671
24 files changed
+1249 -132

No files matched your search

+23 -4
View File
@@ -1,7 +1,26 @@
import process from 'node:process'
import { runDevelop } from './library/development.ts'
runDevelop().catch((error) => {
console.error('❌ Development server failed:', error)
process.exitCode = 1
})
const abort = new AbortController()
const stop = () => abort.abort()
process.once('SIGINT', stop)
process.once('SIGTERM', stop)
try {
const configured = process.env.ARTPLAYER_DEV_PORT
const port = configured === undefined ? 8082 : Number(configured)
if (configured?.trim() === '' || !Number.isInteger(port) || port < 0 || port > 65535)
throw new Error('ARTPLAYER_DEV_PORT must be an integer from 0 through 65535')
const session = await runDevelop({ port, signal: abort.signal })
await session?.done
}
catch (error) {
if (!abort.signal.aborted) {
console.error('❌ Development server failed:', error)
process.exitCode = 1
}
}
finally {
process.off('SIGINT', stop)
process.off('SIGTERM', stop)
}
+56 -26
View File
@@ -2,19 +2,24 @@
Use Node from `.node-version` and Yarn Classic 1.22.22. The historical JS/MJS
entrypoints remain checked compatibility shims; implementation lives in strict TS.
No runtime/build dependency or bundler was replaced by this migration.
Vite still owns library bundling. The dev HTTP server is repository-owned; the
root dev dependency mrmime 2.0.1 supplies MIME names. Servor 4.0.2 remains only for
its browser-opening helper and the isolated historical failure probe.
| Module | Responsibility |
| --- | --- |
| `projects.ts` | Deterministic workspace discovery, CLI selection, non-TTY failure, one JS/TS entry |
| `names.ts` | Historical camelCase/PascalCase library global names |
| `config.ts` | Vite configuration and resource/worker/output targets; typed with installed Vite declarations |
| `banner.ts` | Version/license/notices, UMD AMD/global identity, inline-worker banner removal |
| `production.ts` | Sequential package/format builds, checked dist cleanup, docs copy and optional analysis |
| `analysis.ts` | Per-build module and artifact size/hash reports outside dist |
| `development.ts` | Servor demo startup, initial IIFE build, source watcher, first-success browser opening |
| `rebuild.ts` | One active build Promise, coalesced pending changes and later retry after rejection |
| `vendor.d.ts` | Only the consumed prompts 2.4.2 / Servor 4.0.2 APIs; verified against installed sources |
| Module | Responsibility |
| ---------------- | --------------------------------------------------------------------------------------------- |
| `projects.ts` | Deterministic workspace discovery, CLI selection, non-TTY failure, one JS/TS entry |
| `names.ts` | Historical camelCase/PascalCase library global names |
| `config.ts` | Vite configuration and resource/worker/output targets; typed with installed Vite declarations |
| `banner.ts` | Version/license/notices, UMD AMD/global identity, inline-worker banner removal |
| `production.ts` | Sequential package/format builds, checked dist cleanup, docs copy and optional analysis |
| `analysis.ts` | Per-build module and artifact size/hash reports outside dist |
| `development.ts` | Session startup/close/done, IIFE build queue, source watcher and first-success browser opening |
| `server.ts` | HTTP binding, docs watcher, request/socket ownership and shutdown |
| `assets.ts` | Root-contained files, directory indexes, HTML injection, gzip and media ranges |
| `reload.ts` | SSE clients, shared heartbeat and the browser reload script |
| `rebuild.ts` | One active build Promise, coalesced pending changes and later retry after rejection |
| `vendor.d.ts` | Only the consumed prompts 2.4.2 / Servor 4.0.2 openBrowser APIs |
`build.js` and `dev.js` own command execution/error reporting. `utils.js`,
`projects.js`, `rebuild.js` and `build-analysis.mjs` keep their previous named
@@ -33,6 +38,7 @@ yarn build --help
yarn dev --help
yarn typecheck:library
yarn test:library
yarn test:dev-server
```
No-argument TTY selection uses the same prompts UI. Missing names in non-TTY mode,
@@ -54,13 +60,13 @@ under `scripts/library/`, including additions/removals. Do not reduce that cover
to `config.ts` alone: banners, name mapping, project discovery or analysis may also
affect artifacts. Markdown maintenance changes are not build inputs.
## Development lifetime and remaining server work
## Development lifetime
CLI defaults remain port 8082 and docs/index.html, with output at
docs/uncompiled/<package>/index.js. The output directory is created before Servor
enumerates directories for its Linux file watchers. The internal `runDevelop`
function accepts a port only for isolated callers/tests; the CLI does not add a
new flag. Do not stop an unrelated server occupying 8082 to run a test.
docs/uncompiled/<package>/index.js. Optional `ARTPLAYER_DEV_PORT` accepts an integer
0..65535; 0 atomically binds an available port for isolated callers/tests. Invalid
values or occupied ports exit nonzero; there is no silent port fallback. Do not
stop an unrelated server occupying 8082 to run a test.
Build errors are reported while keeping the source watcher alive. Changes during
a build schedule one subsequent build rather than concurrent writes. Browser
@@ -69,12 +75,31 @@ Only the package src tree is watched; external dependencies/configuration change
still require restart. Tests use real automatic reload rather than racing it with
a second manual navigation.
Servor still owns its HTTP server, docs watchers, reload sockets/timers and SIGINT
handler until process exit; its API exposes no close handle. MOD-DEV-01 tracks
replacing/adapting that lifetime and fixing its reproduced occupied-port exit code
zero. `node refactor/scripts/servor-port-probe.mjs` reproduces that upstream defect
in an isolated child without touching 8082. It is not a candidate-success test.
This migration does not claim those server limitations are resolved.
`startDevelopment` returns `{ url, close, done }`. The CLI owns SIGINT/SIGTERM
handlers and its AbortController; importing implementation modules installs none.
`close()` is idempotent: stop queuing builds, remove the abort handler and source
watcher, close HTTP/docs watching/SSE, then await the active build. A late build
cannot open a browser or reload a closed session. Aborting during asynchronous
startup waits for and closes a late-bound server. `done` rejects on fatal watcher
or server errors; ordinary compiler errors retain the session for correction.
`server.ts` owns every HTTP socket and active asset pipeline, the recursive docs
watcher and 75ms debounce timer. Its close destroys only its own sockets and waits
for request pipelines to settle. `reload.ts` has one 30s heartbeat while clients
exist; the last disconnection or shutdown removes it. The browser closes its SSE
connection on pagehide. Docs edits reload automatically; the selected output tree
is ignored by the docs watcher and reloads only after a successful queued build.
`assets.ts` leaves classic JS assets intact for Monaco, injects reload into HTML,
serves directory index.html and escaped listings, and falls back to root index.html
for missing extensionless routes. HTTP corrections include valid directory
redirects, fallback status200, body-free HEAD, explicit gzip;q=0, exact/suffix/open
media ranges and 416 for malformed/unsatisfiable ranges. Realpath containment
rejects outside symlinks/junctions. Stream pipelines dispose file/gzip resources on
disconnect. This local docs server is not a production hosting server.
`node refactor/scripts/servor-port-probe.mjs` preserves the old Servor occupied-port
exit0 reproduction. Candidate failure/cleanup checks live in test/dev-server.test.js.
## Evidence and regression ownership
@@ -86,13 +111,18 @@ This migration does not claim those server limitations are resolved.
- `test/plugin-scaffold.test.js`: newly generated plugins use these real tools.
- `test/browser/library-development.spec.js`: real dev server in Chromium, Firefox
and WebKit, worker messaging, automatic reload, source edits and error recovery.
Uses fixture ports and disposes its own child; it does not test media playback.
The real JS CLI receives SIGTERM and must exit naturally with status0. A second
case serves actual docs/Monaco, executes TypeScript and plays/seeks/decodes local
MP4. Its recorded tracked core5.4.1 asset is not a newly built release candidate.
- `test/dev-server.test.js`: real occupied-port CLI failure, HTTP/HEAD/ranges,
repeated same-port startup/shutdown with SSE, aborted download and startup/build
cancellation; a controlled clock separately checks heartbeat disposal.
- `node refactor/scripts/library-build-comparison.mjs`: copy current package sources
into a retained fixture, build all libraries with frozen pre-migration scripts
and current scripts at the same paths, compare all artifact hashes. This is an
explicit whole-build validation, not part of every quick unit test.
Remote Linux/macOS/Windows CI remains a separate release gate. The local browser
evidence for MOD-02 is Windows; source inspection of Linux watcher registration
does not substitute for a remote Linux run. Lerna and root installation hooks were
evidence for MOD-02 and MOD-DEV-01 is Windows; it does not substitute for remote
Linux/macOS runs or real device playback. Lerna and root installation hooks were
not changed. The scaffold's separate guide remains in `scripts/plugin/README.md`.
+134
View File
@@ -0,0 +1,134 @@
import type { IncomingMessage, ServerResponse } from 'node:http'
import { Buffer } from 'node:buffer'
import fs from 'node:fs'
import path from 'node:path'
import { pipeline } from 'node:stream/promises'
import { createGzip, gzipSync } from 'node:zlib'
import { lookup } from 'mrmime'
import { reloadScript } from './reload.ts'
function inside(root: string, file: string): boolean {
const relative = path.relative(root, file)
return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)
}
function error(response: ServerResponse, status: number) {
response.writeHead(status).end(String(status))
}
function acceptsGzip(request: IncomingMessage): boolean {
const encodings = (request.headers['accept-encoding'] || '').split(',').map((entry) => {
const [name = '', ...parameters] = entry.toLowerCase().split(';')
const quality = parameters.find(value => value.trim().startsWith('q='))
return { name: name.trim(), quality: quality ? Number(quality.trim().slice(2)) : 1 }
})
const quality = (encodings.find(entry => entry.name === 'gzip') || encodings.find(entry => entry.name === '*'))?.quality ?? 0
return quality > 0 && quality <= 1
}
function html(request: IncomingMessage, response: ServerResponse, content: string) {
const compressed = acceptsGzip(request)
const bytes = Buffer.from(`${content}\n${reloadScript}`)
const body = compressed ? gzipSync(bytes) : bytes
response.setHeader('Content-Type', 'text/html; charset=utf-8')
response.setHeader('Content-Length', body.length)
response.setHeader('Vary', 'Accept-Encoding')
if (compressed)
response.setHeader('Content-Encoding', 'gzip')
response.end(request.method === 'HEAD' ? undefined : body)
}
const escape = (value: string) => value.replace(/[&<>"']/g, value => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', '\'': '&#39;' })[value]!)
export async function serveAsset(root: string, pathname: string, request: IncomingMessage, response: ServerResponse): Promise<void> {
const requested = path.resolve(root, `.${pathname}`)
if (!inside(root, requested) || /[\\\0:]/.test(pathname)) {
error(response, 403)
return
}
let file = requested
let stat
try {
stat = await fs.promises.stat(file)
}
catch (failure) {
if ((failure as NodeJS.ErrnoException).code !== 'ENOENT' && (failure as NodeJS.ErrnoException).code !== 'ENOTDIR')
throw failure
if (path.extname(pathname)) {
error(response, 404)
return
}
file = path.join(root, 'index.html')
stat = await fs.promises.stat(file)
}
if (!inside(root, await fs.promises.realpath(file))) {
error(response, 403)
return
}
if (stat.isDirectory()) {
if (!pathname.endsWith('/')) {
const query = new URL(request.url || '/', 'http://localhost').search
response.writeHead(301, { Location: `${pathname.split('/').map(encodeURIComponent).join('/')}/${query}` }).end()
return
}
const index = path.join(file, 'index.html')
if (fs.existsSync(index)) {
file = index
stat = await fs.promises.stat(file)
if (!inside(root, await fs.promises.realpath(file))) {
error(response, 403)
return
}
}
else {
const entries = await fs.promises.readdir(file, { withFileTypes: true })
const base = pathname.endsWith('/') ? pathname : `${pathname}/`
html(request, response, `<!doctype html><meta charset="utf-8"><ul>${entries.map(entry => `<li><a href="${escape(base + encodeURIComponent(entry.name) + (entry.isDirectory() ? '/' : ''))}">${escape(entry.name)}</a></li>`).join('')}</ul>`)
return
}
}
if (!stat.isFile()) {
error(response, 404)
return
}
if (path.extname(file).toLowerCase() === '.html') {
html(request, response, await fs.promises.readFile(file, 'utf8'))
return
}
const type = lookup(file) || 'application/octet-stream'
response.setHeader('Content-Type', type)
response.setHeader('Last-Modified', stat.mtime.toUTCString())
response.setHeader('Accept-Ranges', 'bytes')
let start = 0
let end = stat.size - 1
if (request.headers.range) {
const match = /^bytes=(\d*)-(\d*)$/.exec(request.headers.range)
if (!match || (!match[1] && !match[2]) || !stat.size) {
response.setHeader('Content-Range', `bytes */${stat.size}`)
error(response, 416)
return
}
start = match[1] ? Number(match[1]) : Math.max(0, stat.size - Number(match[2]))
end = match[1] && match[2] ? Math.min(Number(match[2]), end) : end
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start > end || start >= stat.size) {
response.setHeader('Content-Range', `bytes */${stat.size}`)
error(response, 416)
return
}
response.statusCode = 206
response.setHeader('Content-Range', `bytes ${start}-${end}/${stat.size}`)
}
const compressed = stat.size > 0 && !request.headers.range && acceptsGzip(request) && /^(?:text\/|application\/(?:javascript|json)|image\/svg)/.test(type)
response.setHeader('Vary', 'Accept-Encoding')
if (compressed)
response.setHeader('Content-Encoding', 'gzip')
else response.setHeader('Content-Length', stat.size ? end - start + 1 : 0)
if (request.method === 'HEAD' || !stat.size) {
response.end()
return
}
const stream = fs.createReadStream(file, { start, end })
if (compressed)
await pipeline(stream, createGzip(), response)
else await pipeline(stream, response)
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { bannerPlugin, workerBannerPlugin } from './banner.ts'
export interface LibraryConfigOptions {
entry: string
outDir: string
outDir?: string
name: string
format: LibraryFormats
fileName: string
+113 -57
View File
@@ -1,73 +1,129 @@
import fs from 'node:fs'
import path from 'node:path'
import servor from 'servor'
import process from 'node:process'
import openBrowser from 'servor/utils/openBrowser.js'
import { build as viteBuild } from 'vite'
import { getViteBuildConfig } from './config.ts'
import { getGlobalName } from './names.ts'
import { getEntryFile, getProjects, selectProjects } from './projects.ts'
import { createRebuildQueue } from './rebuild.ts'
import { startDevServer } from './server.ts'
async function develop(projectPath: string, name: string, open: boolean, port: number) {
const uncompiledPath = path.resolve(`docs/uncompiled/${name}`)
let browserOpened = false
// Servor enumerates existing directories when installing its Linux watchers.
fs.mkdirSync(uncompiledPath, { recursive: true })
const { url } = await servor({
root: 'docs',
fallback: 'index.html',
reload: true,
port,
})
async function buildBundle() {
const startTime = Date.now()
try {
const config = getViteBuildConfig({
entry: getEntryFile(projectPath),
outDir: uncompiledPath,
name: getGlobalName(name),
format: 'iife',
fileName: 'index.js',
minify: false,
emptyOutDir: true,
})
config.define['process.env.NODE_ENV'] = JSON.stringify('development')
await viteBuild({ root: projectPath, ...config })
console.log(`[${name}] ✅ Built in ${Date.now() - startTime}ms`)
if (open && !browserOpened) {
browserOpened = true
openBrowser(url)
}
}
catch (error) {
console.error(`[${name}] ❌ Build error:`, error)
}
}
const rebuild = createRebuildQueue(buildBundle)
await rebuild()
console.log(`[${name}] Demo: ${url}/?libs=./uncompiled/${name}/index.js`)
const srcPath = path.join(projectPath, 'src')
console.log(`[${name}] 👀 Watching ${srcPath}...`)
fs.watch(srcPath, { recursive: true }, async (_, filename) => {
if (filename) {
console.log(`[${name}] 📝 Changed: ${filename}`)
await rebuild()
}
})
export interface DevelopmentOptions {
root?: string
port?: number
signal?: AbortSignal
}
export async function runDevelop({ port = 8082 }: { port?: number } = {}): Promise<void> {
export async function startDevelopment(projectPath: string, name: string, open: boolean, { root = process.cwd(), port = 8082, signal }: DevelopmentOptions = {}) {
const uncompiledPath = path.resolve(root, `docs/uncompiled/${name}`)
let browserOpened = false
let stopped = false
let failure: Error | undefined
let watcher: fs.FSWatcher | undefined
let activeBuild = Promise.resolve()
let closing: Promise<void> | undefined
let finish!: () => void
let reject!: (error: Error) => void
const done = new Promise<void>((resolve, failed) => {
finish = resolve
reject = failed
})
// Startup can fail before the caller receives the session; retain rejection for its owner.
void done.catch(() => {})
const startup = startDevServer({
root: path.join(root, 'docs'),
port,
ignoredDirectory: uncompiledPath,
onError: fatal,
})
function fatal(error: Error) {
if (stopped)
return
failure ??= error
void close().catch(() => {})
}
function aborted() {
void close().catch(() => {})
}
function close(): Promise<void> {
if (!closing) {
stopped = true
watcher?.close()
signal?.removeEventListener('abort', aborted)
closing = (async () => {
const server = await startup.catch(() => undefined)
await server?.close()
await activeBuild
if (failure)
throw failure
})()
void closing.then(finish, reject)
}
return closing
}
signal?.addEventListener('abort', aborted, { once: true })
try {
const server = await startup
if (signal?.aborted || stopped) {
await close()
return { url: server.url, close, done }
}
fs.mkdirSync(uncompiledPath, { recursive: true })
async function buildBundle() {
if (stopped)
return
const startTime = Date.now()
try {
const config = getViteBuildConfig({ entry: getEntryFile(projectPath), outDir: uncompiledPath, name: getGlobalName(name), format: 'iife', fileName: 'index.js', minify: false, emptyOutDir: true })
config.define['process.env.NODE_ENV'] = JSON.stringify('development')
await viteBuild({ root: projectPath, ...config })
if (stopped)
return
console.log(`[${name}] ✅ Built in ${Date.now() - startTime}ms`)
server.reload()
if (open && !browserOpened) {
browserOpened = true
openBrowser(server.url)
}
}
catch (error) {
if (!stopped)
console.error(`[${name}] ❌ Build error:`, error)
}
}
const queue = createRebuildQueue(buildBundle)
function rebuild() {
if (!stopped)
activeBuild = queue()
return activeBuild
}
const srcPath = path.join(projectPath, 'src')
watcher = fs.watch(srcPath, { recursive: true }, (_, filename) => {
if (filename && !stopped) {
console.log(`[${name}] 📝 Changed: ${filename}`)
void rebuild().catch(fatal)
}
})
watcher.on('error', fatal)
void rebuild().then(() => {
if (!stopped) {
console.log(`[${name}] Demo: ${server.url}/?libs=./uncompiled/${name}/index.js`)
console.log(`[${name}] 👀 Watching ${srcPath}...`)
}
}, fatal)
return { url: server.url, close, done }
}
catch (error) {
await close()
throw error
}
}
export async function runDevelop({ port = 8082, signal }: DevelopmentOptions = {}) {
const projects = getProjects()
const { names, open } = await selectProjects(projects, 'dev')
const name = names[0]
if (name)
await develop(projects[name]!, name, open, port)
return startDevelopment(projects[name]!, name, open, { port, signal })
}
+60
View File
@@ -0,0 +1,60 @@
import type { ServerResponse } from 'node:http'
export const reloadScript = `<script>(() => {
const source = new EventSource('/livereload');
let reloading = false;
const reload = () => { if (!reloading) { reloading = true; location.reload(); } };
source.onmessage = reload;
source.onerror = () => { source.onopen = reload; };
addEventListener('pagehide', () => source.close(), { once: true });
})();</script>`
export function createReloadChannel() {
const clients = new Set<ServerResponse>()
let heartbeat: ReturnType<typeof setInterval> | undefined
let closed = false
function stopHeartbeat() {
clearInterval(heartbeat)
heartbeat = undefined
}
return {
add(response: ServerResponse) {
if (closed) {
response.writeHead(503).end()
return
}
response.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
})
response.write('event: connected\ndata: ready\n\n')
clients.add(response)
response.once('close', () => {
clients.delete(response)
if (!clients.size)
stopHeartbeat()
})
if (!heartbeat) {
heartbeat = setInterval(() => {
for (const client of clients)
client.write('event: ping\ndata: waiting\n\n')
}, 30000)
heartbeat.unref()
}
},
reload() {
if (!closed) {
for (const client of clients)
client.write('data: reload\n\n')
}
},
close() {
closed = true
stopHeartbeat()
for (const client of clients)
client.end()
clients.clear()
},
}
}
+121
View File
@@ -0,0 +1,121 @@
import type { Socket } from 'node:net'
import fs from 'node:fs'
import http from 'node:http'
import path from 'node:path'
import { serveAsset } from './assets.ts'
import { createReloadChannel } from './reload.ts'
export interface DevServerOptions {
root: string
port: number
ignoredDirectory?: string
onError: (error: Error) => void
}
export async function startDevServer(options: DevServerOptions) {
const root = await fs.promises.realpath(options.root)
const reload = createReloadChannel()
const sockets = new Set<Socket>()
const requests = new Set<Promise<void>>()
let watcher: fs.FSWatcher | undefined
let debounce: ReturnType<typeof setTimeout> | undefined
let closing: Promise<void> | undefined
const server = http.createServer((request, response) => {
if (closing) {
response.writeHead(503).end()
return
}
response.setHeader('Access-Control-Allow-Origin', '*')
response.setHeader('Cache-Control', 'no-store')
if (request.method === 'OPTIONS') {
response.writeHead(204, { 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS' }).end()
return
}
if (request.method !== 'GET' && request.method !== 'HEAD') {
response.writeHead(405, { Allow: 'GET, HEAD, OPTIONS' }).end()
return
}
let pathname: string
try {
pathname = decodeURIComponent(new URL(request.url || '/', 'http://localhost').pathname)
}
catch {
response.writeHead(400).end()
return
}
if (pathname === '/livereload') {
if (request.method === 'HEAD')
response.writeHead(200, { 'Content-Type': 'text/event-stream' }).end()
else reload.add(response)
return
}
const active = serveAsset(root, pathname, request, response).catch((error: NodeJS.ErrnoException) => {
if (!response.destroyed && !response.headersSent)
response.writeHead(error.code === 'ENOENT' ? 404 : 500).end()
else response.destroy()
}).finally(() => requests.delete(active))
requests.add(active)
})
server.on('connection', (socket) => {
sockets.add(socket)
socket.once('close', () => sockets.delete(socket))
})
function close(): Promise<void> {
if (!closing) {
watcher?.close()
clearTimeout(debounce)
reload.close()
closing = new Promise<void>((resolve, reject) => {
server.close((error?: NodeJS.ErrnoException) => {
if (error && error.code !== 'ERR_SERVER_NOT_RUNNING')
reject(error)
else void Promise.all(requests).then(() => resolve(), reject)
})
for (const socket of sockets)
socket.destroy()
})
}
return closing
}
function fail(error: Error) {
if (closing)
return
const pending = close()
options.onError(error)
void pending.catch(options.onError)
}
try {
await new Promise<void>((resolve, reject) => {
function failed(error: Error) {
server.off('listening', ready)
reject(error)
}
function ready() {
server.off('error', failed)
resolve()
}
server.once('error', failed)
server.once('listening', ready)
server.listen(options.port)
})
server.on('error', fail)
watcher = fs.watch(root, { recursive: true }, (_, filename) => {
if (closing)
return
const file = filename && path.resolve(root, filename)
if (file && options.ignoredDirectory && (file === options.ignoredDirectory || file.startsWith(`${options.ignoredDirectory}${path.sep}`)))
return
clearTimeout(debounce)
debounce = setTimeout(() => reload.reload(), 75)
})
watcher.on('error', fail)
const address = server.address()
if (!address || typeof address === 'string')
throw new Error('Development server did not bind a TCP port')
return { url: `http://localhost:${address.port}`, reload: reload.reload, close }
}
catch (error) {
await close()
throw error
}
}
-9
View File
@@ -8,15 +8,6 @@ declare module 'prompts' {
}): Promise<{ value?: string }>
}
declare module 'servor' {
export default function servor(options: {
root: string
fallback: string
reload: boolean
port: number
}): Promise<{ url: string }>
}
declare module 'servor/utils/openBrowser.js' {
export default function openBrowser(url: string): true | undefined
}