Vendor Cordis framework packages as source

cordis 4.0.0-rc.6, plugin-loader, -include, -group, -timer, -hmr,
-logger-console, cosmokit 1.8.1, schemastery 3.18.0 — copied from the
cordis-workspace checkout, flattened under vendor/, original npm names,
private: true. vendor/README.md is the manifest: upstream repos +
commit SHAs, local-modification log, sync procedure.

Local modification: hmr's locale YAML imports and .i18n() call removed
(avoids a runtime YAML import hook we don't vendor).
This commit is contained in:
Tianyi Cui
2026-06-11 10:53:32 +08:00
parent ae2e08b4d6
commit 72688a3888
69 changed files with 6659 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
# Vendored Packages
This directory contains source-vendored copies of the Cordis framework and its
foundation libraries. They are copied into this monorepo instead of being
depended on via npm, so that the harness fully owns its framework layer
(auditable, patchable, pinned).
All vendored packages keep their **original npm names** (they are resolved
through Yarn workspaces) and are marked `private: true` — they are never
published from this repo. Upstream MIT `LICENSE` files are preserved in each
package directory.
## Manifest
Upstream workspace: `cordis-workspace` (local checkout: `~/repos/cordis-workspace`).
| Directory | npm name | Version | Upstream repo | Commit |
|---|---|---|---|---|
| `cosmokit/` | `cosmokit` | 1.8.1 | https://github.com/deepseek-harness/cosmokit | `16f6fc058ade66e8ac5da0033d35a8d0f279f544` |
| `schemastery/` | `schemastery` | 3.18.0 | https://github.com/deepseek-harness/schemastery (`packages/core`) | `e67cee00ad725bd1534aee930a979ea3eec6f698` |
| `cordis/` | `cordis` | 4.0.0-rc.6 | https://github.com/deepseek-harness/cordis (`packages/core`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` |
| `loader/` | `@cordisjs/plugin-loader` | 1.0.0-rc.4 | https://github.com/deepseek-harness/cordis (`packages/loader`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` |
| `include/` | `@cordisjs/plugin-include` | 1.0.4 | https://github.com/deepseek-harness/cordis (`packages/include`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` |
| `group/` | `@cordisjs/plugin-group` | 1.0.0 | https://github.com/deepseek-harness/cordis (`packages/group`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` |
| `timer/` | `@cordisjs/plugin-timer` | 1.1.2 | https://github.com/deepseek-harness/cordis (`packages/timer`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` |
| `hmr/` | `@cordisjs/plugin-hmr` | 1.0.15 | https://github.com/deepseek-harness/cordis (`packages/hmr`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` |
| `logger-console/` | `@cordisjs/plugin-logger-console` | 1.0.0 | https://github.com/deepseek-harness/cordis (`packages/logger-console`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` |
Third-party dependencies of the vendored packages stay on npm:
`@standard-schema/spec`, `js-yaml`, `chokidar`, `picomatch`,
`@babel/code-frame`, `supports-color`.
Intentionally **not** vendored (verified unused by this set): `reggol`,
`@cordisjs/utils`, `@cordisjs/element`, `@cordisjs/unyaml` (dev-time YAML
import hook only).
## Local modifications
Keep this log exhaustive — every divergence from upstream must be listed.
1. **`hmr/src/index.ts`**: removed the `./locales/en-US.yml` /
`./locales/zh-CN.yml` imports, the `.i18n({...})` call on the `Config`
schema, and the `src/locales/` directory. Rationale: those imports require
a runtime YAML loader hook (`@cordisjs/unyaml`) that we do not vendor;
the i18n texts only localize config descriptions.
2. **All `package.json` files**: regenerated — added `private: true`, added
`src` to `files` and a `./src/*` export where missing, removed upstream
`devDependencies`/`scripts`/`repository` fields. Dependency and
peer-dependency ranges preserved.
3. **All `tsconfig.json` files**: regenerated to extend the repo-root
`tsconfig.base.json` and declare project references.
## Sync procedure
To update a vendored package from upstream:
1. In the upstream workspace, note `git rev-parse HEAD` of the relevant
submodule.
2. Copy the package's `src/` (and `bin.js`, `README.md`, `LICENSE` if changed)
over the vendored directory.
3. Re-apply the local modifications listed above (or drop them if upstream
made them unnecessary — update the log either way).
4. Update the version and commit hash in the manifest table.
5. Run `yarn install && yarn test && yarn build` at the repo root.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021-present Shigma
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+101
View File
@@ -0,0 +1,101 @@
# Cordis
Cordis is a TypeScript plugin framework for applications that need explicit
dependency injection, scoped services, lifecycle-managed cleanup, and optional
configuration-driven loading. The core package is published as `cordis`; the
official packages in this repository add a loader, config-file includes, HMR,
console logging, timers, and project scaffolding.
## Install
```sh
yarn add cordis
```
Cordis is ESM-first. The repository is tested on current Node releases, and the
scaffolder requires Node 22 or newer.
## Quick Start
```ts
import { Context, Service } from 'cordis'
declare module 'cordis' {
interface Context {
counter: Counter
}
interface Events {
'app/ready'(message: string): void
}
}
class Counter extends Service {
value = 0
constructor(ctx: Context) {
super(ctx, 'counter')
}
next() {
return ++this.value
}
}
const greeter = Object.assign((ctx: Context) => {
ctx.on('app/ready', (message) => {
ctx.logger.info('%s #%d', message, ctx.counter.next())
})
}, {
inject: ['counter'],
})
const root = new Context()
await root.plugin(Counter)
await root.plugin(greeter)
root.emit('app/ready', 'started')
await root.fiber.dispose()
```
The important pieces are:
- `new Context()` creates the root dependency container.
- `ctx.plugin()` starts a plugin and returns a `Fiber`.
- `inject` tells Cordis which services must exist before the plugin runs.
- Effects, event listeners, and services are removed when their owning fiber is
disposed.
## Documentation
- [Tutorial: build a plugin](../../docs/tutorials/build-a-plugin.md)
- [Guide: plugin lifecycle](../../docs/guides/plugin-lifecycle.md)
- [Guide: loader configuration](../../docs/guides/loader-config.md)
- [API reference](../../docs/api/core.md)
## Packages
| Package | Purpose |
| --- | --- |
| `cordis` | Core context, plugin registry, fiber lifecycle, events, services, and logger. |
| `create-cordis` | Interactive project scaffolder. |
| `@cordisjs/plugin-loader` | Runtime plugin tree and loader service. |
| `@cordisjs/plugin-include` | YAML/JSON config-file include support for the loader. |
| `@cordisjs/plugin-group` | Nested plugin groups for loader configs. |
| `@cordisjs/plugin-hmr` | Hot module replacement for loader-managed plugins. |
| `@cordisjs/plugin-logger-console` | Console exporter for the built-in logger. |
| `@cordisjs/plugin-timer` | Disposal-aware timeout, interval, throttle, and debounce helpers. |
| `@cordisjs/utils` | Shared utilities used by Cordis packages. |
## Development
```sh
yarn install
yarn build
yarn test
yarn lint
```
The monorepo uses Yakumo to build and test all packages. Most examples in the
docs use public APIs from `cordis`; loader examples additionally use
`@cordisjs/plugin-loader` and `@cordisjs/plugin-include`.
Vendored Executable
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env node
import { Context } from 'cordis'
import { pathToFileURL } from 'node:url'
import Loader from '@cordisjs/plugin-loader'
const ctx = new Context()
ctx.baseUrl = pathToFileURL(process.cwd()).href + '/'
await ctx.plugin(Loader)
await ctx.loader.create({
name: '@cordisjs/plugin-include',
config: {
path: './cordis.yml',
},
})
+41
View File
@@ -0,0 +1,41 @@
{
"name": "cordis",
"description": "Meta-Framework for Modern JavaScript Applications",
"version": "4.0.0-rc.6",
"private": true,
"sideEffects": false,
"type": "module",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"bin": "bin.js",
"exports": {
".": {
"types": "./lib/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib",
"bin.js"
],
"author": "Shigma <shigma10826@gmail.com>",
"license": "MIT",
"peerDependencies": {
"@cordisjs/plugin-include": "^1.0.4",
"@cordisjs/plugin-loader": "^1.0.0-rc.4"
},
"peerDependenciesMeta": {
"@cordisjs/plugin-include": {
"optional": true
},
"@cordisjs/plugin-loader": {
"optional": true
}
},
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"cosmokit": "^1.8.1"
}
}
+97
View File
@@ -0,0 +1,97 @@
import { Dict } from 'cosmokit'
import { EventsService } from './events'
import { LoggerService } from './logger'
import { ReflectService } from './reflect'
import { InjectKey, RegistryService } from './registry'
import { getTraceable, symbols } from './utils'
import { Fiber } from './fiber'
/**
* Public shape of a Cordis context.
*
* The concrete `Context` class is proxied at runtime, so this interface is
* augmented by core services and plugins to describe the properties that may
* be read from `ctx`.
*/
export interface Context {
[symbols.isolate]: Dict<symbol>
[symbols.intercept]: Dict
/** @experimental */
root: this
baseUrl?: string
events: EventsService
logger: LoggerService
reflect: ReflectService
registry: RegistryService
}
/**
* Root and child dependency containers for Cordis plugins.
*
* A context is a proxy: normal property reads go through the service resolver,
* while `extend()`, `isolate()`, and `intercept()` create scoped child
* contexts without mutating their parent.
*/
export class Context {
static readonly effect: unique symbol = symbols.effect
static readonly filter: unique symbol = symbols.filter
static readonly isolate: unique symbol = symbols.isolate
static readonly intercept: unique symbol = symbols.intercept
/** Returns true for Cordis context proxies and context prototypes. */
static is(value: any): value is Context {
return !!value?.[Context.is as any]
}
static {
Context.is[Symbol.toPrimitive] = () => Symbol.for('cordis.is')
Context.prototype[Context.is as any] = true
}
/** Create the root context and install the built-in services. */
constructor() {
this[symbols.isolate] = Object.create(null)
this[symbols.intercept] = Object.create(null)
const self = new Proxy<this>(this, ReflectService.handler)
this.root = self
this.baseUrl = undefined
this.fiber = new Fiber(self, {}, Object.create(null), null, () => [])
this.reflect = new ReflectService(self)
this.registry = new RegistryService(self)
this.events = new EventsService(self)
this.logger = new LoggerService(self)
this.fiber._disposables.clear()
return self
}
[Symbol.for('nodejs.util.inspect.custom')]() {
return `Context <${this.fiber.name}>`
}
/** Create a child context with extra metadata on top of the current scope. */
extend(meta = {}): this {
const shadow = Reflect.getOwnPropertyDescriptor(this, symbols.shadow)?.value
const self = Object.create(getTraceable(this, this))
for (const prop of Reflect.ownKeys(meta)) {
Object.defineProperty(self, prop, Reflect.getOwnPropertyDescriptor(meta, prop)!)
}
if (!shadow) return self
return Object.assign(Object.create(self), { [symbols.shadow]: shadow })
}
/** Create a child context with an independent service scope for `name`. */
isolate(name: string, label?: symbol) {
const shadow = Object.create(this[symbols.isolate])
shadow[name] = label ?? Symbol(name)
return this.extend({ [symbols.isolate]: shadow })
}
/** Add service-specific intercept config for plugins started below this context. */
intercept<K extends InjectKey>(name: K, config: Context[K] extends { [symbols.config]: infer T } ? T : never): this
intercept(name: string, config: any): this
intercept(name: string, config: any) {
const intercept = Object.create(this[symbols.intercept])
intercept[name] = config
return this.extend({ [symbols.intercept]: intercept })
}
}
+205
View File
@@ -0,0 +1,205 @@
import { defineProperty, Promisify } from 'cosmokit'
import { Context } from './context'
import { Fiber, FiberState } from './fiber'
import { DisposableList, symbols } from './utils'
/** Return whether an event result should stop a bail-style dispatch. */
export function isBailed(value: any) {
return value !== null && value !== false && value !== undefined
}
/** Extract the parameter tuple from a function type. */
export type Parameters<F> = F extends (...args: infer P) => any ? P : never
/** Extract the return type from a function type. */
export type ReturnType<F> = F extends (...args: any) => infer R ? R : never
/** Extract the explicit `this` type from a function type. */
export type ThisType<F> = F extends (this: infer T, ...args: any) => any ? T : never
/**
* Event dispatch strategy used by the event service.
*
* `emit` runs synchronous listeners without awaiting them, `parallel` awaits
* all listeners together, `serial` awaits them in order until one bails,
* `bail` stops on the first synchronous bail value, and `waterfall` composes
* listeners around a final `next` callback.
*/
export type DispatchMode = 'emit' | 'parallel' | 'serial' | 'bail' | 'waterfall'
declare module './context' {
export interface Context {
/* eslint-disable max-len */
parallel<K extends keyof Events>(name: K, ...args: Parameters<Events[K]>): Promise<void>
parallel<K extends keyof Events>(thisArg: NoInfer<ThisType<Events[K]>>, name: K, ...args: Parameters<Events[K]>): Promise<void>
emit<K extends keyof Events>(name: K, ...args: Parameters<Events[K]>): void
emit<K extends keyof Events>(thisArg: NoInfer<ThisType<Events[K]>>, name: K, ...args: Parameters<Events[K]>): void
serial<K extends keyof Events>(name: K, ...args: Parameters<Events[K]>): Promisify<ReturnType<Events[K]>>
serial<K extends keyof Events>(thisArg: NoInfer<ThisType<Events[K]>>, name: K, ...args: Parameters<Events[K]>): Promisify<ReturnType<Events[K]>>
bail<K extends keyof Events>(name: K, ...args: Parameters<Events[K]>): ReturnType<Events[K]>
bail<K extends keyof Events>(thisArg: NoInfer<ThisType<Events[K]>>, name: K, ...args: Parameters<Events[K]>): ReturnType<Events[K]>
waterfall<K extends keyof Events>(name: K, ...args: Parameters<Events[K]>): ReturnType<Events[K]>
waterfall<K extends keyof Events>(thisArg: NoInfer<ThisType<Events[K]>>, name: K, ...args: Parameters<Events[K]>): ReturnType<Events[K]>
on<K extends keyof Events>(name: K, listener: Events[K], options?: boolean | EventOptions): () => boolean
once<K extends keyof Events>(name: K, listener: Events[K], options?: boolean | EventOptions): () => boolean
/* eslint-enable max-len */
}
}
/** Options accepted by `ctx.on()` and `ctx.once()`. */
export interface EventOptions {
/** Add the listener before existing listeners for the same event. */
prepend?: boolean
/** Receive the event regardless of context filter checks. */
global?: boolean
}
/** Registered listener record stored by the event service. */
export interface Hook extends EventOptions {
ctx: Context
callback: (...args: any[]) => any
}
/**
* Event bus installed as `ctx.events` and mixed into every context.
*
* The service supports concurrent, synchronous, serial, bail, and waterfall
* dispatch and automatically disposes listeners with their owning fiber.
*/
export class EventsService {
_hooks: Record<keyof any, Hook[]> = {}
constructor(private ctx: Context) {
defineProperty(this, symbols.tracker, {
property: 'ctx',
noShadow: true,
})
this.on('internal/listener', function (this: Context, name, listener, options: EventOptions) {
if (name === 'internal/update' && !options.global) {
const hooks = this.fiber._hooks['internal/update'] ??= new DisposableList()
const method = options.prepend ? 'unshift' : 'push'
return hooks[method](listener)
}
})
this.on('internal/update', function (config, noSave, next) {
const cbs = [...this._hooks['internal/update'] || []]
const _next = () => {
const cb = cbs.shift() ?? next
return cb.call(this, config, noSave, _next)
}
return _next()
}, { global: true, prepend: true })
}
/** Resolve listeners for one dispatch and apply context filtering. */
dispatch(type: string, args: any[]) {
const thisArg = typeof args[0] === 'object' || typeof args[0] === 'function' ? args.shift() : null
const name: string = args.shift()
if (!name.startsWith('internal/')) {
this.emit('internal/dispatch', type, name, args, thisArg)
}
const filter = thisArg?.[Context.filter]
return (this._hooks[name] || [])
.filter(hook => hook.global || !filter || filter.call(thisArg, hook.ctx))
.map(hook => hook.callback.bind(thisArg))
}
/** Run listeners concurrently and wait for all of them. */
async parallel(...args: any[]) {
await Promise.all(this.dispatch('emit', args).map(cb => cb(...args)))
}
/** Run listeners synchronously without waiting for returned promises. */
emit(...args: any[]) {
this.dispatch('emit', args).map(cb => cb(...args))
}
/** Run listeners in order until one returns a bail value. */
async serial(...args: any[]) {
for (const cb of this.dispatch('serial', args)) {
const result = await cb(...args)
if (isBailed(result)) return result
}
}
/** Run listeners synchronously until one returns a bail value. */
bail(...args: any[]) {
for (const cb of this.dispatch('bail', args)) {
const result = cb(...args)
if (isBailed(result)) return result
}
}
/** Compose listeners around the final `next` callback. */
waterfall(...args: any[]) {
const cbs = this.dispatch('waterfall', args)
const inner = args.pop()
const next = () => {
const cb = cbs.shift() ?? inner
return cb(...args)
}
args.push(next)
return next()
}
register(label: string, hooks: Hook[], callback: any, options: EventOptions): () => void {
const method = options.prepend ? 'unshift' : 'push'
return this.ctx.fiber.effect(() => {
hooks[method]({ ctx: this.ctx, callback, ...options })
return () => this.unregister(hooks, callback)
}, label)
}
unregister(hooks: Hook[], callback: any) {
const index = hooks.findIndex(hook => hook.callback === callback)
if (index >= 0) {
hooks.splice(index, 1)
return true
}
}
/** Register an event listener owned by the current fiber. */
on(name: string | symbol, listener: (...args: any) => any, options?: boolean | EventOptions) {
if (typeof options !== 'object') {
options = { prepend: options }
}
// handle special events
this.ctx.fiber.assertActive()
listener = this.ctx.reflect.bind(listener)
const result = this.bail(this.ctx, 'internal/listener', name, listener, options)
if (result) return result
const hooks = this._hooks[name] ||= []
const label = `ctx.on(${typeof name === 'string' ? JSON.stringify(name) : name.toString()})`
return this.register(label, hooks, listener, options)
}
/** Register an event listener that disposes itself after the first call. */
once(name: string, listener: (...args: any) => any, options?: boolean | EventOptions) {
const dispose = this.on(name, function (...args: any[]) {
dispose()
return listener.apply(this, args)
}, options)
return dispose
}
}
/**
* Built-in framework events used by core services and extension points.
*
* Plugin and status events track fiber lifecycle, service events observe
* dependency registration, update/get/set/listener events allow core services
* to intercept runtime operations, and `internal/dispatch` exposes event-bus
* diagnostics before public events are delivered.
*/
export interface Events {
'internal/plugin'(fiber: Fiber): void
'internal/status'(fiber: Fiber, oldValue: FiberState): void
'internal/service'(this: Context, name: string, value: any): void
'internal/update'(this: Fiber, config: any, noSave: boolean, next: () => void): void
'internal/get'(ctx: Context, name: string, error: Error, next: () => any): any
'internal/set'(ctx: Context, name: string, value: any, error: Error, next: () => boolean): boolean
'internal/listener'(this: Context, name: string, listener: any, prepend: boolean): void
'internal/dispatch'(mode: DispatchMode, name: string, args: any[], thisArg: any): void
}
+504
View File
@@ -0,0 +1,504 @@
import { Awaitable, defineProperty, Dict, isNullable } from 'cosmokit'
import { Context } from './context'
import { Plugin } from './registry'
import { buildOuterStack, composeError, DisposableList, getTraceable, isConstructor, isObject, symbols } from './utils'
import { Impl } from './reflect'
import { StandardSchemaV1 } from '@standard-schema/spec'
declare module './context' {
export interface Context extends Pick<Fiber, 'effect'> {
fiber: Fiber
}
}
const kValidationError = Symbol.for('ValidationError')
/** Error raised when plugin configuration fails standard-schema validation. */
export class ValidationError extends TypeError {
name = 'ValidationError'
constructor(issues: readonly StandardSchemaV1.Issue[]) {
super(`invalid config:\n` + issues.map(issue => {
if (issue.path) {
return ` - ${issue.message} (at ${issue.path.join('.')})`
} else {
return ` - ${issue.message}`
}
}).join('\n'))
}
}
Object.defineProperty(ValidationError.prototype, kValidationError, {
value: true,
})
/** Validate and normalize config for a plugin runtime before it starts. */
export function resolveConfig(runtime: Plugin.Runtime, config: any) {
if (!runtime.Config) return config
// TODO: async validation
const result = runtime.Config['~standard'].validate(config)
if ('then' in result) {
throw new TypeError('Async config validation is not supported')
}
if (result.issues) {
throw new ValidationError(result.issues)
} else {
return result.value
}
}
interface AsyncDisposable<T extends Awaitable<void> = Awaitable<void>> extends PromiseLike<() => T> {
(): T
}
/** Function returned by an effect to release resources during disposal. */
export type Disposable<T = any> = () => T
/** Effect body result accepted by `ctx.effect()` and plugin startup. */
export type Effect<T = any> =
| SyncEffect<T>
| AsyncEffect<T>
type SyncEffect<T = any> =
| Disposable<T>
| Iterable<Disposable<T>, void, void>
type AsyncEffect<T = any> =
| Promise<Disposable<T>>
| AsyncIterable<Disposable<T>, void, void>
/** Tree node used to expose nested effect labels for diagnostics. */
export interface EffectMeta {
label: string
children: EffectMeta[]
}
interface EffectRunner<T> {
epoch: T
execute: () => any
collect: (dispose: Disposable) => void
getOuterStack: () => string[]
}
/** Lifecycle state for one plugin fiber. */
export const enum FiberState {
PENDING,
LOADING,
ACTIVE,
FAILED,
DISPOSED,
UNLOADING,
}
/** Framework error with a stable machine-readable code. */
export class CordisError extends Error {
constructor(public code: CordisError.Code, message?: string) {
super(message ?? CordisError.Code[code])
}
}
/** Cordis error code definitions. */
export namespace CordisError {
export type Code = keyof typeof Code
export const Code = {
INACTIVE_EFFECT: 'cannot create effect on inactive context',
} as const
}
const INACTIVE = '__INACTIVE__'
/**
* Runtime instance of one plugin application.
*
* A fiber tracks dependency state, validated config, lifecycle effects, and
* cleanup for the plugin context returned by `ctx.plugin()`.
*/
export class Fiber {
public uid: number | null
public readonly ctx: Context
public config: any
public state = FiberState.PENDING
public readonly dispose: () => Promise<void>
public store: Dict<Impl> | undefined
public inertia: Promise<void> | undefined
public readonly _hooks: Dict<DisposableList<Function>> = Object.create(null)
public readonly _disposables = new DisposableList<Disposable>()
// Same as `this.ctx`, but with a more specific type.
protected context: Context
private _error: any
private _runner: EffectRunner<string>
private _store: Dict<Impl> = Object.create(null)
constructor(
public parent: Context,
config: any,
public inject: Dict<any>,
public runtime: Plugin.Runtime | null,
getOuterStack: () => string[],
) {
const collect = (dispose: Disposable) => {
this._disposables.push(dispose)
}
if (runtime) {
this.uid = parent.registry.counter
this.ctx = this.context = parent.extend({ fiber: this })
const injectEntries = Object.entries(this.inject)
if (injectEntries.length) {
this.ctx[Context.intercept] = Object.create(parent[Context.intercept])
for (const [name, config] of injectEntries) {
if (isNullable(config)) continue
this.ctx[Context.intercept][name] = config
}
}
this._runner = {
epoch: INACTIVE,
getOuterStack,
execute: () => {
if (isConstructor(runtime.callback)) {
// eslint-disable-next-line new-cap
const instance = new runtime.callback(this.ctx, this.config)
for (const hook of instance?.[symbols.initHooks] ?? []) {
hook()
}
return instance?.[symbols.init]?.()
} else {
return runtime.callback(this.ctx, this.config)
}
},
collect,
}
this.context.emit('internal/plugin', this)
for (const name of Object.keys(this.inject)) {
this._checkImpl(name)
}
this.dispose = parent.fiber.effect(() => {
const remove = runtime.fibers.push(this)
try {
this.config = resolveConfig(runtime, config)
this._refresh()
} catch (error) {
this.ctx.logger.error(error)
this._error = error
}
return async () => {
this.uid = null
this.context.emit('internal/plugin', this)
if (this.ctx.registry.has(runtime.callback)) {
remove()
if (!runtime.fibers.length) {
this.ctx.registry.delete(runtime.callback)
}
}
this._setEpoch(INACTIVE)
// `this.inertia` itself should never reject — both `_reload` and
// `_unload` swallow their own work errors via `ctx.logger.error`.
// If it *does* reject, the only remaining cause is the logger
// itself failing, which we can't recover from in this exact spot
// (calling the logger again is what just failed). Let the
// rejection propagate; process-level crash is the honest outcome.
while (this.inertia) {
await this.inertia
}
}
}, 'ctx.plugin()')
} else {
this.uid = 0
this.ctx = this.context = parent
this.state = FiberState.ACTIVE
this.store = Object.create(null)
this._runner = {
epoch: '',
getOuterStack,
execute: () => {},
collect,
}
this.dispose = () => this.restart()
}
}
get name() {
let fiber: Fiber = this
do {
if (fiber.runtime?.name) return fiber.runtime.name
fiber = fiber.parent.fiber
} while (fiber !== fiber.parent.fiber)
return 'root'
}
/** Throw if the fiber has already been disposed. */
assertActive() {
if (this.uid !== null) return
throw new CordisError('INACTIVE_EFFECT')
}
private _execute<T>(runner: EffectRunner<T>) {
const oldEpoch = runner.epoch
return composeError((info) => {
const safeCollect = (dispose: void | Disposable) => {
if (typeof dispose === 'function') {
runner.collect(dispose)
} else if (!isNullable(dispose)) {
throw new TypeError('Invalid effect')
}
}
const effect: Effect = runner.execute()
if (typeof effect === 'function') {
return runner.collect(effect)
} else if (isNullable(effect)) {
// return
} else if (!isObject(effect)) {
throw new TypeError('Invalid effect')
} else if ('then' in effect) {
return effect.then(safeCollect)
} else if (Symbol.iterator in effect) {
info.error = new Error()
const iter = effect[Symbol.iterator]()
while (true) {
const result = iter.next()
safeCollect(result.value)
if (result.done) return
}
} else if (Symbol.asyncIterator in effect) {
const iter = effect[Symbol.asyncIterator]()
return (async () => {
// force async stack trace
await Promise.resolve()
info.error = new Error()
while (true) {
if (runner.epoch !== oldEpoch) return
const result = await iter.next()
safeCollect(result.value)
if (result.done) return
}
})()
} else {
throw new TypeError('Invalid effect')
}
}, runner.getOuterStack)
}
/** Register a cleanup-aware effect on this fiber. */
effect(execute: () => SyncEffect, label?: string): Disposable<Promise<void>>
effect(execute: () => Effect, label?: string): AsyncDisposable<Promise<void>>
effect(execute: () => Effect, label = 'anonymous'): any {
this.assertActive()
const disposables: Disposable[] = []
const dispose = () => {
let task!: void | Promise<void>
for (const dispose of disposables.splice(0).reverse()) {
if (task) {
task = task.then(dispose)
} else {
const result = dispose()
if (isObject(result) && 'then' in result) {
task = result as any
}
}
}
return task
}
const meta: EffectMeta = { label, children: [] }
const runner: EffectRunner<boolean> = {
execute,
epoch: true,
collect: (dispose) => {
disposables.push(dispose)
this._disposables.delete(dispose)
if (dispose[symbols.effect]) {
meta.children.push(dispose[symbols.effect])
}
},
getOuterStack: buildOuterStack(),
}
let task: void | Promise<void>
try {
task = this._execute(runner)
} catch (reason) {
dispose()
throw reason
}
// prevent unhandled rejection — both from `task` itself and from the
// disposer chain if it fails to settle cleanly.
task?.catch(dispose).catch((error) => this.ctx.logger.error(error))
const wrapper = defineProperty(() => {
if (!runner.epoch) return
runner.epoch = false
return task ? task.then(dispose) : dispose()
}, symbols.effect, meta) as AsyncDisposable
const disposeAsync = () => {
if (!runner.epoch) return
runner.epoch = false
return dispose()
}
wrapper.then = async (onFulfilled, onRejected) => {
return Promise.resolve(task)
.then(() => disposeAsync)
.then(onFulfilled, onRejected)
}
disposables.push(this._disposables.push(wrapper))
return wrapper
}
/** Return metadata for currently registered effects. */
getEffects() {
return [...this._disposables]
.map<EffectMeta>(dispose => dispose[symbols.effect])
.filter(Boolean)
}
private _getState() {
if (this.uid === null) return FiberState.DISPOSED
if (this._error) return FiberState.FAILED
if (this._runner.epoch !== INACTIVE) return FiberState.ACTIVE
return FiberState.PENDING
}
private _updateState(callback: () => void | FiberState) {
const oldState = this.state
this.state = callback() ?? this._getState()
if (oldState === this.state) return
// FIXME internal/fiber-info
this.context.emit('internal/status', this, oldState)
// only notify changes between ACTIVE and NON-ACTIVE states
if (oldState !== FiberState.ACTIVE && this.state !== FiberState.ACTIVE) return
for (const key of Reflect.ownKeys(this.ctx.reflect.store)) {
const impl = this.ctx.reflect.store[key as symbol]
if (impl.fiber !== this) continue
this.ctx.reflect.notify([impl.name])
}
}
_checkImpl(name: string) {
const impl = this.ctx.reflect._getImpl(name, true)
if (!impl) return delete this._store[name]
try {
if (impl.check && !impl.check.call(getTraceable(this.ctx, impl.value))) {
return delete this._store[name]
}
} catch (error) {
impl.fiber.ctx.logger.error(error)
return delete this._store[name]
}
this._store[name] = impl
}
_refresh() {
let epoch: string | boolean = false
epoch = ''
for (const name of Object.keys(this.inject)) {
const impl = this._store[name]
if (!impl) {
epoch = INACTIVE
break
}
epoch += ':' + impl.fiber.uid
}
this._setEpoch(epoch)
}
private _setEpoch(epoch: string) {
const oldEpoch = this._runner.epoch
if (epoch === oldEpoch) return
this._runner.epoch = epoch
if (this.inertia) return
this._updateState(() => {
if (epoch !== INACTIVE && oldEpoch === INACTIVE) {
this.inertia = this._reload()
return FiberState.LOADING
} else {
this.inertia = this._unload()
return FiberState.UNLOADING
}
})
}
private async _reload() {
this.store = { ...this._store }
const oldEpoch = this._runner.epoch
try {
await Promise.resolve()
await this._execute(this._runner)
} catch (reason) {
// impl guarantees that the error is non-null (?)
this.ctx.logger.error(reason)
this._error = reason
this._runner.epoch = INACTIVE
}
this._updateState(() => {
if (this._runner.epoch === oldEpoch) {
this.inertia = undefined
} else {
this.inertia = this._unload()
return FiberState.UNLOADING
}
})
}
private async _unload() {
await Promise.all(this._disposables.clear().map(async (dispose) => {
try {
await composeError(async (info) => {
await Promise.resolve()
info.error = new Error()
await dispose()
}, this._runner.getOuterStack)
} catch (reason) {
this.ctx.logger.error(reason)
}
}))
this.store = undefined
this._updateState(() => {
if (this._runner.epoch === INACTIVE) {
this.inertia = undefined
} else {
this.inertia = this._reload()
return FiberState.LOADING
}
})
}
/** Wait for current lifecycle work and rethrow startup errors. */
async await() {
while (this.inertia) {
await this.inertia
}
if (this._error) throw this._error
return this
}
/** Dispose and immediately reload this plugin with its current config. */
async restart() {
this.assertActive()
this._setEpoch(INACTIVE)
this._refresh()
await this.await()
}
/** Validate and apply new config, then restart the plugin. */
update(config: any, noSave = false) {
this.assertActive()
config = resolveConfig(this.runtime!, config)
this.context.waterfall(this, 'internal/update', config, noSave, () => {
this.config = config
this._error = undefined
return this.restart()
})
}
}
+14
View File
@@ -0,0 +1,14 @@
/** Core context type and root context implementation. */
export * from './context'
/** Event bus, dispatch modes, and event augmentation types. */
export * from './events'
/** Plugin fiber lifecycle, effects, and config validation helpers. */
export * from './fiber'
/** Logger facade, logger service, message, exporter, and formatting types. */
export * from './logger'
/** Plugin registry, dependency injection, and plugin entrypoint types. */
export * from './registry'
/** Base service class and service lifecycle symbols. */
export * from './service'
/** Shared internal helpers used by context, services, and plugin fibers. */
export * from './utils'
+262
View File
@@ -0,0 +1,262 @@
import { defineProperty, hyphenate } from 'cosmokit'
import { Context } from './context'
import { Fiber } from './fiber'
import { createCallable, joinPrototype, symbols, Tracker } from './utils'
declare module './context' {
interface Intercept {
logger: LoggerService.Intercept
}
}
/** Logger method name and severity category. */
export type LoggerType = 'error' | 'info' | 'warn' | 'debug'
/** Callable shape for one logger severity method. */
export type LoggerMethod = (format: any, ...param: any[]) => void
/** Formatter used to resolve a printf-style placeholder. */
export type Formatter = (value: any, exporter: Exporter, message: Message) => any
/** Numeric severity used when exporters decide whether to emit a message. */
export const enum LoggerLevel {
ERROR = 0,
INFO = 1,
WARN = 2,
DEBUG = 3,
}
/** Structured log record delivered to exporters. */
export interface Message {
sn: number
ts: number
name: string
type: LoggerType
level: number
args: any[]
fiber?: WeakRef<Fiber>
}
/** Sink that receives structured log messages. */
export interface Exporter {
colors?: number | false
maxLength?: number
levels?: Record<string, number>
formatters?: Record<string, Formatter>
export(message: Message): void
}
/** Built-in placeholder formatters used by `Logger.format()`. */
export const defaultFormatters: Record<string, Formatter> = {
s: (value) => String(value),
d: (value) => Math.trunc(Number(value)),
i: (value) => Math.trunc(Number(value)),
f: (value) => Number(value),
o: (value) => JSON.stringify(value),
O: (value) => JSON.stringify(value),
c: () => '',
C: (value, exporter, message) => {
return Logger.color(exporter, Logger.code(message.name, exporter.colors), value)
},
}
/** Options used when creating a named logger facade. */
export interface LoggerOptions {
name: string
meta?: Partial<Message>
level?: number
}
/** Logger facade identity, inherited message metadata, and optional minimum level. */
export interface Logger extends LoggerOptions {}
/** Logger facade severity methods. */
export interface Logger extends Record<LoggerType, LoggerMethod> {}
function isAggregateError(error: any): error is Error & { errors: Error[] } {
return error instanceof Error && Array.isArray(error['errors'])
}
/** Logger facade for one named subsystem. */
export class Logger {
static color(exporter: Exporter, code: number, value: any, decoration = '') {
if (!exporter.colors) return '' + value
return `\u001b[3${code < 8 ? code : '8;5;' + code}${exporter.colors >= 2 ? decoration : ''}m${value}\u001b[0m`
}
static code(name: string, level?: false | number) {
let hash = 0
for (let i = 0; i < name.length; i++) {
hash = ((hash << 3) - hash) + name.charCodeAt(i) + 13
hash |= 0
}
const colors = !level ? [] : level >= 2 ? c256 : c16
return colors[Math.abs(hash) % colors.length]
}
static format(exporter: Exporter, message: Message): string {
const args = message.args.slice()
if (args[0] instanceof Error) {
args[0] = args[0].stack || args[0].message
args.unshift('%s')
} else if (typeof args[0] !== 'string') {
args.unshift('%o')
}
let format: string = args.shift()
format = format.replace(/%([a-zA-Z%])/g, (match, char) => {
if (match === '%%') return '%'
const formatter = exporter.formatters?.[char] ?? defaultFormatters[char]
if (typeof formatter === 'function') {
const value = args.shift()
return formatter(value, exporter, message)
}
return match
})
const oFormatter = exporter.formatters?.o ?? defaultFormatters.o
for (let arg of args) {
if (typeof arg === 'object' && arg) {
arg = oFormatter(arg, exporter, message)
}
format += ' ' + arg
}
const { maxLength = 10240 } = exporter
return format.split(/\r?\n/g).map(line => {
return line.slice(0, maxLength) + (line.length > maxLength ? '...' : '')
}).join('\n')
}
constructor(options: LoggerOptions, private service: LoggerService) {
Object.assign(this, options)
this.error = this._method('error', LoggerLevel.ERROR)
this.info = this._method('info', LoggerLevel.INFO)
this.warn = this._method('warn', LoggerLevel.WARN)
this.debug = this._method('debug', LoggerLevel.DEBUG)
}
private _method(type: LoggerType, level: number): LoggerMethod {
return (...args: any[]) => {
if (args.length === 1 && args[0] instanceof Error) {
if (args[0].cause) {
this[type](args[0].cause)
} else if (isAggregateError(args[0])) {
args[0].errors.forEach(error => this[type](error))
return
}
}
const sn = ++this.service._snMessage
const ts = Date.now()
for (const exporter of this.service.exporters.values()) {
const targetLevel = exporter.levels?.[this.name] ?? exporter.levels?.default ?? this.level ?? LoggerLevel.INFO
if (targetLevel < level) continue
const message: Message = { sn, ts, type, level, name: this.name, ...this.meta, args }
exporter.export(message)
}
}
}
}
/** ANSI 16-color palette indexes used for logger name coloring. */
export const c16 = [6, 2, 3, 4, 5, 1]
/** ANSI 256-color palette indexes used for logger name coloring. */
export const c256 = [
20, 21, 26, 27, 32, 33, 38, 39, 40, 41, 42, 43, 44, 45, 56, 57, 62,
63, 68, 69, 74, 75, 76, 77, 78, 79, 80, 81, 92, 93, 98, 99, 112, 113,
129, 134, 135, 148, 149, 160, 161, 162, 163, 164, 165, 166, 167, 168,
169, 170, 171, 172, 173, 178, 179, 184, 185, 196, 197, 198, 199, 200,
201, 202, 203, 204, 205, 206, 207, 208, 209, 214, 215, 220, 221,
]
/** Logger service configuration merged from context intercepts. */
export namespace LoggerService {
export interface Intercept {
name?: string
level?: number
}
}
/** Callable `ctx.logger` service shape. */
export interface LoggerService extends Record<LoggerType, LoggerMethod> {
(name?: string): Logger
}
/**
* Built-in logging service.
*
* Call `ctx.logger()` to create a named logger, or call `ctx.logger.info()`
* directly to log with the current fiber-derived name.
*/
export class LoggerService {
bufferSize = 1000
buffer: Message[] = []
ctx!: Context
_snMessage = 0
_snExporter = 0
exporters = new Map<number, Exporter>()
constructor(ctx: Context) {
const tracker: Tracker = {
property: 'ctx',
noShadow: true,
}
const self = createCallable('logger', joinPrototype(Object.getPrototypeOf(this), Function.prototype), tracker) as unknown as LoggerService
Object.assign(self, this)
self.ctx = ctx
defineProperty(self, symbols.tracker, tracker)
self.exporter({
colors: 3,
export: (message) => {
self.buffer.push(message)
if (self.buffer.length > self.bufferSize) {
self.buffer = self.buffer.slice(-self.bufferSize)
}
},
})
return self
}
/** Register an exporter and dispose it with the current fiber. */
exporter(exporter: Exporter) {
return this.ctx.effect(() => {
this.exporters.set(++this._snExporter, exporter)
return () => this.exporters.delete(this._snExporter)
}, 'ctx.logger.exporter()')
}
private _resolveConfig(): LoggerService.Intercept {
let intercept = this.ctx[symbols.intercept]
const configs: LoggerService.Intercept[] = []
while ('logger' in intercept) {
if (Object.hasOwn(intercept, 'logger')) {
configs.unshift(intercept['logger'])
}
intercept = Object.getPrototypeOf(intercept)
}
return Object.assign({}, ...configs)
}
[symbols.invoke](name?: string): Logger {
const config = this._resolveConfig()
const fiber = ((this.ctx as any)[symbols.shadow] ?? this.ctx).fiber
name ??= config.name
name ??= hyphenate(fiber.name)
return new Logger({
name,
level: config.level,
meta: { fiber: new WeakRef(fiber) },
}, this)
}
static {
for (const type of ['error', 'info', 'warn', 'debug'] as const) {
;(LoggerService.prototype as any)[type] = function (this: LoggerService, ...args: any[]) {
return (this as any)()[type](...args)
}
}
}
}
+287
View File
@@ -0,0 +1,287 @@
import { defineProperty, Dict, isNullable } from 'cosmokit'
import { Context } from './context'
import { getTraceable, symbols, withProps } from './utils'
import { Fiber, FiberState } from './fiber'
declare module './context' {
interface Context {
get<K extends string & keyof this>(name: K, strict?: boolean): undefined | this[K]
get(name: string, strict?: boolean): any
set<K extends string & keyof this>(name: K, value: undefined | this[K]): void
set(name: string, value: any): void
provide<K extends string & keyof this>(name: K, value: undefined | this[K]): () => void
provide(name: string, value?: any): () => void
accessor(name: string, options: Omit<Property.Accessor, 'type'>): void
mixin<K extends string & keyof this>(name: K, mixins: (keyof this & keyof this[K])[] | Dict<string>): void
mixin<T extends {}>(source: T, mixins: (keyof this & keyof T)[] | Dict<string>): void
}
}
function enhanceError(error: Error) {
const lines = error.stack!.split('\n')
lines.splice(0, 2, `Error: ${error.message}`)
error.stack = lines.join('\n')
return error
}
const RESERVED_WORDS = ['prototype', 'then']
// - is a symbol
// - is a reserved word (prototype, then)
// - is a number string (0, 1, 2, ...)
// - starts with `_`
function isSpecialProperty(prop: string | symbol): prop is symbol {
return typeof prop === 'symbol'
|| RESERVED_WORDS.includes(prop)
|| parseInt(prop).toString() === prop
|| prop.startsWith('_')
}
/** Context property definition known by the reflection service. */
export type Property = Property.Service | Property.Accessor
/** Property definition variants understood by `ReflectService`. */
export namespace Property {
/** Service property backed by a provided implementation. */
export interface Service {
type: 'service'
}
/** Computed context property backed by custom get/set hooks. */
export interface Accessor {
type: 'accessor'
get: (this: Context, receiver: any, error: Error) => any
set?: (this: Context, value: any, receiver: any, error: Error) => boolean
}
}
/** Concrete service implementation record stored in the root reflect service. */
export interface Impl {
name: string
fiber: Fiber
value?: any
check?: () => boolean
}
/**
* Reflection and service-resolution layer installed as `ctx.reflect`.
*
* This service powers the context proxy, service registration, accessors, and
* the mixins that expose core service methods directly on `ctx`.
*/
export class ReflectService {
static handler: ProxyHandler<Context> = {
get: (target, prop, ctx: Context) => {
if (isSpecialProperty(prop)) {
return Reflect.get(target, prop, ctx)
}
if (Reflect.has(target, prop)) {
return getTraceable(ctx, Reflect.get(target, prop, ctx))
}
const error = new Error(`cannot get property "${prop}" without inject`)
try {
const def = target.reflect.props[prop]
if (def?.type === 'accessor') {
return def.get.call(ctx, ctx[symbols.receiver], error)
}
if (!ctx.fiber.runtime) return ctx.reflect.get(prop, false)
return ctx.events.waterfall('internal/get', ctx, prop, error, () => {
const key = target[symbols.isolate][prop]
let fiber = (ctx[symbols.shadow] as Context ?? ctx).fiber
while (true) {
const impl = fiber.store?.[prop]
if (impl) return getTraceable(ctx, impl.value)
if (prop in fiber.inject) {
error.message = `cannot get required service "${prop}" in inactive context`
throw error
}
if (!fiber.runtime) throw error
if (fiber.parent[symbols.isolate][prop] !== key) throw error
fiber = fiber.parent.fiber
}
})
} catch (e: any) {
throw e === error ? enhanceError(e) : e
}
},
set: (target, prop, value, ctx: Context) => {
if (isSpecialProperty(prop)) {
return Reflect.set(target, prop, value, ctx)
}
const error = new Error(`cannot set property "${prop}" without provide`)
const def = target.reflect.props[prop]
if (!def) {
if (!ctx.fiber.runtime) return Reflect.set(target, prop, value, ctx)
throw enhanceError(error)
}
try {
if (def.type === 'accessor') {
if (!def.set) return false
return def.set.call(ctx, value, ctx[symbols.receiver], error)
}
return ctx.events.waterfall('internal/set', ctx, prop, value, error, () => {
return ctx.reflect.set(prop, value, error)
})
} catch (e: any) {
throw e === error ? enhanceError(e) : e
}
},
has: (target, prop) => {
if (isSpecialProperty(prop)) {
return Reflect.has(target, prop)
}
if (Reflect.has(target, prop)) return true
return !!target.reflect.props[prop]
},
}
public store: Dict<Impl, symbol> = Object.create(null)
public props: Dict<Property> = Object.create(null)
constructor(public ctx: Context) {
defineProperty(this, symbols.tracker, {
property: 'ctx',
noShadow: true,
})
this.mixin('reflect', ['get', 'set', 'provide', 'accessor', 'mixin'])
this.mixin('fiber', ['runtime', 'effect'])
this.mixin('registry', ['inject', 'plugin'])
this.mixin('events', ['on', 'once', 'parallel', 'emit', 'serial', 'bail', 'waterfall'])
}
get(name: string, strict = true) {
return getTraceable(this.ctx, this._getImpl(name, strict)?.value)
}
_getImpl(name: string, strict = true) {
const key = this.ctx[symbols.isolate][name]
const impl = key && this.store[key]
if (!impl) return
if (strict && impl.fiber.state !== FiberState.ACTIVE) return
return impl
}
set(name: string, value: any, error?: Error) {
const key = this.ctx[symbols.isolate][name]
const impl = this.store[key]
if (!impl) {
throw new Error(`cannot set property "${name}" without provide`)
}
if (impl.fiber !== this.ctx.fiber) {
throw new Error(`cannot set property "${name}" in multiple fibers`)
}
impl.value = value
return true
}
provide(name: string, value?: any, check?: () => boolean) {
return this.ctx.fiber.effect(() => {
if (!this.props[name]) {
this.props[name] ??= { type: 'service' }
} else if (this.props[name].type !== 'service') {
throw new Error(`property "${name}" is already declared as ${this.props[name].type}`)
}
this.props[name] = { type: 'service' }
this.ctx.root[symbols.isolate][name] ??= Symbol(name)
const key = this.ctx[symbols.isolate][name]
const impl: Impl = { name, value, fiber: this.ctx.fiber, check }
if (this.store[key]) {
throw new Error(`service "${name}" has been registered at <${this.store[key].fiber.name}>`)
}
this.store[key] = impl
this.ctx.fiber.store![name] = impl
if (this.ctx.fiber.state === FiberState.ACTIVE) {
this.notify([name])
}
return async () => {
delete this.store[key]
const fibers = this.notify([name])
await Promise.allSettled(fibers.map(fiber => fiber.await()))
// ensure self access before dependencies cleanup
delete this.ctx.fiber.store![name]
}
}, `ctx.provide(${JSON.stringify(name)})`)
}
notify(names: string[], filter = (ctx: Context, name: string) => ctx[symbols.isolate][name] === this.ctx[symbols.isolate][name]) {
const fibers: Fiber[] = []
for (const runtime of this.ctx.registry.values()) {
for (const fiber of runtime.fibers) {
let hasUpdate = false
for (const name of names) {
if (!(name in fiber.inject)) continue
if (!filter(fiber.ctx, name)) continue
hasUpdate = true
fiber._checkImpl(name)
}
if (!hasUpdate) continue
fiber._refresh()
fibers.push(fiber)
}
}
return fibers
}
accessor(name: string, options: Omit<Property.Accessor, 'type'>) {
return this.ctx.fiber.effect(() => {
if (name in this.props) {
throw new Error(`property "${name}" is already declared as ${this.props[name].type}`)
}
this.props[name] = { type: 'accessor', ...options }
return () => delete this.props[name]
}, `ctx.accessor(${JSON.stringify(name)})`)
}
mixin(source: any, mixins: string[] | Dict<string>) {
const self = this
return this.ctx.fiber.effect(function* () {
const entries = Array.isArray(mixins) ? mixins.map(key => [key, key]) : Object.entries(mixins)
const getTarget = (ctx: Context, error: Error) => {
// TODO enhance error message
return ctx[source]
}
for (const [key, value] of entries) {
yield self.accessor(value, {
get(receiver, error) {
const service = getTarget(this, error)
if (isNullable(service)) return service
const mixin = receiver ? withProps(receiver, service) : service
const value = Reflect.get(service, key, mixin)
if (typeof value !== 'function') return value
return value.bind(mixin ?? service)
},
set(value, receiver, error) {
const service = getTarget(this, error)
const mixin = receiver ? withProps(receiver, service) : service
return Reflect.set(service, key, value, mixin)
},
})
}
}, `ctx.mixin(${JSON.stringify(source)})`)
}
trace<T>(value: T) {
return getTraceable(this.ctx, value)
}
bind<T extends Function>(callback: T) {
return new Proxy(callback, {
apply: (target, thisArg, args) => {
return Reflect.apply(target, this.trace(thisArg), args.map(arg => this.trace(arg)))
},
construct: (target, args, newTarget) => {
return Reflect.construct(target, args.map(arg => this.trace(arg)), newTarget)
},
})
}
}
+249
View File
@@ -0,0 +1,249 @@
import { defineProperty, Dict } from 'cosmokit'
import { StandardSchemaV1 } from '@standard-schema/spec'
import { Context } from './context'
import { Fiber } from './fiber'
import { buildOuterStack, DisposableList, symbols, withProps } from './utils'
function isApplicable(object: Plugin) {
return object && typeof object === 'object' && typeof object.apply === 'function'
}
/**
* Service dependency declaration accepted by plugins and the `@Inject`
* decorator.
*
* Array form requests services without intercept config. Object form maps each
* service name to optional intercept config for the plugin context.
*/
export type Inject<M = Dict> = (keyof M)[] | { [K in keyof M]?: M[K] }
/** Context keys that correspond to services with typed intercept config. */
export type InjectKey = keyof {
[K in keyof Context & string as Context[K] extends { [symbols.config]: any } ? K : never]: any
}
/**
* Decorator for declaring service dependencies on classes or class methods.
*
* On classes it contributes to the plugin's static `inject` map. On methods it
* delays the method call until the declared services are available.
*/
export function Inject<K extends InjectKey>(name: K, config?: Context[K] extends { [symbols.config]: infer T } ? T : never) {
return function (value: any, decorator: ClassDecoratorContext<any> | ClassMethodDecoratorContext<any>) {
if (decorator.kind === 'class') {
if (!Object.hasOwn(value, 'inject')) {
defineProperty(value, 'inject', Object.create(Object.getPrototypeOf(value).inject ?? null))
defineProperty(value.inject, symbols.checkProto, true)
}
value.inject[name] = config
} else if (decorator.kind === 'method') {
const inject = (value[symbols.metadata] ??= {}).inject ??= Object.create(null)
inject[name] = config
decorator.addInitializer(function () {
const property = this[symbols.tracker]?.property
;(this[symbols.initHooks] ??= []).push(() => {
(this.ctx as Context).inject(inject, (ctx) => {
return value.call(property ? withProps(this, { [property]: ctx }) : this)
})
})
})
} else {
throw new Error('@Inject() can only be used on class or class methods')
}
}
}
/** Utilities for normalizing plugin dependency declarations. */
export namespace Inject {
/** Convert array/object/class-inherited inject metadata into a plain map. */
export function resolve(inject: Inject | null | undefined, result: Dict = Object.create(null)) {
if (!inject) return result
if (Array.isArray(inject)) {
for (const name of inject) {
result[name] = null
}
} else if (Reflect.has(inject, symbols.checkProto)) {
Object.assign(result, resolve(Object.getPrototypeOf(inject)))
for (const name of Object.keys(inject)) {
result[name] = inject[name] ?? null
}
} else {
for (const name of Object.keys(inject)) {
result[name] = inject[name] ?? null
}
}
return result
}
}
/** Supported plugin entrypoint shapes. */
export type Plugin<T = any> =
| Plugin.Function<T>
| Plugin.Constructor<T>
| Plugin.Object<T>
/** Types associated with plugin entrypoints and runtime records. */
export namespace Plugin {
/** Shared metadata understood by the plugin registry and related tooling. */
export interface Base<T = any> {
name?: string
Config?: StandardSchemaV1<any, T>
inject?: Inject
provide?: string | string[]
intercept?: Dict<boolean>
}
export interface Transform<S, T> {
/** Marks the transform object as a schema/config transform. */
schema?: true
/** Convert user-facing config to runtime config. */
Config: (config: S) => T
}
/** Function plugin called with `(ctx, config)`. */
export interface Function<T = any> extends Base<T> {
(ctx: Context, config: T): any
}
/** Class plugin constructed with `(ctx, config)`. */
export interface Constructor<T = any> extends Base<T> {
new (ctx: Context, config: T): any
}
/** Object plugin with an `apply(ctx, config)` method. */
export interface Object<T = any> extends Base<T> {
apply(ctx: Context, config: T): any
}
/** Mutable registry record shared by all fibers of one plugin callback. */
export interface Runtime {
name?: string
fibers: DisposableList<Fiber>
callback: globalThis.Function
Config?: StandardSchemaV1
}
}
type Spread<T> = undefined extends T ? [config?: T] : [config: T]
type GetPluginParameters<P> =
| P extends (ctx: Context, ...args: infer R) => any
? R
: P extends new (ctx: Context, ...args: infer R) => any
? R
: P extends { apply(ctx: Context, ...args: infer R): any }
? R
: never
type GetPluginConfig<P> =
| P extends Plugin.Transform<infer S, any>
? S
: GetPluginParameters<P>[0]
declare module './context' {
export interface Context {
inject(deps: Inject, callback: Plugin.Function<void>): Fiber & PromiseLike<Fiber>
plugin<P extends Plugin>(plugin: P, ...args: Spread<GetPluginConfig<P>>): Fiber & PromiseLike<Fiber>
}
}
/**
* Plugin registry installed as `ctx.registry` and mixed into every context.
*
* It normalizes plugin shapes, tracks plugin runtimes, starts fibers, and
* exposes map-like inspection over active plugin callbacks.
*/
export class RegistryService {
private _counter = 0
private _internal = new Map<Function, Plugin.Runtime>()
constructor(public ctx: Context) {
defineProperty(this, symbols.tracker, {
property: 'ctx',
noShadow: true,
})
}
get counter() {
return ++this._counter
}
get size() {
return this._internal.size
}
/** Resolve a supported plugin shape to its executable callback. */
resolve(plugin: Plugin): Function | undefined {
// plugin.apply may throw
try {
if (typeof plugin === 'function') return plugin
if (isApplicable(plugin)) return plugin.apply
} catch {}
}
get(plugin: Plugin) {
const key = this.resolve(plugin)
return key && this._internal.get(key)
}
has(plugin: Plugin) {
const key = this.resolve(plugin)
return !!key && this._internal.has(key)
}
/** Dispose every running fiber for a plugin and remove its runtime record. */
delete(plugin: Plugin) {
const key = this.resolve(plugin)
const runtime = key && this._internal.get(key)
if (!runtime) return
this._internal.delete(key)
for (const fiber of runtime.fibers) {
fiber.dispose()
}
return runtime
}
keys() {
return this._internal.keys()
}
values() {
return this._internal.values()
}
entries() {
return this._internal.entries()
}
forEach(callback: (value: Plugin.Runtime, key: Function) => void) {
return this._internal.forEach(callback)
}
/** Start a callback once the requested dependencies are available. */
inject(inject: Inject, callback: Plugin.Function<void>) {
return this.plugin({ inject, apply: callback, name: callback.name })
}
/** Start a plugin in the current context and return its fiber. */
plugin(plugin: Plugin, config?: any, getOuterStack = buildOuterStack()) {
// check if it's a valid plugin
const callback = this.resolve(plugin)
if (!callback) throw new Error('invalid plugin, expect function or object with an "apply" method, received ' + typeof plugin)
this.ctx.fiber.assertActive()
let runtime = this._internal.get(callback)
if (!runtime) {
let name = plugin.name
if (name === 'apply') name = undefined
runtime = { name, callback, fibers: new DisposableList(), Config: plugin.Config }
this._internal.set(callback, runtime)
}
const fiber = new Fiber(this.ctx, config, Inject.resolve(plugin.inject), runtime, getOuterStack)
const wrapped = Object.create(fiber) as Fiber & PromiseLike<Fiber>
wrapped.then = (onFulfilled, onRejected) => {
return fiber.await().then(onFulfilled, onRejected)
}
return wrapped
}
}
+88
View File
@@ -0,0 +1,88 @@
import { defineProperty } from 'cosmokit'
import { Context } from './context'
import { createCallable, joinPrototype, symbols, Tracker } from './utils'
/**
* Base class for services that expose a named API on `ctx`.
*
* Subclasses call `super(ctx, name)` from their constructor. The service is
* registered immediately and is automatically removed with the owning fiber.
*/
export abstract class Service<out T = never> {
static readonly init: unique symbol = symbols.init
static readonly check: unique symbol = symbols.check
static readonly config: unique symbol = symbols.config
static readonly invoke: unique symbol = symbols.invoke
static readonly extend: unique symbol = symbols.extend
static readonly tracker: unique symbol = symbols.tracker
static readonly resolveConfig: unique symbol = symbols.resolveConfig
declare [symbols.config]: T
public name!: string
/** Register this instance as `name` in the current context. */
constructor(protected ctx: Context, name: string) {
name ??= this.constructor['provide'] as string
let self = this
const tracker: Tracker = {
associate: name,
property: 'ctx',
}
if (self[symbols.invoke]) {
self = createCallable(name, joinPrototype(Object.getPrototypeOf(this), Function.prototype), tracker)
}
self.ctx = ctx
self.name = name
defineProperty(self, symbols.tracker, tracker)
self.ctx.reflect.provide(name, self, this[symbols.check])
return self
}
protected [symbols.filter](ctx: Context) {
return ctx[symbols.isolate][this.name] === this.ctx[symbols.isolate][this.name]
}
protected [symbols.extend](props?: any) {
let self: any
if (this[Service.invoke]) {
self = createCallable(this.name, this, this[symbols.tracker])
} else {
self = Object.create(this)
}
return Object.assign(self, props)
}
/** Merge intercept config from ancestors with optional base and head values. */
[symbols.resolveConfig](base?: T, head?: T): T {
let intercept = this.ctx[Context.intercept]
const configs: any[] = []
while (this.name in intercept) {
if (Object.hasOwn(intercept, this.name)) {
configs.unshift(intercept[this.name])
}
intercept = Object.getPrototypeOf(intercept)
}
if (base) configs.unshift(base)
if (head) configs.push(head)
if (this['Config']?.merge) {
return this['Config'].merge(...configs)
} else {
return Object.assign({}, ...configs)
}
}
static [Symbol.hasInstance](instance: any) {
if (!instance) return false
let constructor = instance.constructor
while (constructor) {
// constructor may be a proxy
constructor = constructor.prototype?.constructor
if (constructor === this) return true
constructor &&= Object.getPrototypeOf(constructor)
}
return false
}
}
+287
View File
@@ -0,0 +1,287 @@
import { defineProperty } from 'cosmokit'
import type { Context, Service } from '.'
/** Ordered collection of disposable values with O(1) deletion by value. */
export class DisposableList<T extends WeakKey> {
private sn = 0
private map = new Map<number, T>()
private weak = new WeakMap<T, number>()
get length() {
return this.map.size
}
push(value: T) {
const sn = ++this.sn
this.map.set(sn, value)
this.weak.set(value, sn)
return () => this.map.delete(sn)
}
delete(value: T) {
const sn = this.weak.get(value)
if (!sn) return false
return this.map.delete(sn)
}
clear() {
const values = [...this.map.values()]
this.map.clear()
return values.reverse()
}
[Symbol.iterator]() {
return this.map.values()
}
[Symbol.for('nodejs.util.inspect.custom')]() {
return [...this]
}
}
/** Metadata used by traceable proxies to rebind `ctx` and associated services. */
export interface Tracker {
associate?: string
property?: string
noShadow?: boolean
}
/** Shared symbols used to avoid public property-name collisions. */
export const symbols = {
// internal symbols
shadow: Symbol.for('cordis.shadow'),
receiver: Symbol.for('cordis.receiver'),
original: Symbol.for('cordis.original'),
metadata: Symbol.for('cordis.metadata'),
initHooks: Symbol.for('cordis.initHooks'),
checkProto: Symbol.for('cordis.checkProto'),
// context symbols
effect: Symbol.for('cordis.effect') as typeof Context.effect,
filter: Symbol.for('cordis.filter') as typeof Context.filter,
isolate: Symbol.for('cordis.isolate') as typeof Context.isolate,
intercept: Symbol.for('cordis.intercept') as typeof Context.intercept,
// service symbols
init: Symbol.for('cordis.init') as typeof Service.init,
check: Symbol.for('cordis.check') as typeof Service.check,
config: Symbol.for('cordis.config') as typeof Service.config,
invoke: Symbol.for('cordis.invoke') as typeof Service.invoke,
extend: Symbol.for('cordis.extend') as typeof Service.extend,
tracker: Symbol.for('cordis.tracker') as typeof Service.tracker,
resolveConfig: Symbol.for('cordis.resolveConfig') as typeof Service.resolveConfig,
}
const GeneratorFunction = function* () {}.constructor
const AsyncGeneratorFunction = async function* () {}.constructor
/** Return true when a plugin callback should be constructed with `new`. */
export function isConstructor(func: any): func is new (...args: any) => any {
// async function or arrow function
if (!func.prototype) return false
// generator function or malformed definition
// we cannot use below check because `mock.fn()` is proxied
// if (func.prototype.constructor !== func) return false
if (func instanceof GeneratorFunction) return false
// polyfilled AsyncGeneratorFunction === Function
if (AsyncGeneratorFunction !== Function && func instanceof AsyncGeneratorFunction) return false
return true
}
/** Merge two prototype chains while preserving descriptors from `proto1`. */
export function joinPrototype(proto1: {}, proto2: {}) {
if (proto1 === Object.prototype) return proto2
const result = Object.create(joinPrototype(Object.getPrototypeOf(proto1), proto2))
for (const key of Reflect.ownKeys(proto1)) {
Object.defineProperty(result, key, Object.getOwnPropertyDescriptor(proto1, key)!)
}
return result
}
/** Return true for non-null objects and functions. */
export function isObject(value: any): value is {} {
return value && (typeof value === 'object' || typeof value === 'function')
}
/** Find a property descriptor by walking an object's prototype chain. */
export function getPropertyDescriptor(target: any, prop: string | symbol) {
let proto = target
while (proto) {
const desc = Reflect.getOwnPropertyDescriptor(proto, prop)
if (desc) return desc
proto = Object.getPrototypeOf(proto)
}
}
/** Wrap services/functions so method calls see the caller's active context. */
export function getTraceable<T>(ctx: Context, value: T): T {
if (!isObject(value)) return value
if (Object.hasOwn(value, symbols.shadow)) {
return Object.getPrototypeOf(value)
}
const tracker = value[symbols.tracker]
if (!tracker) return value
return createTraceable(ctx, value, tracker)
}
/** Return a proxy that overlays readonly or writable properties onto a target. */
export function withProps(target: any, props?: {}) {
if (!props) return target
return new Proxy(target, {
get: (target, prop, receiver) => {
if (prop in props && prop !== 'constructor') return Reflect.get(props, prop, receiver)
return Reflect.get(target, prop, receiver)
},
set: (target, prop, value, receiver) => {
if (prop in props && prop !== 'constructor') return Reflect.set(props, prop, value, receiver)
return Reflect.set(target, prop, value, receiver)
},
})
}
function withProp(target: any, prop: string | symbol, value: any) {
return withProps(target, Object.defineProperty(Object.create(null), prop, {
value,
writable: false,
}))
}
function createShadow(ctx: Context, target: any, property: string | undefined, receiver: any) {
if (!property) return receiver
const origin = Reflect.getOwnPropertyDescriptor(target, property)?.value
if (!origin) return receiver
return withProp(receiver, property, ctx.extend({ [symbols.shadow]: origin }))
}
function createShadowMethod(ctx: Context, value: any, outer: any, shadow: {}) {
return new Proxy(value, {
apply: (target, thisArg, args) => {
if (thisArg === outer) thisArg = shadow
return getTraceable(ctx, Reflect.apply(target, thisArg, args))
},
})
}
function createTraceable(ctx: Context, value: any, tracker: Tracker) {
// noShadow services are identity-aware (e.g. logger uses the origin fiber to
// derive its name): keep the shadow ctx so they can read [symbols.shadow]
// and resolve the origin. Non-noShadow services strip — their side effects
// bind to caller, not origin.
if (ctx[symbols.shadow] && !tracker.noShadow) {
ctx = Object.getPrototypeOf(ctx)
}
const proxy = new Proxy(value, {
get: (target, prop, receiver) => {
if (prop === symbols.original) return target
if (prop === tracker.property) return ctx
if (typeof prop === 'symbol') {
return Reflect.get(target, prop, receiver)
}
if (tracker.associate && ctx.reflect.props[`${tracker.associate}.${prop}`]) {
return Reflect.get(ctx, `${tracker.associate}.${prop}`, withProp(ctx, symbols.receiver, receiver))
}
let shadow: any, innerValue: any
const desc = getPropertyDescriptor(target, prop)
if (desc && 'value' in desc) {
innerValue = desc.value
} else {
shadow = createShadow(ctx, target, tracker.property, receiver)
innerValue = Reflect.get(target, prop, shadow)
}
const innerTracker = innerValue?.[symbols.tracker]
if (innerTracker) {
return createTraceable(ctx, innerValue, innerTracker)
} else if (!tracker.noShadow && typeof innerValue === 'function') {
shadow ??= createShadow(ctx, target, tracker.property, receiver)
return createShadowMethod(ctx, innerValue, receiver, shadow)
} else {
return innerValue
}
},
set: (target, prop, value, receiver) => {
if (prop === symbols.original) return false
if (prop === tracker.property) return false
if (typeof prop === 'symbol') {
return Reflect.set(target, prop, value, receiver)
}
if (tracker.associate && ctx.reflect.props[`${tracker.associate}.${prop}`]) {
return Reflect.set(ctx, `${tracker.associate}.${prop}`, value, withProp(ctx, symbols.receiver, receiver))
}
const shadow = createShadow(ctx, target, tracker.property, receiver)
return Reflect.set(target, prop, value, shadow)
},
apply: (target, thisArg, args) => {
return applyTraceable(proxy, target, thisArg, args)
},
})
return proxy
}
function applyTraceable(proxy: any, value: any, thisArg: any, args: any[]) {
if (!value[symbols.invoke]) return Reflect.apply(value, thisArg, args)
return value[symbols.invoke].apply(proxy, args)
}
/** Create a callable service object that dispatches through `symbols.invoke`. */
export function createCallable(name: string, proto: {}, tracker: Tracker) {
const self = function (...args: any[]) {
const proxy = createTraceable(self['ctx'], self, tracker)
return applyTraceable(proxy, self, this, args)
}
defineProperty(self, 'name', name)
return Object.setPrototypeOf(self, proto)
}
interface StackInfo {
offset: number
error: Error
}
function handleError(info: StackInfo, reason: any, getOuterStack: () => string[]): never {
const innerLines = info.error.stack!.split('\n')
// malformed error
if (typeof reason?.stack !== 'string') {
const outerError = new Error(reason)
const lines = outerError.stack!.split('\n')
lines.splice(1, Infinity, ...getOuterStack())
outerError.stack = lines.join('\n')
throw outerError
}
// long stack trace
const lines: string[] = reason.stack.split('\n')
let index = lines.indexOf(innerLines[2])
if (index === -1) throw reason
index -= info.offset
while (index > 0) {
if (!lines[index - 1].endsWith(' (<anonymous>)')) break
index -= 1
}
lines.splice(index, Infinity, ...getOuterStack())
reason.stack = lines.join('\n')
throw reason
}
/** Run a callback and splice outer call-site frames into thrown async errors. */
export function composeError<T>(callback: (info: StackInfo) => T, getOuterStack = buildOuterStack()): T {
const info: StackInfo = { offset: 1, error: new Error() }
try {
const result: any = callback(info)
if (isObject(result) && 'then' in result) {
return (result as any).then(undefined, (reason) => handleError(info, reason, getOuterStack)) as T
} else {
return result
}
} catch (reason: any) {
handleError(info, reason, getOuterStack)
}
}
/** Capture a lazy stack-frame supplier for later error composition. */
export function buildOuterStack(offset = 0) {
const outerError = new Error()
return () => outerError.stack!.split('\n').slice(3 + offset)
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib",
"noImplicitAny": false,
"noImplicitThis": false,
"strictFunctionTypes": false
},
"include": ["src"],
"references": [
{ "path": "../cosmokit" }
]
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021-present Shigma
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+24
View File
@@ -0,0 +1,24 @@
# cosmokit
[![Codecov](https://img.shields.io/codecov/c/github/shigma/cosmokit?style=flat-square)](https://codecov.io/gh/shigma/cosmokit)
[![npm](https://img.shields.io/npm/v/cosmokit?style=flat-square)](https://www.npmjs.com/package/cosmokit)
A collection of common utilities.
## Usage
### Node.js
```sh
npm install cosmokit
```
```ts
import cosmokit from 'cosmokit'
```
### Deno
```ts
import cosmokit from 'npm:cosmokit@latest'
```
+23
View File
@@ -0,0 +1,23 @@
{
"name": "cosmokit",
"description": "A collection of common utilities",
"version": "1.8.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"exports": {
".": {
"types": "./lib/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib",
"src"
],
"author": "Shigma <shigma10826@gmail.com>",
"license": "MIT"
}
+42
View File
@@ -0,0 +1,42 @@
import { isNullable } from './misc'
/** Return true when every item in `array2` is present in `array1`. */
export function contain(array1: readonly any[], array2: readonly any[]) {
return array2.every(item => array1.includes(item))
}
/** Return items that appear in both arrays. */
export function intersection<T>(array1: readonly T[], array2: readonly T[]) {
return array1.filter(item => array2.includes(item))
}
/** Return items from `array1` that do not appear in `array2`. */
export function difference<S>(array1: readonly S[], array2: readonly any[]) {
return array1.filter(item => !array2.includes(item))
}
/** Return the set-union of two arrays while preserving first occurrence order. */
export function union<T>(array1: readonly T[], array2: readonly T[]) {
return Array.from(new Set([...array1, ...array2]))
}
/** Remove duplicate values while preserving first occurrence order. */
export function deduplicate<T>(array: readonly T[]) {
return [...new Set(array)]
}
/** Remove one item from an array and report whether it was found. */
export function remove<T>(list: T[], item: T) {
const index = list?.indexOf(item)
if (index >= 0) {
list.splice(index, 1)
return true
} else {
return false
}
}
/** Normalize nullish, scalar, or array input to an array. */
export function makeArray<T>(source: null | undefined | T | T[]) {
return Array.isArray(source) ? source : isNullable(source) ? [] : [source]
}
+10
View File
@@ -0,0 +1,10 @@
/** Array set and normalization helpers. */
export * from './array'
/** Runtime type, binary, clone, and equality helpers. */
export * from './types'
/** Shared utility types and object helpers. */
export * from './misc'
/** String case, path, and property formatting helpers. */
export * from './string'
/** Time constants, parsing, and formatting helpers. */
export * from './time'
+78
View File
@@ -0,0 +1,78 @@
/** String/symbol keyed dictionary type. */
export type Dict<T = any, K extends string | symbol = string> = { [key in K]: T }
/** Safely read `T[K]`, returning `never` when `K` is not a key of `T`. */
export type Get<T extends {}, K> = K extends keyof T ? T[K] : never
/** Conditional extraction helper with a configurable return type. */
export type Extract<S, T, U = S> = S extends T ? U : never
/** Accept a value or an array, unless the value is already an array type. */
export type MaybeArray<T> = [T] extends [unknown[]] ? T : T | T[]
/** Wrap a value in `Promise`, preserving the resolved type of existing promises. */
export type Promisify<T> = Promise<T extends Promise<infer S> ? S : T>
/** Accept a value or promise unless the value type is already promise-like. */
export type Awaitable<T> = [T] extends [Promise<unknown>] ? T : T | Promise<T>
/** Convert a union type to an intersection type. */
export type Intersect<U> = (U extends any ? (arg: U) => void : never) extends ((arg: infer I) => void) ? I : never
/** No-op callback returning `undefined` at runtime and `any` at type level. */
export function noop(): any {}
/** Return true when a value is `null` or `undefined`. */
export function isNullable(value: any): value is null | undefined | void {
return value === null || value === undefined
}
/** Return true when a value is neither `null` nor `undefined`. */
export function isNonNullable<T>(value: T): value is NonNullable<T> {
return !isNullable(value)
}
/** Return true for non-array object values. */
export function isPlainObject(data: any) {
return data && typeof data === 'object' && !Array.isArray(data)
}
/** Filter object entries with a key type guard. */
export function filterKeys<T, K extends string, U extends K>(object: Dict<T, K>, filter: (key: K, value: T) => key is U): Dict<T, U>
/** Filter object entries with a boolean predicate. */
export function filterKeys<T, K extends string>(object: Dict<T, K>, filter: (key: K, value: T) => boolean): Dict<T, K>
/** Filter object entries and return a new object. */
export function filterKeys(object: {}, filter: (key: string, value: any) => boolean) {
return Object.fromEntries(Object.entries(object).filter(([key, value]) => filter(key, value)))
}
/** Map object values while preserving the original key set. */
export function mapValues<U, T, K extends string>(object: Dict<T, K>, transform: (value: T, key: K) => U) {
return Object.fromEntries(Object.entries(object).map(([key, value]) => [key, (transform as any)(value, key)])) as Dict<U, K>
}
/** Alias for `mapValues`. */
export { mapValues as valueMap }
/** Pick selected keys from an object, optionally including `undefined` values. */
export function pick<T extends object, K extends keyof T>(source: T, keys?: Iterable<K>, forced?: boolean) {
if (!keys) return { ...source }
const result = {} as Pick<T, K>
for (const key of keys) {
if (forced || source[key] !== undefined) result[key] = source[key]
}
return result
}
/** Omit selected keys from a shallow object copy. */
export function omit<T, K extends keyof T>(source: T, keys?: Iterable<K>) {
if (!keys) return { ...source }
const result = { ...source } as Omit<T, K>
for (const key of keys) {
Reflect.deleteProperty(result, key)
}
return result
}
/** Define a non-enumerable writable property with a typed key. */
export function defineProperty<T, K extends keyof T>(object: T, key: K, value: T[K]): T
/** Define a non-enumerable writable property with an arbitrary key. */
export function defineProperty<T, K extends keyof any>(object: T, key: K, value: any): T
/** Define a non-enumerable writable property and return the object. */
export function defineProperty<T, K extends keyof any>(object: T, key: K, value: any) {
return Object.defineProperty(object, key, { writable: true, value, enumerable: false })
}
+113
View File
@@ -0,0 +1,113 @@
/** Uppercase the first character of a string. */
export function capitalize(source: string) {
return source.charAt(0).toUpperCase() + source.slice(1)
}
/** Lowercase the first character of a string. */
export function uncapitalize(source: string) {
return source.charAt(0).toLowerCase() + source.slice(1)
}
/** Convert dash or underscore delimited text to camelCase. */
export function camelCase(source: string) {
return source.replace(/[_-][a-z]/g, str => str.slice(1).toUpperCase())
}
const enum State {
DELIM,
UPPER,
LOWER,
}
function tokenize(source: string, delimiters: number[], delimiter: number) {
const output: number[] = []
let state = State.DELIM
for (let i = 0; i < source.length; i++) {
const code = source.charCodeAt(i)
if (code >= 65 && code <= 90) {
if (state === State.UPPER) {
const next = source.charCodeAt(i + 1)
if (next >= 97 && next <= 122) {
output.push(delimiter)
}
output.push(code + 32)
} else {
if (state !== State.DELIM) {
output.push(delimiter)
}
output.push(code + 32)
}
state = State.UPPER
} else if (code >= 97 && code <= 122) {
output.push(code)
state = State.LOWER
} else if (delimiters.includes(code)) {
if (state !== State.DELIM) {
output.push(delimiter)
}
state = State.DELIM
} else {
output.push(code)
}
}
return String.fromCharCode(...output)
}
/** Convert text to dash-delimited parameter case. */
export function paramCase(source: string) {
return tokenize(source, [45, 95], 45)
}
/** Convert text to underscore-delimited snake case. */
export function snakeCase(source: string) {
return tokenize(source, [45, 95], 95)
}
/** Runtime alias for `camelCase`. */
export const camelize = camelCase
/** Runtime alias for `paramCase`. */
export const hyphenate = paramCase
namespace Letter {
/* eslint-disable @typescript-eslint/member-delimiter-style */
interface LowerToUpper {
a: 'A', b: 'B', c: 'C', d: 'D', e: 'E', f: 'F', g: 'G', h: 'H', i: 'I', j: 'J', k: 'K', l: 'L', m: 'M',
n: 'N', o: 'O', p: 'P', q: 'Q', r: 'R', s: 'S', t: 'T', u: 'U', v: 'V', w: 'W', x: 'X', y: 'Y', z: 'Z',
}
interface UpperToLower {
A: 'a', B: 'b', C: 'c', D: 'd', E: 'e', F: 'f', G: 'g', H: 'h', I: 'i', J: 'j', K: 'k', L: 'l', M: 'm',
N: 'n', O: 'o', P: 'p', Q: 'q', R: 'r', S: 's', T: 't', U: 'u', V: 'v', W: 'w', X: 'x', Y: 'y', Z: 'z',
}
/* eslint-enable @typescript-eslint/member-delimiter-style */
export type Upper = keyof UpperToLower
export type Lower = keyof LowerToUpper
export type ToUpper<S extends string> = S extends Lower ? LowerToUpper[S] : S
export type ToLower<S extends string, P extends string = ''> = S extends Upper ? `${P}${UpperToLower[S]}` : S
}
/* eslint-disable @typescript-eslint/naming-convention */
/** Type-level conversion from dash-delimited text to camelCase. */
export type camelize<S extends string> = S extends `${infer L}-${infer M}${infer R}` ? `${L}${Letter.ToUpper<M>}${camelize<R>}` : S
/** Type-level conversion from camelCase text to dash-delimited text. */
export type hyphenate<S extends string> = S extends `${infer L}${infer R}` ? `${Letter.ToLower<L, '-'>}${hyphenate<R>}` : S
/* eslint-enable @typescript-eslint/naming-convention */
/** Format a property key as a JavaScript member access suffix. */
export function formatProperty(key: keyof any) {
if (typeof key !== 'string') return `[${key.toString()}]`
return /^[a-z_$][\w$]*$/i.test(key) ? `.${key}` : `[${JSON.stringify(key)}]`
}
/** Remove one trailing slash from a path string. */
export function trimSlash(source: string) {
return source.replace(/\/$/, '')
}
/** Ensure a path starts with `/` and has no trailing slash. */
export function sanitize(source: string) {
if (!source.startsWith('/')) source = '/' + source
return trimSlash(source)
}
+92
View File
@@ -0,0 +1,92 @@
/** Time constants plus parsing and formatting helpers. */
export namespace Time {
export const millisecond = 1
export const second = 1000
export const minute = second * 60
export const hour = minute * 60
export const day = hour * 24
export const week = day * 7
let timezoneOffset = new Date().getTimezoneOffset()
export function setTimezoneOffset(offset: number) {
timezoneOffset = offset
}
export function getTimezoneOffset() {
return timezoneOffset
}
export function getDateNumber(date: number | Date = new Date(), offset?: number) {
if (typeof date === 'number') date = new Date(date)
if (offset === undefined) offset = timezoneOffset
return Math.floor((date.valueOf() / minute - offset) / 1440)
}
export function fromDateNumber(value: number, offset?: number) {
const date = new Date(value * day)
if (offset === undefined) offset = timezoneOffset
return new Date(+date + offset * minute)
}
const numeric = /\d+(?:\.\d+)?/.source
const timeRegExp = new RegExp(`^${[
'w(?:eek(?:s)?)?',
'd(?:ay(?:s)?)?',
'h(?:our(?:s)?)?',
'm(?:in(?:ute)?(?:s)?)?',
's(?:ec(?:ond)?(?:s)?)?',
].map(unit => `(${numeric}${unit})?`).join('')}$`)
export function parseTime(source: string) {
const capture = timeRegExp.exec(source)
if (!capture) return 0
return (parseFloat(capture[1]) * week || 0)
+ (parseFloat(capture[2]) * day || 0)
+ (parseFloat(capture[3]) * hour || 0)
+ (parseFloat(capture[4]) * minute || 0)
+ (parseFloat(capture[5]) * second || 0)
}
export function parseDate(date: string) {
const parsed = parseTime(date)
if (parsed) {
date = Date.now() + parsed as any
} else if (/^\d{1,2}(:\d{1,2}){1,2}$/.test(date)) {
date = `${new Date().toLocaleDateString()}-${date}`
} else if (/^\d{1,2}-\d{1,2}-\d{1,2}(:\d{1,2}){1,2}$/.test(date)) {
date = `${new Date().getFullYear()}-${date}`
}
return date ? new Date(date) : new Date()
}
export function format(ms: number) {
const abs = Math.abs(ms)
if (abs >= day - hour / 2) {
return Math.round(ms / day) + 'd'
} else if (abs >= hour - minute / 2) {
return Math.round(ms / hour) + 'h'
} else if (abs >= minute - second / 2) {
return Math.round(ms / minute) + 'm'
} else if (abs >= second) {
return Math.round(ms / second) + 's'
}
return ms + 'ms'
}
export function toDigits(source: number, length = 2) {
return source.toString().padStart(length, '0')
}
export function template(template: string, time = new Date()) {
return template
.replace('yyyy', time.getFullYear().toString())
.replace('yy', time.getFullYear().toString().slice(2))
.replace('MM', toDigits(time.getMonth() + 1))
.replace('dd', toDigits(time.getDate()))
.replace('hh', toDigits(time.getHours()))
.replace('mm', toDigits(time.getMinutes()))
.replace('ss', toDigits(time.getSeconds()))
.replace('SSS', toDigits(time.getMilliseconds(), 3))
}
}
+142
View File
@@ -0,0 +1,142 @@
import { isNullable } from './misc'
type GlobalConstructorNames = keyof {
[K in keyof typeof globalThis as typeof globalThis[K] extends abstract new (...args: any) => any ? K : never]: K
}
/** Create a predicate for a global constructor name. */
export function is<K extends GlobalConstructorNames>(type: K): (value: any) => value is InstanceType<typeof globalThis[K]>
/** Test whether a value matches a global constructor name. */
export function is<K extends GlobalConstructorNames>(type: K, value: any): value is InstanceType<typeof globalThis[K]>
/** Test values using `instanceof` with a `toStringTag` fallback. */
export function is<K extends GlobalConstructorNames>(type: K, value?: any): any {
if (arguments.length === 1) return (value: any) => is(type, value)
return type in globalThis && value instanceof (globalThis[type] as any)
|| Object.prototype.toString.call(value).slice(8, -1) === type
}
function isArrayBufferLike(value: any): value is ArrayBufferLike {
return is('ArrayBuffer', value) || is('SharedArrayBuffer', value)
}
function isArrayBufferSource(value: any): value is Binary.Source {
return isArrayBufferLike(value) || ArrayBuffer.isView(value)
}
/** Binary source detection and base64/hex conversion helpers. */
export namespace Binary {
export type Source<T extends ArrayBufferLike = ArrayBufferLike> = T | ArrayBufferView<T>
export const is = isArrayBufferLike
export const isSource = isArrayBufferSource
export function fromSource<T extends ArrayBufferLike>(source: Source<T>): T {
if (ArrayBuffer.isView(source)) {
// https://stackoverflow.com/questions/8609289/convert-a-binary-nodejs-buffer-to-javascript-arraybuffer#answer-31394257
return source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength) as T
} else {
return source
}
}
export function toBase64(source: Source) {
source = fromSource(source)
if (typeof Buffer !== 'undefined') {
return Buffer.from(source).toString('base64')
}
let binary = ''
const bytes = new Uint8Array(source)
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary)
}
export function fromBase64(source: string) {
if (typeof Buffer !== 'undefined') return fromSource(Buffer.from(source, 'base64'))
return Uint8Array.from(atob(source), c => c.charCodeAt(0))
}
export function toHex(source: Source) {
source = fromSource(source)
if (typeof Buffer !== 'undefined') return Buffer.from(source).toString('hex')
return Array.from(new Uint8Array(source), byte => byte.toString(16).padStart(2, '0')).join('')
}
export function fromHex(source: string) {
if (typeof Buffer !== 'undefined') return fromSource(Buffer.from(source, 'hex'))
const hex = source.length % 2 === 0 ? source : source.slice(0, source.length - 1)
const buffer: number[] = []
for (let i = 0; i < hex.length; i += 2) {
buffer.push(parseInt(`${hex[i]}${hex[i + 1]}`, 16))
}
return Uint8Array.from(buffer).buffer
}
}
/** Decode a base64 string into binary data. */
export const base64ToArrayBuffer = Binary.fromBase64
/** Encode binary data as base64. */
export const arrayBufferToBase64 = Binary.toBase64
/** Decode a hex string into binary data. */
export const hexToArrayBuffer = Binary.fromHex
/** Encode binary data as hex. */
export const arrayBufferToHex = Binary.toHex
/** Deep-clone common JavaScript values while preserving prototypes. */
export function clone<T>(source: T): T
/** Deep-clone common JavaScript values while preserving prototypes and cycles. */
export function clone(source: any, refs = new Map<any, any>()) {
if (!source || typeof source !== 'object') return source
if (is('Date', source)) return new Date(source.valueOf())
if (is('RegExp', source)) return new RegExp(source.source, source.flags)
if (isArrayBufferLike(source)) return source.slice(0)
if (ArrayBuffer.isView(source)) return source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength)
const cached = refs.get(source)
if (cached) return cached
if (Array.isArray(source)) {
const result: any[] = []
refs.set(source, result)
source.forEach((value, index) => {
result[index] = Reflect.apply(clone, null, [value, refs])
})
return result
}
const result = Object.create(Object.getPrototypeOf(source))
refs.set(source, result)
for (const key of Reflect.ownKeys(source)) {
const descriptor = { ...Reflect.getOwnPropertyDescriptor(source, key) }
if ('value' in descriptor) {
descriptor.value = Reflect.apply(clone, null, [descriptor.value, refs])
}
Reflect.defineProperty(result, key, descriptor)
}
return result
}
/** Deeply compare arrays, dates, regexps, buffers, and plain object fields. */
export function deepEqual(a: any, b: any, strict?: boolean): boolean {
if (a === b) return true
if (!strict && isNullable(a) && isNullable(b)) return true
if (typeof a !== typeof b) return false
if (typeof a !== 'object') return false
if (!a || !b) return false
function check<T>(test: (x: any) => x is T, then: (a: T, b: T) => boolean) {
return test(a) ? test(b) ? then(a, b) : false : test(b) ? false : undefined
}
return check(Array.isArray, (a, b) => a.length === b.length && a.every((item, index) => deepEqual(item, b[index])))
?? check(is('Date'), (a, b) => a.valueOf() === b.valueOf())
?? check(is('RegExp'), (a, b) => a.source === b.source && a.flags === b.flags)
?? check(isArrayBufferLike, (a, b) => {
if (a.byteLength !== b.byteLength) return false
const viewA = new Uint8Array(a)
const viewB = new Uint8Array(b)
for (let i = 0; i < viewA.length; i++) {
if (viewA[i] !== viewB[i]) return false
}
return true
})
?? Object.keys({ ...a, ...b }).every(key => deepEqual(a[key], b[key], strict))
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib"
},
"include": ["src"]
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021-present Shigma
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
# @cordisjs/plugin-group
Loader group plugin for nesting Cordis entries.
## Usage
```yaml
- id: tools
name: '@cordisjs/plugin-group'
group: true
config:
- id: logger
name: '@cordisjs/plugin-logger-console'
```
Groups are always considered enabled themselves, but disabling a group entry
prevents its child entries from running. Nested entry ids use `:` separators,
for example `tools:logger`.
The package re-exports the `Group` implementation from
`@cordisjs/plugin-loader` as its default plugin.
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@cordisjs/plugin-group",
"description": "Nested plugin group for cordis",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"exports": {
".": {
"types": "./lib/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib",
"src"
],
"author": "Shigma <shigma10826@gmail.com>",
"license": "MIT",
"peerDependencies": {
"@cordisjs/plugin-loader": "^1.0.0-rc.4",
"cordis": "^4.0.0-rc.6"
}
}
+3
View File
@@ -0,0 +1,3 @@
import { Group } from '@cordisjs/plugin-loader'
export default Group
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib"
},
"include": ["src"],
"references": [
{ "path": "../cordis" },
{ "path": "../loader" }
]
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021-present Shigma
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+47
View File
@@ -0,0 +1,47 @@
# @cordisjs/plugin-hmr
Hot module replacement for loader-managed Cordis plugins.
The HMR plugin watches source files, traces Node's module graph, clears affected
module caches, and reloads only the plugin entries that depend on changed
application files. Changes to framework-level dependencies fall back to
`loader.exit()`, letting the host process restart.
## Requirements
- `@cordisjs/plugin-loader`
- `@cordisjs/plugin-timer`
- A runtime that exposes Node's internal module loader. The package throws if
the loader service has no internal module loader available.
## Usage
```yaml
- id: timer
name: '@cordisjs/plugin-timer'
- id: hmr
name: '@cordisjs/plugin-hmr'
config:
root:
- src
ignored:
- '**/node_modules'
- '**/.*'
debounce: 100
```
## Config
| Field | Description |
| --- | --- |
| `base` | Optional base directory resolved from `ctx.baseUrl`. |
| `root` | Chokidar roots to watch. Defaults to `['.']`. |
| `ignored` | Picomatch patterns excluded from watch and reload analysis. |
| `debounce` | Milliseconds to wait before processing a burst of changes. |
## Events
| Event | Description |
| --- | --- |
| `hmr/change` | Emitted for changed files that are not handled by plugin reload or config reload. |
| `hmr/reload` | Emitted after one or more plugin entries are reloaded. |
+49
View File
@@ -0,0 +1,49 @@
{
"name": "@cordisjs/plugin-hmr",
"description": "Hot Module Replacement Plugin for Cordis",
"version": "1.0.15",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"exports": {
".": {
"types": "./lib/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib",
"src"
],
"author": "Shigma <shigma10826@gmail.com>",
"license": "MIT",
"cordis": {
"services": {
"required": [
"timer"
]
},
"description": {
"en": "Hot Module Replacement",
"zh": "模块热替换"
}
},
"peerDependencies": {
"@cordisjs/plugin-timer": "^1.1.2",
"cordis": "^4.0.0-rc.6"
},
"dependencies": {
"@babel/code-frame": "^7.29.0",
"chokidar": "^4.0.3",
"cosmokit": "^1.8.1",
"picomatch": "^4.0.3",
"schemastery": "^3.18.0"
},
"devDependencies": {
"@types/babel__code-frame": "^7.27.0",
"@types/picomatch": "^3.0.2"
}
}
+36
View File
@@ -0,0 +1,36 @@
import { Context } from 'cordis'
import { BuildFailure } from 'esbuild'
import { codeFrameColumns } from '@babel/code-frame'
import { readFileSync } from 'node:fs'
function isBuildFailure(e: any): e is BuildFailure {
return Array.isArray(e?.errors) && e.errors.every((error: any) => error.text)
}
/** Log HMR build failures with code frames when source locations are available. */
export function handleError(ctx: Context, e: any) {
if (!isBuildFailure(e)) {
ctx.logger.warn(e)
return
}
for (const error of e.errors) {
if (!error.location) {
ctx.logger.warn(error.text)
continue
}
try {
const { file, line, column } = error.location
const source = readFileSync(file, 'utf8')
const formatted = codeFrameColumns(source, {
start: { line, column },
}, {
highlightCode: true,
message: error.text,
})
ctx.logger.warn(`File: ${file}:${line}:${column}\n` + formatted)
} catch (e) {
ctx.logger.warn(e)
}
}
}
+403
View File
@@ -0,0 +1,403 @@
import { Context, Inject, Plugin, Service } from 'cordis'
import { Dict } from 'cosmokit'
import { ModuleJob, ModuleLoader, ResolveResult } from '@cordisjs/plugin-loader'
import type { Include } from '@cordisjs/plugin-include'
import { ChokidarOptions, FSWatcher, watch } from 'chokidar'
import { relative, resolve } from 'node:path'
import { handleError } from './error.ts'
import type {} from '@cordisjs/plugin-timer'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { createRequire } from 'node:module'
import picomatch from 'picomatch'
import z from 'schemastery'
declare module 'cordis' {
interface Context {
hmr: Hmr
}
interface Events {
'hmr/change'(url: string): void
'hmr/reload'(reloads: Map<Plugin, Reload>): void
}
}
/**
* Recursively collect all module dependencies from a ModuleJob.
* Skips node: builtins and node_modules to focus on user code.
*/
async function loadDependencies(job: ModuleJob, ignored = new Set<string>()) {
const dependencies = new Set<string>()
async function traverse(job: ModuleJob) {
if (ignored.has(job.url) || dependencies.has(job.url)) return
if (job.url.startsWith('node:') || job.url.includes('/node_modules/')) return
dependencies.add(job.url)
const children = await job.linked
await Promise.all(Array.prototype.map.call(children, traverse))
}
await traverse(job)
return dependencies
}
interface Reload {
filename: string
runtime?: Plugin.Runtime
}
@Inject('loader')
@Inject('timer')
class Hmr extends Service {
public baseDir: string
private internal: ModuleLoader
private watcher!: FSWatcher
/**
* Changes from externals will always trigger a full reload.
* Externals are the dependency tree of the CLI worker entry point.
*/
private externals!: Set<string>
/**
* Files that should be reloaded (accepted changes).
* Includes all stashed files and their dependents.
*/
private accepted!: Set<string>
/**
* Files that should NOT be reloaded.
* Includes externals and files whose dependents are all declined.
*/
private declined!: Set<string>
/** Stashed file changes waiting to be processed */
private stashed = new Set<string>()
constructor(ctx: Context, public config: Hmr.Config) {
super(ctx, 'hmr')
if (!this.ctx.loader.internal) {
throw new Error('--expose-internals is required for HMR service')
}
this.internal = this.ctx.loader.internal
this.baseDir = fileURLToPath(new URL(config.base || '.', ctx.baseUrl))
}
/**
* Resolve a module specifier to a URL, compatible with Node 22-24.
*/
private async _resolve(specifier: string, parentURL: string, attrs: ImportAttributes): Promise<ResolveResult> {
switch (this.internal.version) {
case 'v1': return await this.internal.resolve(specifier, parentURL, attrs)
case 'v2': return this.internal.resolveSync(parentURL, { specifier, attributes: attrs })
}
}
async* [Service.init]() {
yield () => this.watcher?.close()
const { loader } = this.ctx
const { root, ignored } = this.config
if (!this.config.base) {
this.ctx.logger.info('watching %o', root)
} else {
this.ctx.logger.info('watching %o in %s', root, this.baseDir)
}
const match = picomatch(ignored)
this.watcher = watch(root, {
...this.config,
cwd: this.baseDir,
ignored: path => match(relative(this.baseDir, path)),
})
// Collect externals: framework modules reachable from the main entry.
// Changes to these files require a full process restart, not HMR.
const mainUrl = pathToFileURL(resolve(process.argv[1])).href
const mainJob = this.internal.loadCache.get(mainUrl)
if (mainJob) {
this.externals = await loadDependencies(mainJob)
} else {
this.externals = new Set()
}
const partialReload = this.ctx.debounce(() => this.partialReload(), this.config.debounce)
this.watcher.on('change', async (path) => {
this.ctx.logger.debug('change detected at %C', path)
const filename = resolve(this.baseDir, path)
const url = pathToFileURL(filename).href
// Full reload: the changed file is part of the framework
if (this.externals.has(url)) return loader.exit()
// Partial reload: the file is in the ESM loadCache
// In Node 24, both CJS and ESM modules imported via import() end up
// in loadCache, so this check covers all module formats.
if (loader.internal!.loadCache.has(url)) {
this.stashed.add(url)
return partialReload()
}
// Config reload: the file is a loader config file (e.g. cordis.yml)
for (const entry of this.ctx.loader.entries()) {
const include = entry.subtree as Include | undefined
if (include?.filename !== filename) continue
await include.refresh()
return
}
this.ctx.emit('hmr/change', url)
})
}
// hide stack trace from HMR
getOuterStack = (): string[] => [
// ' at HMR.partialReload (<anonymous>)',
]
async getLinked(url: string) {
const job = this.internal.loadCache.get(url)
if (!job) return []
const linked = await job.linked
return Array.prototype.map.call(linked, (job: ModuleJob) => job.url) as string[]
}
/**
* Classify changed files into accepted (should reload) and declined (should not).
*
* A file is accepted if it's directly changed (stashed) or if any of its
* dependents are accepted. A file is declined if all its dependents are
* declined or if it's an external.
*/
private async analyzeChanges() {
const pending: string[] = []
this.accepted = new Set(this.stashed)
this.declined = new Set(this.externals)
const isExcluded = (url: string) => url.startsWith('node:') || url.includes('/node_modules/')
await Promise.all([...this.stashed].map(async (url) => {
const children = await this.getLinked(url)
for (const child of children) {
if (this.accepted.has(child) || this.declined.has(child) || isExcluded(child)) continue
pending.push(child)
}
}))
while (pending.length) {
let index = 0, hasUpdate = false
while (index < pending.length) {
const url = pending[index]
const children = await this.getLinked(url)
let isDeclined = true, isAccepted = false
for (const child of children) {
if (this.declined.has(child) || isExcluded(child)) continue
if (this.accepted.has(child)) {
isAccepted = true
break
} else {
isDeclined = false
if (!pending.includes(child)) {
hasUpdate = true
pending.push(child)
}
}
}
if (isAccepted || isDeclined) {
hasUpdate = true
pending.splice(index, 1)
if (isAccepted) {
this.accepted.add(url)
} else {
this.declined.add(url)
}
} else {
index++
}
}
if (!hasUpdate) break
}
for (const url of pending) {
this.declined.add(url)
}
}
private async partialReload() {
await this.analyzeChanges()
const pending = new Map<ModuleJob, Plugin>()
const reloads = new Map<Plugin, Reload>()
// Build a map of plugin names per config tree URL.
// Plugin entry files are treated as atomic reload units.
const nameMap: Dict<Set<string>> = Object.create(null)
for (const entry of this.ctx.loader.entries()) {
(nameMap[entry.parent.tree.ctx.baseUrl!] ??= new Set()).add(entry.options.name)
}
// Resolve each plugin name to its file URL and check if it needs reload
for (const baseUrl in nameMap) {
for (const name of nameMap[baseUrl]) {
try {
const { url } = await this._resolve(name, baseUrl, {})
if (this.declined.has(url)) continue
const job = this.internal.loadCache.get(url)
const plugin = this.ctx.loader.unwrapExports(job?.module?.getNamespace())
if (!job || !plugin) continue
pending.set(job, plugin)
this.declined.add(url)
} catch (err) {
this.ctx.logger.warn(err)
}
}
}
// Check each pending plugin's dependency tree for accepted files
for (const [job, plugin] of pending) {
this.declined.delete(job.url)
const dependencies = [...await loadDependencies(job, this.declined)]
this.declined.add(job.url)
if (!dependencies.some(dep => this.accepted.has(dep))) continue
dependencies.forEach(dep => this.accepted.add(dep))
reloads.set(plugin, {
filename: job.url,
runtime: this.ctx.registry.get(plugin),
})
}
/**
* Clear module caches for all accepted files before re-importing.
*
* We need to clear both:
* 1. ESM loadCache — managed by Node's internal ModuleLoader
* 2. CJS Module._cache — for CJS modules that were imported via import()
*
* In Node 24, CJS modules loaded via import() appear in both caches.
* If we only clear loadCache, the CJS cache may serve stale modules.
*
* We use Map.prototype methods directly on loadCache because:
* - In Node 22/23, loadCache is a plain Map<url, ModuleJob>
* - In Node 24, loadCache is a LoadCache extends Map<url, { [type]: ModuleJob }>
* where .delete() only sets the type slot to undefined (doesn't remove the entry)
* Using Map.prototype.delete ensures complete removal in both versions.
*/
const esmBackup: Dict = Object.create(null)
const cjsBackup: Dict = Object.create(null)
const require = createRequire(import.meta.url)
for (const filename of this.accepted) {
// Backup and clear ESM loadCache
const job = Map.prototype.get.call(this.internal.loadCache, filename)
esmBackup[filename] = job
Map.prototype.delete.call(this.internal.loadCache, filename)
// Backup and clear CJS Module._cache
try {
const filepath = fileURLToPath(filename)
if (require.cache[filepath]) {
cjsBackup[filepath] = require.cache[filepath]
delete require.cache[filepath]
}
} catch {
// filename might not be a file: URL (e.g. node: protocol), ignore
}
}
const rollback = () => {
for (const filename in esmBackup) {
Map.prototype.set.call(this.internal.loadCache, filename, esmBackup[filename])
}
for (const filepath in cjsBackup) {
require.cache[filepath] = cjsBackup[filepath]
}
}
// Attempt to re-import all plugin entry files
const attempts: Dict = {}
try {
for (const [, { filename }] of reloads) {
attempts[filename] = this.ctx.loader.unwrapExports(await this.ctx.loader.import(filename, this.getOuterStack))
}
} catch (e) {
handleError(this.ctx, e)
return rollback()
}
const reload = (plugin: any, runtime: Plugin.Runtime) => {
if (!runtime) return
for (const oldFiber of runtime.fibers) {
const fiber = oldFiber.parent.registry.plugin(plugin, oldFiber.config, this.getOuterStack)
fiber.entry = oldFiber.entry
if (fiber.entry) fiber.entry.fiber = fiber
}
}
try {
for (const [plugin, { filename, runtime }] of reloads) {
if (!runtime) continue
const path = relative(this.baseDir, fileURLToPath(filename))
try {
this.ctx.registry.delete(plugin)
} catch (err) {
this.ctx.logger.warn('failed to dispose plugin at %C', path)
this.ctx.logger.warn(err)
}
try {
reload(attempts[filename], runtime)
this.ctx.logger.info('reload plugin at %C', path)
} catch (err) {
this.ctx.logger.warn('failed to reload plugin at %C', path)
this.ctx.logger.warn(err)
throw err
}
}
} catch {
// Rollback: restore caches and re-register old plugins
rollback()
for (const [plugin, { filename, runtime }] of reloads) {
if (!runtime) continue
try {
this.ctx.registry.delete(attempts[filename])
reload(plugin, runtime)
} catch (err) {
this.ctx.logger.warn(err)
}
}
return
}
this.ctx.emit('hmr/reload', reloads)
this.stashed = new Set()
}
}
namespace Hmr {
export interface Config extends ChokidarOptions {
base?: string
root: string[]
debounce: number
ignored: string[]
}
export const Config: z<Config> = z.object({
base: z.string(),
root: z.array(String).role('table').default(['.']),
ignored: z.array(String).role('table').default([
'**/node_modules',
'**/.*',
'cache',
'data',
]),
debounce: z.natural().role('ms').default(100),
})
// [deepseek-harness] vendored modification: removed `.i18n({ 'en-US': enUS, 'zh-CN': zhCN })`
// and the corresponding `./locales/*.yml` imports, to avoid a runtime YAML import hook
// (@cordisjs/unyaml) that we don't vendor. See vendor/README.md.
}
export default Hmr
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib"
},
"include": ["src"],
"references": [
{ "path": "../cosmokit" },
{ "path": "../cordis" },
{ "path": "../loader" },
{ "path": "../include" },
{ "path": "../timer" },
{ "path": "../schemastery" }
]
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021-present Shigma
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+43
View File
@@ -0,0 +1,43 @@
# @cordisjs/plugin-include
File-backed loader tree for Cordis. The include plugin reads a YAML or JSON
file, turns it into loader entries, and writes updates back when the file is
writable.
## Usage
```ts
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include from '@cordisjs/plugin-include'
const root = new Context()
await root.plugin(Loader, { baseUrl: import.meta.url })
await root.plugin(Include, {
path: './cordis.yml',
initial: [],
enableLogs: true,
})
```
Example `cordis.yml`:
```yaml
- id: timer
name: '@cordisjs/plugin-timer'
- id: app
name: ./plugins/app
config:
message: hello
```
## Config
| Field | Description |
| --- | --- |
| `path` | YAML or JSON file path resolved from `ctx.baseUrl`. |
| `initial` | Entry list written when the file is missing. |
| `patches` | Runtime patches applied after reading the file. |
| `enableLogs` | Enables loader apply, reload, and unload logs. |
Patches can insert entries or override fields on entries with a matching `id`.
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@cordisjs/plugin-include",
"description": "Include files in cordis configurations",
"version": "1.0.4",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"exports": {
".": {
"types": "./lib/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib",
"src"
],
"author": "Shigma <shigma10826@gmail.com>",
"license": "MIT",
"peerDependencies": {
"@cordisjs/plugin-loader": "^1.0.0-rc.4",
"cordis": "^4.0.0-rc.6"
},
"dependencies": {
"cosmokit": "^1.8.1",
"js-yaml": "^4.1.0"
}
}
+229
View File
@@ -0,0 +1,229 @@
import { EntryOptions, EntryTree, isJsExpr } from '@cordisjs/plugin-loader'
import { Context, Service } from 'cordis'
import { extname } from 'node:path'
import { access, constants, readFile, rename, writeFile } from 'node:fs/promises'
import { fileURLToPath, pathToFileURL } from 'node:url'
import * as yaml from 'js-yaml'
const JsExpr = new yaml.Type('tag:yaml.org,2002:js', {
kind: 'scalar',
resolve: (data) => typeof data === 'string',
construct: (data) => ({ __jsExpr: data }),
predicate: isJsExpr,
represent: (data) => data['__jsExpr'],
})
const schema = yaml.JSON_SCHEMA.extend(JsExpr)
const writable: Record<string, string> = {
'.json': 'application/json',
'.yaml': 'application/yaml',
'.yml': 'application/yaml',
}
const supported = new Set(Object.keys(writable))
/** Runtime patch applied to entries loaded from an included config file. */
export interface PatchOptions {
id?: string
insert?: EntryOptions[]
name?: string
config?: any
group?: boolean | null
disabled?: boolean | null
inject?: any
intercept?: any
isolate?: any
[key: string]: any
}
/** Config namespace for the file-backed include loader. */
export namespace Include {
/** Config for a file-backed loader subtree. */
export interface Config {
/** YAML or JSON path resolved from `ctx.baseUrl`. */
path: string
/** Entry list written when the file does not already exist. */
initial?: any[]
/** Runtime patches applied after reading the file. */
patches?: PatchOptions[]
/** Enables loader apply/reload/unload logs for this subtree. */
enableLogs?: boolean
}
}
/** Loader entry tree backed by a YAML or JSON file. */
export class Include extends EntryTree {
static inject = ['loader']
public filename: string
private type?: string
private readonly: boolean
private content?: string
private data?: EntryOptions[]
private writeTask?: NodeJS.Timeout
constructor(ctx: Context, public config: Include.Config) {
super(ctx)
this.enableLogs = config.enableLogs ?? ctx.fiber.entry?.parent.tree.enableLogs ?? false
this.filename = fileURLToPath(new URL(this.config.path, this.ctx.baseUrl))
const ext = extname(this.filename)
if (!supported.has(ext)) {
throw new Error(`extension "${ext}" not supported`)
}
this.type = writable[ext]
this.readonly = !this.type
this.ctx.baseUrl = new URL('.', pathToFileURL(this.filename)).href
ctx.on('internal/update', (config, _, next) => {
if (config.path !== this.config.path) return next()
this.root.update(this.data!)
})
}
private async checkAccess() {
if (!this.type) return
try {
await access(this.filename, constants.W_OK)
} catch {
this.readonly = true
}
}
private async read(forced = false) {
const content = await readFile(this.filename, 'utf8')
if (!forced && this.content === content) return false
this.content = content
if (this.type === 'application/yaml') {
this.data = yaml.load(this.content, { schema }) as any
} else if (this.type === 'application/json') {
this.data = JSON.parse(this.content) as any
} else {
const module = await import(/* @vite-ignore */ this.filename)
this.data = module.default || module
}
await this.checkAccess()
return true
}
private applyPatches(data: EntryOptions[]): EntryOptions[] {
const { patches } = this.config
if (!patches?.length) return data
const entryMap = new Map<string, EntryOptions>()
const buildMap = (entries: EntryOptions[]) => {
for (const entry of entries) {
if (entry.id) entryMap.set(entry.id, entry)
if (entry.group && Array.isArray(entry.config)) {
buildMap(entry.config)
}
}
}
buildMap(data)
for (const patch of patches) {
const { id, insert, name, ...overrides } = patch
if (insert) {
if (id) {
const target = entryMap.get(id)
if (!target) {
this.ctx.root.logger?.('loader').warn('patch insert: entry %C not found', id)
continue
}
if (!target.group) {
this.ctx.root.logger?.('loader').warn('patch insert: entry %C is not a group', id)
continue
}
if (!Array.isArray(target.config)) target.config = []
target.config.push(...insert)
} else {
data.push(...insert)
}
continue
}
if (!id) {
this.ctx.root.logger?.('loader').warn('patch: id is required for non-insert patches')
continue
}
const target = entryMap.get(id)
if (!target) {
this.ctx.root.logger?.('loader').warn('patch: entry %C not found', id)
continue
}
if (name && name !== target.name) {
this.ctx.root.logger?.('loader').warn(
'patch: name mismatch for %C (expected %C, got %C), skipping',
id, target.name, name,
)
continue
}
for (const [key, value] of Object.entries(overrides)) {
if (key === 'id') continue
target[key] = value
}
}
return data
}
async* [Service.init]() {
try {
await this.read()
} catch {
if (this.config.initial) {
this.writeFile(this.config.initial as any)
await this.read()
} else {
throw new Error(`config file not found: ${this.filename}`)
}
}
yield () => this.stop()
const data = this.applyPatches([...this.data!])
await this.root.update(data)
}
stop() {
this.root.stop()
}
/** Re-read the file and refresh child entries when content changed. */
async refresh() {
if (!await this.read()) return
this.root.update(this.data!)
}
private async _writeFile(config: EntryOptions[]) {
if (this.readonly) {
throw new Error(`cannot overwrite readonly config`)
}
if (this.type === 'application/yaml') {
this.content = yaml.dump(config, { schema })
} else if (this.type === 'application/json') {
this.content = JSON.stringify(config, null, 2)
}
await writeFile(this.filename + '.tmp', this.content!)
await rename(this.filename + '.tmp', this.filename)
}
private writeFile(config: EntryOptions[]) {
clearTimeout(this.writeTask)
this.writeTask = setTimeout(() => {
this.writeTask = undefined
this._writeFile(config)
}, 0)
}
/** Schedule a write of the current root entry data. */
write() {
this.context.emit('loader/config-update')
return this.writeFile(this.root.data)
}
}
export default Include
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib"
},
"include": ["src"],
"references": [
{ "path": "../cosmokit" },
{ "path": "../cordis" },
{ "path": "../loader" }
]
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021-present Shigma
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+48
View File
@@ -0,0 +1,48 @@
# @cordisjs/plugin-loader
Runtime plugin loader for Cordis. The loader owns an `EntryTree`, imports plugin
modules by name, applies their config, and keeps the running plugin graph in
sync with entry updates.
## Usage
```ts
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
const root = new Context()
await root.plugin(Loader, { baseUrl: import.meta.url })
const id = await root.loader.create({
name: './plugins/example',
config: { enabled: true },
})
await root.loader.await()
root.loader.update(id, { config: { enabled: false } })
```
## Entry Options
| Field | Description |
| --- | --- |
| `id` | Stable id for resolving, updating, and removing the entry. |
| `name` | Module specifier imported by the loader. |
| `config` | Config passed to the plugin. |
| `group` | Marks the entry as a group whose `config` is a child entry list. |
| `disabled` | Stops the entry and prevents it from starting. |
| `inject` | Adds required services or intercept config for this entry. |
## API
| API | Description |
| --- | --- |
| `loader.create(options, parent?, position?)` | Add and start an entry. |
| `loader.update(id, options, parent?, position?)` | Update, move, and restart an entry. |
| `loader.remove(id)` | Stop and delete an entry. |
| `loader.resolve(id)` | Resolve an entry by id, including nested `a:b` ids. |
| `loader.resolveGroup(id)` | Resolve the root group or a nested group. |
| `loader.await()` | Wait for pending entry imports and fiber reloads. |
| `loader.locate(fiber?)` | Return the loader entry id that owns a fiber. |
For file-backed trees, use `@cordisjs/plugin-include`.
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@cordisjs/plugin-loader",
"description": "Plugin loader for cordis",
"version": "1.0.0-rc.4",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"exports": {
".": {
"types": "./lib/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib",
"src"
],
"author": "Shigma <shigma10826@gmail.com>",
"license": "MIT",
"peerDependencies": {
"cordis": "^4.0.0-rc.6"
},
"dependencies": {
"cosmokit": "^1.8.1"
}
}
+184
View File
@@ -0,0 +1,184 @@
import { Context, Fiber, Inject } from 'cordis'
import { deepEqual, isNullable } from 'cosmokit'
import { Loader } from '../index.ts'
import { EntryGroup } from './group.ts'
import { EntryTree } from './tree.ts'
import { evaluate, interpolate } from './utils.ts'
/** Serialized plugin entry options stored in loader config files. */
export interface EntryOptions {
/** Stable id inside the containing entry tree. */
id: string
/** Module specifier imported by the entry tree. */
name: string
/** Config passed to the plugin. */
config?: any
/** Marks this entry as a nested group. */
group?: boolean | null
/** Prevents this entry and descendants from running. */
disabled?: boolean | null
/** Required services or service intercept config for this entry. */
inject?: Inject | null
}
function takeEntries(object: {}, keys: string[]) {
const result: [string, any][] = []
for (const key of keys) {
if (!(key in object)) continue
result.push([key, object[key]])
delete object[key]
}
return result
}
function sortKeys<T extends {}>(object: T, prepend = ['id', 'name'], append = ['config']): T {
const part1 = takeEntries(object, prepend)
const part2 = takeEntries(object, append)
const rest = takeEntries(object, Object.keys(object)).sort(([a], [b]) => a.localeCompare(b))
return Object.assign(object, Object.fromEntries([...part1, ...rest, ...part2]))
}
/** One configured plugin node inside an `EntryTree`. */
export class Entry {
static readonly key = Symbol.for('cordis.entry')
public ctx: Context
public fiber?: Fiber
public parent!: EntryGroup
// safety: call `entry.update()` immediately after creating an entry
public options = {} as EntryOptions
public subgroup?: EntryGroup
public subtree?: EntryTree
_initTask?: Promise<void>
constructor(public loader: Loader) {
this.ctx = loader.ctx.extend({ [Entry.key]: this })
this.context.emit('loader/entry-init', this)
}
get context(): Context {
return this.ctx
}
get id() {
let id = this.options.id
if (this.parent.tree.ctx.fiber.entry) {
id = this.parent.tree.ctx.fiber.entry.id + EntryTree.sep + id
}
return id
}
/** True when this entry or any owning parent entry is disabled. */
get disabled() {
// group is always enabled
if (this.options.group) return false
let entry: Entry | undefined = this
do {
if (entry.options.disabled) return true
entry = entry.parent.ctx.fiber.entry
} while (entry)
return false
}
evaluate(expr: string) {
return evaluate(this.ctx, expr)
}
_resolveConfig(plugin: any): [any, any?] {
if (plugin[EntryGroup.key]) return this.options.config
return interpolate(this.ctx, this.options.config)
}
private _patchContext(diff: string[]) {
this.context.waterfall('loader/patch-context', this, () => {
Object.setPrototypeOf(this.ctx, this.parent.ctx)
if (this.fiber?.uid && (diff.includes('config') || this.options.group)) {
this.fiber.update(this._resolveConfig(this.fiber.runtime!.callback), true)
}
})
}
async refresh() {
if (this.fiber) return
if (this.disabled) return
await this.init()
}
/** Merge new options, restart as needed, and persist through the parent tree. */
async update(options: Partial<EntryOptions>, create = false, force = false) {
const legacy = { ...this.options }
// step 1: update options
if (create) {
this.options = options as EntryOptions
} else {
for (const [key, value] of Object.entries(options)) {
if (isNullable(value)) {
delete this.options[key]
} else {
this.options[key] = value
}
}
}
sortKeys(this.options)
// step 2: execute
if (this.disabled) {
this.fiber?.dispose()
return
}
// step 3: check if options are changed
if (this.fiber?.uid) {
const diff = Object
.keys({ ...this.options, ...legacy })
.filter(key => !deepEqual(this.options[key], legacy[key]))
if (!diff.length && !force) return
this.context.emit('loader/partial-dispose', this, legacy, true)
this._patchContext(diff)
} else {
await this.init()
}
}
getOuterStack = () => {
let entry: Entry | undefined = this
const result: string[] = []
do {
result.push(` at ${entry.parent.tree.ctx.baseUrl}#${entry.options.id}`)
entry = entry.parent.ctx.fiber.entry
} while (entry)
return result
}
/** Import and start the configured plugin if it is not already running. */
async init() {
try {
await (this._initTask ??= this._init())
} finally {
this._initTask = undefined
}
this.fiber?.await().finally(() => {
if (this.loader.getTasks().length) return
this.ctx.reflect.notify(['loader'])
})
}
private async _init() {
let exports: any
try {
exports = await this.parent.tree.import(this.options.name, this.getOuterStack)
} catch (error) {
this.ctx.logger.error(error)
return
} finally {
this._initTask = undefined
}
const plugin = this.loader.unwrapExports(exports)
this._patchContext([])
this.loader.showLog(this, 'apply')
this.fiber = this.ctx.registry.plugin(plugin, this._resolveConfig(plugin), this.getOuterStack)
}
}
+90
View File
@@ -0,0 +1,90 @@
import { Context, Service } from 'cordis'
import { Entry, EntryOptions } from './entry.ts'
import { EntryTree } from './tree.ts'
/** Runtime owner for a list of child loader entries. */
export class EntryGroup {
static readonly key = Symbol.for('cordis.group')
public data: EntryOptions[] = []
constructor(public ctx: Context, public tree: EntryTree) {
const entry = ctx.fiber.entry
if (entry) entry.subgroup = this
}
get context(): Context {
return this.ctx
}
async create(options: Omit<EntryOptions, 'id'>) {
const id = this.tree.ensureId(options)
const entry: Entry = this.tree.store[id] ??= new Entry(this.ctx.loader)
// Entry may be moved from another group,
// so we need to update the parent reference.
entry.parent = this
// Use `create: true` to replace existing entry.options.
await entry.update(options, true, true)
return entry.id
}
unlink(options: EntryOptions) {
const config = this.data
const index = config.indexOf(options)
if (index >= 0) config.splice(index, 1)
}
remove(id: string, isDispose = false) {
const entry = this.tree.store[id]
if (!entry) return
entry.fiber?.dispose()
if (!isDispose) {
this.unlink(entry.options)
}
delete this.tree.store[id]
this.context.emit('loader/partial-dispose', entry, entry.options, false)
}
async update(config: EntryOptions[]) {
const oldConfig = this.data as EntryOptions[]
this.data = config
const oldMap = Object.fromEntries(oldConfig.map(options => [options.id, options]))
const newMap = Object.fromEntries(config.map(options => [options.id ?? Symbol('anonymous'), options]))
// update inner plugins
const ids = Reflect.ownKeys({ ...oldMap, ...newMap }) as string[]
await Promise.all(ids.map(async (id) => {
if (newMap[id]) {
await this.create(newMap[id]).catch((error) => {
this.ctx.logger.error(error)
})
} else {
this.remove(id)
}
}))
}
stop() {
for (const options of this.data) {
this.remove(options.id, true)
}
}
}
/** Plugin that mounts a nested loader entry group. */
export class Group extends EntryGroup {
static initial: Omit<EntryOptions, 'id'>[] = []
static readonly [EntryGroup.key] = true
constructor(public ctx: Context, public config: EntryOptions[]) {
super(ctx, ctx.fiber.entry!.parent.tree)
ctx.on('internal/update', (config) => {
this.update(config)
})
}
async* [Service.init]() {
yield () => this.stop()
await this.update(this.config)
}
}
+173
View File
@@ -0,0 +1,173 @@
import { Context } from 'cordis'
import { Dict } from 'cosmokit'
import { Entry } from './entry.ts'
declare module './entry.ts' {
interface EntryOptions {
intercept?: Dict | null
isolate?: Dict<true | string> | null
}
interface Entry {
realm: LocalRealm
}
}
function swap<T extends {}>(target: T, source?: T | null) {
for (const key of Reflect.ownKeys(target)) {
Reflect.deleteProperty(target, key)
}
for (const key of Reflect.ownKeys(source || {})) {
Reflect.defineProperty(target, key, Reflect.getOwnPropertyDescriptor(source!, key)!)
}
}
/** Symbol realm used to isolate service implementations by entry or label. */
export abstract class Realm {
protected store: Dict<symbol> = Object.create(null)
abstract get suffix(): string
access(key: string, create = false) {
if (create) {
return this.store[key] ??= Symbol(`${key}${this.suffix}`)
} else {
return this.store[key] ?? Symbol(`${key}${this.suffix}`)
}
}
delete(key: string) {
delete this.store[key]
}
get size() {
return Object.keys(this.store).length
}
}
/** Entry-local isolation realm. */
export class LocalRealm extends Realm {
constructor(private entry: Entry) {
super()
}
get suffix() {
return '#' + this.entry.options.id
}
}
/** Named isolation realm shared by entries that use the same label. */
export class GlobalRealm extends Realm {
constructor(public label: string) {
super()
}
get suffix() {
return '@' + this.label
}
}
/** Install loader hooks that apply `intercept` and `isolate` entry options. */
export default function isolate(ctx: Context) {
const realms: Dict<GlobalRealm> = Object.create(null)
const delims: Dict<symbol> = Object.create(null)
function access(entry: Entry, name: string, create: true): symbol
function access(entry: Entry, name: string, create?: boolean): symbol | undefined
function access(entry: Entry, name: string, create = false) {
let realm: Realm | undefined
const label = entry.options.isolate?.[name]
if (!label) return
if (label === true) {
realm = entry.realm ??= new LocalRealm(entry)
} else if (create) {
realm = realms[label] ??= new GlobalRealm(label)
} else {
realm = realms[label]
}
return realm?.access(name, create)
}
ctx.on('loader/entry-init', (entry) => {
entry.ctx[Context.intercept] = Object.create(entry.ctx[Context.intercept])
entry.ctx[Context.isolate] = Object.create(entry.ctx[Context.isolate])
})
ctx.on('loader/patch-context', (entry, next) => {
// step 1: generate new isolate map
const newMap: Dict<symbol> = Object.create(entry.parent.ctx[Context.isolate])
for (const name of Object.keys(entry.options.isolate ?? {})) {
newMap[name] = access(entry, name, true)
}
// step 2: generate service diff
const diff: Dict<[symbol, symbol, symbol, symbol]> = Object.create(null)
const oldMap = entry.ctx[Context.isolate]
for (const name in { ...newMap, ...delims }) {
if (newMap[name] === oldMap[name]) continue
const delim = delims[name] ??= Symbol(`delim:${name}`)
entry.ctx[delim] = Symbol(`${name}#${entry.id}`)
for (const symbol of [oldMap[name], newMap[name]]) {
const impl = symbol && entry.ctx.reflect.store[symbol]
if (!impl) continue
if (!impl.fiber) {
entry.ctx.logger.warn(new Error(`expected service ${name} to be implemented`))
continue
}
diff[name] = [oldMap[name], newMap[name], entry.ctx[delim], impl.fiber.ctx[delim]]
if (entry.ctx[delim] !== impl.fiber.ctx[delim]) break
}
}
// step 3: set prototype for transferred context
Object.setPrototypeOf(entry.ctx[Context.isolate], entry.parent.ctx[Context.isolate])
Object.setPrototypeOf(entry.ctx[Context.intercept], entry.parent.ctx[Context.intercept])
swap(entry.ctx[Context.isolate], newMap)
swap(entry.ctx[Context.intercept], entry.options.intercept)
// step 4: reload fiber
next()
// step 5: replace service impl
for (const [symbol1, symbol2, flag1, flag2] of Object.values(diff)) {
if (flag1 === flag2 && entry.ctx.reflect.store[symbol1] && !entry.ctx.reflect.store[symbol2]) {
entry.ctx.reflect.store[symbol2] = entry.ctx.reflect.store[symbol1]
delete entry.ctx.reflect.store[symbol1]
}
}
// step 6: reflect notify
ctx.reflect.notify(Object.keys(diff), (ctx, name) => {
const [symbol1, symbol2, flag1, flag2] = diff[name]
const symbol3 = ctx[Context.isolate][name]
const flag3 = ctx[delims[name]]
return (symbol1 === symbol3 || symbol2 === symbol3) && (flag1 === flag3) !== (flag1 === flag2)
})
// step 7: clean up delimiters
for (const name in delims) {
if (!Reflect.ownKeys(newMap).includes(name)) {
delete entry.ctx[delims[name]]
}
}
})
ctx.on('loader/partial-dispose', (entry, legacy, active) => {
for (const [name, label] of Object.entries(legacy.isolate ?? {})) {
if (label === true) continue
if (active && entry.options.isolate?.[name] === label) continue
const realm = realms[label]
if (!realm) continue
// realm garbage collection
for (const entry of ctx.loader.entries()) {
// has reference to this realm
if (entry.options.isolate?.[name] === realm.label) return
}
realm.delete(name)
if (!realm.size) {
delete realms[realm.label]
}
}
})
}
+133
View File
@@ -0,0 +1,133 @@
import { composeError, Context } from 'cordis'
import { Dict, isNonNullable } from 'cosmokit'
import { Entry, EntryOptions } from './entry.ts'
import { EntryGroup } from './group.ts'
/** Mutable tree of loader entries. Persistence is supplied by subclasses. */
export abstract class EntryTree {
static readonly sep = ':'
public ctx: Context
public enableLogs?: boolean
public root: EntryGroup
public store: Dict<Entry> = Object.create(null)
constructor(ctx: Context) {
this.ctx = ctx.extend({ baseUrl: ctx.baseUrl })
this.root = new EntryGroup(this.ctx, this)
const entry = this.ctx.fiber.entry
if (entry) entry.subtree = this
}
get context(): Context {
return this.ctx
}
/** Iterate entries in this tree and any nested subtrees. */
* entries(): Generator<Entry, void, void> {
for (const entry of Object.values(this.store)) {
yield entry
if (!entry.subtree) continue
yield* entry.subtree.entries()
}
}
/** Return pending import and lifecycle tasks owned by this tree. */
getTasks() {
return [...this.entries()]
.map(entry => entry._initTask || entry.fiber?.inertia)
.filter(isNonNullable)
}
/** Wait until this tree has no pending import or lifecycle tasks. */
async await() {
while (true) {
const tasks = this.getTasks()
if (!tasks.length) return
await Promise.allSettled(tasks)
}
}
ensureId(options: Partial<EntryOptions>) {
if (!options.id) {
do {
options.id = Math.random().toString(16).slice(2, 10)
} while (this.store[options.id])
}
return options.id!
}
/** Resolve an entry by id, including nested ids separated by `EntryTree.sep`. */
resolve(id: string) {
const parts = id.split(EntryTree.sep)
let tree: EntryTree | undefined = this
const final = parts.pop()!
for (const part of parts) {
tree = tree.store[part]?.subtree
if (!tree) throw new Error(`cannot resolve entry ${id}`)
}
const entry = tree.store[final]
if (!entry) throw new Error(`cannot resolve entry ${id}`)
return entry
}
resolveGroup(id: string | null) {
if (!id) return this.root
const entry = this.resolve(id)
if (!entry.subgroup) throw new Error(`entry ${id} is not a group`)
return entry.subgroup
}
/** Create an entry in the root group or a nested group. */
async create(options: Omit<EntryOptions, 'id'>, parent: string | null = null, position = Infinity) {
const group = this.resolveGroup(parent)
group.data.splice(position, 0, options as EntryOptions)
group.tree.write()
return group.create(options)
}
/** Stop and remove an entry from its parent group. */
remove(id: string) {
const entry = this.resolve(id)
entry.parent.remove(id)
entry.parent.tree.write()
}
/** Update an entry and optionally move it to another group. */
async update(id: string, options: Omit<EntryOptions, 'id' | 'name'>, parent?: string | null, position?: number) {
const entry = this.resolve(id)
const source = entry.parent
if (parent !== undefined) {
const target = this.resolveGroup(parent)
source.unlink(entry.options)
target.data.splice(position ?? Infinity, 0, entry.options)
target.tree.write()
entry.parent = target
}
source.tree.write()
return entry.update(options, false, true)
}
/** Import a plugin module from a specifier or `cordis:` builtin. */
import(name: string, getOuterStack?: () => string[]) {
if (name.startsWith('cordis:')) {
return this.ctx.loader.builtins[name.slice(7)]
}
return composeError(async (info) => {
// ModuleJob.run
// onImport.tracePromise.__proto__
// internal.import
info.offset += 3
if (this.ctx.loader.internal) {
return await this.ctx.loader.internal.import(name, this.ctx.baseUrl!, {})
} else if (name.startsWith('.')) {
return await import(/* @vite-ignore */new URL(name, this.ctx.baseUrl).href)
} else {
return await import(/* @vite-ignore */name)
}
}, getOuterStack)
}
/** Persist current tree state. In-memory trees may implement this as a no-op. */
abstract write(): void
}
+32
View File
@@ -0,0 +1,32 @@
import { valueMap } from 'cosmokit'
// eslint-disable-next-line no-new-func
/** Evaluate a JavaScript expression against a loader context scope. */
export const evaluate = new Function('ctx', 'expr', `
with (ctx) {
return eval(expr)
}
`) as ((ctx: object, expr: string) => any)
/** Recursively replace YAML `!js` expression nodes with evaluated values. */
export function interpolate(ctx: object, value: any) {
if (isJsExpr(value)) {
return evaluate(ctx, value.__jsExpr)
} else if (!value || typeof value !== 'object') {
return value
} else if (Array.isArray(value)) {
return value.map(item => interpolate(ctx, item))
} else {
return valueMap(value, item => interpolate(ctx, item))
}
}
/** Return true when a value is a serialized loader JavaScript expression. */
export function isJsExpr(value: any): value is JsExpr {
return value instanceof Object && '__jsExpr' in value
}
/** Serialized JavaScript expression produced by the include YAML tag. */
export interface JsExpr {
__jsExpr: string
}
+185
View File
@@ -0,0 +1,185 @@
import { Context, Inject, Service } from 'cordis'
import { defineProperty, Dict, isNullable } from 'cosmokit'
import { ModuleLoader } from './internal.ts'
import { Entry, EntryOptions } from './config/entry.ts'
import isolate from './config/isolate.ts'
import { EntryTree } from './config/tree.ts'
/** Re-export entry node APIs. */
export * from './config/entry.ts'
/** Re-export nested entry group APIs. */
export * from './config/group.ts'
/** Re-export service isolation helpers. */
export * from './config/isolate.ts'
/** Re-export entry tree persistence APIs. */
export * from './config/tree.ts'
/** Re-export loader config expression helpers. */
export * from './config/utils.ts'
/** Re-export Node internal module loader compatibility types. */
export * from './internal.ts'
declare module 'cordis' {
interface Events {
'exit'(signal: NodeJS.Signals): Promise<void>
'loader/config-update'(): void
'loader/entry-init'(entry: Entry): void
'loader/partial-dispose'(entry: Entry, legacy: Partial<EntryOptions>, active: boolean): void
'loader/patch-context'(entry: Entry, next: () => void): void
}
interface Context {
loader: Loader
}
interface EnvData {
startTime?: number
}
interface Fiber {
entry?: Entry
}
}
/** Loader config and dependency intercept namespace. */
export namespace Loader {
/** Root loader configuration. */
export interface Config {
/** Base URL used to resolve relative plugin specifiers and config paths. */
baseUrl?: string
}
/** Intercept config used when other plugins depend on `loader`. */
export interface Intercept {
/** Keep dependent plugins pending while loader entries are still loading. */
await?: boolean
}
}
/**
* Service that owns a loader entry tree and imports configured plugins.
*
* Subclasses provide persistence by implementing `write()` on `EntryTree`.
*/
export class Loader extends EntryTree {
declare [Service.config]: Loader.Intercept
public envData = process.env.CORDIS_SHARED
? JSON.parse(process.env.CORDIS_SHARED)
: { startTime: Date.now() }
public name = 'loader'
public internal = ModuleLoader.fromInternal()
public builtins: Dict<any> = Object.create(null)
constructor(ctx: Context, public config: Loader.Config = {}) {
super(ctx)
if (config.baseUrl) {
this.ctx.baseUrl = config.baseUrl
}
const self = this
defineProperty(this, Service.tracker, {
associate: 'loader',
property: 'ctx',
noShadow: true,
})
ctx.reflect.provide('loader', this, this[Service.check])
ctx.on('internal/update', function (config, noSave, next) {
if (!this.entry || noSave || this.parent.fiber?.entry === this.entry) return next()
const unparse = this.runtime?.Config?.['simplify']
this.entry.options.config = unparse ? unparse(config) : config
this.entry.parent.tree.write()
return next()
}, { global: true, prepend: true })
ctx.on('internal/update', function (config, _, next) {
if (!this.entry || this.parent.fiber?.entry === this.entry) return next()
self.showLog(this.entry, 'reload')
return next()
}, { global: true })
ctx.on('internal/plugin', (fiber) => {
// 1. set `fiber.entry`
if (fiber.parent[Entry.key] && !fiber.entry) {
fiber.entry = fiber.parent[Entry.key]
// FIXME merge config
Inject.resolve(fiber.entry!.options.inject, fiber.inject)
}
// 2. handle self-dispose
// We only care about `ctx.fiber.dispose()`, so we need to filter out other cases.
// case 1: fiber is created
if (fiber.uid) return
// case 2: fiber is not tracked by loader
if (!fiber.entry) return
// case 3: fiber is a child plugin under the entry (not the entry's root fiber)
if (fiber.parent.fiber?.entry === fiber.entry) return
// case 4: fiber is disposed on behalf of plugin deletion (such as plugin hmr)
// self-dispose: ctx.fiber.dispose() -> fiber / runtime dispose -> delete(plugin)
// plugin hmr: delete(plugin) -> runtime dispose -> fiber dispose
if (!ctx.registry.has(fiber.runtime!.callback)) return
// case 5: the entry's tree is being disposed
if (!fiber.entry.parent.tree.ctx.fiber.uid) return
this.showLog(fiber.entry, 'unload')
// case 6: fiber is disposed by loader behavior
// such as inject checker, config file update, ancestor group disable
if (fiber.entry.disabled) return
fiber.entry.options.disabled = true
fiber.entry.parent.tree.write()
})
ctx.plugin(isolate)
}
write() {
// Loader's root tree is in-memory; writes are no-ops.
}
[Service.check]() {
const config: Loader.Intercept = Service.prototype[Service.resolveConfig].call(this)
if (config.await && this.getTasks().length) return false
return true
}
showLog(entry: Entry, type: string) {
if (entry.options.group || !entry.parent.tree.enableLogs) return
this.ctx.root.logger?.('loader').info('%s plugin %C', type, entry.options.name)
}
/** Return the loader entry id that owns `fiber`, if any. */
locate(fiber = this.ctx.fiber) {
while (1) {
if (fiber.entry) return fiber.entry.id
const next = fiber.parent.fiber
if (fiber === next) return
fiber = next
}
}
/** Hook for hosts that can restart the process on full-reload requests. */
exit() {
}
/** Normalize ESM/CJS/default export shapes before applying a plugin. */
unwrapExports(exports: any) {
if (isNullable(exports)) return exports
exports = exports.default ?? exports
// https://github.com/evanw/esbuild/issues/2623
// https://esbuild.github.io/content-types/#default-interop
if (!exports.__esModule) return exports
return exports.default ?? exports
}
}
export default Loader
+122
View File
@@ -0,0 +1,122 @@
import { createRequire, LoadHookContext } from 'node:module'
import { Dict } from 'cosmokit'
/** Node internal module format names handled by loader hooks. */
export type ModuleFormat = 'builtin' | 'commonjs' | 'json' | 'module' | 'wasm'
/** Source payload accepted by Node internal module load hooks. */
export type ModuleSource = string | ArrayBuffer
/** Result returned by a Node internal resolve hook. */
export interface ResolveResult {
format: ModuleFormat
url: string
}
/** Result returned by a Node internal load hook. */
export interface LoadResult {
format: ModuleFormat
source?: ModuleSource
}
type LoadCacheData = ModuleJob // | Function
/** @see https://github.com/nodejs/node/blob/main/lib/internal/modules/esm/module_map.js */
interface LoadCache extends Omit<Map<string, Dict<LoadCacheData>>, 'get' | 'set' | 'has'> {
get(url: string, type?: string): LoadCacheData | undefined
set(url: string, type?: string, job?: LoadCacheData): this
has(url: string, type?: string): boolean
}
/** Minimal Node internal ModuleWrap surface used by HMR helpers. */
export interface ModuleWrap {
url: string
getNamespace(): any
}
/** @see https://github.com/nodejs/node/blob/main/lib/internal/modules/esm/module_job.js */
export interface ModuleJob {
url: string
loader: ModuleLoader
module?: ModuleWrap
importAttributes: ImportAttributes
linked: Promise<ModuleJob[]>
instantiate(): Promise<void>
run(): Promise<{ module: ModuleWrap }>
}
/**
* Node 22/23 ModuleLoader interface.
*
* Key methods:
* - getModuleJobForImport(specifier, parentURL, importAttributes)
* - resolve(specifier, parentURL, importAttributes) → Promise<ResolveResult>
* - resolveSync(specifier, parentURL, importAttributes) → ResolveResult
*/
export interface ModuleLoaderV1 {
version: 'v1'
loadCache: LoadCache
import(specifier: string, parentURL: string, importAttributes: ImportAttributes): Promise<any>
register(specifier: string | URL, parentURL?: string | URL, data?: any, transferList?: any[]): void
getModuleJobForImport(specifier: string, parentURL: string, importAttributes: ImportAttributes): Promise<ModuleJob>
resolve(specifier: string, parentURL: string, importAttributes: ImportAttributes): Promise<ResolveResult>
resolveSync(specifier: string, parentURL: string, importAttributes: ImportAttributes): ResolveResult
load(specifier: string, context: Pick<LoadHookContext, 'format' | 'importAttributes'>): Promise<LoadResult>
}
/** Node 24+ module request object. */
export interface ModuleRequest {
specifier: string
attributes?: ImportAttributes
phase?: ModulePhase
}
/** @see https://github.com/nodejs/node/blob/main/src/module_wrap.h */
export const enum ModulePhase {
Source = 1,
Evaluation = 2,
}
/** Opaque Node internal module request type marker. */
export type ModuleRequestType = unknown // internal symbols
/**
* Node 24+ ModuleLoader interface.
*
* Breaking changes from v1:
* - getModuleJobForImport removed → getOrCreateModuleJob(parentURL, request, requestType)
* - resolve removed (became private #resolve) → resolveSync(parentURL, request)
* - Parameter order reversed for resolveSync, request object { specifier, attributes }
* - LoadCache became typed Map<url, { [type]: ModuleJob }> with delete only setting undefined
*/
export interface ModuleLoaderV2 {
version: 'v2'
loadCache: LoadCache
import(specifier: string, parentURL: string, importAttributes: ImportAttributes, phase?: ModulePhase, isEntryPoint?: boolean): Promise<any>
register(specifier: string | URL, parentURL?: string | URL, data?: any, transferList?: any[], isInternal?: boolean): void
getOrCreateModuleJob(parentURL: string, request: ModuleRequest, requestType?: ModuleRequestType): Promise<ModuleJob>
resolveSync(parentURL: string, request: ModuleRequest): ResolveResult
load(url: string, context: Pick<LoadHookContext, 'format' | 'importAttributes'>): Promise<LoadResult>
}
/** Supported Node internal ESM loader shapes. */
export type ModuleLoader = ModuleLoaderV1 | ModuleLoaderV2
/** Helpers for locating the current Node internal module loader. */
export namespace ModuleLoader {
let _cachedLoader: ModuleLoader | undefined
export function fromInternal(): ModuleLoader | undefined {
if (!process.execArgv.includes('--expose-internals')) return
if (_cachedLoader) return _cachedLoader
const require = createRequire(import.meta.url)
const [major] = process.versions.node.split('.').map(Number)
if (major >= 24) {
const raw = require('internal/modules/esm/loader').getOrInitializeCascadedLoader()
return _cachedLoader = Object.assign(raw, { version: 'v2' })
} else if (major >= 22) {
const raw = require('internal/modules/esm/loader').getOrInitializeCascadedLoader()
return _cachedLoader = Object.assign(raw, { version: 'v1' })
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib"
},
"include": ["src"],
"references": [
{ "path": "../cosmokit" },
{ "path": "../cordis" }
]
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021-present Shigma
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+35
View File
@@ -0,0 +1,35 @@
# @cordisjs/plugin-logger-console
Console exporter for the built-in Cordis logger service.
## Usage
```ts
import { Context } from 'cordis'
import ConsoleLogger from '@cordisjs/plugin-logger-console'
const root = new Context()
await root.plugin(ConsoleLogger, {
showDiff: true,
levels: {
default: 2,
hmr: 3,
},
})
root.logger('app').info('started')
```
## Config
| Field | Description |
| --- | --- |
| `colors` | Color support level, or `false` to disable colors. |
| `maxLength` | Maximum rendered line length before truncation. |
| `levels` | Per-logger minimum level map. |
| `showDiff` | Show elapsed time since the previous message. |
| `showTime` | Timestamp template. |
| `label` | Label width, margin, and alignment options. |
The Node entry uses `node:util.inspect` for `%o` and `%O`; the browser entry
passes log arguments through to `console`.
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@cordisjs/plugin-logger-console",
"description": "Console logger exporter for cordis",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/shared.d.ts",
"exports": {
".": {
"types": "./lib/shared.d.ts",
"node": "./lib/index.js",
"default": "./lib/browser.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib",
"src"
],
"author": "Shigma <shigma10826@gmail.com>",
"license": "MIT",
"peerDependencies": {
"cordis": "^4.0.0-rc.6"
},
"dependencies": {
"cosmokit": "^1.8.1",
"schemastery": "^3.18.0",
"supports-color": "^9.4.0"
}
}
+17
View File
@@ -0,0 +1,17 @@
import { Message } from 'cordis'
import { ConsoleExporter as Base } from './shared.js'
/** Re-export shared console exporter config and base implementation. */
export * from './shared.js'
/** Browser console exporter that dispatches to native console methods. */
export class ConsoleExporter extends Base {
export(message: Message) {
const prefix = `[${message.type[0].toUpperCase()}] ${message.name}`
const method = message.type === 'error' ? 'error' : message.type === 'warn' ? 'warn' : 'log'
// eslint-disable-next-line no-console
console[method](prefix, ...message.args)
}
}
export default ConsoleExporter
+28
View File
@@ -0,0 +1,28 @@
import { Formatter } from 'cordis'
import { inspect } from 'node:util'
import supportsColor from 'supports-color'
import { ConsoleExporter as Base } from './shared.js'
/** Re-export shared console exporter config and base implementation. */
export * from './shared.js'
const inspectFormatter: Formatter = (value, target) => {
return inspect(value, { colors: !!target.colors, depth: Infinity, compact: true, breakLength: Infinity })
}
/** Node console exporter with `util.inspect` object formatting. */
export class ConsoleExporter extends Base {
formatters: Record<string, Formatter> = {
o: inspectFormatter,
O: inspectFormatter,
}
getDefaults() {
return {
...super.getDefaults(),
colors: (supportsColor.stdout ? supportsColor.stdout.level : 0) as false | 0 | 1 | 2 | 3,
}
}
}
export default ConsoleExporter
+100
View File
@@ -0,0 +1,100 @@
import { Context, Exporter, Formatter, Logger, Message } from 'cordis'
import { Time } from 'cosmokit'
import z from 'schemastery'
/** Terminal color support level compatible with supports-color. */
export type ColorSupportLevel = 0 | 1 | 2 | 3
/** Formatting options for the logger name label. */
export interface LabelStyle {
width?: number
margin?: number
align?: 'left' | 'right'
}
/** Config namespace for console logger exporters. */
export namespace ConsoleExporter {
export interface Config {
colors?: false | ColorSupportLevel
maxLength?: number
levels?: Record<string, number>
showDiff?: boolean
showTime?: string
label?: LabelStyle
}
}
/** Shared console log exporter implementation used by Node and browser builds. */
export class ConsoleExporter implements Exporter {
static readonly name = 'logger-console'
static readonly Config: z<ConsoleExporter.Config> = z.object({
colors: z.union([z.const(false), z.number()]),
maxLength: z.number(),
levels: z.dict(z.number()),
showDiff: z.boolean().default(false),
showTime: z.string().default('yyyy-MM-dd hh:mm:ss '),
label: z.object({
width: z.number(),
margin: z.number(),
align: z.union(['left', 'right']),
}),
}) as z<ConsoleExporter.Config>
colors!: false | ColorSupportLevel
maxLength?: number
levels?: Record<string, number>
showDiff!: boolean
showTime!: string
label?: LabelStyle
timestamp: number
formatters: Record<string, Formatter> = {}
constructor(public ctx: Context, config: ConsoleExporter.Config = {}) {
Object.assign(this, this.getDefaults(), config)
this.timestamp = Date.now()
ctx.logger.exporter(this)
}
getDefaults() {
return {
colors: false as false | ColorSupportLevel,
showTime: 'yyyy-MM-dd hh:mm:ss ',
showDiff: false,
}
}
export(message: Message) {
// eslint-disable-next-line no-console
console.log(this.render(message))
}
render(message: Message) {
const prefix = `[${message.type[0].toUpperCase()}]`
const space = ' '.repeat(this.label?.margin ?? 1)
let indent = 3 + space.length, output = ''
if (this.showTime) {
indent += this.showTime.length
output += Logger.color(this, 8, Time.template(this.showTime))
}
const code = Logger.code(message.name, this.colors)
const label = Logger.color(this, code, message.name, ';1')
const padLength = (this.label?.width ?? 0) + label.length - message.name.length
if (this.label?.align === 'right') {
output += label.padStart(padLength) + space + prefix + space
indent += (this.label.width ?? 0) + space.length
} else {
output += prefix + space + label.padEnd(padLength) + space
}
output += Logger.format(this, message).replace(/\n/g, '\n' + ' '.repeat(indent))
if (this.showDiff && this.timestamp) {
const diff = message.ts - this.timestamp
output += Logger.color(this, code, ' +' + Time.format(diff))
}
this.timestamp = message.ts
return output
}
}
export default ConsoleExporter
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib"
},
"include": ["src"],
"references": [
{ "path": "../cosmokit" },
{ "path": "../cordis" },
{ "path": "../schemastery" }
]
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021-present Shigma
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+389
View File
@@ -0,0 +1,389 @@
# Schemastery
[![Codecov](https://img.shields.io/codecov/c/github/shigma/schemastery?style=flat-square)](https://codecov.io/gh/shigma/schemastery)
[![downloads](https://img.shields.io/npm/dm/schemastery?style=flat-square)](https://www.npmjs.com/package/schemastery)
[![npm](https://img.shields.io/npm/v/schemastery?style=flat-square)](https://www.npmjs.com/package/schemastery)
[![GitHub](https://img.shields.io/github/license/shigma/schemastery?style=flat-square)](https://github.com/shigma/schemastery/blob/master/LICENSE)
Type Driven Schema Validator.
## Features
- **Lightweight.** Much smaller than other validation libraries.
- **Easy to use.** You can use any schema as a function or constructor directly.
- **Powerful.** Schemastery supports some advanced types such as `union`, `intersect` and `transform`.
- **Extensible.** You can create your own schema types via `Schema.extend()`.
- **Serializable.** Schema objects can be serialized into JSON and then be hydrated in another environment.
## Basic Examples
### use as validator (JavaScript)
```js
const Schema = require('schemastery')
const validate = Schema.number().default(10)
validate(0) // 0
validate(null) // 10
validate('') // TypeError
```
### use as constructor (TypeScript)
```ts
import Schema from 'schemastery'
interface Config {
foo: Record<string, string>
bar: string[]
}
const Config = Schema.object({
foo: Schema.dict(Schema.string()).default({}),
bar: Schema.array(Schema.string()).default([]),
})
// config is an instance of Config
// in this case, that is { foo: {}, bar: [] }
const config = new Config()
```
## General Types
### Schema.any()
Assert that the value is of any type.
```js
const validate = Schema.any()
validate() // undefined
validate(0) // 0
validate({}) // {}
```
### Schema.never()
Assert that the value is nullable.
```js
const validate = Schema.never()
validate() // undefined
validate(0) // TypeError
validate({}) // TypeError
```
### Schema.const(value)
Assert that the value is equal to the given constant.
```js
const validate = Schema.const(10)
validate(10) // 10
validate(0) // TypeError
```
### Schema.number()
Assert that the value is a number.
```js
const validate = Schema.number()
validate() // undefined
validate(1) // 1
validate('') // TypeError
```
### Schema.string()
Assert that the value is a string.
```js
const validate = Schema.string()
validate() // undefined
validate(0) // TypeError
validate('foo') // 'foo'
```
### Schema.boolean()
Assert that the value is a boolean.
```js
const validate = Schema.boolean()
validate() // undefined
validate(0) // TypeError
validate(true) // true
```
### Schema.is(constructor)
Assert that the value is an instance of the given constructor.
```js
const validate = Schema.is(RegExp)
validate() // undefined
validate(/foo/) // /foo/
validate('foo') // TypeError
```
### Schema.array(inner)
Assert that the value is an array of `inner`. The default value will be `[]` if not specified.
```js
const validate = Schema.array(Schema.number())
validate() // []
validate(0) // TypeError
validate([0, 1]) // [0, 1]
validate([0, '1']) // TypeError
```
### Schema.dict(inner)
Assert that the value is a dictionary of `inner`. The default value will be `{}` if not specified.
```js
const validate = Schema.dict(Schema.number())
validate() // {}
validate(0) // TypeError
validate({ a: 0, b: 1 }) // { a: 0, b: 1 }
validate({ a: 0, b: '1' }) // TypeError
```
### Schema.tuple(list)
Assert that the value is a tuple whose each element is of corresponding subtype. The default value will be `[]` if not specified.
```js
const validate = Schema.tuple([
Schema.number(),
Schema.string(),
])
validate() // []
validate([0]) // { a: 0 }
validate([0, 1]) // TypeError
validate([0, '1']) // [0, '1']
```
### Schema.object(dict)
Assert that the value is an object whose each property is of corresponding subtype. The default value will be `{}` if not specified.
```js
const validate = Schema.object({
a: Schema.number(),
b: Schema.string(),
})
validate() // {}
validate({ a: 0 }) // { a: 0 }
validate({ a: 0, b: 1 }) // TypeError
validate({ a: 0, b: '1' }) // { a: 0, b: '1' }
```
### Schema.union(list)
Assert that the value is one of the specified types.
```js
const validate = Schema.union([
Schema.number(),
Schema.string(),
])
validate() // undefined
validate(0) // 0
validate('1') // '1'
validate(true) // TypeError
```
### Schema.intersect(list)
Assert that the value should match each specified type.
```js
const validate = Schema.intersect([
Schema.object({ a: Schema.string().required() }),
Schema.object({ b: Schema.number().default(0) }),
])
validate() // TypeError
validate({ a: '' }) // { a: '', b: 0 }
validate({ a: '', b: 1 }) // { a: '', b: 1 }
validate({ a: '', b: '2' }) // TypeError
```
### Schema.transform(inner, callback)
Assert that the value is of the specified subtype and then transformed by `callback`.
```js
const validate = Schema.transform(Schema.number().default(0), n => n + 1)
validate() // 1
validate('0') // TypeError
validate(10) // 11
```
## Instance Methods
Note: `default` and `required` are mutually exclusive.
### schema.required()
Assert that the value is not nullable.
### schema.default(value)
Set the fallback value when nullable.
### schema.description(text)
Set the description of the schema.
### schema.simplify(value)
Normalize a value by removing parts that are equal to schema defaults. This is
useful when storing user configuration and keeping persisted files compact.
```js
const Config = Schema.object({
foo: Schema.string().default(''),
bar: Schema.number().default(0),
})
Config.simplify({ foo: '', bar: 1 }) // { bar: 1 }
```
## Validation Options
All schemas are callable. The second argument accepts validation options:
```js
const Config = Schema.object({
foo: Schema.number(),
})
Config({ foo: '1' }, { autofix: true }) // {}
```
- `autofix`: remove invalid object properties where possible.
- `ignore`: skip validation for selected values and schema nodes.
- `path`: provide an initial path for nested validation errors.
## Shorthand Syntax
Some shorthand syntax is available for inner types.
- `undefined` -> `Schema.any()`
- `String` -> `Schema.string()`
- `Number` -> `Schema.number()`
- `Boolean` -> `Schema.boolean()`
- `1` -> `Schema.const(1)` (only for primitive types)
- `Date` -> `Schema.is(Date)`
```js
Schema.array(String) // Schema.array(Schema.string())
Schema.dict(RegExp) // Schema.dict(Schema.is(RegExp))
Schema.union([1, 2]) // Schema.union([Schema.const(1), Schema.const(2)])
```
You can also use `Schema.from()` to get the inferred schema from a shorthand value.
```js
Schema.from() // Schema.any()
Schema.from(Date) // Schema.is(Date)
Schema.from('foo') // Schema.const('foo')
```
## Advanced Examples
Here are some examples which demonstrate how to define advanced types.
### Enumeration
```js
const Enum = Schema.union(['red', 'blue'])
Enum('red') // 'red'
Enum('blue') // 'blue'
Enum('green') // TypeError
```
### ToString
```js
const ToString = Schema.transform(Schema.any(), v => String(v))
ToString('') // ''
ToString(0) // '0'
ToString({}) // '{}'
```
### Listable
```js
const Listable = Schema.union([
Schema.array(Number),
Schema.transform(Number, n => [n]),
]).default([])
Listable() // []
Listable(0) // [0]
Listable([1, 2]) // [1, 2]
```
### Alias
```js
const Config = Schema.dict(Number, Schema.union([
'foo',
Schema.transform('bar', () => 'foo'),
]))
Config({ foo: 1 }) // { foo: 1 }
Config({ bar: 2 }) // { foo: 2 }
Config({ bar: '3' }) // TypeError
```
## Extensibility
Custom schema types are registered with `Schema.extend(type, resolve)`. A
resolver receives the input value, schema node, validation options, and a strict
flag. Return `[value]` for accepted input, or `[value, adapted]` when the caller
should write an adapted value back to the source object.
```js
Schema.extend('trimmed', (data, schema, options) => {
if (typeof data !== 'string') {
throw new Schema.ValidationError(`expected string but got ${data}`, options)
}
return [data.trim()]
})
```
## Serializability
```js
const schema1 = Schema.object({
foo: Schema.string(),
bar: Schema.number(),
})
// should have the same effect as schema1
const schema2 = new Schema(JSON.parse(JSON.stringify(schema1)))
```
Schemastery also exposes the Standard Schema `~standard` property, so compatible
tools can validate values without depending on Schemastery-specific APIs.
+19
View File
@@ -0,0 +1,19 @@
{
"name": "schemastery",
"description": "Type driven schema validator",
"version": "3.18.0",
"private": true,
"main": "lib/index.cjs",
"module": "lib/index.mjs",
"types": "lib/index.d.ts",
"files": [
"lib",
"src"
],
"author": "Shigma <shigma10826@gmail.com>",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"cosmokit": "^1.8.1"
}
}
+902
View File
@@ -0,0 +1,902 @@
import { Binary, clone, deepEqual, Dict, filterKeys, isNullable, isPlainObject, pick, valueMap } from 'cosmokit'
import type { StandardSchemaV1 } from '@standard-schema/spec'
const kSchema = Symbol.for('schemastery')
const kValidationError = Symbol.for('ValidationError')
declare global {
namespace Schemastery {
/** Convert primitive constructors, constants, and existing schemas into a schema type. */
export type From<X> =
| X extends string | number | boolean ? Schema<X>
: X extends Schema ? X
: X extends typeof String ? Schema<string>
: X extends typeof Number ? Schema<number>
: X extends typeof Boolean ? Schema<boolean>
: X extends typeof Function ? Schema<Function, (...args: any[]) => any>
: X extends Constructor<infer S> ? Schema<S>
: never
type TypeS1<X> = X extends Schema<infer S, unknown> ? S : never
type Inverse<X> = X extends Schema<any, infer Y> ? (arg: Y) => void : never
/** Input type accepted by a schema-like value. */
export type TypeS<X> = TypeS1<From<X>>
/** Output type returned by a schema-like value after validation. */
export type TypeT<X> = ReturnType<From<X>>
/** Resolver callback used by custom schema types registered with `Schema.extend()`. */
export type Resolve = (data: any, schema: Schema, options: Options, strict?: boolean) => [any, any?]
/** Input type accepted by one schema in an intersection. */
export type IntersectS<X> = From<X> extends Schema<infer S, unknown> ? S : never
/** Output type returned by one schema in an intersection. */
export type IntersectT<X> = Inverse<From<X>> extends ((arg: infer T) => void) ? T : never
type TupleS<X extends readonly any[]> = X extends readonly [infer L, ...infer R] ? [TypeS<L>?, ...TupleS<R>] : any[]
type TupleT<X extends readonly any[]> = X extends readonly [infer L, ...infer R] ? [TypeT<L>?, ...TupleT<R>] : any[]
type ObjectS<X extends Dict> = { [K in keyof X]?: TypeS<X[K]> | null } & Dict
type ObjectT<X extends Dict> = { [K in keyof X]: TypeT<X[K]> } & Dict
type Constructor<T = any> = new (...args: any[]) => T
/** Static constructor and factory methods exposed by the default `Schema` export. */
export interface Static {
<T = any>(options: Partial<Schema<T>>): Schema<T>
new <T = any>(options: Partial<Schema<T>>): Schema<T>
prototype: Schema
/** Validate a value against a schema node and return `[output, adaptedInput?]`. */
resolve: Resolve
/** Infer a schema from a primitive value, constructor, or existing schema. */
from<X = any>(source?: X): From<X>
/** Register a resolver for a custom schema `type`. */
extend(type: string, resolve: Resolve): void
/** Accept any value without validation. */
any<T = any>(): Schema<T>
/** Accept only nullable input. */
never(): Schema<never>
/** Accept exactly one constant value. */
const<const T>(value: T): Schema<T>
/** Accept strings, with optional metadata constraints added by instance methods. */
string(): Schema<string>
/** Accept numbers, with optional range and step constraints. */
number(): Schema<number>
/** Accept non-negative integer numbers. */
natural(): Schema<number>
/** Accept a number between 0 and 1 and mark it as a slider. */
percent(): Schema<number>
/** Accept booleans. */
boolean(): Schema<boolean>
/** Accept `Date` instances or parse datetime strings into `Date` objects. */
date(): Schema<string | Date, Date>
/** Accept `RegExp` instances or parse strings into regular expressions. */
regExp(flag?: string): Schema<string | RegExp, RegExp>
/** Accept binary sources and normalize them to `ArrayBufferLike`. */
arrayBuffer(): Schema<Binary.Source, ArrayBufferLike>
arrayBuffer(encoding: 'hex' | 'base64'): Schema<Binary.Source | string, ArrayBufferLike>
/** Accept a numeric bitset or string keys and normalize to a number. */
bitset<K extends string>(bits: Partial<Record<K, number>>): Schema<number | readonly K[], number>
/** Accept functions. */
function(): Schema<Function, (...args: any[]) => any>
/** Accept instances of a constructor or objects whose constructor name matches. */
is(constructor: string): Schema
is<T>(constructor: Constructor<T>): Schema<T>
/** Accept arrays whose elements match `inner`. */
array<X>(inner: X): Schema<TypeS<X>[], TypeT<X>[]>
/** Accept plain objects with values matching `inner` and optional key schema. */
dict<X, Y extends Schema<any, string> = Schema<string>>(inner: X, sKey?: Y): Schema<Dict<TypeS<X>, TypeS<Y>>, Dict<TypeT<X>, TypeT<Y>>>
/** Accept tuple arrays where each index matches the corresponding schema. */
tuple<const X extends readonly any[]>(list: X): Schema<TupleS<X>, TupleT<X>>
/** Accept plain objects whose declared properties match the schema dictionary. */
object<X extends Dict>(dict: X): Schema<ObjectS<X>, ObjectT<X>>
/** Accept values matching at least one schema in `list`. */
union<const X>(list: readonly X[]): Schema<TypeS<X>, TypeT<X>>
/** Accept values matching every schema in `list`, merging object outputs. */
intersect<const X>(list: readonly X[]): Schema<IntersectS<X>, IntersectT<X>>
/** Validate with `inner`, then convert the result with `callback`. */
transform<X, T>(inner: X, callback: (value: TypeS<X>, options: Schemastery.Options) => T, preserve?: boolean): Schema<TypeS<X>, T>
/** Defer construction of a recursive schema until validation or serialization. */
lazy<X extends Schema>(callback: () => X): X
ValidationError: typeof ValidationError
}
/** Runtime validation options shared by all schema calls. */
interface Options {
/** Remove invalid object properties instead of throwing when possible. */
autofix?: boolean
/** Skip validation for selected values and schema nodes. */
ignore?(data: any, schema: Schema): boolean
/** Path used to format nested validation errors. */
path?: (keyof any)[]
}
/** UI and validation metadata attached by schema builder methods. */
export interface Meta<T = any> {
default?: T extends {} ? Partial<T> : T
required?: boolean
disabled?: boolean
collapse?: boolean
badges?: { text: string; type: string }[]
hidden?: boolean
loose?: boolean
role?: string
extra?: any
link?: string
description?: string | Dict<string>
comment?: string
pattern?: { source: string; flags?: string }
max?: number
min?: number
step?: number
}
}
/** Callable schema instance that validates input and returns normalized output. */
interface Schemastery<S = any, T = S> {
(data?: S | null, options?: Schemastery.Options): T
new (data?: S | null, options?: Schemastery.Options): T
[kSchema]: true
uid: number
meta: Schemastery.Meta<T>
type: string
sKey?: Schema
inner?: Schema
list?: Schema[]
dict?: Dict<Schema>
bits?: Dict<number>
callback?: Function
constructor?: string | Function
builder?: Function
value?: T
refs?: Dict<Schema>
preserve?: boolean
'~standard': StandardSchemaV1.Props // <S, T>
/** Format this schema as a compact TypeScript-like type string. */
toString(inline?: boolean): string
/** Serialize this schema, preserving shared and recursive references. */
toJSON(): Schema<S, T>
/** Mark nullable input as invalid unless a default supplies a fallback. */
required(value?: boolean): Schema<S, T>
/** Hide this schema node from UI renderers. */
hidden(value?: boolean): Schema<S, T>
/** Return the default value instead of throwing when validation fails. */
loose(value?: boolean): Schema<S, T>
/** Attach a renderer role and optional role-specific metadata. */
role(text: string, extra?: any): Schema<S, T>
/** Attach an external documentation link. */
link(link: string): Schema<S, T>
/** Set the fallback value used for nullable input. */
default(value: T): Schema<S, T>
/** Attach an auxiliary comment for documentation or form UIs. */
comment(text: string): Schema<S, T>
/** Attach a localized or plain description for documentation or form UIs. */
description(text: string): Schema<S, T>
/** Mark this schema node as disabled for form UIs. */
disabled(value?: boolean): Schema<S, T>
/** Request collapsed rendering for nested form UIs. */
collapse(value?: boolean): Schema<S, T>
/** Add a deprecated badge to this schema node. */
deprecated(): Schema<S, T>
/** Add an experimental badge to this schema node. */
experimental(): Schema<S, T>
/** Require strings to match a regular expression. */
pattern(regexp: RegExp): Schema<S, T>
/** Set an inclusive maximum for numbers or collection lengths. */
max(value: number): Schema<S, T>
/** Set an inclusive minimum for numbers or collection lengths. */
min(value: number): Schema<S, T>
/** Set the numeric increment constraint. */
step(value: number): Schema<S, T>
/** Add or replace an object property schema. */
set(key: string, value: Schema): Schema<S, T>
/** Append a tuple, union, or intersection member schema. */
push(value: Schema): Schema<S, T>
/** Remove values equal to schema defaults from normalized output. */
simplify(value?: any): any
/** Return a schema clone with descriptions merged from locale messages. */
i18n(messages: Dict): Schema<S, T>
/** Attach arbitrary metadata consumed by form renderers and downstream tools. */
extra<K extends keyof Schemastery.Meta>(key: K, value: Schemastery.Meta[K]): Schema<S, T>
}
}
declare namespace globalThis {
// eslint-disable-next-line @typescript-eslint/naming-convention
export let __schemastery_index__: number
export let __schemastery_refs__: Record<number, Schema> | undefined
}
globalThis.__schemastery_index__ ??= 0
globalThis.__schemastery_refs__ = undefined
class ValidationError extends TypeError {
name = 'ValidationError'
constructor(message: string, public options: Schemastery.Options) {
let prefix = '$'
for (const segment of options.path || []) {
if (typeof segment === 'string') {
prefix += '.' + segment
} else if (typeof segment === 'number') {
prefix += '[' + segment + ']'
} else if (typeof segment === 'symbol') {
prefix += `[Symbol(${segment.toString()})]`
}
}
if (prefix.startsWith('.')) prefix = prefix.slice(1)
super((prefix === '$' ? '' : `${prefix} `) + message)
}
static is(error: any): error is ValidationError {
return !!error?.[kValidationError]
}
}
Object.defineProperty(ValidationError.prototype, kValidationError, {
value: true,
})
type Schema<S = any, T = S> = Schemastery<S, T>
const Schema = function (options: Schema) {
const schema = function (data: any, options: Schemastery.Options = {}) {
return Schema.resolve(data, schema, options)[0]
} as Schema
if (options.refs) {
const refs = valueMap(options.refs, options => new Schema(options))
const getRef = (uid: any) => refs[uid]!
for (const key in refs) {
const options = refs[key]!
options.sKey = getRef(options.sKey)
options.inner = getRef(options.inner)
options.list = options.list && options.list.map(getRef)
options.dict = options.dict && valueMap(options.dict, getRef)
}
return refs[options.uid!]
}
Object.assign(schema, options)
if (typeof schema.callback === 'string') {
try {
// eslint-disable-next-line no-new-func
schema.callback = new Function('return ' + schema.callback)()
} catch {}
}
Object.defineProperty(schema, 'uid', { value: globalThis.__schemastery_index__++ })
Object.setPrototypeOf(schema, Schema.prototype)
schema.meta ||= {}
schema.toString = schema.toString.bind(schema)
return schema
} as Schemastery.Static
Schema.prototype = Object.create(Function.prototype)
Schema.prototype[kSchema] = true
Object.defineProperty(Schema.prototype, '~standard', {
get(this: Schema) {
return {
version: 1,
vendor: 'schemastery',
validate: (value: unknown) => {
try {
return { value: Schema.resolve(value, this, {})[0] }
} catch (error) {
if (ValidationError.is(error)) {
return { issues: [{ message: error.message, path: error.options.path }] }
}
throw error
}
},
}
},
})
Schema.ValidationError = ValidationError
Schema.prototype.toJSON = function toJSON() {
if (globalThis.__schemastery_refs__) {
globalThis.__schemastery_refs__[this.uid] ??= JSON.parse(JSON.stringify({ ...this }))
return this.uid as any
}
globalThis.__schemastery_refs__ = { [this.uid]: { ...this } as Schema }
globalThis.__schemastery_refs__[this.uid] = JSON.parse(JSON.stringify({ ...this }))
const result = { uid: this.uid, refs: globalThis.__schemastery_refs__ }
globalThis.__schemastery_refs__ = undefined
return result
}
Schema.prototype.set = function set(key, value) {
this.dict![key] = value
return this
}
Schema.prototype.push = function push(value) {
this.list!.push(value)
return this
}
function mergeDesc(original: undefined | string | Dict<string>, messages: Dict) {
const result: Dict<string> = typeof original === 'string' ? { '': original } : { ...original }
for (const locale in messages) {
const value = messages[locale]
if (value?.$description || value?.$desc) {
result[locale] = value.$description || value.$desc
} else if (typeof value === 'string') {
result[locale] = value
}
}
return result
}
function getInner(value: any) {
return value?.$value ?? value?.$inner
}
function extractKeys(data: any) {
return filterKeys(data ?? {}, key => !key.startsWith('$'))
}
Schema.prototype.i18n = function i18n(messages) {
const schema = Schema(this)
const desc = mergeDesc(schema.meta.description, messages)
if (Object.keys(desc).length) schema.meta.description = desc
if (schema.dict) {
schema.dict = valueMap(schema.dict, (inner, key) => {
return inner.i18n(valueMap(messages, (data) => getInner(data)?.[key] ?? data?.[key]))
})
}
if (schema.list) {
schema.list = schema.list!.map((inner, index) => {
return inner.i18n(valueMap(messages, (data = {}) => {
if (Array.isArray(getInner(data))) return getInner(data)[index]
if (Array.isArray(data)) return data[index]
return extractKeys(data)
}))
})
}
if (schema.inner) {
schema.inner = schema.inner.i18n(valueMap(messages, (data) => {
if (getInner(data)) return getInner(data)
return extractKeys(data)
}))
}
if (schema.sKey) {
schema.sKey = schema.sKey.i18n(valueMap(messages, (data) => data?.$key))
}
return schema
}
Schema.prototype.extra = function extra(key, value) {
const schema = Schema(this)
schema.meta = { ...schema.meta, [key]: value }
return schema
}
for (const key of ['required', 'disabled', 'collapse', 'hidden', 'loose']) {
Object.assign(Schema.prototype, {
[key](this: Schema, value = true) {
const schema = Schema(this)
schema.meta = { ...schema.meta, [key]: value }
return schema
},
})
}
Schema.prototype.deprecated = function deprecated() {
const schema = Schema(this)
schema.meta.badges ||= []
schema.meta.badges.push({ text: 'deprecated', type: 'danger' })
return schema
}
Schema.prototype.experimental = function experimental() {
const schema = Schema(this)
schema.meta.badges ||= []
schema.meta.badges.push({ text: 'experimental', type: 'warning' })
return schema
}
Schema.prototype.pattern = function pattern(regexp) {
const schema = Schema(this)
const pattern = pick(regexp, ['source', 'flags'])
schema.meta = { ...schema.meta, pattern }
return schema
}
Schema.prototype.simplify = function simplify(this: Schema, value) {
if (deepEqual(value, this.meta.default, this.type === 'dict')) return null
if (isNullable(value)) return value
if (this.type === 'object' || this.type === 'dict') {
const result: Dict = {}
for (const key in value) {
const schema = this.type === 'object' ? this.dict![key] : this.inner
const item = schema?.simplify(value[key])
if (this.type === 'dict' || !isNullable(item)) result[key] = item
}
if (deepEqual(result, this.meta.default, this.type === 'dict')) return null
return result
} else if (this.type === 'array' || this.type === 'tuple') {
const result: any[] = []
;(value as any[]).forEach((value, index) => {
const schema = this.type === 'array' ? this.inner : this.list![index]
const item = schema ? schema.simplify(value) : value
result.push(item)
})
return result
} else if (this.type === 'intersect') {
const result: Dict = {}
for (const item of this.list!) {
Object.assign(result, item.simplify(value))
}
return result
} else if (this.type === 'union') {
for (const schema of this.list!) {
try {
Schema.resolve(value, schema, {})
return schema.simplify(value)
} catch {}
}
}
return value
}
Schema.prototype.toString = function toString(inline?: boolean) {
return formatters[this.type]?.(this, inline) ?? `Schema<${this.type}>`
}
Schema.prototype.role = function role(role, extra) {
const schema = Schema(this)
schema.meta = { ...schema.meta, role, extra }
return schema
}
for (const key of ['default', 'link', 'comment', 'description', 'max', 'min', 'step']) {
Object.assign(Schema.prototype, {
[key](this: Schema, value: any) {
const schema = Schema(this)
schema.meta = { ...schema.meta, [key]: value }
return schema
},
})
}
const resolvers: Dict<Schemastery.Resolve> = {}
Schema.extend = function extend(type, resolve) {
resolvers[type] = resolve
}
Schema.resolve = function resolve(data, schema, options = {}, strict = false) {
if (!schema) return [data]
if (options.ignore?.(data, schema)) return [data]
if (isNullable(data) && schema.type !== 'lazy') {
if (schema.meta.required) throw new ValidationError(`missing required value`, options)
let current = schema
let fallback = schema.meta.default
while (current?.type === 'intersect' && isNullable(fallback)) {
current = current.list![0]
fallback = current?.meta.default
}
if (isNullable(fallback)) return [data]
data = clone(fallback)
}
const callback = resolvers[schema.type]
if (!callback) throw new ValidationError(`unsupported type "${schema.type}"`, options)
try {
return callback(data, schema, options, strict)
} catch (error) {
if (!schema.meta.loose) throw error
return [schema.meta.default]
}
}
Schema.from = function from(source: any) {
if (isNullable(source)) {
return Schema.any()
} else if (['string', 'number', 'boolean'].includes(typeof source)) {
return Schema.const(source).required()
} else if (source[kSchema]) {
return source
} else if (typeof source === 'function') {
switch (source) {
case String: return Schema.string().required()
case Number: return Schema.number().required()
case Boolean: return Schema.boolean().required()
case Function: return Schema.function().required()
default: return Schema.is(source).required()
}
} else {
throw new TypeError(`cannot infer schema from ${source}`)
}
}
Schema.lazy = function lazy(builder) {
const toJSON = () => {
if (!schema.inner![kSchema]) {
schema.inner = schema.builder!()
schema.inner!.meta = { ...schema.meta, ...schema.inner!.meta }
}
return schema.inner!.toJSON()
}
const schema = new Schema({ type: 'lazy', builder, inner: { toJSON } as any })
return schema as any
}
Schema.natural = function natural() {
return Schema.number().step(1).min(0)
}
Schema.percent = function percent() {
return Schema.number().step(0.01).min(0).max(1).role('slider')
}
Schema.date = function date() {
return Schema.union([
Schema.is(Date),
Schema.transform(Schema.string().role('datetime'), (value, options) => {
const date = new Date(value)
if (isNaN(+date)) throw new ValidationError(`invalid date "${value}"`, options)
return date
}, true),
])
}
Schema.regExp = function regExp(flag = '') {
return Schema.union([
Schema.is(RegExp),
Schema.transform(Schema.string().role('regexp', { flag }), (value, options) => {
try {
return new RegExp(value, flag)
} catch (e: any) {
throw new ValidationError(e.message, options)
}
}, true),
])
}
Schema.arrayBuffer = function arrayBuffer(encoding?: 'hex' | 'base64'): any {
return Schema.union([
Schema.is(ArrayBuffer),
Schema.is(SharedArrayBuffer),
Schema.transform(Schema.any<ArrayBufferView>(), (value, options) => {
if (Binary.isSource(value)) return Binary.fromSource(value)
throw new ValidationError(`expected ArrayBufferSource but got ${value}`, options)
}, true),
...encoding ? [Schema.transform(Schema.string(), (value, options) => {
try {
return encoding === 'base64'
? Binary.fromBase64(value)
: Binary.fromHex(value)
} catch (e: any) {
throw new ValidationError(e.message, options)
}
}, true)] as const : [],
])
}
Schema.extend('lazy', (data, schema, options, strict) => {
if (!schema.inner![kSchema]) {
schema.inner = schema.builder!()
schema.inner!.meta = { ...schema.meta, ...schema.inner!.meta }
}
return Schema.resolve(data, schema.inner!, options, strict)
})
Schema.extend('any', (data) => {
return [data]
})
Schema.extend('never', (data, _, options) => {
throw new ValidationError(`expected nullable but got ${data}`, options)
})
Schema.extend('const', (data, { value }, options) => {
if (deepEqual(data, value)) return [value]
throw new ValidationError(`expected ${value} but got ${data}`, options)
})
function checkWithinRange(data: number, meta: Schemastery.Meta<any>, description: string, options: Schemastery.Options, skipMin = false) {
const { max = Infinity, min = -Infinity } = meta
if (data > max) throw new ValidationError(`expected ${description} <= ${max} but got ${data}`, options)
if (data < min && !skipMin) throw new ValidationError(`expected ${description} >= ${min} but got ${data}`, options)
}
Schema.extend('string', (data, { meta }, options) => {
if (typeof data !== 'string') throw new ValidationError(`expected string but got ${data}`, options)
if (meta.pattern) {
const regexp = new RegExp(meta.pattern.source, meta.pattern.flags)
if (!regexp.test(data)) throw new ValidationError(`expect string to match regexp ${regexp}`, options)
}
checkWithinRange(data.length, meta, 'string length', options)
return [data]
})
function decimalShift(data: number, digits: number) {
const str = data.toString()
if (str.includes('e')) return data * Math.pow(10, digits)
const index = str.indexOf('.')
if (index === -1) return data * Math.pow(10, digits)
const frac = str.slice(index + 1)
const integer = str.slice(0, index)
if (frac.length <= digits) return +(integer + frac.padEnd(digits, '0'))
return +(integer + frac.slice(0, digits) + '.' + frac.slice(digits))
}
function isMultipleOf(data: number, min: number, step: number) {
step = Math.abs(step)
if (!/^\d+\.\d+$/.test(step.toString())) {
return (data - min) % step === 0
}
const index = step.toString().indexOf('.')
const digits = step.toString().slice(index + 1).length
return Math.abs(decimalShift(data, digits) - decimalShift(min, digits)) % decimalShift(step, digits) === 0
}
Schema.extend('number', (data, { meta }, options) => {
if (typeof data !== 'number') throw new ValidationError(`expected number but got ${data}`, options)
checkWithinRange(data, meta, 'number', options)
const { step } = meta
if (step && !isMultipleOf(data, meta.min ?? 0, step)) {
throw new ValidationError(`expected number multiple of ${step} but got ${data}`, options)
}
return [data]
})
Schema.extend('boolean', (data, _, options) => {
if (typeof data === 'boolean') return [data]
throw new ValidationError(`expected boolean but got ${data}`, options)
})
Schema.extend('bitset', (data, { bits, meta }, options) => {
let value = 0, keys: string[] = []
if (typeof data === 'number') {
value = data
for (const key in bits!) {
if (data & bits![key]!) {
keys.push(key)
}
}
} else if (Array.isArray(data)) {
keys = data
for (const key of keys) {
if (typeof key !== 'string') throw new ValidationError(`expected string but got ${key}`, options)
if (key in bits!) value |= bits![key]!
}
} else {
throw new ValidationError(`expected number or array but got ${data}`, options)
}
if (value === meta.default) return [value]
return [value, keys]
})
Schema.extend('function', (data, _, options) => {
if (typeof data === 'function') return [data]
throw new ValidationError(`expected function but got ${data}`, options)
})
Schema.extend('is', (data, { constructor }, options) => {
if (typeof constructor === 'function') {
if (data instanceof constructor) return [data]
throw new ValidationError(`expected ${constructor.name} but got ${data}`, options)
} else {
if (isNullable(data)) {
throw new ValidationError(`expected ${constructor} but got ${data}`, options)
}
let prototype = Object.getPrototypeOf(data)
while (prototype) {
if (prototype.constructor?.name === constructor) return [data]
prototype = Object.getPrototypeOf(prototype)
}
throw new ValidationError(`expected ${constructor} but got ${data}`, options)
}
})
function property(data: any, key: keyof any, schema: Schema, options: Schemastery.Options) {
try {
const [value, adapted] = Schema.resolve(data[key], schema, {
...options,
path: [...options.path || [], key],
})
if (adapted !== undefined) data[key] = adapted
return value
} catch (e) {
if (!options?.autofix) throw e
delete data[key]
return schema.meta.default
}
}
Schema.extend('array', (data, { inner, meta }, options) => {
if (!Array.isArray(data)) throw new ValidationError(`expected array but got ${data}`, options)
checkWithinRange(data.length, meta, 'array length', options, !isNullable(inner!.meta.default))
return [data.map((_, index) => property(data, index, inner!, options))]
})
Schema.extend('dict', (data, { inner, sKey }, options, strict) => {
if (!isPlainObject(data)) throw new ValidationError(`expected object but got ${data}`, options)
const result: any = {}
for (const key in data) {
let rKey: string
try {
rKey = Schema.resolve(key, sKey!, options)[0]
} catch (error) {
if (strict) continue
throw error
}
result[rKey] = property(data, key, inner!, options)
data[rKey] = data[key]
if (key !== rKey) delete data[key]
}
return [result]
})
Schema.extend('tuple', (data, { list }, options, strict) => {
if (!Array.isArray(data)) throw new ValidationError(`expected array but got ${data}`, options)
const result = list!.map((inner, index) => property(data, index, inner, options))
if (strict) return [result]
result.push(...data.slice(list!.length))
return [result]
})
function merge(result: any, data: any) {
for (const key in data) {
if (key in result) continue
result[key] = data[key]
}
}
Schema.extend('object', (data, { dict }, options, strict) => {
if (!isPlainObject(data)) throw new ValidationError(`expected object but got ${data}`, options)
const result: any = {}
for (const key in dict) {
const value = property(data, key, dict![key]!, options)
if (!isNullable(value) || key in data) {
result[key] = value
}
}
if (!strict) merge(result, data)
return [result]
})
Schema.extend('union', (data, { list, toString }, options, strict) => {
const messages: any[] = []
for (const inner of list!) {
try {
return Schema.resolve(data, inner, options, strict)
} catch (error) {
messages.push(error)
}
}
throw new ValidationError(`expected ${toString()} but got ${JSON.stringify(data)}`, options)
})
Schema.extend('intersect', (data, { list, toString }, options, strict) => {
if (!list!.length) return [data]
let result
for (const inner of list!) {
const value: any = Schema.resolve(data, inner, options, true)[0]
if (isNullable(value)) continue
if (isNullable(result)) {
result = value
} else if (typeof result !== typeof value) {
throw new ValidationError(`expected ${toString()} but got ${JSON.stringify(data)}`, options)
} else if (typeof value === 'object') {
merge(result ??= {}, value)
} else if (result !== value) {
throw new ValidationError(`expected ${toString()} but got ${JSON.stringify(data)}`, options)
}
}
if (!strict && isPlainObject(data)) merge(result, data)
return [result]
})
Schema.extend('transform', (data, { inner, callback, preserve }, options) => {
const [result, adapted = data] = Schema.resolve(data, inner!, options, true)
if (preserve) {
return [callback!(result)]
// } else if (isPlainObject(data)) {
// const temp: any = {}
// for (const key in result) {
// if (!(key in data)) continue
// temp[key] = data[key]
// delete data[key]
// }
// Object.assign(data, callback!(temp))
// return [callback!(result)]
} else {
return [callback!(result), callback!(adapted)]
}
})
type Formatter = (schema: Schema, inline?: boolean) => string
const formatters: Dict<Formatter> = {}
function defineMethod(name: string, keys: (keyof Schema)[], format: Formatter) {
formatters[name] = format
Object.assign(Schema, {
[name](...args: any[]) {
const schema = new Schema({ type: name } as Schema)
keys.forEach((key, index) => {
switch (key) {
case 'sKey': schema.sKey = args[index] ?? Schema.string(); break
case 'inner': schema.inner = Schema.from(args[index]); break
case 'list': schema.list = args[index].map(Schema.from); break
case 'dict': schema.dict = valueMap(args[index], Schema.from); break
case 'bits': {
schema.bits = {}
for (const key in args[index]) {
if (typeof args[index][key] !== 'number') continue
schema.bits[key] = args[index][key]
}
break
}
case 'callback': {
const callback = schema.callback = args[index]
;callback['toJSON'] ||= () => callback.toString()
break
}
case 'constructor': {
const constructor = schema.constructor = args[index]
if (typeof constructor === 'function') {
;constructor['toJSON'] ||= () => constructor['name']
}
break
}
default: schema[key] = args[index] as never
}
})
if (name === 'object' || name === 'dict') {
schema.meta.default = {}
} else if (name === 'array' || name === 'tuple') {
schema.meta.default = []
} else if (name === 'bitset') {
schema.meta.default = 0
}
return schema
},
})
}
defineMethod('is', ['constructor'], ({ constructor }) => {
if (typeof constructor === 'function') {
return constructor.name
} else {
return constructor!
}
})
defineMethod('any', [], () => 'any')
defineMethod('never', [], () => 'never')
defineMethod('const', ['value'], ({ value }) => typeof value === 'string' ? JSON.stringify(value) : value)
defineMethod('string', [], () => 'string')
defineMethod('number', [], () => 'number')
defineMethod('boolean', [], () => 'boolean')
defineMethod('bitset', ['bits'], () => 'bitset')
defineMethod('function', [], () => 'function')
defineMethod('array', ['inner'], ({ inner }) => `${inner!.toString(true)}[]`)
defineMethod('dict', ['inner', 'sKey'], ({ inner, sKey }) => `{ [key: ${sKey!.toString()}]: ${inner!.toString()} }`)
defineMethod('tuple', ['list'], ({ list }) => `[${list!.map((inner) => inner.toString()).join(', ')}]`)
defineMethod('object', ['dict'], ({ dict }) => {
if (Object.keys(dict!).length === 0) return '{}'
return `{ ${Object.entries(dict!).map(([key, inner]) => {
return `${key}${inner!.meta.required ? '' : '?'}: ${inner!.toString()}`
}).join(', ')} }`
})
defineMethod('union', ['list'], ({ list }, inline) => {
const result = list!.map(({ toString: format }) => format()).join(' | ')
return inline ? `(${result})` : result
})
defineMethod('intersect', ['list'], ({ list }) => {
return `${list!.map((inner) => inner.toString(true)).join(' & ')}`
})
defineMethod('transform', ['inner', 'callback', 'preserve'], ({ inner }, isInner) => inner!.toString(isInner))
export = Schema
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib",
"module": "preserve"
},
"include": ["src"],
"references": [
{ "path": "../cosmokit" }
]
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021-present Shigma
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+36
View File
@@ -0,0 +1,36 @@
# @cordisjs/plugin-timer
Disposal-aware timer service for Cordis.
## Usage
```ts
import { Context } from 'cordis'
import Timer from '@cordisjs/plugin-timer'
const root = new Context()
await root.plugin(Timer)
const dispose = root.timeout(() => {
root.logger.info('done')
}, 1000)
dispose()
```
Timer handles are registered on the current fiber, so they are cleared
automatically when the plugin that created them is disposed.
## API
| API | Description |
| --- | --- |
| `ctx.timeout(callback, delay)` | Run once and return a disposer. |
| `ctx.timeout(delay)` | Return a promise that resolves after `delay`. |
| `ctx.interval(callback, delay)` | Run repeatedly and return a disposer. |
| `ctx.interval(delay)` | Return an async iterator that yields on each interval. |
| `ctx.throttle(callback, delay, noTrailing?)` | Return a throttled function with `.dispose()`. |
| `ctx.debounce(callback, delay)` | Return a debounced function with `.dispose()`. |
`ctx.setTimeout()` and `ctx.setInterval()` are kept as deprecated aliases for
`ctx.timeout()` and `ctx.interval()`.
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@cordisjs/plugin-timer",
"description": "Timer service for cordis",
"version": "1.1.2",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"exports": {
".": {
"types": "./lib/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib",
"src"
],
"author": "Shigma <shigma10826@gmail.com>",
"license": "MIT",
"peerDependencies": {
"cordis": "^4.0.0-rc.6"
},
"dependencies": {
"cosmokit": "^1.8.1"
}
}
+147
View File
@@ -0,0 +1,147 @@
import { Context, Service } from 'cordis'
declare module 'cordis' {
interface Context extends Pick<TimerService, 'interval' | 'timeout' | 'throttle' | 'debounce' | 'setTimeout' | 'setInterval'> {
timer: TimerService
}
}
type WithDispose<T> = T & { dispose: () => void }
/** Disposable timer helpers mixed into Cordis contexts. */
export class TimerService extends Service {
constructor(ctx: Context) {
super(ctx, 'timer')
ctx.mixin('timer', ['timeout', 'interval', 'throttle', 'debounce', 'setTimeout', 'setInterval'])
}
/** @deprecated use `ctx.timeout()` instead */
setTimeout(callback: () => void, delay: number) {
return this.timeout(callback, delay)
}
/** @deprecated use `ctx.interval()` instead */
setInterval(callback: () => void, delay: number) {
return this.interval(callback, delay)
}
/** Run a callback once, or return a promise that resolves after `delay`. */
timeout(callback: () => void, delay: number): () => void
timeout(delay: number): Promise<void>
timeout(...args: any[]): any {
const callback = typeof args[0] === 'function' ? args.shift() : undefined
const delay = args[0] as number
if (callback) {
const dispose = this.ctx.effect(() => {
const timer = setTimeout(() => {
dispose()
callback()
}, delay)
return () => clearTimeout(timer)
}, 'ctx.timeout()')
return dispose
} else {
const { promise, resolve, reject } = Promise.withResolvers<void>()
const dispose = this.ctx.effect(() => {
const timer = setTimeout(resolve, delay)
return () => {
clearTimeout(timer)
reject(new Error('Context has been disposed'))
}
}, 'ctx.timeout()')
return promise.finally(dispose)
}
}
/** Run a callback repeatedly, or return an async iterator of ticks. */
interval(callback: () => void, delay: number): () => void
interval<R = any>(delay: number): AsyncIterableIterator<void, R, void>
interval(...args: any[]): any {
const callback = typeof args[0] === 'function' ? args.shift() : undefined
const delay = args[0] as number
if (callback) {
return this.ctx.effect(() => {
const timer = setInterval(callback, delay)
return () => clearInterval(timer)
}, 'ctx.interval()')
} else {
let done: { kind: 'return'; value: any } | { kind: 'throw'; reason: any } | undefined
let nextTask: PromiseWithResolvers<IteratorResult<void>> | undefined
const dispose = this.ctx.effect(() => {
const timer = setInterval(() => {
nextTask?.resolve({ done: false, value: undefined })
}, delay)
return () => {
clearInterval(timer)
if (done) return
done = { kind: 'throw', reason: new Error('Context has been disposed') }
nextTask?.reject(done.reason)
}
}, 'ctx.interval()')
return {
next: () => {
if (!done) return (nextTask = Promise.withResolvers()).promise
if (done.kind === 'return') return Promise.resolve({ done: true, value: done.value })
return Promise.reject(done.reason)
},
return: (value) => {
if (!done) done = { kind: 'return', value }
nextTask?.resolve({ done: true, value })
dispose()
return Promise.resolve({ done: true, value })
},
throw: (reason) => {
if (!done) done = { kind: 'throw', reason }
nextTask?.reject(reason)
dispose()
return Promise.resolve({ done: true, value: undefined })
},
[Symbol.asyncIterator]() {
return this
},
} satisfies AsyncIterableIterator<void>
}
}
private _schedule(label: string, trigger: (args: any[], isDisposed: boolean) => any, isDisposed = false) {
let timer: number | NodeJS.Timeout | undefined
const dispose = this.ctx.effect(() => () => {
isDisposed = true
clearTimeout(timer)
}, label)
const wrapper: any = (...args: any[]) => {
clearTimeout(timer)
timer = trigger(args, isDisposed)
}
wrapper.dispose = dispose
return wrapper
}
/** Return a throttled function whose timer is disposed with the current fiber. */
throttle<F extends (...args: any[]) => void>(callback: F, delay: number, noTrailing?: boolean): WithDispose<F> {
let lastCall = -Infinity
const execute = (...args: any[]) => {
lastCall = Date.now()
callback(...args)
}
return this._schedule('ctx.throttle()', (args, isDisposed) => {
const now = Date.now()
const remaining = delay - now + lastCall
if (remaining <= 0) {
execute(...args)
} else if (!isDisposed) {
return setTimeout(execute, remaining, ...args)
}
}, noTrailing)
}
/** Return a debounced function whose timer is disposed with the current fiber. */
debounce<F extends (...args: any[]) => void>(callback: F, delay: number): WithDispose<F> {
return this._schedule('ctx.debounce()', (args, isDisposed) => {
if (isDisposed) return
return setTimeout(callback, delay, ...args)
})
}
}
export default TimerService
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib"
},
"include": ["src"],
"references": [
{ "path": "../cosmokit" },
{ "path": "../cordis" }
]
}