@applicvision/js-toolbox
Version:
A collection of tools for modern JavaScript development
292 lines (233 loc) • 8.17 kB
JavaScript
import { parseArguments } from '@applicvision/js-toolbox/args';
import http, { OutgoingMessage } from 'node:http'
import style from '@applicvision/js-toolbox/style'
import { createReadStream, statSync } from 'node:fs';
import { stat, readFile } from 'node:fs/promises'
import { contentTypes } from '../content-types.js';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { FileChangeStream } from '../autoreload/streams.js';
function logState() {
console.clear()
console.log(style.dim('~'.repeat(12)), 'self-serve', style.dim('~'.repeat(12)))
console.log('Serving directory:', directoryToServe, 'on port:', assignedPort, 'base:', style.green(basePath))
console.log('Connected clients:', attachedClients.size)
}
const selfImportPrefix = '/_package/'
const importmapRegex = /<script type="importmap">(.+?)<\/script>/s
let importMapEntries = []
async function readPackageJSON() {
const packageData = await readFile('./package.json')
const { name, exports = {}, dependencies = {}, devDependencies = {} } = JSON.parse(packageData.toString())
const entries = []
// To enable self imports
if (Object.keys(exports).length > 0) {
entries.push(name)
}
entries.push(...Object.keys(dependencies))
entries.push(...Object.keys(devDependencies))
return entries
}
try {
importMapEntries = await readPackageJSON()
} catch (error) {
console.log(error)
console.warn('Could not find package.json')
}
/** @type {Set<OutgoingMessage>} */
const attachedClients = new Set()
/** @type {number} */
let assignedPort
let basePath = '/'
/** @type {{help?: string, options: any, args: string[]}|undefined} */
let parsedArguments
try {
parsedArguments = parseArguments()
.option('port', { description: 'Specify which port to run on.' })
.option('watch', { description: 'Specify a directory to watch besides the served directory. By default, the working directory is watched' })
.option('ignore', { description: 'Specify ignore pattern' })
.option('base', { description: 'Specify optional base path' })
.option('map', { description: 'Specify entry or entries for import map. Format <specifier>:<path>' })
.flag('spa', { short: false, description: 'Fallback requests to index.html' })
.flag('no-watch', { short: false, description: 'Do not watch for file changes.' })
.help('Welcome to the playground server. Start this, specifying which directory to serve.\nUsage: [<options>] [--] <pathspec>')
.parse()
} catch (error) {
console.error(error.message)
process.exitCode = 1
}
const [directoryToServe] = parsedArguments?.args ?? []
if (!parsedArguments) {
// already handled
} else if (parsedArguments.help) {
console.log(parsedArguments.help)
} else if (parsedArguments.args.length != 1) {
console.error('Please specify one directory to serve')
process.exitCode = 1
} else if (!(await stat(directoryToServe)).isDirectory()) {
console.error('Please specify a directory')
process.exitCode = 1
} else {
const { base } = parsedArguments.options
if (base) {
basePath = path.resolve('/', base)
}
let watchedPaths = [].concat(parsedArguments.options.watch ?? [])
if (parsedArguments.options.noWatch) {
watchedPaths = []
} else if (watchedPaths.length == 0) {
watchedPaths = ['.']
} else {
if (!watchedPaths.includes(directoryToServe)) {
watchedPaths.push(directoryToServe)
}
if (importMapEntries.length && !watchedPaths.includes('package.json')) {
watchedPaths.push('package.json')
}
}
const ignorePatterns = [].concat(parsedArguments.options.ignore ?? [])
const clientScriptPath = new URL('../autoreload/client.js', import.meta.url)
const extraImportMapEntries = []
.concat(parsedArguments.options.map ?? [])
.map(entry => {
const dividerIndex = entry.indexOf(':')
if (dividerIndex == -1) {
throw new Error('Invalid import map')
}
return [
entry.slice(0, dividerIndex),
entry.slice(1 + dividerIndex)
]
})
/**
* @param {http.ServerResponse} response
* @param {string} directory
*/
async function respondWithIndexFile(response, directory = directoryToServe) {
response.writeHead(200)
const indexFile = String(await readFile(path.join(directory, 'index.html')))
let htmlString = ''
if ((importMapEntries.length + extraImportMapEntries.length) > 0) {
const imports = Object.fromEntries(
importMapEntries.flatMap(module => [
[module, `${selfImportPrefix}${module}`],
[`${module}/`, `${selfImportPrefix}${module}/`]
]).concat(extraImportMapEntries)
)
if (indexFile.match(importmapRegex)) {
htmlString = indexFile.replace(importmapRegex, (_, importMapContent) => {
const map = JSON.parse(importMapContent)
map.imports = { ...map.imports, ...imports }
return `<script type="importmap">
${JSON.stringify(map, null, 2)}
</script>`
})
} else {
htmlString += `<script type="importmap">
${JSON.stringify({ imports }, null, 2)}
</script>${indexFile}`
}
}
if (watchedPaths.length > 0) {
htmlString += `<script type="module">
import {start} from '/autoreload.js'
start()
</script>`
}
response.end(htmlString)
}
const server = http.createServer((request, response) => {
const { url } = request
if (path.relative(basePath, url) == '') {
return respondWithIndexFile(response)
}
if (url == '/autoreload.js') {
response.writeHead(200, contentTypes['.js'])
return createReadStream(clientScriptPath).pipe(response)
}
if (url == '/stream') {
response.writeHead(200, { 'content-type': 'text/event-stream' })
attachedClients.add(response)
response.write(`event: welcome\ndata: ${Date.now()}\n\n`)
response.on('close', () => {
attachedClients.delete(response)
logState()
})
return logState()
}
if (url.startsWith(selfImportPrefix)) {
const moduleSpecifier = url.slice(selfImportPrefix.length)
let modulePath, fileUrl
try {
modulePath = import.meta.resolve(moduleSpecifier, pathToFileURL(path.join(process.cwd(), 'placeholder.js')))
fileUrl = new URL(modulePath)
statSync(fileUrl)
} catch (error) {
console.error('Could not resolve import:', moduleSpecifier)
console.error(error)
return response.writeHead(404).end()
}
const type = path.extname(modulePath)
response.writeHead(200, contentTypes[type])
return createReadStream(fileUrl).pipe(response)
}
}).listen(parsedArguments.options.port ?? 0, () => {
// @ts-ignore
assignedPort = server.address().port
logState()
})
// serving static files
server.on('request', async (request, response) => {
if (response.headersSent) {
return
}
if (!request.url.startsWith(basePath)) {
return response.writeHead(404).end()
}
const filePath = path.join(directoryToServe, path.relative(basePath, request.url))
try {
const stats = await stat(filePath)
if (stats.isDirectory()) {
if (parsedArguments.options.spa) {
return respondWithIndexFile(response)
}
// look for index.html
await stat(path.join(filePath, 'index.html'))
return respondWithIndexFile(response, filePath)
}
} catch (error) {
if (error.code == 'ENOENT') {
if (parsedArguments.options.spa) {
return respondWithIndexFile(response)
}
return response.writeHead(404).end()
}
throw error
}
response.writeHead(200, contentTypes[path.extname(filePath)])
createReadStream(filePath).pipe(response)
})
process.stdin.resume()
process.stdin.on('close', () => {
console.log('exiting gracefully')
attachedClients.forEach(stream =>
stream.end(`event: stop\ndata: ${Date.now()}\n\n`)
)
server.close()
fileChangeStream?.stop()
})
/** @type {FileChangeStream?} */
let fileChangeStream
if (watchedPaths.length > 0) {
fileChangeStream = new FileChangeStream(watchedPaths, ignorePatterns, { objectMode: true })
for await (const change of fileChangeStream) {
if (change.file == 'package.json') {
importMapEntries = await readPackageJSON()
}
attachedClients.forEach(stream => {
stream.write(`event: filechange\ndata: ${change.file}\n\n`)
})
}
}
}