mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-11 13:56:15 -08:00
refactor(tooling): [MOD-DEV-01] own dev server lifecycle and report port failures
This commit is contained in:
1 parent
401d4cdd65
commit
772d79a671
24 files changed
+1249
-132
No files matched your search
+23
-4
@@ -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
@@ -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`.
|
||||
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''' })[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)
|
||||
}
|
||||
@@ -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
@@ -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 })
|
||||
}
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Vendored
-9
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user