UNPKG

@applicvision/js-toolbox

Version:

A collection of tools for modern JavaScript development

292 lines (233 loc) 8.17 kB
#! /usr/bin/env -S node --experimental-import-meta-resolve 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`) }) } } }