From 72688a38886978f4a5e3a62ed53061d8b2a40331 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:53:32 +0800 Subject: [PATCH] Vendor Cordis framework packages as source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- vendor/README.md | 64 ++ vendor/cordis/LICENSE | 21 + vendor/cordis/README.md | 101 +++ vendor/cordis/bin.js | 16 + vendor/cordis/package.json | 41 ++ vendor/cordis/src/context.ts | 97 +++ vendor/cordis/src/events.ts | 205 ++++++ vendor/cordis/src/fiber.ts | 504 +++++++++++++++ vendor/cordis/src/index.ts | 14 + vendor/cordis/src/logger.ts | 262 ++++++++ vendor/cordis/src/reflect.ts | 287 +++++++++ vendor/cordis/src/registry.ts | 249 ++++++++ vendor/cordis/src/service.ts | 88 +++ vendor/cordis/src/utils.ts | 287 +++++++++ vendor/cordis/tsconfig.json | 14 + vendor/cosmokit/LICENSE | 21 + vendor/cosmokit/README.md | 24 + vendor/cosmokit/package.json | 23 + vendor/cosmokit/src/array.ts | 42 ++ vendor/cosmokit/src/index.ts | 10 + vendor/cosmokit/src/misc.ts | 78 +++ vendor/cosmokit/src/string.ts | 113 ++++ vendor/cosmokit/src/time.ts | 92 +++ vendor/cosmokit/src/types.ts | 142 +++++ vendor/cosmokit/tsconfig.json | 8 + vendor/group/LICENSE | 21 + vendor/group/README.md | 21 + vendor/group/package.json | 27 + vendor/group/src/index.ts | 3 + vendor/group/tsconfig.json | 12 + vendor/hmr/LICENSE | 21 + vendor/hmr/README.md | 47 ++ vendor/hmr/package.json | 49 ++ vendor/hmr/src/error.ts | 36 ++ vendor/hmr/src/index.ts | 403 ++++++++++++ vendor/hmr/tsconfig.json | 16 + vendor/include/LICENSE | 21 + vendor/include/README.md | 43 ++ vendor/include/package.json | 31 + vendor/include/src/index.ts | 229 +++++++ vendor/include/tsconfig.json | 13 + vendor/loader/LICENSE | 21 + vendor/loader/README.md | 48 ++ vendor/loader/package.json | 29 + vendor/loader/src/config/entry.ts | 184 ++++++ vendor/loader/src/config/group.ts | 90 +++ vendor/loader/src/config/isolate.ts | 173 +++++ vendor/loader/src/config/tree.ts | 133 ++++ vendor/loader/src/config/utils.ts | 32 + vendor/loader/src/index.ts | 185 ++++++ vendor/loader/src/internal.ts | 122 ++++ vendor/loader/tsconfig.json | 12 + vendor/logger-console/LICENSE | 21 + vendor/logger-console/README.md | 35 ++ vendor/logger-console/package.json | 32 + vendor/logger-console/src/browser.ts | 17 + vendor/logger-console/src/index.ts | 28 + vendor/logger-console/src/shared.ts | 100 +++ vendor/logger-console/tsconfig.json | 13 + vendor/schemastery/LICENSE | 21 + vendor/schemastery/README.md | 389 ++++++++++++ vendor/schemastery/package.json | 19 + vendor/schemastery/src/index.ts | 902 +++++++++++++++++++++++++++ vendor/schemastery/tsconfig.json | 12 + vendor/timer/LICENSE | 21 + vendor/timer/README.md | 36 ++ vendor/timer/package.json | 29 + vendor/timer/src/index.ts | 147 +++++ vendor/timer/tsconfig.json | 12 + 69 files changed, 6659 insertions(+) create mode 100644 vendor/README.md create mode 100644 vendor/cordis/LICENSE create mode 100644 vendor/cordis/README.md create mode 100755 vendor/cordis/bin.js create mode 100644 vendor/cordis/package.json create mode 100644 vendor/cordis/src/context.ts create mode 100644 vendor/cordis/src/events.ts create mode 100644 vendor/cordis/src/fiber.ts create mode 100644 vendor/cordis/src/index.ts create mode 100644 vendor/cordis/src/logger.ts create mode 100644 vendor/cordis/src/reflect.ts create mode 100644 vendor/cordis/src/registry.ts create mode 100644 vendor/cordis/src/service.ts create mode 100644 vendor/cordis/src/utils.ts create mode 100644 vendor/cordis/tsconfig.json create mode 100644 vendor/cosmokit/LICENSE create mode 100644 vendor/cosmokit/README.md create mode 100644 vendor/cosmokit/package.json create mode 100644 vendor/cosmokit/src/array.ts create mode 100644 vendor/cosmokit/src/index.ts create mode 100644 vendor/cosmokit/src/misc.ts create mode 100644 vendor/cosmokit/src/string.ts create mode 100644 vendor/cosmokit/src/time.ts create mode 100644 vendor/cosmokit/src/types.ts create mode 100644 vendor/cosmokit/tsconfig.json create mode 100644 vendor/group/LICENSE create mode 100644 vendor/group/README.md create mode 100644 vendor/group/package.json create mode 100644 vendor/group/src/index.ts create mode 100644 vendor/group/tsconfig.json create mode 100644 vendor/hmr/LICENSE create mode 100644 vendor/hmr/README.md create mode 100644 vendor/hmr/package.json create mode 100644 vendor/hmr/src/error.ts create mode 100644 vendor/hmr/src/index.ts create mode 100644 vendor/hmr/tsconfig.json create mode 100644 vendor/include/LICENSE create mode 100644 vendor/include/README.md create mode 100644 vendor/include/package.json create mode 100644 vendor/include/src/index.ts create mode 100644 vendor/include/tsconfig.json create mode 100644 vendor/loader/LICENSE create mode 100644 vendor/loader/README.md create mode 100644 vendor/loader/package.json create mode 100644 vendor/loader/src/config/entry.ts create mode 100644 vendor/loader/src/config/group.ts create mode 100644 vendor/loader/src/config/isolate.ts create mode 100644 vendor/loader/src/config/tree.ts create mode 100644 vendor/loader/src/config/utils.ts create mode 100644 vendor/loader/src/index.ts create mode 100644 vendor/loader/src/internal.ts create mode 100644 vendor/loader/tsconfig.json create mode 100644 vendor/logger-console/LICENSE create mode 100644 vendor/logger-console/README.md create mode 100644 vendor/logger-console/package.json create mode 100644 vendor/logger-console/src/browser.ts create mode 100644 vendor/logger-console/src/index.ts create mode 100644 vendor/logger-console/src/shared.ts create mode 100644 vendor/logger-console/tsconfig.json create mode 100644 vendor/schemastery/LICENSE create mode 100644 vendor/schemastery/README.md create mode 100644 vendor/schemastery/package.json create mode 100644 vendor/schemastery/src/index.ts create mode 100644 vendor/schemastery/tsconfig.json create mode 100644 vendor/timer/LICENSE create mode 100644 vendor/timer/README.md create mode 100644 vendor/timer/package.json create mode 100644 vendor/timer/src/index.ts create mode 100644 vendor/timer/tsconfig.json diff --git a/vendor/README.md b/vendor/README.md new file mode 100644 index 0000000000..6506ec4b3b --- /dev/null +++ b/vendor/README.md @@ -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. diff --git a/vendor/cordis/LICENSE b/vendor/cordis/LICENSE new file mode 100644 index 0000000000..9fdec8c979 --- /dev/null +++ b/vendor/cordis/LICENSE @@ -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. diff --git a/vendor/cordis/README.md b/vendor/cordis/README.md new file mode 100644 index 0000000000..538809ddfb --- /dev/null +++ b/vendor/cordis/README.md @@ -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`. diff --git a/vendor/cordis/bin.js b/vendor/cordis/bin.js new file mode 100755 index 0000000000..9aecc7ce10 --- /dev/null +++ b/vendor/cordis/bin.js @@ -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', + }, +}) diff --git a/vendor/cordis/package.json b/vendor/cordis/package.json new file mode 100644 index 0000000000..6b9e59a00b --- /dev/null +++ b/vendor/cordis/package.json @@ -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 ", + "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" + } +} diff --git a/vendor/cordis/src/context.ts b/vendor/cordis/src/context.ts new file mode 100644 index 0000000000..768ba52d6f --- /dev/null +++ b/vendor/cordis/src/context.ts @@ -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 + [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, 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(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 }) + } +} diff --git a/vendor/cordis/src/events.ts b/vendor/cordis/src/events.ts new file mode 100644 index 0000000000..f7dcf011f4 --- /dev/null +++ b/vendor/cordis/src/events.ts @@ -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 extends (...args: infer P) => any ? P : never +/** Extract the return type from a function type. */ +export type ReturnType = F extends (...args: any) => infer R ? R : never +/** Extract the explicit `this` type from a function type. */ +export type ThisType = 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(name: K, ...args: Parameters): Promise + parallel(thisArg: NoInfer>, name: K, ...args: Parameters): Promise + emit(name: K, ...args: Parameters): void + emit(thisArg: NoInfer>, name: K, ...args: Parameters): void + serial(name: K, ...args: Parameters): Promisify> + serial(thisArg: NoInfer>, name: K, ...args: Parameters): Promisify> + bail(name: K, ...args: Parameters): ReturnType + bail(thisArg: NoInfer>, name: K, ...args: Parameters): ReturnType + waterfall(name: K, ...args: Parameters): ReturnType + waterfall(thisArg: NoInfer>, name: K, ...args: Parameters): ReturnType + on(name: K, listener: Events[K], options?: boolean | EventOptions): () => boolean + once(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 = {} + + 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 +} diff --git a/vendor/cordis/src/fiber.ts b/vendor/cordis/src/fiber.ts new file mode 100644 index 0000000000..840bc54352 --- /dev/null +++ b/vendor/cordis/src/fiber.ts @@ -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: 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 = Awaitable> extends PromiseLike<() => T> { + (): T +} + +/** Function returned by an effect to release resources during disposal. */ +export type Disposable = () => T + +/** Effect body result accepted by `ctx.effect()` and plugin startup. */ +export type Effect = + | SyncEffect + | AsyncEffect + +type SyncEffect = + | Disposable + | Iterable, void, void> + +type AsyncEffect = + | Promise> + | AsyncIterable, void, void> + +/** Tree node used to expose nested effect labels for diagnostics. */ +export interface EffectMeta { + label: string + children: EffectMeta[] +} + +interface EffectRunner { + 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 + public store: Dict | undefined + public inertia: Promise | undefined + + public readonly _hooks: Dict> = Object.create(null) + public readonly _disposables = new DisposableList() + + // Same as `this.ctx`, but with a more specific type. + protected context: Context + + private _error: any + private _runner: EffectRunner + private _store: Dict = Object.create(null) + + constructor( + public parent: Context, + config: any, + public inject: Dict, + 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(runner: EffectRunner) { + 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> + effect(execute: () => Effect, label?: string): AsyncDisposable> + effect(execute: () => Effect, label = 'anonymous'): any { + this.assertActive() + + const disposables: Disposable[] = [] + const dispose = () => { + let task!: void | Promise + 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 = { + 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 + 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(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() + }) + } +} diff --git a/vendor/cordis/src/index.ts b/vendor/cordis/src/index.ts new file mode 100644 index 0000000000..83d160395e --- /dev/null +++ b/vendor/cordis/src/index.ts @@ -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' diff --git a/vendor/cordis/src/logger.ts b/vendor/cordis/src/logger.ts new file mode 100644 index 0000000000..f76ac2cdb7 --- /dev/null +++ b/vendor/cordis/src/logger.ts @@ -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 +} + +/** Sink that receives structured log messages. */ +export interface Exporter { + colors?: number | false + maxLength?: number + levels?: Record + formatters?: Record + export(message: Message): void +} + +/** Built-in placeholder formatters used by `Logger.format()`. */ +export const defaultFormatters: Record = { + 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 + 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 {} + +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 { + (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() + + 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) + } + } + } +} diff --git a/vendor/cordis/src/reflect.ts b/vendor/cordis/src/reflect.ts new file mode 100644 index 0000000000..4bc9fb44db --- /dev/null +++ b/vendor/cordis/src/reflect.ts @@ -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(name: K, strict?: boolean): undefined | this[K] + get(name: string, strict?: boolean): any + set(name: K, value: undefined | this[K]): void + set(name: string, value: any): void + provide(name: K, value: undefined | this[K]): () => void + provide(name: string, value?: any): () => void + accessor(name: string, options: Omit): void + mixin(name: K, mixins: (keyof this & keyof this[K])[] | Dict): void + mixin(source: T, mixins: (keyof this & keyof T)[] | Dict): 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 = { + 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 = Object.create(null) + public props: Dict = 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) { + 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) { + 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(value: T) { + return getTraceable(this.ctx, value) + } + + bind(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) + }, + }) + } +} diff --git a/vendor/cordis/src/registry.ts b/vendor/cordis/src/registry.ts new file mode 100644 index 0000000000..fae7712df9 --- /dev/null +++ b/vendor/cordis/src/registry.ts @@ -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 = (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(name: K, config?: Context[K] extends { [symbols.config]: infer T } ? T : never) { + return function (value: any, decorator: ClassDecoratorContext | ClassMethodDecoratorContext) { + 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 = + | Plugin.Function + | Plugin.Constructor + | Plugin.Object + +/** Types associated with plugin entrypoints and runtime records. */ +export namespace Plugin { + /** Shared metadata understood by the plugin registry and related tooling. */ + export interface Base { + name?: string + Config?: StandardSchemaV1 + inject?: Inject + provide?: string | string[] + intercept?: Dict + } + + export interface Transform { + /** 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 extends Base { + (ctx: Context, config: T): any + } + + /** Class plugin constructed with `(ctx, config)`. */ + export interface Constructor extends Base { + new (ctx: Context, config: T): any + } + + /** Object plugin with an `apply(ctx, config)` method. */ + export interface Object extends Base { + apply(ctx: Context, config: T): any + } + + /** Mutable registry record shared by all fibers of one plugin callback. */ + export interface Runtime { + name?: string + fibers: DisposableList + callback: globalThis.Function + Config?: StandardSchemaV1 + } +} + +type Spread = undefined extends T ? [config?: T] : [config: T] + +type GetPluginParameters

= + | 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 extends Plugin.Transform + ? S + : GetPluginParameters

[0] + +declare module './context' { + export interface Context { + inject(deps: Inject, callback: Plugin.Function): Fiber & PromiseLike + plugin

(plugin: P, ...args: Spread>): Fiber & PromiseLike + } +} + +/** + * 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() + + 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) { + 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 + wrapped.then = (onFulfilled, onRejected) => { + return fiber.await().then(onFulfilled, onRejected) + } + return wrapped + } +} diff --git a/vendor/cordis/src/service.ts b/vendor/cordis/src/service.ts new file mode 100644 index 0000000000..4cc9f307f2 --- /dev/null +++ b/vendor/cordis/src/service.ts @@ -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 { + 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 + } +} diff --git a/vendor/cordis/src/utils.ts b/vendor/cordis/src/utils.ts new file mode 100644 index 0000000000..46dd962c6f --- /dev/null +++ b/vendor/cordis/src/utils.ts @@ -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 { + private sn = 0 + private map = new Map() + private weak = new WeakMap() + + 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(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(' ()')) 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(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) +} diff --git a/vendor/cordis/tsconfig.json b/vendor/cordis/tsconfig.json new file mode 100644 index 0000000000..fac5e0f15b --- /dev/null +++ b/vendor/cordis/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib", + "noImplicitAny": false, + "noImplicitThis": false, + "strictFunctionTypes": false + }, + "include": ["src"], + "references": [ + { "path": "../cosmokit" } + ] +} diff --git a/vendor/cosmokit/LICENSE b/vendor/cosmokit/LICENSE new file mode 100644 index 0000000000..9fdec8c979 --- /dev/null +++ b/vendor/cosmokit/LICENSE @@ -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. diff --git a/vendor/cosmokit/README.md b/vendor/cosmokit/README.md new file mode 100644 index 0000000000..5398a146c2 --- /dev/null +++ b/vendor/cosmokit/README.md @@ -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' +``` diff --git a/vendor/cosmokit/package.json b/vendor/cosmokit/package.json new file mode 100644 index 0000000000..92fdf8e903 --- /dev/null +++ b/vendor/cosmokit/package.json @@ -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 ", + "license": "MIT" +} diff --git a/vendor/cosmokit/src/array.ts b/vendor/cosmokit/src/array.ts new file mode 100644 index 0000000000..ccbc4b2752 --- /dev/null +++ b/vendor/cosmokit/src/array.ts @@ -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(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(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(array1: readonly T[], array2: readonly T[]) { + return Array.from(new Set([...array1, ...array2])) +} + +/** Remove duplicate values while preserving first occurrence order. */ +export function deduplicate(array: readonly T[]) { + return [...new Set(array)] +} + +/** Remove one item from an array and report whether it was found. */ +export function remove(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(source: null | undefined | T | T[]) { + return Array.isArray(source) ? source : isNullable(source) ? [] : [source] +} diff --git a/vendor/cosmokit/src/index.ts b/vendor/cosmokit/src/index.ts new file mode 100644 index 0000000000..088e81c54f --- /dev/null +++ b/vendor/cosmokit/src/index.ts @@ -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' diff --git a/vendor/cosmokit/src/misc.ts b/vendor/cosmokit/src/misc.ts new file mode 100644 index 0000000000..11ed0a8597 --- /dev/null +++ b/vendor/cosmokit/src/misc.ts @@ -0,0 +1,78 @@ +/** String/symbol keyed dictionary type. */ +export type Dict = { [key in K]: T } +/** Safely read `T[K]`, returning `never` when `K` is not a key of `T`. */ +export type Get = K extends keyof T ? T[K] : never +/** Conditional extraction helper with a configurable return type. */ +export type Extract = S extends T ? U : never +/** Accept a value or an array, unless the value is already an array type. */ +export type MaybeArray = [T] extends [unknown[]] ? T : T | T[] +/** Wrap a value in `Promise`, preserving the resolved type of existing promises. */ +export type Promisify = Promise ? S : T> +/** Accept a value or promise unless the value type is already promise-like. */ +export type Awaitable = [T] extends [Promise] ? T : T | Promise +/** Convert a union type to an intersection type. */ +export type Intersect = (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(value: T): value is NonNullable { + 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(object: Dict, filter: (key: K, value: T) => key is U): Dict +/** Filter object entries with a boolean predicate. */ +export function filterKeys(object: Dict, filter: (key: K, value: T) => boolean): Dict +/** 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(object: Dict, transform: (value: T, key: K) => U) { + return Object.fromEntries(Object.entries(object).map(([key, value]) => [key, (transform as any)(value, key)])) as Dict +} + +/** Alias for `mapValues`. */ +export { mapValues as valueMap } + +/** Pick selected keys from an object, optionally including `undefined` values. */ +export function pick(source: T, keys?: Iterable, forced?: boolean) { + if (!keys) return { ...source } + const result = {} as Pick + 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(source: T, keys?: Iterable) { + if (!keys) return { ...source } + const result = { ...source } as Omit + for (const key of keys) { + Reflect.deleteProperty(result, key) + } + return result +} + +/** Define a non-enumerable writable property with a typed key. */ +export function defineProperty(object: T, key: K, value: T[K]): T +/** Define a non-enumerable writable property with an arbitrary key. */ +export function defineProperty(object: T, key: K, value: any): T +/** Define a non-enumerable writable property and return the object. */ +export function defineProperty(object: T, key: K, value: any) { + return Object.defineProperty(object, key, { writable: true, value, enumerable: false }) +} diff --git a/vendor/cosmokit/src/string.ts b/vendor/cosmokit/src/string.ts new file mode 100644 index 0000000000..6f70844881 --- /dev/null +++ b/vendor/cosmokit/src/string.ts @@ -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 Lower ? LowerToUpper[S] : S + export type ToLower = 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 `${infer L}-${infer M}${infer R}` ? `${L}${Letter.ToUpper}${camelize}` : S +/** Type-level conversion from camelCase text to dash-delimited text. */ +export type hyphenate = S extends `${infer L}${infer R}` ? `${Letter.ToLower}${hyphenate}` : 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) +} diff --git a/vendor/cosmokit/src/time.ts b/vendor/cosmokit/src/time.ts new file mode 100644 index 0000000000..6f9065493e --- /dev/null +++ b/vendor/cosmokit/src/time.ts @@ -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)) + } +} diff --git a/vendor/cosmokit/src/types.ts b/vendor/cosmokit/src/types.ts new file mode 100644 index 0000000000..b4d1e5bed8 --- /dev/null +++ b/vendor/cosmokit/src/types.ts @@ -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(type: K): (value: any) => value is InstanceType +/** Test whether a value matches a global constructor name. */ +export function is(type: K, value: any): value is InstanceType +/** Test values using `instanceof` with a `toStringTag` fallback. */ +export function is(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 | ArrayBufferView + + export const is = isArrayBufferLike + export const isSource = isArrayBufferSource + + export function fromSource(source: Source): 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(source: T): T +/** Deep-clone common JavaScript values while preserving prototypes and cycles. */ +export function clone(source: any, refs = new Map()) { + 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(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)) +} diff --git a/vendor/cosmokit/tsconfig.json b/vendor/cosmokit/tsconfig.json new file mode 100644 index 0000000000..a90855e9ef --- /dev/null +++ b/vendor/cosmokit/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": ["src"] +} diff --git a/vendor/group/LICENSE b/vendor/group/LICENSE new file mode 100644 index 0000000000..9fdec8c979 --- /dev/null +++ b/vendor/group/LICENSE @@ -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. diff --git a/vendor/group/README.md b/vendor/group/README.md new file mode 100644 index 0000000000..756974d31f --- /dev/null +++ b/vendor/group/README.md @@ -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. diff --git a/vendor/group/package.json b/vendor/group/package.json new file mode 100644 index 0000000000..86e5043a10 --- /dev/null +++ b/vendor/group/package.json @@ -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 ", + "license": "MIT", + "peerDependencies": { + "@cordisjs/plugin-loader": "^1.0.0-rc.4", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/vendor/group/src/index.ts b/vendor/group/src/index.ts new file mode 100644 index 0000000000..7654ba9691 --- /dev/null +++ b/vendor/group/src/index.ts @@ -0,0 +1,3 @@ +import { Group } from '@cordisjs/plugin-loader' + +export default Group diff --git a/vendor/group/tsconfig.json b/vendor/group/tsconfig.json new file mode 100644 index 0000000000..b3c2b96cba --- /dev/null +++ b/vendor/group/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": ["src"], + "references": [ + { "path": "../cordis" }, + { "path": "../loader" } + ] +} diff --git a/vendor/hmr/LICENSE b/vendor/hmr/LICENSE new file mode 100644 index 0000000000..9fdec8c979 --- /dev/null +++ b/vendor/hmr/LICENSE @@ -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. diff --git a/vendor/hmr/README.md b/vendor/hmr/README.md new file mode 100644 index 0000000000..db5ac1a49a --- /dev/null +++ b/vendor/hmr/README.md @@ -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. | diff --git a/vendor/hmr/package.json b/vendor/hmr/package.json new file mode 100644 index 0000000000..3d0af11e96 --- /dev/null +++ b/vendor/hmr/package.json @@ -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 ", + "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" + } +} diff --git a/vendor/hmr/src/error.ts b/vendor/hmr/src/error.ts new file mode 100644 index 0000000000..a5f7b21702 --- /dev/null +++ b/vendor/hmr/src/error.ts @@ -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) + } + } +} diff --git a/vendor/hmr/src/index.ts b/vendor/hmr/src/index.ts new file mode 100644 index 0000000000..ada10cc934 --- /dev/null +++ b/vendor/hmr/src/index.ts @@ -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): 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()) { + const dependencies = new Set() + 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 + + /** + * Files that should be reloaded (accepted changes). + * Includes all stashed files and their dependents. + */ + private accepted!: Set + + /** + * Files that should NOT be reloaded. + * Includes externals and files whose dependents are all declined. + */ + private declined!: Set + + /** Stashed file changes waiting to be processed */ + private stashed = new Set() + + 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 { + 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 ()', + ] + + 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() + const reloads = new Map() + + // Build a map of plugin names per config tree URL. + // Plugin entry files are treated as atomic reload units. + const nameMap: Dict> = 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 + * - In Node 24, loadCache is a LoadCache extends Map + * 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 = 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 diff --git a/vendor/hmr/tsconfig.json b/vendor/hmr/tsconfig.json new file mode 100644 index 0000000000..75aaf3e261 --- /dev/null +++ b/vendor/hmr/tsconfig.json @@ -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" } + ] +} diff --git a/vendor/include/LICENSE b/vendor/include/LICENSE new file mode 100644 index 0000000000..9fdec8c979 --- /dev/null +++ b/vendor/include/LICENSE @@ -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. diff --git a/vendor/include/README.md b/vendor/include/README.md new file mode 100644 index 0000000000..de0ecdc51f --- /dev/null +++ b/vendor/include/README.md @@ -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`. diff --git a/vendor/include/package.json b/vendor/include/package.json new file mode 100644 index 0000000000..d42a1c0739 --- /dev/null +++ b/vendor/include/package.json @@ -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 ", + "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" + } +} diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts new file mode 100644 index 0000000000..2258d3af06 --- /dev/null +++ b/vendor/include/src/index.ts @@ -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 = { + '.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() + 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 diff --git a/vendor/include/tsconfig.json b/vendor/include/tsconfig.json new file mode 100644 index 0000000000..96be18685e --- /dev/null +++ b/vendor/include/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": ["src"], + "references": [ + { "path": "../cosmokit" }, + { "path": "../cordis" }, + { "path": "../loader" } + ] +} diff --git a/vendor/loader/LICENSE b/vendor/loader/LICENSE new file mode 100644 index 0000000000..9fdec8c979 --- /dev/null +++ b/vendor/loader/LICENSE @@ -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. diff --git a/vendor/loader/README.md b/vendor/loader/README.md new file mode 100644 index 0000000000..fb71b774a8 --- /dev/null +++ b/vendor/loader/README.md @@ -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`. diff --git a/vendor/loader/package.json b/vendor/loader/package.json new file mode 100644 index 0000000000..ee5dd088ff --- /dev/null +++ b/vendor/loader/package.json @@ -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 ", + "license": "MIT", + "peerDependencies": { + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "cosmokit": "^1.8.1" + } +} diff --git a/vendor/loader/src/config/entry.ts b/vendor/loader/src/config/entry.ts new file mode 100644 index 0000000000..c2959fe61e --- /dev/null +++ b/vendor/loader/src/config/entry.ts @@ -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(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 + + 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, 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) + } +} diff --git a/vendor/loader/src/config/group.ts b/vendor/loader/src/config/group.ts new file mode 100644 index 0000000000..f6ce0fe306 --- /dev/null +++ b/vendor/loader/src/config/group.ts @@ -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) { + 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[] = [] + 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) + } +} diff --git a/vendor/loader/src/config/isolate.ts b/vendor/loader/src/config/isolate.ts new file mode 100644 index 0000000000..a2e930c4fb --- /dev/null +++ b/vendor/loader/src/config/isolate.ts @@ -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 | null + } + + interface Entry { + realm: LocalRealm + } +} + +function swap(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 = 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 = Object.create(null) + const delims: Dict = 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 = 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] + } + } + }) +} diff --git a/vendor/loader/src/config/tree.ts b/vendor/loader/src/config/tree.ts new file mode 100644 index 0000000000..6855884e11 --- /dev/null +++ b/vendor/loader/src/config/tree.ts @@ -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 = 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 { + 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) { + 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, 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, 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 +} diff --git a/vendor/loader/src/config/utils.ts b/vendor/loader/src/config/utils.ts new file mode 100644 index 0000000000..4e193fcc4f --- /dev/null +++ b/vendor/loader/src/config/utils.ts @@ -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 +} diff --git a/vendor/loader/src/index.ts b/vendor/loader/src/index.ts new file mode 100644 index 0000000000..e18fc2ffa2 --- /dev/null +++ b/vendor/loader/src/index.ts @@ -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 + 'loader/config-update'(): void + 'loader/entry-init'(entry: Entry): void + 'loader/partial-dispose'(entry: Entry, legacy: Partial, 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 = 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 diff --git a/vendor/loader/src/internal.ts b/vendor/loader/src/internal.ts new file mode 100644 index 0000000000..6e1e2c6780 --- /dev/null +++ b/vendor/loader/src/internal.ts @@ -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>, '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 + instantiate(): Promise + run(): Promise<{ module: ModuleWrap }> +} + +/** + * Node 22/23 ModuleLoader interface. + * + * Key methods: + * - getModuleJobForImport(specifier, parentURL, importAttributes) + * - resolve(specifier, parentURL, importAttributes) → Promise + * - resolveSync(specifier, parentURL, importAttributes) → ResolveResult + */ +export interface ModuleLoaderV1 { + version: 'v1' + loadCache: LoadCache + import(specifier: string, parentURL: string, importAttributes: ImportAttributes): Promise + register(specifier: string | URL, parentURL?: string | URL, data?: any, transferList?: any[]): void + getModuleJobForImport(specifier: string, parentURL: string, importAttributes: ImportAttributes): Promise + resolve(specifier: string, parentURL: string, importAttributes: ImportAttributes): Promise + resolveSync(specifier: string, parentURL: string, importAttributes: ImportAttributes): ResolveResult + load(specifier: string, context: Pick): Promise +} + +/** 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 with delete only setting undefined + */ +export interface ModuleLoaderV2 { + version: 'v2' + loadCache: LoadCache + import(specifier: string, parentURL: string, importAttributes: ImportAttributes, phase?: ModulePhase, isEntryPoint?: boolean): Promise + register(specifier: string | URL, parentURL?: string | URL, data?: any, transferList?: any[], isInternal?: boolean): void + getOrCreateModuleJob(parentURL: string, request: ModuleRequest, requestType?: ModuleRequestType): Promise + resolveSync(parentURL: string, request: ModuleRequest): ResolveResult + load(url: string, context: Pick): Promise +} + +/** 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' }) + } + } +} diff --git a/vendor/loader/tsconfig.json b/vendor/loader/tsconfig.json new file mode 100644 index 0000000000..5748cb0c7b --- /dev/null +++ b/vendor/loader/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": ["src"], + "references": [ + { "path": "../cosmokit" }, + { "path": "../cordis" } + ] +} diff --git a/vendor/logger-console/LICENSE b/vendor/logger-console/LICENSE new file mode 100644 index 0000000000..9fdec8c979 --- /dev/null +++ b/vendor/logger-console/LICENSE @@ -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. diff --git a/vendor/logger-console/README.md b/vendor/logger-console/README.md new file mode 100644 index 0000000000..15a202da59 --- /dev/null +++ b/vendor/logger-console/README.md @@ -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`. diff --git a/vendor/logger-console/package.json b/vendor/logger-console/package.json new file mode 100644 index 0000000000..b4a4c9634e --- /dev/null +++ b/vendor/logger-console/package.json @@ -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 ", + "license": "MIT", + "peerDependencies": { + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "cosmokit": "^1.8.1", + "schemastery": "^3.18.0", + "supports-color": "^9.4.0" + } +} diff --git a/vendor/logger-console/src/browser.ts b/vendor/logger-console/src/browser.ts new file mode 100644 index 0000000000..bdbeaaf226 --- /dev/null +++ b/vendor/logger-console/src/browser.ts @@ -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 diff --git a/vendor/logger-console/src/index.ts b/vendor/logger-console/src/index.ts new file mode 100644 index 0000000000..87ab53d6dc --- /dev/null +++ b/vendor/logger-console/src/index.ts @@ -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 = { + o: inspectFormatter, + O: inspectFormatter, + } + + getDefaults() { + return { + ...super.getDefaults(), + colors: (supportsColor.stdout ? supportsColor.stdout.level : 0) as false | 0 | 1 | 2 | 3, + } + } +} + +export default ConsoleExporter diff --git a/vendor/logger-console/src/shared.ts b/vendor/logger-console/src/shared.ts new file mode 100644 index 0000000000..61d91abcb3 --- /dev/null +++ b/vendor/logger-console/src/shared.ts @@ -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 + 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 = 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 + + colors!: false | ColorSupportLevel + maxLength?: number + levels?: Record + showDiff!: boolean + showTime!: string + label?: LabelStyle + timestamp: number + + formatters: Record = {} + + 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 diff --git a/vendor/logger-console/tsconfig.json b/vendor/logger-console/tsconfig.json new file mode 100644 index 0000000000..80df0add89 --- /dev/null +++ b/vendor/logger-console/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": ["src"], + "references": [ + { "path": "../cosmokit" }, + { "path": "../cordis" }, + { "path": "../schemastery" } + ] +} diff --git a/vendor/schemastery/LICENSE b/vendor/schemastery/LICENSE new file mode 100644 index 0000000000..9fdec8c979 --- /dev/null +++ b/vendor/schemastery/LICENSE @@ -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. diff --git a/vendor/schemastery/README.md b/vendor/schemastery/README.md new file mode 100644 index 0000000000..24fdf3662f --- /dev/null +++ b/vendor/schemastery/README.md @@ -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 + 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. diff --git a/vendor/schemastery/package.json b/vendor/schemastery/package.json new file mode 100644 index 0000000000..71f7744e5e --- /dev/null +++ b/vendor/schemastery/package.json @@ -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 ", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "cosmokit": "^1.8.1" + } +} diff --git a/vendor/schemastery/src/index.ts b/vendor/schemastery/src/index.ts new file mode 100644 index 0000000000..b57375d64b --- /dev/null +++ b/vendor/schemastery/src/index.ts @@ -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 extends string | number | boolean ? Schema + : X extends Schema ? X + : X extends typeof String ? Schema + : X extends typeof Number ? Schema + : X extends typeof Boolean ? Schema + : X extends typeof Function ? Schema any> + : X extends Constructor ? Schema + : never + + type TypeS1 = X extends Schema ? S : never + type Inverse = X extends Schema ? (arg: Y) => void : never + + /** Input type accepted by a schema-like value. */ + export type TypeS = TypeS1> + /** Output type returned by a schema-like value after validation. */ + export type TypeT = ReturnType> + /** 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 = From extends Schema ? S : never + /** Output type returned by one schema in an intersection. */ + export type IntersectT = Inverse> extends ((arg: infer T) => void) ? T : never + + type TupleS = X extends readonly [infer L, ...infer R] ? [TypeS?, ...TupleS] : any[] + type TupleT = X extends readonly [infer L, ...infer R] ? [TypeT?, ...TupleT] : any[] + type ObjectS = { [K in keyof X]?: TypeS | null } & Dict + type ObjectT = { [K in keyof X]: TypeT } & Dict + type Constructor = new (...args: any[]) => T + + /** Static constructor and factory methods exposed by the default `Schema` export. */ + export interface Static { + (options: Partial>): Schema + new (options: Partial>): Schema + 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(source?: X): From + /** Register a resolver for a custom schema `type`. */ + extend(type: string, resolve: Resolve): void + /** Accept any value without validation. */ + any(): Schema + /** Accept only nullable input. */ + never(): Schema + /** Accept exactly one constant value. */ + const(value: T): Schema + /** Accept strings, with optional metadata constraints added by instance methods. */ + string(): Schema + /** Accept numbers, with optional range and step constraints. */ + number(): Schema + /** Accept non-negative integer numbers. */ + natural(): Schema + /** Accept a number between 0 and 1 and mark it as a slider. */ + percent(): Schema + /** Accept booleans. */ + boolean(): Schema + /** Accept `Date` instances or parse datetime strings into `Date` objects. */ + date(): Schema + /** Accept `RegExp` instances or parse strings into regular expressions. */ + regExp(flag?: string): Schema + /** Accept binary sources and normalize them to `ArrayBufferLike`. */ + arrayBuffer(): Schema + arrayBuffer(encoding: 'hex' | 'base64'): Schema + /** Accept a numeric bitset or string keys and normalize to a number. */ + bitset(bits: Partial>): Schema + /** Accept functions. */ + function(): Schema any> + /** Accept instances of a constructor or objects whose constructor name matches. */ + is(constructor: string): Schema + is(constructor: Constructor): Schema + /** Accept arrays whose elements match `inner`. */ + array(inner: X): Schema[], TypeT[]> + /** Accept plain objects with values matching `inner` and optional key schema. */ + dict = Schema>(inner: X, sKey?: Y): Schema, TypeS>, Dict, TypeT>> + /** Accept tuple arrays where each index matches the corresponding schema. */ + tuple(list: X): Schema, TupleT> + /** Accept plain objects whose declared properties match the schema dictionary. */ + object(dict: X): Schema, ObjectT> + /** Accept values matching at least one schema in `list`. */ + union(list: readonly X[]): Schema, TypeT> + /** Accept values matching every schema in `list`, merging object outputs. */ + intersect(list: readonly X[]): Schema, IntersectT> + /** Validate with `inner`, then convert the result with `callback`. */ + transform(inner: X, callback: (value: TypeS, options: Schemastery.Options) => T, preserve?: boolean): Schema, T> + /** Defer construction of a recursive schema until validation or serialization. */ + lazy(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 { + default?: T extends {} ? Partial : 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 + 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 { + (data?: S | null, options?: Schemastery.Options): T + new (data?: S | null, options?: Schemastery.Options): T + [kSchema]: true + uid: number + meta: Schemastery.Meta + type: string + sKey?: Schema + inner?: Schema + list?: Schema[] + dict?: Dict + bits?: Dict + callback?: Function + constructor?: string | Function + builder?: Function + value?: T + refs?: Dict + preserve?: boolean + '~standard': StandardSchemaV1.Props // + /** Format this schema as a compact TypeScript-like type string. */ + toString(inline?: boolean): string + /** Serialize this schema, preserving shared and recursive references. */ + toJSON(): Schema + /** Mark nullable input as invalid unless a default supplies a fallback. */ + required(value?: boolean): Schema + /** Hide this schema node from UI renderers. */ + hidden(value?: boolean): Schema + /** Return the default value instead of throwing when validation fails. */ + loose(value?: boolean): Schema + /** Attach a renderer role and optional role-specific metadata. */ + role(text: string, extra?: any): Schema + /** Attach an external documentation link. */ + link(link: string): Schema + /** Set the fallback value used for nullable input. */ + default(value: T): Schema + /** Attach an auxiliary comment for documentation or form UIs. */ + comment(text: string): Schema + /** Attach a localized or plain description for documentation or form UIs. */ + description(text: string): Schema + /** Mark this schema node as disabled for form UIs. */ + disabled(value?: boolean): Schema + /** Request collapsed rendering for nested form UIs. */ + collapse(value?: boolean): Schema + /** Add a deprecated badge to this schema node. */ + deprecated(): Schema + /** Add an experimental badge to this schema node. */ + experimental(): Schema + /** Require strings to match a regular expression. */ + pattern(regexp: RegExp): Schema + /** Set an inclusive maximum for numbers or collection lengths. */ + max(value: number): Schema + /** Set an inclusive minimum for numbers or collection lengths. */ + min(value: number): Schema + /** Set the numeric increment constraint. */ + step(value: number): Schema + /** Add or replace an object property schema. */ + set(key: string, value: Schema): Schema + /** Append a tuple, union, or intersection member schema. */ + push(value: Schema): Schema + /** 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 + /** Attach arbitrary metadata consumed by form renderers and downstream tools. */ + extra(key: K, value: Schemastery.Meta[K]): Schema + } +} + +declare namespace globalThis { + // eslint-disable-next-line @typescript-eslint/naming-convention + export let __schemastery_index__: number + export let __schemastery_refs__: Record | 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 = Schemastery + +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, messages: Dict) { + const result: Dict = 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 = {} + +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(), (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, 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 = {} + +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 diff --git a/vendor/schemastery/tsconfig.json b/vendor/schemastery/tsconfig.json new file mode 100644 index 0000000000..af296e6523 --- /dev/null +++ b/vendor/schemastery/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib", + "module": "preserve" + }, + "include": ["src"], + "references": [ + { "path": "../cosmokit" } + ] +} diff --git a/vendor/timer/LICENSE b/vendor/timer/LICENSE new file mode 100644 index 0000000000..9fdec8c979 --- /dev/null +++ b/vendor/timer/LICENSE @@ -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. diff --git a/vendor/timer/README.md b/vendor/timer/README.md new file mode 100644 index 0000000000..3a1dcb1533 --- /dev/null +++ b/vendor/timer/README.md @@ -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()`. diff --git a/vendor/timer/package.json b/vendor/timer/package.json new file mode 100644 index 0000000000..30bfe58280 --- /dev/null +++ b/vendor/timer/package.json @@ -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 ", + "license": "MIT", + "peerDependencies": { + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "cosmokit": "^1.8.1" + } +} diff --git a/vendor/timer/src/index.ts b/vendor/timer/src/index.ts new file mode 100644 index 0000000000..1a33850aa3 --- /dev/null +++ b/vendor/timer/src/index.ts @@ -0,0 +1,147 @@ +import { Context, Service } from 'cordis' + +declare module 'cordis' { + interface Context extends Pick { + timer: TimerService + } +} + +type WithDispose = 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 + 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() + 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(delay: number): AsyncIterableIterator + 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> | 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 + } + } + + 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 void>(callback: F, delay: number, noTrailing?: boolean): WithDispose { + 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 void>(callback: F, delay: number): WithDispose { + return this._schedule('ctx.debounce()', (args, isDisposed) => { + if (isDisposed) return + return setTimeout(callback, delay, ...args) + }) + } +} + +export default TimerService diff --git a/vendor/timer/tsconfig.json b/vendor/timer/tsconfig.json new file mode 100644 index 0000000000..5748cb0c7b --- /dev/null +++ b/vendor/timer/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": ["src"], + "references": [ + { "path": "../cosmokit" }, + { "path": "../cordis" } + ] +}