@applicvision/js-toolbox
Version:
A collection of tools for modern JavaScript development
163 lines (135 loc) • 4.81 kB
JavaScript
// @ts-check
import fs from 'node:fs'
import path from 'node:path'
import http, { OutgoingMessage } from 'node:http'
import https from 'node:https'
import { readFile } from 'node:fs/promises'
import { contentTypes } from '../content-types.js'
import { FileChangeStream } from '../autoreload/streams.js'
import { fileURLToPath, pathToFileURL } from 'node:url'
const htmlPath = new URL('./public/index.html', import.meta.url)
const htmlFileContents = await readFile(htmlPath)
const importMap = {
"@applicvision/": "/node_modules/@applicvision/",
"node:assert/strict": "/public/browser-assert.js"
}
try {
const packageData = await readFile('./package.json')
const { name, exports } = JSON.parse(packageData.toString())
// replace self imports
for (const [exportName, exportPath] of Object.entries(exports)) {
importMap[path.posix.join(name, exportName)] = path.posix.join('/hostproject',
typeof exportPath == 'string' ? exportPath : exportPath.import ?? exportPath.default
)
}
} catch (error) {
console.warn('Could not find package.json')
}
let htmlString = ''
/** @param {string[]} mapImports */
function buildHtmlString(mapImports) {
mapImports
.map(mapImport => mapImport.split(':'))
.forEach(([importName, mappedPath]) => {
importMap[importName] = mappedPath
})
htmlString = htmlFileContents.toString().replace(
/(<script type="importmap">).*?(<\/script>)/s,
`$1\n${JSON.stringify({ imports: importMap }, null, 2)}\n$2`)
}
const routes = {
testfiles: '/testfiles',
nodeModules: '/node_modules/',
hostProject: '/hostproject/',
public: '/public/',
filechangestream: '/filechangestream',
}
/**
* @param {string[]} paths
* @param {Set<OutgoingMessage>} clientsToNotify
*/
async function watchPaths(paths, clientsToNotify) {
for await (const change of new FileChangeStream(paths, [], { objectMode: true })) {
clientsToNotify.forEach(stream => stream.write(`event: filechange\ndata: ${change.file}\n\n`))
}
}
/**
* @param {URL[]} testFiles
* @param {string[]} watchedPaths
*/
export function createServer(testFiles, watchedPaths, certPath = process.env.JS_TOOLBOX_CERT) {
/** @type {Set<OutgoingMessage>} */
const attachedClients = new Set()
const cwdFileUrl = pathToFileURL(process.cwd())
cwdFileUrl.pathname += '/'
const testFileUrls = testFiles
.filter(url => url.pathname.startsWith(cwdFileUrl.pathname))
.map(url => url.pathname.slice((cwdFileUrl.pathname).length))
const server = certPath ? https.createServer({
key: fs.readFileSync(path.resolve(certPath, 'key.pem')),
cert: fs.readFileSync(path.resolve(certPath, 'cert.pem'))
}) : http.createServer()
if (watchedPaths) {
const paths = [].concat(watchedPaths)
watchPaths(paths, attachedClients)
}
server.on('request', async (request, response) => {
const requestedPath = request.url
if (!requestedPath) {
return response.writeHead(400).end()
}
if (requestedPath.startsWith(routes.public)) {
const fileUrl = new URL(`.${requestedPath}`, import.meta.url)
try {
response.writeHead(200, contentTypes[path.extname(fileURLToPath(fileUrl))])
fs.createReadStream(fileUrl).pipe(response)
} catch (e) {
response.writeHead(404).end()
}
} else if (requestedPath.startsWith(routes.nodeModules)) {
try {
const requestedModule = requestedPath.slice(routes.nodeModules.length)
const resolvedPath = import.meta.resolve(requestedModule)
const fileURL = new URL(resolvedPath)
response.writeHead(200, { 'content-type': 'application/javascript' })
fs.createReadStream(fileURL).pipe(response)
} catch (error) {
response.writeHead(404, 'Module not found').end()
}
} else if (requestedPath == routes.testfiles) {
response.setHeader('content-type', 'application/json')
response.end(JSON.stringify({
files: testFileUrls,
watchedPaths
}))
} else if (requestedPath.startsWith(routes.hostProject)) {
const fileUrl = new URL(requestedPath.slice(routes.hostProject.length), cwdFileUrl)
response.writeHead(200, contentTypes[path.extname(fileURLToPath(fileUrl))])
fs.createReadStream(fileUrl).pipe(response)
} else if (requestedPath == routes.filechangestream) {
if (!watchedPaths) {
return response.end()
}
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)
})
} else {
response.writeHead(200, contentTypes['.html'])
response.end(htmlString)
}
})
return {
start(port, mapImports) {
buildHtmlString(mapImports)
return new Promise(resolve => server.listen(port, () => resolve(`${certPath ? 'https' : 'http'}://localhost:${port}`)))
},
stop() {
server.close()
}
}
}