@arcships/rutis 0.0.0-stage → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -2
- package/package.json +35 -4
- package/src/index.d.ts +32 -0
- package/src/index.mjs +37 -0
- package/src/testing.d.ts +23 -0
- package/src/testing.mjs +154 -0
package/README.md
CHANGED
|
@@ -1,3 +1,31 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @arcships/rutis
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Write [rutis](https://github.com/arcships/rutis) plugins in TypeScript or JavaScript.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { definePlugin } from '@arcships/rutis'
|
|
7
|
+
|
|
8
|
+
export default definePlugin({
|
|
9
|
+
inject: ['llm'], // services it uses
|
|
10
|
+
provides: { weather: { today: 'async' } }, // services it provides
|
|
11
|
+
config: { type: 'object', properties: { city: { type: 'string' } } },
|
|
12
|
+
apply(ctx, config) {
|
|
13
|
+
ctx.provide('weather', new Weather(ctx.use('llm'), config.city))
|
|
14
|
+
return () => {} // cleanup
|
|
15
|
+
},
|
|
16
|
+
})
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Test it without a host:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { load } from '@arcships/rutis/testing'
|
|
23
|
+
|
|
24
|
+
const t = await load(plugin, { config: { city: 'Oslo' }, services: { llm: { ask: async () => 'sunny' } } })
|
|
25
|
+
assert.equal(await t.service('weather').today(), 'sunny in Oslo')
|
|
26
|
+
await t.unload()
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
This package has no dependencies; the host installs the runtime that runs the plugin (`@arcships/rutis-runtime`). Start a project with `npx @arcships/rutis-host new <name> --lang node`.
|
|
30
|
+
|
|
31
|
+
Guide (Chinese): [docs/guide/typescript-plugin.md](https://github.com/arcships/rutis/blob/main/docs/guide/typescript-plugin.md).
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcships/rutis",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "Write rutis plugins in JavaScript or TypeScript: definePlugin, types and a test kit",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/arcships/rutis.git",
|
|
9
|
+
"directory": "node/rutis"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/arcships/rutis/tree/main/docs/guide#readme",
|
|
12
|
+
"keywords": ["rutis", "plugin", "rutis-plugin"],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./src/index.d.ts",
|
|
17
|
+
"default": "./src/index.mjs"
|
|
18
|
+
},
|
|
19
|
+
"./testing": {
|
|
20
|
+
"types": "./src/testing.d.ts",
|
|
21
|
+
"default": "./src/testing.mjs"
|
|
22
|
+
},
|
|
23
|
+
"./package.json": "./package.json"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"src"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=24"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"test": "node --test test/*.test.mjs"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type MethodKind = 'sync' | 'async'
|
|
2
|
+
|
|
3
|
+
export interface Context {
|
|
4
|
+
/** The service `name`, which the plugin declares in `inject`: the object itself when a plugin in the same process provides it, else a proxy whose methods are sync or async as declared. */
|
|
5
|
+
use<T = any>(name: string): T
|
|
6
|
+
/** Provide `value` as the service `name` until the plugin unloads, or until the returned function is called. */
|
|
7
|
+
provide(name: string, value: unknown): () => unknown
|
|
8
|
+
/** Run `cleanup` when the plugin unloads. */
|
|
9
|
+
effect(cleanup: () => unknown): void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface PluginSpec<C = any> {
|
|
13
|
+
name?: string
|
|
14
|
+
/** Services the plugin uses: rutis starts it once they are all available, and stops it when one goes. */
|
|
15
|
+
inject?: string[]
|
|
16
|
+
/** Services it provides to rutis, with each method's kind. */
|
|
17
|
+
provides?: Record<string, Record<string, MethodKind>>
|
|
18
|
+
/** The JSON Schema of its config. */
|
|
19
|
+
config?: object
|
|
20
|
+
apply(ctx: Context, config: C): void | (() => unknown) | Promise<void | (() => unknown)>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface Plugin<C = any> extends Readonly<Required<Pick<PluginSpec<C>, 'inject' | 'provides'>>>, Readonly<PluginSpec<C>> {
|
|
24
|
+
/** The plugin API it was written against. */
|
|
25
|
+
readonly api: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The plugin API this SDK writes plugins against. */
|
|
29
|
+
export declare const PLUGIN_API: number
|
|
30
|
+
export declare const PLUGIN: unique symbol
|
|
31
|
+
export declare function definePlugin<C = any>(spec: PluginSpec<C>): Plugin<C>
|
|
32
|
+
export declare function isPlugin(value: unknown): value is Plugin
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Write a rutis plugin in JavaScript or TypeScript. A plugin declares the
|
|
2
|
+
// services it uses (`inject`) and provides (`provides`), and `apply(ctx,
|
|
3
|
+
// config)` uses and provides them through `ctx`; rutis decides when it
|
|
4
|
+
// starts, stops and restarts. The runtime that runs it is installed by the
|
|
5
|
+
// host (`@arcships/rutis-runtime`), not by the plugin.
|
|
6
|
+
//
|
|
7
|
+
// export default definePlugin({
|
|
8
|
+
// inject: ['llm'],
|
|
9
|
+
// provides: { weather: { today: 'async' } },
|
|
10
|
+
// config: { type: 'object', properties: { city: { type: 'string' } } },
|
|
11
|
+
// apply(ctx, config) {
|
|
12
|
+
// ctx.provide('weather', new Weather(ctx.use('llm'), config.city))
|
|
13
|
+
// return () => {}
|
|
14
|
+
// },
|
|
15
|
+
// })
|
|
16
|
+
|
|
17
|
+
// The plugin API this SDK writes plugins against. A runtime refuses a plugin
|
|
18
|
+
// that needs a newer one, naming both.
|
|
19
|
+
export const PLUGIN_API = 1
|
|
20
|
+
|
|
21
|
+
// Marks what definePlugin returns, across copies of this package.
|
|
22
|
+
export const PLUGIN = Symbol.for('rutis.leaf-plugin')
|
|
23
|
+
|
|
24
|
+
export function definePlugin(spec) {
|
|
25
|
+
if (typeof spec?.apply !== 'function') throw new TypeError('a plugin needs apply(ctx, config)')
|
|
26
|
+
const inject = spec.inject ?? []
|
|
27
|
+
if (!Array.isArray(inject) || inject.some(name => typeof name !== 'string')) throw new TypeError('inject must be a list of service names')
|
|
28
|
+
for (const [name, methods] of Object.entries(spec.provides ?? {})) {
|
|
29
|
+
for (const [method, kind] of Object.entries(methods ?? {})) {
|
|
30
|
+
if (kind !== 'sync' && kind !== 'async') throw new TypeError(`${name}.${method}: kind must be 'sync' or 'async'`)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return Object.freeze({ ...spec, inject, provides: spec.provides ?? {}, api: PLUGIN_API, [PLUGIN]: true })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Whether `value` is a plugin definePlugin made.
|
|
37
|
+
export const isPlugin = value => value?.[PLUGIN] === true
|
package/src/testing.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Plugin } from './index.js'
|
|
2
|
+
|
|
3
|
+
export declare class PluginTestError extends Error {}
|
|
4
|
+
|
|
5
|
+
export interface Loaded {
|
|
6
|
+
/** The service `name` the plugin provides, as rutis sees it: its declared methods only. */
|
|
7
|
+
service<T = any>(name: string): T
|
|
8
|
+
/** The names of the services the plugin provides now. */
|
|
9
|
+
provided(): string[]
|
|
10
|
+
/** Run the cleanups, latest first; the plugin's services are withdrawn. */
|
|
11
|
+
unload(): Promise<void>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface LoadOptions {
|
|
15
|
+
config?: unknown
|
|
16
|
+
/** The services the plugin injects, by name. */
|
|
17
|
+
services?: Record<string, unknown>
|
|
18
|
+
/** Values cross as between processes (default true). */
|
|
19
|
+
strict?: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Load `plugin` (or a module whose default export it is) without a host. */
|
|
23
|
+
export declare function load(plugin: Plugin | { default: Plugin }, options?: LoadOptions): Promise<Loaded>
|
package/src/testing.mjs
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Test a plugin without a host: give it the services it injects, call the
|
|
2
|
+
// services it provides, unload it.
|
|
3
|
+
//
|
|
4
|
+
// const t = await load(plugin, { config: { city: 'Oslo' }, services: { llm } })
|
|
5
|
+
// assert.equal(await t.service('weather').today(), 'sunny in Oslo')
|
|
6
|
+
// await t.unload()
|
|
7
|
+
//
|
|
8
|
+
// It checks what a host would: the plugin uses only the services it declares
|
|
9
|
+
// in `inject`, provides what it declares in `provides` with every declared
|
|
10
|
+
// method, and runs its cleanups on unload. With `strict` (the default),
|
|
11
|
+
// values cross between the plugin and the test as they would between
|
|
12
|
+
// processes: data is copied, functions and objects with behaviour pass by
|
|
13
|
+
// reference, sync methods return values and async ones promises; what
|
|
14
|
+
// works only in one process fails here too.
|
|
15
|
+
|
|
16
|
+
import { PLUGIN_API, isPlugin } from './index.mjs'
|
|
17
|
+
|
|
18
|
+
export class PluginTestError extends Error {
|
|
19
|
+
name = 'PluginTestError'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const builtins = [Date, RegExp, Map, Set, WeakMap, WeakSet, ArrayBuffer, DataView, Error, Promise]
|
|
23
|
+
|
|
24
|
+
// Objects with behaviour cross by reference: class instances and objects
|
|
25
|
+
// with methods. Everything else is data.
|
|
26
|
+
function hasBehaviour(value) {
|
|
27
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value) || ArrayBuffer.isView(value)) return false
|
|
28
|
+
if (builtins.some(type => value instanceof type)) return false
|
|
29
|
+
const prototype = Object.getPrototypeOf(value)
|
|
30
|
+
if (prototype !== Object.prototype && prototype !== null) return true
|
|
31
|
+
return Object.values(value).some(item => typeof item === 'function')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// `value` as the other process sees it.
|
|
35
|
+
function cross(value, where, seen = new Set()) {
|
|
36
|
+
if (value === undefined || value === null) return value
|
|
37
|
+
if (typeof value === 'symbol' || typeof value === 'bigint') {
|
|
38
|
+
throw new PluginTestError(`${where}: a ${typeof value} cannot cross between processes`)
|
|
39
|
+
}
|
|
40
|
+
if (typeof value === 'function') return (...args) => crossResult(value(...args.map((arg, i) => cross(arg, `${where} argument ${i}`))), where)
|
|
41
|
+
if (value instanceof Promise) return value.then(result => cross(result, where))
|
|
42
|
+
if (typeof value !== 'object') return value
|
|
43
|
+
if (hasBehaviour(value)) return reference(value, where)
|
|
44
|
+
if (value instanceof Error) return Object.assign(new Error(value.message), { name: value.name })
|
|
45
|
+
if (seen.has(value)) throw new PluginTestError(`${where}: cyclic data cannot cross between processes`)
|
|
46
|
+
seen.add(value)
|
|
47
|
+
try {
|
|
48
|
+
if (Array.isArray(value)) return value.map((item, i) => cross(item, `${where}[${i}]`, seen))
|
|
49
|
+
if (builtins.some(type => value instanceof type) || ArrayBuffer.isView(value)) return JSON.parse(JSON.stringify(value))
|
|
50
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, cross(item, `${where}.${key}`, seen)]))
|
|
51
|
+
} finally {
|
|
52
|
+
seen.delete(value)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function crossResult(result, where) {
|
|
57
|
+
return result instanceof Promise ? result.then(value => cross(value, where)) : cross(result, where)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// An object with behaviour, used from the other process: its methods are
|
|
61
|
+
// called by reference.
|
|
62
|
+
function reference(target, where) {
|
|
63
|
+
return new Proxy(target, {
|
|
64
|
+
get(object, property) {
|
|
65
|
+
const value = Reflect.get(object, property, object)
|
|
66
|
+
return typeof value === 'function' ? cross(value.bind(object), `${where}.${String(property)}`) : cross(value, `${where}.${String(property)}`)
|
|
67
|
+
},
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// A service seen through its declared shape: only declared methods, sync
|
|
72
|
+
// ones returning values, async ones promises.
|
|
73
|
+
function shaped(name, object, shape, strict) {
|
|
74
|
+
const service = {}
|
|
75
|
+
for (const [method, kind] of Object.entries(shape)) {
|
|
76
|
+
service[method] = (...args) => {
|
|
77
|
+
const where = `${name}.${method}`
|
|
78
|
+
const crossed = strict ? args.map((arg, i) => cross(arg, `${where} argument ${i}`)) : args
|
|
79
|
+
const result = object[method](...crossed)
|
|
80
|
+
if (kind === 'sync') {
|
|
81
|
+
if (result instanceof Promise) throw new PluginTestError(`${where} is declared sync but returned a promise: declare it 'async'`)
|
|
82
|
+
return strict ? cross(result, where) : result
|
|
83
|
+
}
|
|
84
|
+
return Promise.resolve(result).then(value => (strict ? cross(value, where) : value))
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return service
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Load `plugin` (what definePlugin returns, or a module whose default export
|
|
92
|
+
* it is) with `config` and the services in `services`.
|
|
93
|
+
*/
|
|
94
|
+
export async function load(plugin, { config = {}, services = {}, strict = true } = {}) {
|
|
95
|
+
plugin = isPlugin(plugin) ? plugin : plugin?.default
|
|
96
|
+
if (!isPlugin(plugin)) throw new PluginTestError('load needs a plugin made with definePlugin')
|
|
97
|
+
if (plugin.api > PLUGIN_API) {
|
|
98
|
+
throw new PluginTestError(`the plugin needs plugin API ${plugin.api}; this SDK supports ${PLUGIN_API}`)
|
|
99
|
+
}
|
|
100
|
+
for (const name of plugin.inject) {
|
|
101
|
+
if (!(name in services)) throw new PluginTestError(`the plugin injects ${name}: give the test a service ${name}`)
|
|
102
|
+
}
|
|
103
|
+
const provided = new Map()
|
|
104
|
+
const cleanups = []
|
|
105
|
+
let unloaded = false
|
|
106
|
+
const ctx = {
|
|
107
|
+
use(name) {
|
|
108
|
+
if (!plugin.inject.includes(name)) throw new PluginTestError(`the plugin uses ${name} without declaring it in inject`)
|
|
109
|
+
return strict ? cross(services[name], `service ${name}`) : services[name]
|
|
110
|
+
},
|
|
111
|
+
provide(name, value) {
|
|
112
|
+
const shape = plugin.provides[name]
|
|
113
|
+
if (shape) {
|
|
114
|
+
for (const method of Object.keys(shape)) {
|
|
115
|
+
if (typeof value?.[method] !== 'function') throw new PluginTestError(`${name} is declared with ${method} in provides, but the service has no such method`)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const entry = { value }
|
|
119
|
+
provided.set(name, entry)
|
|
120
|
+
return () => {
|
|
121
|
+
if (provided.get(name) === entry) provided.delete(name)
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
effect(cleanup) {
|
|
125
|
+
if (typeof cleanup !== 'function') throw new PluginTestError('effect needs a cleanup function')
|
|
126
|
+
cleanups.push(cleanup)
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
const returned = await plugin.apply(ctx, strict ? cross(config, 'config') : config)
|
|
130
|
+
if (returned !== undefined && returned !== null) {
|
|
131
|
+
if (typeof returned !== 'function') throw new PluginTestError('apply must return a cleanup function or nothing')
|
|
132
|
+
cleanups.push(returned)
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
/** The service `name` the plugin provides, as rutis sees it: its declared methods only. */
|
|
136
|
+
service(name) {
|
|
137
|
+
if (unloaded) throw new PluginTestError('the plugin is unloaded')
|
|
138
|
+
const shape = plugin.provides[name]
|
|
139
|
+
if (!shape) throw new PluginTestError(`${name} is not declared in provides, so rutis cannot use it`)
|
|
140
|
+
const entry = provided.get(name)
|
|
141
|
+
if (!entry) throw new PluginTestError(`the plugin does not provide ${name} (now)`)
|
|
142
|
+
return shaped(name, entry.value, shape, strict)
|
|
143
|
+
},
|
|
144
|
+
/** The names of the services the plugin provides now. */
|
|
145
|
+
provided: () => [...provided.keys()],
|
|
146
|
+
/** Run the cleanups, latest first; the plugin's services are withdrawn. */
|
|
147
|
+
async unload() {
|
|
148
|
+
if (unloaded) return
|
|
149
|
+
unloaded = true
|
|
150
|
+
for (const cleanup of cleanups.reverse()) await cleanup()
|
|
151
|
+
provided.clear()
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
}
|