From f343c65ad8cd63b1fc4bf8646c1fdb3d1b75e997 Mon Sep 17 00:00:00 2001 From: Donald Pakkies Date: Fri, 6 Jun 2025 09:57:29 +0200 Subject: [PATCH] wip --- bin/formidablejs/Commands/Build.js | 2 +- bin/formidablejs/runtime.js | 18 +- bin/imba/server.imba | 17 +- bin/imba/server/env.imba | 16 + bin/imba/server/serve.imba | 439 ++++++++++++++++++ scripts/2-install.sh | 2 +- src/Foundation/Console.imba | 23 +- .../Console/Commands/ServeCommand.imba | 29 +- src/Hashing/Hash.imba | 28 +- src/Http/Kernel.imba | 15 +- src/Http/server/env.imba | 16 + src/Http/server/serve.imba | 439 ++++++++++++++++++ src/Support/Helpers/runtime.imba | 17 +- types/Support/Helpers/runtime.d.ts | 2 +- 14 files changed, 1027 insertions(+), 36 deletions(-) create mode 100644 bin/imba/server/env.imba create mode 100644 bin/imba/server/serve.imba create mode 100644 src/Http/server/env.imba create mode 100644 src/Http/server/serve.imba diff --git a/bin/formidablejs/Commands/Build.js b/bin/formidablejs/Commands/Build.js index dbf14217..60e75b8b 100644 --- a/bin/formidablejs/Commands/Build.js +++ b/bin/formidablejs/Commands/Build.js @@ -84,7 +84,7 @@ class Build extends Command { workerThreadErrors = true; - return Output.write(" WARN Bun does not support node.js worker_threads yet.\n"); + return Output.write(" WARN Bun does not support Node.js worker_threads yet.\n"); } process.stdout.write(data); diff --git a/bin/formidablejs/runtime.js b/bin/formidablejs/runtime.js index 92992ddf..88a2a54a 100644 --- a/bin/formidablejs/runtime.js +++ b/bin/formidablejs/runtime.js @@ -1,16 +1,22 @@ const getRuntime = () => { - const args = process.argv; + const path = process.argv[0]; let runtime = 'node'; - if (args) { - const executor = args[0].split('/').pop(); + if (path) { + const isWindows = path.endsWith('.exe'); + const separator = isWindows ? '\\' : '/'; + let executor = path.split(separator).slice(-1)[0]; - if (executor != undefined) { + if (isWindows) { + executor = executor.slice(0, -4); + } + + if (executor) { runtime = executor; } } return runtime; -} +}; -module.exports = { getRuntime } +module.exports = { getRuntime }; diff --git a/bin/imba/server.imba b/bin/imba/server.imba index ca5522c9..04387543 100644 --- a/bin/imba/server.imba +++ b/bin/imba/server.imba @@ -3,18 +3,33 @@ import { Kernel } from '@formidablejs/framework' import { join } from 'path' import { execSync } from 'child_process' import { writeFileSync, existsSync } from 'fs-extra' +import { serve } from './server/serve' const { app } = require('../../../../../bootstrap/main') const application = app.initiate(app.make(Kernel), true) application.then do(instance) + const runtime = do + const args = process.argv + let runtime = 'node' + + if args && args.length > 0 + const executor = args[0].split('/').pop! + + runtime = executor if executor != undefined + + runtime + const start = do let port = process.env.PORT || 3000 let host = process.env.HOST || 'localhost' let addr = process.env.ADDR || false - imba.serve instance.fastify().server + if runtime! == 'bun' + serve instance.fastify().server + else + imba.serve instance.fastify().server instance.fastify().listen({ port: Number(port), diff --git a/bin/imba/server/env.imba b/bin/imba/server/env.imba new file mode 100644 index 00000000..9b1c1755 --- /dev/null +++ b/bin/imba/server/env.imba @@ -0,0 +1,16 @@ +# imba$stdlib=1 +import np from 'path' + +export const env = new class Env + + # TODO: remove pm2 hack + # when launching pm2 with an ecosystem file, + # process.argv[1] is ProcessContainerFork.js + # the problem with using process.env.pm_exec_path is if the shell is inherited + # from another process that was started with pm2, the pm_exec_path environment variable + # will also be inherited, which may or may not be a completely different path. + get rootDir + process.env.IMBA_OUTDIR or np.dirname(process.env.pm_exec_path or process.argv[1]) + + get publicPath + np.resolve(rootDir,process.env.IMBA_PUBDIR or global.IMBA_PUBDIR or 'public') diff --git a/bin/imba/server/serve.imba b/bin/imba/server/serve.imba new file mode 100644 index 00000000..565818f7 --- /dev/null +++ b/bin/imba/server/serve.imba @@ -0,0 +1,439 @@ +# imba$stdlib=1 +import cluster from 'cluster' +import nfs from 'fs' +import np from 'path' +import {EventEmitter} from 'events' +import {env} from './env' + +import http from 'http' +import https from 'https' + +# TODO share mimeType list with bundler to +# bundle supported file extensions +const defaultHeaders = { + html: {'Content-Type': 'text/html; charset=utf-8'} + txt: {'Content-Type': 'text/plain; charset=utf-8'} + js: {'Content-Type': 'text/javascript; charset=utf-8'} + cjs: {'Content-Type': 'text/javascript; charset=utf-8'} + mjs: {'Content-Type': 'text/javascript; charset=utf-8'} + json: {'Content-Type': 'application/json; charset=utf-8'} + css: {'Content-Type': 'text/css; charset=utf-8'} + map: {'Content-Type': 'application/json; charset=utf-8'} + + otf: {'Content-Type': 'font/otf'} + ttf: {'Content-Type': 'font/ttf'} + woff: {'Content-Type': 'font/woff'} + woff2: {'Content-Type': 'font/woff2'} + + svg: {'Content-Type': 'image/svg+xml'} + avif: {'Content-Type': 'image/avif'} + gif: {'Content-Type': 'image/gif'} + png: {'Content-Type': 'image/png'} + apng: {'Content-Type': 'image/apng'} + webp: {'Content-Type': 'image/webp'} + jpg: {'Content-Type': 'image/jpeg'} + jpeg: {'Content-Type': 'image/jpeg'} + ico: {'Content-Type': 'image/x-icon'} + bmp: {'Content-Type': 'image/bmp'} + pdf: {'Content-Type': 'application/pdf'} + + webm: {'Content-Type': 'video/webm'} + weba: {'Content-Type': 'audio/webm'} + avi: {'Content-Type': 'video/x-msvideo'} + mp3: {'Content-Type': 'audio/mpeg'} + mp4: {'Content-Type': 'video/mp4'} + m4a: {'Content-Type': 'audio/m4a'} + mov: {'Content-Type': 'video/quicktime'} + wmv: {'Content-Type': 'video/x-ms-wmv'} + mpeg: {'Content-Type': 'video/mpeg'} + wav: {'Content-Type': 'audio/wav'} + ogg: {'Content-Type': 'audio/ogg'} + ogv: {'Content-Type': 'video/ogg'} + oga: {'Content-Type': 'audio/ogg'} + opus: {'Content-Type': 'audio/opus'} +} + +const hmrState = { + id: Date.now() +} + +const proc = global.process + +class Servers < Set + + def call name,...params + for server of self + server[name](...params) + + def close o = {} + for server of self + server.close(o) + + def reload o = {} + for server of self + server.reload(o) + + def broadcast msg, ...rest + for server of self + server.broadcast(msg,...rest) + + def emit event, data + for server of self + server.emit(event,data) + + def sseEnd + let promises = [] + for server of self + for client of server.clients + promises.push new Promise do(resolve) + client.on('finish',resolve) + client.end() + return Promise.all(promises) + +const servers = new Servers + +const process = new class Process < EventEmitter + def constructor + super + autoreload = no + state = {} + + if global.IMBA_RUN + if cluster.isWorker + proc.on('message') do(msg) + emit('message',msg) + emit(...msg.slice(1)) if msg[0] == 'emit' + # reload! if msg == 'reload' + else + proc.on('message') do(msg) + emit(...msg.slice(1)) if msg[0] == 'emit' + + self + + def #setup + return unless #setup? =? yes + + on('rebuild') do(e) + let prev = global.IMBA_MANIFEST + global.IMBA_MANIFEST = e + servers.broadcast('rebuild',e) + + on('reloadHard') do(e) + servers.broadcast('reloadHard',e) + await servers.sseEnd() + proc.exit(0) + + on('reloading') do(e) + state.reloading = yes + for server of servers + server.pause! + + on('reloaded') do(e) + state.reloaded = yes + servers.broadcast('reloaded') + await new Promise do setTimeout($1,100) + + let promises = for server of servers + server.close! + + setTimeout(&,100) do proc.exit(0) + await Promise.all(promises) + proc.exit(0) + yes + + def send msg + if proc.send isa Function + proc.send(msg) + + def on name, cb + super + + def reload + # only allow reloading once + return self unless isReloading =? yes + state.reloading = yes + + unless proc.env.IMBA_SERVE + console.warn "not possible to gracefully reload servers not started via imba start" + return + + send('reload') + return + +def deepImports src, links = [], depth = 0 + let asset = global.IMBA_MANIFEST[src] + return links if links.indexOf(src) >= 0 + if asset..imports + + for item in asset..imports + # if links.indexOf(item) >= 0 and depth > 10 + # return links + links.push(item) + deepImports(item, links, depth + 1) + return links + +class AssetResponder + + def constructor server, url, asset = {} + server = server + url = url + [pathname,query] = url.split('?') + ext = np.extname(pathname) + + headers = { + 'Content-Type': 'text/plain' + 'Access-Control-Allow-Origin': '*' + 'cache-control': 'public, max-age=31536000' + } + Object.assign(headers,server.options.assetHeaders or {}) + Object.assign(headers,defaultHeaders[ext.slice(1)] or {}) + + headers["max-age"] = 86400000 + + if asset.imports and server.options.preload !== no + headers['Link'] = deepImports(url).map(do "<{$1}>; rel=modulepreload; as=script").join(', ') + + path = server.localPathForUrl(url) + + def respond req, res + nfs.access(path,nfs.constants.R_OK) do(err) + if err + res.writeHead(404,{}) + return res.end! + + try + if server.options.setHeaders + server.options.setHeaders(res,path) + if global.BUN + nfs.readFile(path) do(err,data) + res.writeHead(200,headers) + res.end(data) + else + let stream = nfs.createReadStream(path) + res.writeHead(200, headers) + return stream.pipe(res) + catch e + res.writeHead(503,{}) + return res.end! + + def createReadStream + nfs.createReadStream(path) + + def pipe response + createReadStream!.pipe(response) + +class Server + + static def wrap server, o = {} + new self(server,o) + + def localPathForUrl url + let src = url.replace(/\?.*$/,'') + return urlToLocalPathMap[src] ??= if true + let path = np.resolve(env.publicPath,'.' + src) + let res = nfs.existsSync(path) and path + if !res and staticDir + path = np.resolve(staticDir,'.' + src) + res = nfs.existsSync(path) and path + res + + def headersForAsset path + let ext = np.extname(path) + let headers = Object.assign({ + 'Content-Type': 'text/plain' + 'Access-Control-Allow-Origin': '*' + 'cache-control': 'public' + },defaultHeaders[ext.slice(1)] or {}) + + get manifest + global.IMBA_MANIFEST or {} + + def constructor srv,options = {} + servers.add(self) + id = Math.random! + startedAt = Date.now! + options = options + closed = no + paused = no + server = srv + clients = new Set + stalledResponses = [] + assetResponders = {} + urlToLocalPathMap = {} + publicExistsMap = {} + + staticDir = global.IMBA_STATICDIR or '' + + if proc.env.IMBA_PATH + # what if there is no imba path? + devtoolsPath = np.resolve(proc.env.IMBA_PATH,'hmr.js') + + scheme = srv isa http.Server ? 'http' : 'https' + + # fetch and remove the original request listener + let originalHandler = server._events.request + let defaultHandler = server._events.request[0] + let dom = global.#dom + + srv.off('request',originalHandler[0]) + + # check if this is an express app? + originalHandler.#server = self + + srv.on('listening') do + # if not silent? + let adr = server.address! + let host = adr.address + if host == '::' or host == '0.0.0.0' + host = 'localhost' + let url = "{scheme}://{host}:{adr.port}/" + # unless proc.env.IMBA_CLUSTER + unless proc.env.IMBA_CLUSTER + console.log "listening on {url}" + + if global.IMBA_HMR + global.IMBA_HMR_PATH = '/__hmr__.js' + + handler = do(req,res) + let ishttp2 = req.constructor.name == 'Http2ServerRequest' + let url = req.url + + if paused or closed + res.statusCode=302 + res.setHeader('Location',req.url) + + unless ishttp2 + res.setHeader('Connection','close') + + if closed + if ishttp2 + req.stream.session.close! + return res.end! + else + return stalledResponses.push(res) + + if url == '/__imba__.mjs' + res.writeHead(200, defaultHeaders.mjs) + let path = np.resolve(proc.env.IMBA_PATH,'dist','imba.mjs') + let stream = nfs.createReadStream(path) + return stream.pipe(res) + + if global.IMBA_HMR + if url == '/__hmr__.json' + res.writeHead(200, defaultHeaders.json) + return res.end(JSON.stringify(hmrState)) + + elif url == '/__hmr__.js' and devtoolsPath + # and if hmr? + let stream = nfs.createReadStream(devtoolsPath) + res.writeHead(200, defaultHeaders.js) + return stream.pipe(res) + + if url == '/__hmr__' + let headers = { + 'Content-Type': 'text/event-stream' + 'Cache-Control': 'no-cache' + } + unless ishttp2 + headers['Connection'] = 'keep-alive' + + res.writeHead(200,headers) + clients.add(res) + broadcast('init',global.IMBA_MANIFEST,[res]) + broadcast('state',hmrState,[res]) + + req.on('close') do clients.delete(res) + return true + + # create full url + let headers = req.headers + let base + if ishttp2 + base = headers[':scheme'] + '://' + headers[':authority'] + else + let scheme = req.connection.encrypted ? 'https' : 'http' # + base = scheme + '://' + headers.host + + let asset = manifest[url] + + if asset + let path = localPathForUrl(url) + if path + let responder = assetResponders[url] ||= new AssetResponder(self,url,asset) + return responder.respond(req,res) + + if url.match(/\.[A-Z\d]{8}\./) or url.match(/\.\w{1,4}($|\?)/) + if let path = localPathForUrl(url) + try + let headers = headersForAsset(path) + if options.setHeaders + options.setHeaders(res,path) + if global.BUN + return nfs.readFile(path) do(err,data) + if err + res.writeHead(500,{}) + res.write("Error getting the file: {err}") + else + res.writeHead(200,headers) + res.end(data) + else + let stream = nfs.createReadStream(path) + res.writeHead(200, headers) + return stream.pipe(res) + catch e + res.writeHead(503,{}) + return res.end! + + # continue to the real server + if dom + let loc = new dom.Location(req.url,base) + # create a context - not a document? + dom.Document.create(location: loc) do + return defaultHandler(req,res) + else + return defaultHandler(req,res) + + srv.on('request',handler) + + srv.on('close') do + console.log "server is closing!" + + if global.IMBA_RUN + if cluster.isWorker or proc.env.IMBA_WATCH + process.#setup! + process.send('serve') + + def broadcast event, data = {}, clients = clients + data = JSON.stringify(data) + let msg = "data: {data}\n\n\n" + for client of clients + client.write("event: {event}\n") + client.write("id: imba\n") + client.write(msg) + return self + + def pause + if paused =? yes + broadcast('paused') + self + + def resume + if paused =? no + broadcast('resumed') + flushStalledResponses! + + def flushStalledResponses + for res in stalledResponses + res.end! + stalledResponses = [] + + def close + pause! + + new Promise do(resolve) + closed = yes + server.close(resolve) + flushStalledResponses! + +export def serve srv,...params + return Server.wrap(srv,...params) diff --git a/scripts/2-install.sh b/scripts/2-install.sh index 7e676163..cba03bfd 100755 --- a/scripts/2-install.sh +++ b/scripts/2-install.sh @@ -29,7 +29,7 @@ echo cd "$E2E" || exit 1 if [ "$PKG_MGR" = "bun" ]; then - rm -rf node_modules yarn.lock package-lock.json bun.lockb && \ + rm -rf node_modules yarn.lock package-lock.json bun.lock && \ sed -i '/"@formidablejs\/framework"/d' package.json && \ bun add sqlite3 "$PACKAGE" else diff --git a/src/Foundation/Console.imba b/src/Foundation/Console.imba index 74ee2409..3c8c9722 100644 --- a/src/Foundation/Console.imba +++ b/src/Foundation/Console.imba @@ -4,6 +4,7 @@ import { existsSync } from 'fs-extra' import { join } from 'path' import { spawn, spawnSync, execSync } from 'child_process' import { ServeEvents } from './Console/ServeEvents' +import executor from '../Support/Helpers/runtime' export default class Console prop runtime\string @@ -159,7 +160,7 @@ export default class Console const srv = './node_modules/@formidablejs/framework/bin/imba/server.imba' - const instance = spawn(runtime, [srv, '-s', '-w', '--esm'], { + const instance = spawn(executor!, [runtime, srv, '-s', '-w', '--esm'], { stdio: 'pipe', cwd: process.cwd!, env: { @@ -188,9 +189,12 @@ export default class Console address = address.replace('::1', 'localhost') + if !address.startsWith('http://') && !address.startsWith('https://') + address = 'http://' + address + Output.write "{devCommands.length > 0 ? '' : '\n'} INFO Development Server running…\n" - Output.write " Local: http://{address}\n" + Output.write " Local: {address}\n" Output.write " Press Ctrl+C to stop the server\n" @@ -212,8 +216,18 @@ export default class Console if file && file.endsWith('\x1B[22m\x1B[32mmanifest.json\x1B[39m') runDevCommands! + let workerThreadErrors = false + instance.stderr.on 'data', do(data) - process.stdout.write data.toString! + if data.toString().startsWith('NotImplementedError: worker_threads.Worker') + if workerThreadErrors + return + + workerThreadErrors = true + + return Output.write("\n WARN Bun does not support Node.js worker_threads yet.") + + process.stdout.write data instance.on 'exit', do process.exit! @@ -231,9 +245,10 @@ export default class Console return spawn(sh, [shFlag, self.runtime, self.console, ...args], { ...self.config env: process.env + shell: true }) - spawn(runtime, [self.console, ...args], { + spawn(self.runtime, [ self.console, ...args], { ...self.config env: process.env }) diff --git a/src/Foundation/Console/Commands/ServeCommand.imba b/src/Foundation/Console/Commands/ServeCommand.imba index 6e2ce0c8..448c7deb 100644 --- a/src/Foundation/Console/Commands/ServeCommand.imba +++ b/src/Foundation/Console/Commands/ServeCommand.imba @@ -5,7 +5,7 @@ import { existsSync } from 'fs' import { Prop } from '@formidablejs/console' import { spawnSync } from 'child_process' import { ServeEvents } from '../ServeEvents' -import isNumber from '../../../Support/Helpers/isNumber' +import executor from '../../../Support/Helpers/runtime' import nodemon from 'nodemon' export class ServeCommand < Command @@ -28,6 +28,9 @@ export class ServeCommand < Command prop #fullAddress\string + get isBun + typeof Bun !== 'undefined' + get ext const appPackage = join(process.cwd!, 'package.json') @@ -39,7 +42,15 @@ export class ServeCommand < Command language.toLowerCase! == 'typescript' ? '.ts' : '.imba' get runtime - join process.cwd!, 'node_modules', '.bin', 'imba' + (process.platform === 'win32' ? '.cmd' : '') + let _path = join process.cwd!, 'node_modules', '.bin', 'imba' + + if process.platform === 'win32' + if existsSync(_path + '.cmd') + return _path + '.cmd' + elif existsSync(_path + '.exe') + return _path + '.exe' + + _path get devConfigDefaults { @@ -109,7 +120,7 @@ export class ServeCommand < Command get commandList const list\array = self.devCommands - list.push("{runtime} server{ext} -f -s -v --esm") + list.push("{!isBun ? executor! + ' ' : ''}{runtime} server{ext} -f -s -v --esm") list.join(' && ') @@ -129,7 +140,7 @@ export class ServeCommand < Command self.setEnvVars(port) - const args = ['-s'] + const args = ['-s', '--esm'] if self.option('addr') then process.env.FORMIDABLE_ADDRESS_SET = '1' @@ -146,7 +157,7 @@ export class ServeCommand < Command return spawnSync sh, [ shFlag, self.runtime, "server{ext}", ...args ], conf - spawnSync self.runtime, [ "server{ext}", ...args ], conf + spawnSync executor!, [ self.runtime, "server{ext}", ...args ], conf else process.env.CONSOLE_FORMIDABLE_GROUP = JSON.stringify({ newLine: false @@ -162,13 +173,17 @@ export class ServeCommand < Command watch: ['.', '.env'] }) - process.once('SIGINT', do + def exitApp self.message 'info', 'Shutting down development server…' server.reset() process.exit(0) - ) + + if isBun + process.on('SIGINT', exitApp) + else + process.once('SIGINT', exitApp) server.on 'stdout', do(e) const data = e.toString() diff --git a/src/Hashing/Hash.imba b/src/Hashing/Hash.imba index db9db16c..8db6aa63 100644 --- a/src/Hashing/Hash.imba +++ b/src/Hashing/Hash.imba @@ -1,6 +1,5 @@ import InvalidHashDriverException from './Exceptions/InvalidHashDriverException' import InvalidHashConfigurationException from './Exceptions/InvalidHashConfigurationException' -import bcrypt from 'bcrypt' const settings = { config: null @@ -17,14 +16,26 @@ export default class Hash static def make value\string if settings.config.driver == 'argon2' + if isBun! + return await Bun.password.hash(password, settings.config.argon2) + return await getDriver!.hash(value, settings.config.argon2) if settings.config.driver == 'bcrypt' + if isBun! + return await Bun.password.hash(password, { + algorithm: "bcrypt", + cost: settings.config.bcrypt.rounds ?? 10 + }) + return await getDriver!.hash(value, settings.config.bcrypt.rounds ?? 10) throw new InvalidHashDriverException "{settings.config.driver} is not a valid driver." static def check value\string, hash\string + if isBun! + return await Bun.password.verify(value, hash) + if settings.config.driver == 'argon2' return await getDriver!.verify(hash, value, settings.config.argon2) @@ -49,19 +60,24 @@ export default class Hash if config.argon2.parallelism == null throw new InvalidHashConfigurationException 'argon2 parallelism is missing.' - try - settings.driver = require('argon2') - catch - throw new InvalidHashDriverException 'argon2 is not installed. Please run "npm install argon2".' + if !isBun! + try + settings.driver = require('argon2') + catch + throw new InvalidHashDriverException 'argon2 is not installed. Please run "npm install argon2".' if config.driver == 'bcrypt' if config.bcrypt.rounds == null throw new InvalidHashConfigurationException 'bcrypt rounds is missing.' - settings.driver = bcrypt + if !isBun! + settings.driver = require('bcrypt') settings.config = config static def reset settings.config = null settings.driver = null + + static def isBun + typeof Bun !== 'undefined' diff --git a/src/Http/Kernel.imba b/src/Http/Kernel.imba index 8f15e6a1..4ff9d05a 100644 --- a/src/Http/Kernel.imba +++ b/src/Http/Kernel.imba @@ -1,20 +1,22 @@ -import http from 'http' -import { writeFileSync } from 'fs-extra' -import { join } from 'path' import { handleMaintenanceMode } from '../Foundation/Exceptions/Handler/handleException' +import { join } from 'path' +import { serve } from './server/serve' +import { writeFileSync } from 'fs-extra' +import runtime from '../Support/Helpers/runtime' import fastify from 'fastify' import FormRequest from './Request/FormRequest' import getResponse from './Kernel/getResponse' import handleNotFound from './Kernel/handleNotFound' import hasContentTypes from './Kernel/hasContentTypes' +import http from 'http' import InvalidRouteActionException from './Router/Exceptions/InvalidRouteActionException' import isArray from '../Support/Helpers/isArray' import isClass from '../Support/Helpers/isClass' import isEmpty from '../Support/Helpers/isEmpty' import isFunction from '../Support/Helpers/isFunction' import MaintenanceModeException from '../Foundation/Exceptions/MaintenanceModeException' -import resolveResponse from './Kernel/resolveResponse' import Redirect from './Redirect/Redirect' +import resolveResponse from './Kernel/resolveResponse' import Route from './Router/Route' import UndefinedMiddlewareException from './Exceptions/UndefinedMiddlewareException' import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' @@ -134,7 +136,10 @@ export default class Kernel console.log "listening on {url}" - imba.serve router.server + if runtime! == 'bun' + serve router.server + else + imba.serve router.server router.listen({ port: Number(port) }) .then(do(address) diff --git a/src/Http/server/env.imba b/src/Http/server/env.imba new file mode 100644 index 00000000..9b1c1755 --- /dev/null +++ b/src/Http/server/env.imba @@ -0,0 +1,16 @@ +# imba$stdlib=1 +import np from 'path' + +export const env = new class Env + + # TODO: remove pm2 hack + # when launching pm2 with an ecosystem file, + # process.argv[1] is ProcessContainerFork.js + # the problem with using process.env.pm_exec_path is if the shell is inherited + # from another process that was started with pm2, the pm_exec_path environment variable + # will also be inherited, which may or may not be a completely different path. + get rootDir + process.env.IMBA_OUTDIR or np.dirname(process.env.pm_exec_path or process.argv[1]) + + get publicPath + np.resolve(rootDir,process.env.IMBA_PUBDIR or global.IMBA_PUBDIR or 'public') diff --git a/src/Http/server/serve.imba b/src/Http/server/serve.imba new file mode 100644 index 00000000..565818f7 --- /dev/null +++ b/src/Http/server/serve.imba @@ -0,0 +1,439 @@ +# imba$stdlib=1 +import cluster from 'cluster' +import nfs from 'fs' +import np from 'path' +import {EventEmitter} from 'events' +import {env} from './env' + +import http from 'http' +import https from 'https' + +# TODO share mimeType list with bundler to +# bundle supported file extensions +const defaultHeaders = { + html: {'Content-Type': 'text/html; charset=utf-8'} + txt: {'Content-Type': 'text/plain; charset=utf-8'} + js: {'Content-Type': 'text/javascript; charset=utf-8'} + cjs: {'Content-Type': 'text/javascript; charset=utf-8'} + mjs: {'Content-Type': 'text/javascript; charset=utf-8'} + json: {'Content-Type': 'application/json; charset=utf-8'} + css: {'Content-Type': 'text/css; charset=utf-8'} + map: {'Content-Type': 'application/json; charset=utf-8'} + + otf: {'Content-Type': 'font/otf'} + ttf: {'Content-Type': 'font/ttf'} + woff: {'Content-Type': 'font/woff'} + woff2: {'Content-Type': 'font/woff2'} + + svg: {'Content-Type': 'image/svg+xml'} + avif: {'Content-Type': 'image/avif'} + gif: {'Content-Type': 'image/gif'} + png: {'Content-Type': 'image/png'} + apng: {'Content-Type': 'image/apng'} + webp: {'Content-Type': 'image/webp'} + jpg: {'Content-Type': 'image/jpeg'} + jpeg: {'Content-Type': 'image/jpeg'} + ico: {'Content-Type': 'image/x-icon'} + bmp: {'Content-Type': 'image/bmp'} + pdf: {'Content-Type': 'application/pdf'} + + webm: {'Content-Type': 'video/webm'} + weba: {'Content-Type': 'audio/webm'} + avi: {'Content-Type': 'video/x-msvideo'} + mp3: {'Content-Type': 'audio/mpeg'} + mp4: {'Content-Type': 'video/mp4'} + m4a: {'Content-Type': 'audio/m4a'} + mov: {'Content-Type': 'video/quicktime'} + wmv: {'Content-Type': 'video/x-ms-wmv'} + mpeg: {'Content-Type': 'video/mpeg'} + wav: {'Content-Type': 'audio/wav'} + ogg: {'Content-Type': 'audio/ogg'} + ogv: {'Content-Type': 'video/ogg'} + oga: {'Content-Type': 'audio/ogg'} + opus: {'Content-Type': 'audio/opus'} +} + +const hmrState = { + id: Date.now() +} + +const proc = global.process + +class Servers < Set + + def call name,...params + for server of self + server[name](...params) + + def close o = {} + for server of self + server.close(o) + + def reload o = {} + for server of self + server.reload(o) + + def broadcast msg, ...rest + for server of self + server.broadcast(msg,...rest) + + def emit event, data + for server of self + server.emit(event,data) + + def sseEnd + let promises = [] + for server of self + for client of server.clients + promises.push new Promise do(resolve) + client.on('finish',resolve) + client.end() + return Promise.all(promises) + +const servers = new Servers + +const process = new class Process < EventEmitter + def constructor + super + autoreload = no + state = {} + + if global.IMBA_RUN + if cluster.isWorker + proc.on('message') do(msg) + emit('message',msg) + emit(...msg.slice(1)) if msg[0] == 'emit' + # reload! if msg == 'reload' + else + proc.on('message') do(msg) + emit(...msg.slice(1)) if msg[0] == 'emit' + + self + + def #setup + return unless #setup? =? yes + + on('rebuild') do(e) + let prev = global.IMBA_MANIFEST + global.IMBA_MANIFEST = e + servers.broadcast('rebuild',e) + + on('reloadHard') do(e) + servers.broadcast('reloadHard',e) + await servers.sseEnd() + proc.exit(0) + + on('reloading') do(e) + state.reloading = yes + for server of servers + server.pause! + + on('reloaded') do(e) + state.reloaded = yes + servers.broadcast('reloaded') + await new Promise do setTimeout($1,100) + + let promises = for server of servers + server.close! + + setTimeout(&,100) do proc.exit(0) + await Promise.all(promises) + proc.exit(0) + yes + + def send msg + if proc.send isa Function + proc.send(msg) + + def on name, cb + super + + def reload + # only allow reloading once + return self unless isReloading =? yes + state.reloading = yes + + unless proc.env.IMBA_SERVE + console.warn "not possible to gracefully reload servers not started via imba start" + return + + send('reload') + return + +def deepImports src, links = [], depth = 0 + let asset = global.IMBA_MANIFEST[src] + return links if links.indexOf(src) >= 0 + if asset..imports + + for item in asset..imports + # if links.indexOf(item) >= 0 and depth > 10 + # return links + links.push(item) + deepImports(item, links, depth + 1) + return links + +class AssetResponder + + def constructor server, url, asset = {} + server = server + url = url + [pathname,query] = url.split('?') + ext = np.extname(pathname) + + headers = { + 'Content-Type': 'text/plain' + 'Access-Control-Allow-Origin': '*' + 'cache-control': 'public, max-age=31536000' + } + Object.assign(headers,server.options.assetHeaders or {}) + Object.assign(headers,defaultHeaders[ext.slice(1)] or {}) + + headers["max-age"] = 86400000 + + if asset.imports and server.options.preload !== no + headers['Link'] = deepImports(url).map(do "<{$1}>; rel=modulepreload; as=script").join(', ') + + path = server.localPathForUrl(url) + + def respond req, res + nfs.access(path,nfs.constants.R_OK) do(err) + if err + res.writeHead(404,{}) + return res.end! + + try + if server.options.setHeaders + server.options.setHeaders(res,path) + if global.BUN + nfs.readFile(path) do(err,data) + res.writeHead(200,headers) + res.end(data) + else + let stream = nfs.createReadStream(path) + res.writeHead(200, headers) + return stream.pipe(res) + catch e + res.writeHead(503,{}) + return res.end! + + def createReadStream + nfs.createReadStream(path) + + def pipe response + createReadStream!.pipe(response) + +class Server + + static def wrap server, o = {} + new self(server,o) + + def localPathForUrl url + let src = url.replace(/\?.*$/,'') + return urlToLocalPathMap[src] ??= if true + let path = np.resolve(env.publicPath,'.' + src) + let res = nfs.existsSync(path) and path + if !res and staticDir + path = np.resolve(staticDir,'.' + src) + res = nfs.existsSync(path) and path + res + + def headersForAsset path + let ext = np.extname(path) + let headers = Object.assign({ + 'Content-Type': 'text/plain' + 'Access-Control-Allow-Origin': '*' + 'cache-control': 'public' + },defaultHeaders[ext.slice(1)] or {}) + + get manifest + global.IMBA_MANIFEST or {} + + def constructor srv,options = {} + servers.add(self) + id = Math.random! + startedAt = Date.now! + options = options + closed = no + paused = no + server = srv + clients = new Set + stalledResponses = [] + assetResponders = {} + urlToLocalPathMap = {} + publicExistsMap = {} + + staticDir = global.IMBA_STATICDIR or '' + + if proc.env.IMBA_PATH + # what if there is no imba path? + devtoolsPath = np.resolve(proc.env.IMBA_PATH,'hmr.js') + + scheme = srv isa http.Server ? 'http' : 'https' + + # fetch and remove the original request listener + let originalHandler = server._events.request + let defaultHandler = server._events.request[0] + let dom = global.#dom + + srv.off('request',originalHandler[0]) + + # check if this is an express app? + originalHandler.#server = self + + srv.on('listening') do + # if not silent? + let adr = server.address! + let host = adr.address + if host == '::' or host == '0.0.0.0' + host = 'localhost' + let url = "{scheme}://{host}:{adr.port}/" + # unless proc.env.IMBA_CLUSTER + unless proc.env.IMBA_CLUSTER + console.log "listening on {url}" + + if global.IMBA_HMR + global.IMBA_HMR_PATH = '/__hmr__.js' + + handler = do(req,res) + let ishttp2 = req.constructor.name == 'Http2ServerRequest' + let url = req.url + + if paused or closed + res.statusCode=302 + res.setHeader('Location',req.url) + + unless ishttp2 + res.setHeader('Connection','close') + + if closed + if ishttp2 + req.stream.session.close! + return res.end! + else + return stalledResponses.push(res) + + if url == '/__imba__.mjs' + res.writeHead(200, defaultHeaders.mjs) + let path = np.resolve(proc.env.IMBA_PATH,'dist','imba.mjs') + let stream = nfs.createReadStream(path) + return stream.pipe(res) + + if global.IMBA_HMR + if url == '/__hmr__.json' + res.writeHead(200, defaultHeaders.json) + return res.end(JSON.stringify(hmrState)) + + elif url == '/__hmr__.js' and devtoolsPath + # and if hmr? + let stream = nfs.createReadStream(devtoolsPath) + res.writeHead(200, defaultHeaders.js) + return stream.pipe(res) + + if url == '/__hmr__' + let headers = { + 'Content-Type': 'text/event-stream' + 'Cache-Control': 'no-cache' + } + unless ishttp2 + headers['Connection'] = 'keep-alive' + + res.writeHead(200,headers) + clients.add(res) + broadcast('init',global.IMBA_MANIFEST,[res]) + broadcast('state',hmrState,[res]) + + req.on('close') do clients.delete(res) + return true + + # create full url + let headers = req.headers + let base + if ishttp2 + base = headers[':scheme'] + '://' + headers[':authority'] + else + let scheme = req.connection.encrypted ? 'https' : 'http' # + base = scheme + '://' + headers.host + + let asset = manifest[url] + + if asset + let path = localPathForUrl(url) + if path + let responder = assetResponders[url] ||= new AssetResponder(self,url,asset) + return responder.respond(req,res) + + if url.match(/\.[A-Z\d]{8}\./) or url.match(/\.\w{1,4}($|\?)/) + if let path = localPathForUrl(url) + try + let headers = headersForAsset(path) + if options.setHeaders + options.setHeaders(res,path) + if global.BUN + return nfs.readFile(path) do(err,data) + if err + res.writeHead(500,{}) + res.write("Error getting the file: {err}") + else + res.writeHead(200,headers) + res.end(data) + else + let stream = nfs.createReadStream(path) + res.writeHead(200, headers) + return stream.pipe(res) + catch e + res.writeHead(503,{}) + return res.end! + + # continue to the real server + if dom + let loc = new dom.Location(req.url,base) + # create a context - not a document? + dom.Document.create(location: loc) do + return defaultHandler(req,res) + else + return defaultHandler(req,res) + + srv.on('request',handler) + + srv.on('close') do + console.log "server is closing!" + + if global.IMBA_RUN + if cluster.isWorker or proc.env.IMBA_WATCH + process.#setup! + process.send('serve') + + def broadcast event, data = {}, clients = clients + data = JSON.stringify(data) + let msg = "data: {data}\n\n\n" + for client of clients + client.write("event: {event}\n") + client.write("id: imba\n") + client.write(msg) + return self + + def pause + if paused =? yes + broadcast('paused') + self + + def resume + if paused =? no + broadcast('resumed') + flushStalledResponses! + + def flushStalledResponses + for res in stalledResponses + res.end! + stalledResponses = [] + + def close + pause! + + new Promise do(resolve) + closed = yes + server.close(resolve) + flushStalledResponses! + +export def serve srv,...params + return Server.wrap(srv,...params) diff --git a/src/Support/Helpers/runtime.imba b/src/Support/Helpers/runtime.imba index dcb57724..6a0fb961 100644 --- a/src/Support/Helpers/runtime.imba +++ b/src/Support/Helpers/runtime.imba @@ -1,10 +1,19 @@ export default def runtime - const args = process.argv + if process == undefined + return 'browser' + + const path = process.argv[0] let runtime = 'node' - if args && args.length > 0 - const executor = args[0].split('/').pop! + if path + const isWindows = path.endsWith('.exe') + const separator = isWindows ? '\\' : '/' + let executor = path.split(separator).slice(-1)[0]; + + if isWindows + executor = executor.slice(0, -4); - runtime = executor if executor != undefined + if executor + runtime = executor runtime diff --git a/types/Support/Helpers/runtime.d.ts b/types/Support/Helpers/runtime.d.ts index bd5a5603..16e0268e 100644 --- a/types/Support/Helpers/runtime.d.ts +++ b/types/Support/Helpers/runtime.d.ts @@ -1,4 +1,4 @@ -type Runtime = 'node' | 'bun' | 'deno' +type Runtime = 'node' | 'bun' | 'deno' | 'browser' /** * Get the current runtime.