Skip to content

Commit

Permalink
feat: add cli, loader, hmr packages
Browse files Browse the repository at this point in the history
  • Loading branch information
shigma committed Feb 3, 2024
1 parent 21a86a3 commit 413a999
Show file tree
Hide file tree
Showing 23 changed files with 1,377 additions and 3 deletions.
2 changes: 2 additions & 0 deletions packages/cli/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.DS_Store
tsconfig.tsbuildinfo
49 changes: 49 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{
"name": "@cordisjs/cli",
"description": "CLI for cordis loader",
"type": "module",
"version": "0.3.4",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"bin": {
"cordis": "lib/cli.js"
},
"exports": {
".": "./lib/index.js",
"./worker": "./lib/worker/index.js",
"./worker/main": "./lib/worker/main.js",
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib",
"src"
],
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"author": "Shigma <[email protected]>",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/cordisjs/cordis.git",
"directory": "packages/cli"
},
"bugs": {
"url": "https://github.com/cordisjs/cordis/issues"
},
"homepage": "https://github.com/cordisjs/cordis",
"keywords": [
"cordis",
"loader",
"cli"
],
"dependencies": {
"@cordisjs/loader": "0.3.3",
"@cordisjs/logger": "^0.1.4",
"cac": "^6.7.14",
"cordis": "^3.7.1",
"cosmokit": "^1.5.2",
"kleur": "^4.1.5"
}
}
72 changes: 72 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#!/usr/bin/env node

import { cac } from 'cac'
import kleur from 'kleur'
import { start } from './index.js'
import { Dict, hyphenate } from 'cosmokit'
import { version } from '../package.json'

export function isInteger(source: any) {
return typeof source === 'number' && Math.floor(source) === source
}

const cli = cac('cordis').help().version(version)

function toArg(key: string) {
return key.length === 1 ? `-${key}` : `--${hyphenate(key)}`
}

function addToArray(args: string[], arg: string) {
if (!args.includes(arg)) args.push(arg)
}

function unparse(argv: Dict) {
const execArgv = Object.entries(argv).flatMap<string>(([key, value]) => {
if (key === '--') return []
key = toArg(key)
if (value === true) {
return [key]
} else if (value === false) {
return ['--no-' + key.slice(2)]
} else if (Array.isArray(value)) {
return value.flatMap(value => [key, value])
} else {
return [key, value]
}
})
execArgv.push(...argv['--'])
addToArray(execArgv, '--expose-internals')
return execArgv
}

cli.command('start [file]', 'start a cordis application')
.alias('run')
.allowUnknownOptions()
.option('--debug [namespace]', 'specify debug namespace')
.option('--log-level [level]', 'specify log level (default: 2)')
.option('--log-time [format]', 'show timestamp in logs')
.action((file, options) => {
const { logLevel, debug, logTime, ...rest } = options
if (logLevel !== undefined && (!isInteger(logLevel) || logLevel < 0)) {
console.warn(`${kleur.red('error')} log level should be a positive integer.`)
process.exit(1)
}
process.env.CORDIS_LOG_LEVEL = logLevel || ''
process.env.CORDIS_LOG_DEBUG = debug || ''
process.env.CORDIS_LOADER_ENTRY = file || ''
start({
name: 'cordis',
daemon: {
execArgv: unparse(rest),
},
logger: {
showTime: logTime,
},
})
})

const argv = cli.parse()

if (!cli.matchedCommand && !argv.options.help) {
cli.outputHelp()
}
104 changes: 104 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { ChildProcess, fork } from 'child_process'
import { extname, resolve } from 'path'
import kleur from 'kleur'
import type * as worker from './worker/index.js'
import { fileURLToPath } from 'url'

type Event = Event.Start | Event.Env | Event.Heartbeat

namespace Event {
export interface Start {
type: 'start'
}

export interface Env {
type: 'shared'
body: string
}

export interface Heartbeat {
type: 'heartbeat'
}
}

let child: ChildProcess

process.env.CORDIS_SHARED = JSON.stringify({
startTime: Date.now(),
})

function createWorker(options: worker.Options) {
let timer: 0 | NodeJS.Timeout | undefined
let started = false

const filename = fileURLToPath(import.meta.url)
child = fork(resolve(filename, `../worker/main${extname(filename)}`), [], {
execArgv: [
...process.execArgv,
...options.daemon?.execArgv || [],
],
env: {
...process.env,
CORDIS_LOADER_OPTIONS: JSON.stringify(options),
},
})

child.on('message', (message: Event) => {
if (message.type === 'start') {
started = true
timer = options.daemon?.heartbeatTimeout && setTimeout(() => {
console.log(kleur.red('daemon: heartbeat timeout'))
child.kill('SIGKILL')
}, options.daemon?.heartbeatTimeout)
} else if (message.type === 'shared') {
process.env.CORDIS_SHARED = message.body
} else if (message.type === 'heartbeat') {
if (timer) timer.refresh()
}
})

// https://nodejs.org/api/process.html#signal-events
// https://learn.microsoft.com/en-us/cpp/c-runtime-library/reference/signal
const signals: NodeJS.Signals[] = [
'SIGABRT',
'SIGBREAK',
'SIGBUS',
'SIGFPE',
'SIGHUP',
'SIGILL',
'SIGINT',
'SIGKILL',
'SIGSEGV',
'SIGSTOP',
'SIGTERM',
]

function shouldExit(code: number, signal: NodeJS.Signals) {
// start failed
if (!started) return true

// exit manually
if (code === 0) return true
if (signals.includes(signal)) return true

// restart manually
if (code === 51) return false
if (code === 52) return true

// fallback to autoRestart
return !options.daemon?.autoRestart
}

child.on('exit', (code, signal) => {
if (shouldExit(code!, signal!)) {
process.exit(code!)
}
createWorker(options)
})
}

export async function start(options: worker.Options) {
if (options.daemon) return createWorker(options)
const worker = await import('./worker/index.js')
worker.start(options)
}
31 changes: 31 additions & 0 deletions packages/cli/src/worker/daemon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Context } from 'cordis'

export interface Config {
execArgv?: string[]
autoRestart?: boolean
heartbeatInterval?: number
heartbeatTimeout?: number
}

export const name = 'daemon'

export function apply(ctx: Context, config: Config = {}) {
function handleSignal(signal: NodeJS.Signals) {
// prevent restarting when child process is exiting
if (config.autoRestart) {
process.send!({ type: 'exit' })
}
ctx.logger('app').info(`terminated by ${signal}`)
ctx.parallel('exit', signal).finally(() => process.exit())
}

ctx.on('ready', () => {
process.send!({ type: 'start', body: config })
process.on('SIGINT', handleSignal)
process.on('SIGTERM', handleSignal)

config.heartbeatInterval && setInterval(() => {
process.send!({ type: 'heartbeat' })
}, config.heartbeatInterval)
})
}
30 changes: 30 additions & 0 deletions packages/cli/src/worker/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Loader from '@cordisjs/loader'
import * as daemon from './daemon.js'
import * as logger from './logger.js'
import { ModuleLoader } from './internal.js'

export type * from './internal.js'

declare module '@cordisjs/loader' {
interface Loader {
internal?: ModuleLoader
}
}

export interface Options extends Loader.Options {
logger?: logger.Config
daemon?: daemon.Config
}

export async function start(options: Options) {
const loader = new Loader(options)
if (process.execArgv.includes('--expose-internals')) {
const { internal } = await import('./internal.js')
loader.internal = internal
}
await loader.init(process.env.CORDIS_LOADER_ENTRY)
if (options.logger) loader.app.plugin(logger)
if (options.daemon) loader.app.plugin(daemon)
await loader.readConfig()
await loader.start()
}
52 changes: 52 additions & 0 deletions packages/cli/src/worker/internal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { createRequire, LoadHookContext } from 'module'
import { Dict } from 'cosmokit'

const require = createRequire(import.meta.url)

type ModuleFormat = 'builtin' | 'commonjs' | 'json' | 'module' | 'wasm'
type ModuleSource = string | ArrayBuffer

interface ResolveResult {
format: ModuleFormat
url: string
}

interface LoadResult {
format: ModuleFormat
source?: ModuleSource
}

interface LoadCache extends Omit<Map<string, Dict<ModuleJob | Function>>, 'get' | 'set' | 'has'> {
get(url: string, type?: string): ModuleJob | Function | undefined
set(url: string, type?: string, job?: ModuleJob | Function): this
has(url: string, type?: string): boolean
}

export interface ModuleWrap {
url: string
getNamespace(): any
}

export interface ModuleJob {
url: string
loader: ModuleLoader
module?: ModuleWrap
importAttributes: ImportAttributes
linked: Promise<ModuleJob[]>
instantiate(): Promise<void>
run(): Promise<{ module: ModuleWrap }>
}

export interface ModuleLoader {
loadCache: LoadCache
import(specifier: string, parentURL: string, importAttributes: ImportAttributes): Promise<any>
register(specifier: string | URL, parentURL?: string | URL, data?: any, transferList?: any[]): void
getModuleJob(specifier: string, parentURL: string, importAttributes: ImportAttributes): Promise<ModuleJob>
getModuleJobSync(specifier: string, parentURL: string, importAttributes: ImportAttributes): ModuleJob
resolve(originalSpecifier: string, parentURL: string, importAttributes: ImportAttributes): Promise<ResolveResult>
resolveSync(originalSpecifier: string, parentURL: string, importAttributes: ImportAttributes): ResolveResult
load(specifier: string, context: Pick<LoadHookContext, 'format' | 'importAttributes'>): Promise<LoadResult>
loadSync(specifier: string, context: Pick<LoadHookContext, 'format' | 'importAttributes'>): LoadResult
}

export const internal: ModuleLoader = require('internal/process/esm_loader').esmLoader
Loading

0 comments on commit 413a999

Please sign in to comment.