@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 CHANGED
@@ -1,3 +1,31 @@
1
- # Temporary Holding Version
1
+ # @arcships/rutis
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
@@ -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>
@@ -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
+ }