@zhin.js/plugin-runtime 1.0.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/LICENSE +21 -0
- package/README.md +29 -0
- package/lib/capability.d.ts +10 -0
- package/lib/capability.js +7 -0
- package/lib/database-host.d.ts +78 -0
- package/lib/database-host.js +2 -0
- package/lib/dispose.d.ts +7 -0
- package/lib/dispose.js +36 -0
- package/lib/handoff.d.ts +30 -0
- package/lib/handoff.js +96 -0
- package/lib/html-renderer.d.ts +23 -0
- package/lib/html-renderer.js +2 -0
- package/lib/identity.d.ts +22 -0
- package/lib/identity.js +32 -0
- package/lib/index.d.ts +13 -0
- package/lib/index.js +13 -0
- package/lib/outbound-host.d.ts +41 -0
- package/lib/outbound-host.js +2 -0
- package/lib/plugin.d.ts +33 -0
- package/lib/plugin.js +11 -0
- package/lib/root-controller.d.ts +18 -0
- package/lib/root-controller.js +140 -0
- package/lib/schedule-host.d.ts +21 -0
- package/lib/schedule-host.js +2 -0
- package/lib/shared-lifetime.d.ts +11 -0
- package/lib/shared-lifetime.js +31 -0
- package/lib/snapshot.d.ts +50 -0
- package/lib/snapshot.js +155 -0
- package/lib/token.d.ts +20 -0
- package/lib/token.js +52 -0
- package/package.json +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 凉菜
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# @zhin.js/plugin-runtime
|
|
2
|
+
|
|
3
|
+
Zhin Plugin-first 架构的零生产依赖底座。它定义 Plugin tree、Scope、Capability Slot、
|
|
4
|
+
不可变 RuntimeSnapshot、generation lease、handoff 与 RootController,不理解 IM、Agent、
|
|
5
|
+
配置格式、文件发现或构建工具。
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import {
|
|
9
|
+
createToken,
|
|
10
|
+
definePlugin,
|
|
11
|
+
RootController,
|
|
12
|
+
} from '@zhin.js/plugin-runtime';
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## 生命周期
|
|
16
|
+
|
|
17
|
+
```text
|
|
18
|
+
prepare shadow state -> validate -> handoff -> atomic commit
|
|
19
|
+
-> new work leases new snapshot -> old generation drains -> dispose
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
局部 HMR 只缩小 prepare/setup/dispose 范围;每次 commit 始终发布完整一致的 snapshot。
|
|
23
|
+
长期 socket、worker 或 admission loop 必须参加 generation handoff,不能成为模块级副作用。
|
|
24
|
+
|
|
25
|
+
本包没有生产依赖。YAML、AJV、watcher、IM 与 AI 都属于上层 adapter 或 Runtime。
|
|
26
|
+
|
|
27
|
+
验证:`pnpm --filter @zhin.js/plugin-runtime test && pnpm --filter @zhin.js/plugin-runtime build`。
|
|
28
|
+
|
|
29
|
+
详细契约见 [Kernel 与 Generation](../../../docs/architecture/target-implementation/kernel-and-generation.md)。
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type CapabilityId, type FeatureId, type PluginId } from './identity.js';
|
|
2
|
+
export interface CapabilitySlot<T = unknown> {
|
|
3
|
+
readonly id: CapabilityId;
|
|
4
|
+
readonly owner: PluginId;
|
|
5
|
+
readonly feature: FeatureId;
|
|
6
|
+
readonly localName: string;
|
|
7
|
+
readonly source: string;
|
|
8
|
+
readonly definition: T;
|
|
9
|
+
}
|
|
10
|
+
export declare function createCapabilitySlot<T>(input: Omit<CapabilitySlot<T>, 'id'>): Readonly<CapabilitySlot<T>>;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal model surface shared by lottery / rss / group-suite memory stores
|
|
3
|
+
* and the Host-backed `@zhin.js/database` adapter.
|
|
4
|
+
*/
|
|
5
|
+
export interface DatabaseHostModel {
|
|
6
|
+
select(): {
|
|
7
|
+
where(query: Record<string, unknown>): Promise<Record<string, unknown>[]>;
|
|
8
|
+
then<TResult1 = Record<string, unknown>[], TResult2 = never>(onfulfilled?: ((value: Record<string, unknown>[]) => TResult1 | PromiseLike<TResult1>) | null, onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null): Promise<TResult1 | TResult2>;
|
|
9
|
+
};
|
|
10
|
+
insert(row: Record<string, unknown>): Promise<unknown>;
|
|
11
|
+
delete(): {
|
|
12
|
+
where(query: Record<string, unknown>): Promise<unknown>;
|
|
13
|
+
};
|
|
14
|
+
update(patch: Record<string, unknown>): {
|
|
15
|
+
where(query: Record<string, unknown>): Promise<unknown>;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export type DatabaseHostType = 'related' | 'document' | 'keyvalue';
|
|
19
|
+
export interface DatabaseHostTable {
|
|
20
|
+
readonly name: string;
|
|
21
|
+
readonly columns?: Readonly<Record<string, unknown>>;
|
|
22
|
+
}
|
|
23
|
+
export interface DatabaseHostSelectResult {
|
|
24
|
+
readonly rows: readonly unknown[];
|
|
25
|
+
readonly total: number;
|
|
26
|
+
readonly page: number;
|
|
27
|
+
readonly pageSize: number;
|
|
28
|
+
}
|
|
29
|
+
/** Root-only database administration surface used by Console RPC. */
|
|
30
|
+
export interface DatabaseHostConsole {
|
|
31
|
+
info(): {
|
|
32
|
+
readonly dialect: string;
|
|
33
|
+
readonly type: DatabaseHostType;
|
|
34
|
+
readonly tables: readonly string[];
|
|
35
|
+
readonly connected: boolean;
|
|
36
|
+
};
|
|
37
|
+
tables(): readonly DatabaseHostTable[];
|
|
38
|
+
select(table: string, page: number, pageSize: number, where?: Record<string, unknown>): Promise<DatabaseHostSelectResult>;
|
|
39
|
+
insert(table: string, row: Record<string, unknown>): Promise<void>;
|
|
40
|
+
update(table: string, row: Record<string, unknown>, where: Record<string, unknown>): Promise<unknown>;
|
|
41
|
+
delete(table: string, where: Record<string, unknown>): Promise<unknown>;
|
|
42
|
+
dropTable(table: string): Promise<void>;
|
|
43
|
+
kvGet(table: string, key: string): Promise<unknown>;
|
|
44
|
+
kvSet(table: string, key: string, value: unknown, ttl?: number): Promise<void>;
|
|
45
|
+
kvDelete(table: string, key: string): Promise<void>;
|
|
46
|
+
kvEntries(table: string): Promise<readonly {
|
|
47
|
+
key: string;
|
|
48
|
+
value: unknown;
|
|
49
|
+
}[]>;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Thin Host Resource for Plugin Runtime persistence.
|
|
53
|
+
* Plugins call `define` during `setup()`; Host starts the dialect on generation
|
|
54
|
+
* `activateNext` so models are available before commands / cron run.
|
|
55
|
+
*/
|
|
56
|
+
export interface DatabaseHost {
|
|
57
|
+
readonly dialect: string;
|
|
58
|
+
readonly started: boolean;
|
|
59
|
+
/** Register a table schema before `start` (column defs match `@zhin.js/database`). */
|
|
60
|
+
define(name: string, definition: Record<string, unknown>): void;
|
|
61
|
+
/** Names of tables registered via `define` (Console `db:tables`). */
|
|
62
|
+
tables(): readonly string[];
|
|
63
|
+
models: {
|
|
64
|
+
get(name: string): DatabaseHostModel | undefined;
|
|
65
|
+
};
|
|
66
|
+
/** Root-only management port; absent on lightweight test/memory hosts. */
|
|
67
|
+
readonly console?: DatabaseHostConsole;
|
|
68
|
+
/**
|
|
69
|
+
* Raw `@zhin.js/database` registry (full `create` / `select` API).
|
|
70
|
+
* Available after `start()` — used by Agent Host ADR 0009 persistence.
|
|
71
|
+
*/
|
|
72
|
+
getRawDatabase(): {
|
|
73
|
+
models?: Map<string, unknown>;
|
|
74
|
+
} | undefined;
|
|
75
|
+
start(): Promise<void>;
|
|
76
|
+
stop(): Promise<void>;
|
|
77
|
+
}
|
|
78
|
+
export declare const databaseHostToken: import("./token.js").Token<DatabaseHost>;
|
package/lib/dispose.d.ts
ADDED
package/lib/dispose.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export class DisposeStack {
|
|
2
|
+
#items = [];
|
|
3
|
+
#sealed = false;
|
|
4
|
+
#disposed = false;
|
|
5
|
+
add(dispose) {
|
|
6
|
+
if (this.#sealed)
|
|
7
|
+
throw new Error('DisposeStack is sealed');
|
|
8
|
+
if (this.#disposed)
|
|
9
|
+
throw new Error('DisposeStack is disposed');
|
|
10
|
+
this.#items.push(dispose);
|
|
11
|
+
return dispose;
|
|
12
|
+
}
|
|
13
|
+
seal() {
|
|
14
|
+
if (this.#disposed)
|
|
15
|
+
throw new Error('DisposeStack is disposed');
|
|
16
|
+
this.#sealed = true;
|
|
17
|
+
}
|
|
18
|
+
async dispose() {
|
|
19
|
+
if (this.#disposed)
|
|
20
|
+
return;
|
|
21
|
+
this.#disposed = true;
|
|
22
|
+
this.#sealed = true;
|
|
23
|
+
const errors = [];
|
|
24
|
+
for (const dispose of this.#items.splice(0).reverse()) {
|
|
25
|
+
try {
|
|
26
|
+
await dispose();
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
errors.push(error);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (errors.length > 0) {
|
|
33
|
+
throw new AggregateError(errors, 'One or more disposers failed');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
package/lib/handoff.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { RuntimeSnapshot } from './snapshot.js';
|
|
2
|
+
export interface GenerationHandoff {
|
|
3
|
+
quiescePrevious(previous: RuntimeSnapshot): void | Promise<void>;
|
|
4
|
+
activateNext(): void | Promise<void>;
|
|
5
|
+
deactivateNext(): void | Promise<void>;
|
|
6
|
+
resumePrevious(): void | Promise<void>;
|
|
7
|
+
openNext(): void;
|
|
8
|
+
}
|
|
9
|
+
export interface GenerationHandoffParticipant {
|
|
10
|
+
quiescePrevious?(previous: RuntimeSnapshot): void | Promise<void>;
|
|
11
|
+
activateNext?(): void | Promise<void>;
|
|
12
|
+
deactivateNext?(): void | Promise<void>;
|
|
13
|
+
resumePrevious?(): void | Promise<void>;
|
|
14
|
+
openNext?(): void;
|
|
15
|
+
}
|
|
16
|
+
export interface GenerationHandoffRegistry {
|
|
17
|
+
add(participant: GenerationHandoffParticipant): GenerationHandoffParticipant;
|
|
18
|
+
}
|
|
19
|
+
/** Composes owner-ordered Resource handoffs and compensates partial progress. */
|
|
20
|
+
export declare class GenerationHandoffStack implements GenerationHandoffRegistry, GenerationHandoff {
|
|
21
|
+
#private;
|
|
22
|
+
get size(): number;
|
|
23
|
+
add(participant: GenerationHandoffParticipant): GenerationHandoffParticipant;
|
|
24
|
+
seal(): GenerationHandoff | undefined;
|
|
25
|
+
quiescePrevious(previous: RuntimeSnapshot): Promise<void>;
|
|
26
|
+
activateNext(): Promise<void>;
|
|
27
|
+
deactivateNext(): Promise<void>;
|
|
28
|
+
resumePrevious(): Promise<void>;
|
|
29
|
+
openNext(): void;
|
|
30
|
+
}
|
package/lib/handoff.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { DisposeStack } from './dispose.js';
|
|
2
|
+
/** Composes owner-ordered Resource handoffs and compensates partial progress. */
|
|
3
|
+
export class GenerationHandoffStack {
|
|
4
|
+
#participants = [];
|
|
5
|
+
#quiesced = [];
|
|
6
|
+
#activated = [];
|
|
7
|
+
#sealed = false;
|
|
8
|
+
get size() {
|
|
9
|
+
return this.#participants.length;
|
|
10
|
+
}
|
|
11
|
+
add(participant) {
|
|
12
|
+
if (this.#sealed)
|
|
13
|
+
throw new Error('GenerationHandoffStack is sealed');
|
|
14
|
+
this.#participants.push(participant);
|
|
15
|
+
return participant;
|
|
16
|
+
}
|
|
17
|
+
seal() {
|
|
18
|
+
this.#sealed = true;
|
|
19
|
+
return this.size > 0 ? this : undefined;
|
|
20
|
+
}
|
|
21
|
+
async quiescePrevious(previous) {
|
|
22
|
+
this.#assertSealed();
|
|
23
|
+
try {
|
|
24
|
+
for (const participant of [...this.#participants].reverse()) {
|
|
25
|
+
if (!participant.quiescePrevious)
|
|
26
|
+
continue;
|
|
27
|
+
await participant.quiescePrevious(previous);
|
|
28
|
+
this.#quiesced.push(participant);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
await compensate(error, () => this.resumePrevious(), 'Quiesce and resume both failed');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async activateNext() {
|
|
36
|
+
this.#assertSealed();
|
|
37
|
+
try {
|
|
38
|
+
for (const participant of this.#participants) {
|
|
39
|
+
if (!participant.activateNext)
|
|
40
|
+
continue;
|
|
41
|
+
await participant.activateNext();
|
|
42
|
+
this.#activated.push(participant);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
await compensate(error, () => this.deactivateNext(), 'Activation and deactivation both failed');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async deactivateNext() {
|
|
50
|
+
await unwind(this.#activated, (participant) => participant.deactivateNext?.(), 'One or more next-generation Resources failed to deactivate');
|
|
51
|
+
}
|
|
52
|
+
async resumePrevious() {
|
|
53
|
+
await unwind(this.#quiesced, (participant) => participant.resumePrevious?.(), 'One or more previous-generation Resources failed to resume');
|
|
54
|
+
}
|
|
55
|
+
openNext() {
|
|
56
|
+
const errors = [];
|
|
57
|
+
for (const participant of this.#participants) {
|
|
58
|
+
try {
|
|
59
|
+
participant.openNext?.();
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
errors.push(error);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (errors.length > 0)
|
|
66
|
+
throw new AggregateError(errors, 'One or more Resources failed to open');
|
|
67
|
+
}
|
|
68
|
+
#assertSealed() {
|
|
69
|
+
if (!this.#sealed)
|
|
70
|
+
throw new Error('GenerationHandoffStack is not sealed');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async function compensate(primary, rollback, message) {
|
|
74
|
+
try {
|
|
75
|
+
await rollback();
|
|
76
|
+
}
|
|
77
|
+
catch (rollbackError) {
|
|
78
|
+
throw new AggregateError([primary, rollbackError], message, { cause: rollbackError });
|
|
79
|
+
}
|
|
80
|
+
throw primary;
|
|
81
|
+
}
|
|
82
|
+
async function unwind(completed, run, message) {
|
|
83
|
+
const disposers = new DisposeStack();
|
|
84
|
+
for (const participant of completed.splice(0)) {
|
|
85
|
+
disposers.add(() => run(participant));
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
await disposers.dispose();
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
if (error instanceof AggregateError) {
|
|
92
|
+
throw new AggregateError(error.errors, message, { cause: error });
|
|
93
|
+
}
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface HtmlRenderOptions {
|
|
2
|
+
readonly width?: number;
|
|
3
|
+
readonly format?: 'png' | 'svg';
|
|
4
|
+
readonly backgroundColor?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface HtmlRenderResult {
|
|
7
|
+
/** PNG 时为 Buffer/Uint8Array,SVG 时为 string。 */
|
|
8
|
+
readonly data: unknown;
|
|
9
|
+
readonly format: 'png' | 'svg';
|
|
10
|
+
readonly width: number;
|
|
11
|
+
readonly height: number;
|
|
12
|
+
readonly mimeType: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Thin Host Resource for Plugin Runtime outbound html→image rendering.
|
|
16
|
+
* Implemented by the optional `@zhin.js/html-renderer` package (wired by the
|
|
17
|
+
* CLI Host); absent when the package is not installed — consumers must fall
|
|
18
|
+
* back to plain text.
|
|
19
|
+
*/
|
|
20
|
+
export interface HtmlRendererHost {
|
|
21
|
+
render(html: string, options?: HtmlRenderOptions): Promise<HtmlRenderResult>;
|
|
22
|
+
}
|
|
23
|
+
export declare const htmlRendererToken: import("./token.js").Token<HtmlRendererHost>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
declare const tokenIdBrand: unique symbol;
|
|
2
|
+
declare const pluginIdBrand: unique symbol;
|
|
3
|
+
declare const featureIdBrand: unique symbol;
|
|
4
|
+
declare const capabilityIdBrand: unique symbol;
|
|
5
|
+
export type TokenId = string & {
|
|
6
|
+
readonly [tokenIdBrand]: true;
|
|
7
|
+
};
|
|
8
|
+
export type PluginId = string & {
|
|
9
|
+
readonly [pluginIdBrand]: true;
|
|
10
|
+
};
|
|
11
|
+
export type FeatureId = string & {
|
|
12
|
+
readonly [featureIdBrand]: true;
|
|
13
|
+
};
|
|
14
|
+
export type CapabilityId = string & {
|
|
15
|
+
readonly [capabilityIdBrand]: true;
|
|
16
|
+
};
|
|
17
|
+
export declare function tokenId(value: string): TokenId;
|
|
18
|
+
export declare function featureId(value: string): FeatureId;
|
|
19
|
+
export declare function rootPluginId(): PluginId;
|
|
20
|
+
export declare function childPluginId(parent: PluginId, instanceKey: string): PluginId;
|
|
21
|
+
export declare function capabilityId(owner: PluginId, feature: FeatureId, localName: string): CapabilityId;
|
|
22
|
+
export {};
|
package/lib/identity.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const namespacePattern = /^[a-z][a-z0-9.-]*$/;
|
|
2
|
+
const localNamePattern = /^[a-z0-9][a-z0-9-]*$/;
|
|
3
|
+
const capabilityLocalSegment = String.raw `(?:[a-z0-9][a-z0-9-]*|\$[a-z][a-zA-Z0-9]*)`;
|
|
4
|
+
const capabilityLocalNamePattern = new RegExp(`^${capabilityLocalSegment}(?:/${capabilityLocalSegment})*$`);
|
|
5
|
+
function assertNamespace(value, label) {
|
|
6
|
+
if (!namespacePattern.test(value)) {
|
|
7
|
+
throw new TypeError(`Invalid ${label}: ${value}`);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export function tokenId(value) {
|
|
11
|
+
assertNamespace(value, 'token id');
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
export function featureId(value) {
|
|
15
|
+
assertNamespace(value, 'feature id');
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
export function rootPluginId() {
|
|
19
|
+
return 'root';
|
|
20
|
+
}
|
|
21
|
+
export function childPluginId(parent, instanceKey) {
|
|
22
|
+
if (!localNamePattern.test(instanceKey)) {
|
|
23
|
+
throw new TypeError(`Invalid plugin instance key: ${instanceKey}`);
|
|
24
|
+
}
|
|
25
|
+
return `${parent}/${instanceKey}`;
|
|
26
|
+
}
|
|
27
|
+
export function capabilityId(owner, feature, localName) {
|
|
28
|
+
if (!capabilityLocalNamePattern.test(localName)) {
|
|
29
|
+
throw new TypeError(`Invalid capability local name: ${localName}`);
|
|
30
|
+
}
|
|
31
|
+
return `${owner}\0${feature}\0${localName}`;
|
|
32
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export * from './capability.js';
|
|
2
|
+
export * from './dispose.js';
|
|
3
|
+
export * from './identity.js';
|
|
4
|
+
export * from './handoff.js';
|
|
5
|
+
export * from './plugin.js';
|
|
6
|
+
export * from './root-controller.js';
|
|
7
|
+
export * from './shared-lifetime.js';
|
|
8
|
+
export * from './snapshot.js';
|
|
9
|
+
export * from './token.js';
|
|
10
|
+
export * from './schedule-host.js';
|
|
11
|
+
export * from './database-host.js';
|
|
12
|
+
export * from './html-renderer.js';
|
|
13
|
+
export * from './outbound-host.js';
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export * from './capability.js';
|
|
2
|
+
export * from './dispose.js';
|
|
3
|
+
export * from './identity.js';
|
|
4
|
+
export * from './handoff.js';
|
|
5
|
+
export * from './plugin.js';
|
|
6
|
+
export * from './root-controller.js';
|
|
7
|
+
export * from './shared-lifetime.js';
|
|
8
|
+
export * from './snapshot.js';
|
|
9
|
+
export * from './token.js';
|
|
10
|
+
export * from './schedule-host.js';
|
|
11
|
+
export * from './database-host.js';
|
|
12
|
+
export * from './html-renderer.js';
|
|
13
|
+
export * from './outbound-host.js';
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin Host Resource for proactive Adapter outbound (RSS poll, lottery push,
|
|
3
|
+
* activity-feedback typing text). Implementations typically wrap ImRuntime
|
|
4
|
+
* `sendEndpointMessage` (resolve adapter/endpoint → AdapterIndex.send).
|
|
5
|
+
*/
|
|
6
|
+
export interface OutboundSendInput {
|
|
7
|
+
readonly adapter: string;
|
|
8
|
+
readonly endpointId: string;
|
|
9
|
+
readonly channelType: string;
|
|
10
|
+
readonly channelId: string;
|
|
11
|
+
readonly content: string;
|
|
12
|
+
}
|
|
13
|
+
export interface OutboundReactionInput {
|
|
14
|
+
readonly adapter: string;
|
|
15
|
+
readonly endpointId: string;
|
|
16
|
+
readonly messageId: string;
|
|
17
|
+
readonly emoji: string;
|
|
18
|
+
readonly sceneType?: string;
|
|
19
|
+
readonly channelId?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface OutboundRemoveReactionInput {
|
|
22
|
+
readonly adapter: string;
|
|
23
|
+
readonly endpointId: string;
|
|
24
|
+
readonly messageId: string;
|
|
25
|
+
readonly reactionId: string;
|
|
26
|
+
}
|
|
27
|
+
export interface OutboundRecallInput {
|
|
28
|
+
readonly adapter: string;
|
|
29
|
+
readonly endpointId: string;
|
|
30
|
+
readonly messageId: string;
|
|
31
|
+
}
|
|
32
|
+
export interface OutboundHost {
|
|
33
|
+
/** Returns platform message id when available (activity-feedback needs it). */
|
|
34
|
+
send(input: OutboundSendInput): Promise<string | null>;
|
|
35
|
+
/** Optional: platform message reactions (icqq group emoji, etc.). */
|
|
36
|
+
addReaction?(input: OutboundReactionInput): Promise<string | null>;
|
|
37
|
+
removeReaction?(input: OutboundRemoveReactionInput): Promise<void>;
|
|
38
|
+
/** Optional: recall/delete a status message (activity-feedback autoRemove). */
|
|
39
|
+
recall?(input: OutboundRecallInput): Promise<void>;
|
|
40
|
+
}
|
|
41
|
+
export declare const outboundHostToken: import("./token.js").Token<OutboundHost>;
|
package/lib/plugin.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Dispose, DisposeStack } from './dispose.js';
|
|
2
|
+
import type { GenerationHandoffRegistry } from './handoff.js';
|
|
3
|
+
import type { PluginId } from './identity.js';
|
|
4
|
+
import type { Scope, Token } from './token.js';
|
|
5
|
+
export interface PluginMetadata {
|
|
6
|
+
readonly displayName?: string;
|
|
7
|
+
readonly icon?: string;
|
|
8
|
+
readonly order?: number;
|
|
9
|
+
}
|
|
10
|
+
export interface ConfigView<T> {
|
|
11
|
+
get(): Readonly<T>;
|
|
12
|
+
}
|
|
13
|
+
export interface PluginInstanceView {
|
|
14
|
+
readonly id: PluginId;
|
|
15
|
+
readonly instanceKey: string;
|
|
16
|
+
readonly parent?: PluginId;
|
|
17
|
+
readonly root: PluginId;
|
|
18
|
+
readonly role: 'root' | 'child';
|
|
19
|
+
}
|
|
20
|
+
export interface PluginSetupContext<TConfig = unknown> {
|
|
21
|
+
readonly plugin: PluginInstanceView;
|
|
22
|
+
readonly config: ConfigView<TConfig>;
|
|
23
|
+
readonly resources: Scope;
|
|
24
|
+
readonly lifecycle: DisposeStack;
|
|
25
|
+
readonly handoff: GenerationHandoffRegistry;
|
|
26
|
+
}
|
|
27
|
+
export interface PluginDefinition<TConfig = unknown> {
|
|
28
|
+
readonly name: string;
|
|
29
|
+
readonly metadata?: PluginMetadata;
|
|
30
|
+
readonly requires?: readonly Token<unknown>[];
|
|
31
|
+
setup?(context: PluginSetupContext<TConfig>): void | Dispose | Promise<void | Dispose>;
|
|
32
|
+
}
|
|
33
|
+
export declare function definePlugin<TConfig = unknown>(definition: PluginDefinition<TConfig>): Readonly<PluginDefinition<TConfig>>;
|
package/lib/plugin.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const pluginNamePattern = /^[a-z][a-z0-9-]*$/;
|
|
2
|
+
export function definePlugin(definition) {
|
|
3
|
+
if (!pluginNamePattern.test(definition.name)) {
|
|
4
|
+
throw new TypeError(`Invalid plugin name: ${definition.name}`);
|
|
5
|
+
}
|
|
6
|
+
return Object.freeze({
|
|
7
|
+
...definition,
|
|
8
|
+
metadata: definition.metadata ? Object.freeze({ ...definition.metadata }) : undefined,
|
|
9
|
+
requires: Object.freeze([...(definition.requires ?? [])]),
|
|
10
|
+
});
|
|
11
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { PluginId } from './identity.js';
|
|
2
|
+
import { SnapshotStore, type PreparedGeneration, type RuntimeSnapshot, type SnapshotState } from './snapshot.js';
|
|
3
|
+
export type RootState = 'idle' | 'running' | 'stopping' | 'stopped' | 'failed';
|
|
4
|
+
export type PrepareGeneration = (current: RuntimeSnapshot) => PreparedGeneration | Promise<PreparedGeneration>;
|
|
5
|
+
export type PrepareTransaction = (current: RuntimeSnapshot) => PreparedGeneration | undefined | Promise<PreparedGeneration | undefined>;
|
|
6
|
+
export type ControlErrorHandler = (error: unknown) => void;
|
|
7
|
+
export declare class RootController {
|
|
8
|
+
#private;
|
|
9
|
+
private readonly onControlError;
|
|
10
|
+
readonly snapshots: SnapshotStore;
|
|
11
|
+
constructor(initial: SnapshotState, onControlError?: ControlErrorHandler);
|
|
12
|
+
get state(): RootState;
|
|
13
|
+
get generation(): number;
|
|
14
|
+
start(prepare: PrepareGeneration): Promise<RuntimeSnapshot>;
|
|
15
|
+
transact(prepare: PrepareTransaction): Promise<RuntimeSnapshot>;
|
|
16
|
+
reload(_target: PluginId | string, prepare: PrepareTransaction): Promise<RuntimeSnapshot>;
|
|
17
|
+
stop(): Promise<void>;
|
|
18
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { SnapshotStore, } from './snapshot.js';
|
|
2
|
+
export class RootController {
|
|
3
|
+
onControlError;
|
|
4
|
+
snapshots;
|
|
5
|
+
#state = 'idle';
|
|
6
|
+
#tail = Promise.resolve();
|
|
7
|
+
constructor(initial, onControlError = () => undefined) {
|
|
8
|
+
this.onControlError = onControlError;
|
|
9
|
+
this.snapshots = new SnapshotStore(initial);
|
|
10
|
+
}
|
|
11
|
+
get state() {
|
|
12
|
+
return this.#state;
|
|
13
|
+
}
|
|
14
|
+
get generation() {
|
|
15
|
+
return this.snapshots.current.generation;
|
|
16
|
+
}
|
|
17
|
+
start(prepare) {
|
|
18
|
+
return this.#enqueue(async () => {
|
|
19
|
+
if (this.#state !== 'idle') {
|
|
20
|
+
throw new Error(`Cannot start RootController from ${this.#state}`);
|
|
21
|
+
}
|
|
22
|
+
let prepared;
|
|
23
|
+
let activated = false;
|
|
24
|
+
try {
|
|
25
|
+
prepared = await prepare(this.snapshots.current);
|
|
26
|
+
if (prepared.handoff) {
|
|
27
|
+
activated = true;
|
|
28
|
+
await prepared.handoff.activateNext();
|
|
29
|
+
}
|
|
30
|
+
const handoff = prepared.handoff;
|
|
31
|
+
const snapshot = this.snapshots.commit(0, prepared);
|
|
32
|
+
prepared = undefined;
|
|
33
|
+
this.#state = 'running';
|
|
34
|
+
this.#openNext(handoff);
|
|
35
|
+
return snapshot;
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
this.#state = 'failed';
|
|
39
|
+
return this.#rollback(prepared, { activated }, error);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
transact(prepare) {
|
|
44
|
+
return this.#enqueue(async () => {
|
|
45
|
+
if (this.#state !== 'running') {
|
|
46
|
+
throw new Error(`Cannot transact RootController from ${this.#state}`);
|
|
47
|
+
}
|
|
48
|
+
const previous = this.snapshots.acquire();
|
|
49
|
+
let prepared;
|
|
50
|
+
let quiesced = false;
|
|
51
|
+
let activated = false;
|
|
52
|
+
try {
|
|
53
|
+
prepared = await prepare(previous.value);
|
|
54
|
+
if (!prepared)
|
|
55
|
+
return previous.value;
|
|
56
|
+
if (prepared.handoff) {
|
|
57
|
+
quiesced = true;
|
|
58
|
+
await prepared.handoff.quiescePrevious(previous.value);
|
|
59
|
+
activated = true;
|
|
60
|
+
await prepared.handoff.activateNext();
|
|
61
|
+
}
|
|
62
|
+
const handoff = prepared.handoff;
|
|
63
|
+
const snapshot = this.snapshots.commit(previous.value.generation, prepared);
|
|
64
|
+
prepared = undefined;
|
|
65
|
+
this.#openNext(handoff);
|
|
66
|
+
return snapshot;
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
return this.#rollback(prepared, { quiesced, activated }, error);
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
previous.release();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
reload(_target, prepare) {
|
|
77
|
+
return this.transact(prepare);
|
|
78
|
+
}
|
|
79
|
+
stop() {
|
|
80
|
+
return this.#enqueue(async () => {
|
|
81
|
+
if (this.#state === 'stopped')
|
|
82
|
+
return;
|
|
83
|
+
if (this.#state !== 'running' && this.#state !== 'failed') {
|
|
84
|
+
throw new Error(`Cannot stop RootController from ${this.#state}`);
|
|
85
|
+
}
|
|
86
|
+
this.#state = 'stopping';
|
|
87
|
+
await this.snapshots.close();
|
|
88
|
+
this.#state = 'stopped';
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
#enqueue(operation) {
|
|
92
|
+
const result = this.#tail.then(operation, operation);
|
|
93
|
+
this.#tail = result.catch(() => undefined);
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
#openNext(handoff) {
|
|
97
|
+
try {
|
|
98
|
+
handoff?.openNext();
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
try {
|
|
102
|
+
this.onControlError(error);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// Error reporting cannot roll back an already committed generation.
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async #rollback(prepared, state, transactionError) {
|
|
110
|
+
const errors = [transactionError];
|
|
111
|
+
if (prepared && state.activated) {
|
|
112
|
+
try {
|
|
113
|
+
await prepared.handoff?.deactivateNext();
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
errors.push(error);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (prepared && state.quiesced) {
|
|
120
|
+
try {
|
|
121
|
+
await prepared.handoff?.resumePrevious();
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
errors.push(error);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (prepared) {
|
|
128
|
+
try {
|
|
129
|
+
await prepared.dispose();
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
errors.push(error);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (errors.length > 1) {
|
|
136
|
+
throw new AggregateError(errors, 'Generation transaction and rollback both failed');
|
|
137
|
+
}
|
|
138
|
+
throw transactionError;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin Host Resource for Plugin Runtime cron jobs.
|
|
3
|
+
* Implementations typically wrap `@zhin.js/schedule` CalendarScheduler (solar cron).
|
|
4
|
+
*/
|
|
5
|
+
export interface ScheduleJobRegistration {
|
|
6
|
+
readonly id: string;
|
|
7
|
+
/** 6-field solar cron: `秒 分 时 日 月 周` */
|
|
8
|
+
readonly cron: string;
|
|
9
|
+
readonly description?: string;
|
|
10
|
+
execute(): void | Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
export interface ScheduleHost {
|
|
13
|
+
/** Register a solar cron job; returns disposer that cancels the job. */
|
|
14
|
+
register(job: ScheduleJobRegistration): () => void;
|
|
15
|
+
list(): readonly {
|
|
16
|
+
readonly id: string;
|
|
17
|
+
readonly cron: string;
|
|
18
|
+
readonly description?: string;
|
|
19
|
+
}[];
|
|
20
|
+
}
|
|
21
|
+
export declare const scheduleHostToken: import("./token.js").Token<ScheduleHost>;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Dispose } from './dispose.js';
|
|
2
|
+
export interface LifetimeLease {
|
|
3
|
+
release(): Promise<void>;
|
|
4
|
+
}
|
|
5
|
+
export declare class SharedLifetime {
|
|
6
|
+
#private;
|
|
7
|
+
private readonly dispose;
|
|
8
|
+
constructor(dispose: Dispose);
|
|
9
|
+
get references(): number;
|
|
10
|
+
acquire(): LifetimeLease;
|
|
11
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export class SharedLifetime {
|
|
2
|
+
dispose;
|
|
3
|
+
#references = 0;
|
|
4
|
+
#closed = false;
|
|
5
|
+
#disposal;
|
|
6
|
+
constructor(dispose) {
|
|
7
|
+
this.dispose = dispose;
|
|
8
|
+
}
|
|
9
|
+
get references() {
|
|
10
|
+
return this.#references;
|
|
11
|
+
}
|
|
12
|
+
acquire() {
|
|
13
|
+
if (this.#closed)
|
|
14
|
+
throw new Error('SharedLifetime is closed');
|
|
15
|
+
this.#references += 1;
|
|
16
|
+
let released = false;
|
|
17
|
+
return {
|
|
18
|
+
release: async () => {
|
|
19
|
+
if (released)
|
|
20
|
+
return this.#disposal;
|
|
21
|
+
released = true;
|
|
22
|
+
this.#references -= 1;
|
|
23
|
+
if (this.#references === 0) {
|
|
24
|
+
this.#closed = true;
|
|
25
|
+
this.#disposal = Promise.resolve().then(this.dispose);
|
|
26
|
+
}
|
|
27
|
+
await this.#disposal;
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { CapabilitySlot } from './capability.js';
|
|
2
|
+
import type { Dispose } from './dispose.js';
|
|
3
|
+
import type { GenerationHandoff } from './handoff.js';
|
|
4
|
+
import type { CapabilityId, FeatureId, PluginId, TokenId } from './identity.js';
|
|
5
|
+
import type { PluginMetadata } from './plugin.js';
|
|
6
|
+
export interface PluginNodeSnapshot {
|
|
7
|
+
readonly id: PluginId;
|
|
8
|
+
readonly instanceKey: string;
|
|
9
|
+
readonly packageName: string;
|
|
10
|
+
readonly packageRoot: string;
|
|
11
|
+
readonly parent?: PluginId;
|
|
12
|
+
readonly children: readonly PluginId[];
|
|
13
|
+
readonly metadata?: PluginMetadata;
|
|
14
|
+
}
|
|
15
|
+
export interface RuntimeSnapshot {
|
|
16
|
+
readonly generation: number;
|
|
17
|
+
readonly root: PluginId;
|
|
18
|
+
readonly tree: ReadonlyMap<PluginId, PluginNodeSnapshot>;
|
|
19
|
+
readonly config: ReadonlyMap<PluginId, unknown>;
|
|
20
|
+
readonly resources: ReadonlyMap<PluginId, ReadonlyMap<TokenId, unknown>>;
|
|
21
|
+
readonly capabilities: ReadonlyMap<CapabilityId, CapabilitySlot>;
|
|
22
|
+
readonly projections: ReadonlyMap<FeatureId, unknown>;
|
|
23
|
+
}
|
|
24
|
+
export type SnapshotState = Omit<RuntimeSnapshot, 'generation'>;
|
|
25
|
+
export interface PreparedGeneration {
|
|
26
|
+
readonly snapshot: SnapshotState;
|
|
27
|
+
readonly dispose: Dispose;
|
|
28
|
+
readonly handoff?: GenerationHandoff;
|
|
29
|
+
}
|
|
30
|
+
export declare class GenerationConflictError extends Error {
|
|
31
|
+
readonly expected: number;
|
|
32
|
+
readonly actual: number;
|
|
33
|
+
constructor(expected: number, actual: number);
|
|
34
|
+
}
|
|
35
|
+
export declare class SnapshotLease {
|
|
36
|
+
#private;
|
|
37
|
+
readonly value: RuntimeSnapshot;
|
|
38
|
+
private readonly releaseRecord;
|
|
39
|
+
constructor(value: RuntimeSnapshot, releaseRecord: () => void);
|
|
40
|
+
release(): void;
|
|
41
|
+
}
|
|
42
|
+
export declare class SnapshotStore {
|
|
43
|
+
#private;
|
|
44
|
+
constructor(initial: SnapshotState);
|
|
45
|
+
get current(): RuntimeSnapshot;
|
|
46
|
+
acquire(): SnapshotLease;
|
|
47
|
+
commit(expectedGeneration: number, prepared: PreparedGeneration): RuntimeSnapshot;
|
|
48
|
+
close(): Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
export declare function createSnapshotView(generation: number, state: SnapshotState): RuntimeSnapshot;
|
package/lib/snapshot.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
export class GenerationConflictError extends Error {
|
|
2
|
+
expected;
|
|
3
|
+
actual;
|
|
4
|
+
constructor(expected, actual) {
|
|
5
|
+
super(`Generation conflict: expected ${expected}, actual ${actual}`);
|
|
6
|
+
this.expected = expected;
|
|
7
|
+
this.actual = actual;
|
|
8
|
+
this.name = 'GenerationConflictError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export class SnapshotLease {
|
|
12
|
+
value;
|
|
13
|
+
releaseRecord;
|
|
14
|
+
#released = false;
|
|
15
|
+
constructor(value, releaseRecord) {
|
|
16
|
+
this.value = value;
|
|
17
|
+
this.releaseRecord = releaseRecord;
|
|
18
|
+
}
|
|
19
|
+
release() {
|
|
20
|
+
if (this.#released)
|
|
21
|
+
return;
|
|
22
|
+
this.#released = true;
|
|
23
|
+
this.releaseRecord();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export class SnapshotStore {
|
|
27
|
+
#current;
|
|
28
|
+
#retired = new Set();
|
|
29
|
+
#closed = false;
|
|
30
|
+
constructor(initial) {
|
|
31
|
+
this.#current = {
|
|
32
|
+
snapshot: createSnapshotView(0, initial),
|
|
33
|
+
dispose: () => undefined,
|
|
34
|
+
leases: 0,
|
|
35
|
+
retired: false,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
get current() {
|
|
39
|
+
return this.#current.snapshot;
|
|
40
|
+
}
|
|
41
|
+
acquire() {
|
|
42
|
+
if (this.#closed)
|
|
43
|
+
throw new Error('SnapshotStore is closed');
|
|
44
|
+
const record = this.#current;
|
|
45
|
+
record.leases += 1;
|
|
46
|
+
return new SnapshotLease(record.snapshot, () => {
|
|
47
|
+
record.leases -= 1;
|
|
48
|
+
this.#disposeIfReady(record);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
commit(expectedGeneration, prepared) {
|
|
52
|
+
// The caller owns a candidate until this method succeeds. This lets the
|
|
53
|
+
// transaction authority undo handoff state before disposing a rejection.
|
|
54
|
+
if (this.#closed) {
|
|
55
|
+
throw new Error('SnapshotStore is closed');
|
|
56
|
+
}
|
|
57
|
+
if (this.#current.snapshot.generation !== expectedGeneration) {
|
|
58
|
+
throw new GenerationConflictError(expectedGeneration, this.#current.snapshot.generation);
|
|
59
|
+
}
|
|
60
|
+
const previous = this.#current;
|
|
61
|
+
// Commit only swaps the active pointer. The previous record may still be
|
|
62
|
+
// serving in-flight work, so disposal is deferred to its final lease.
|
|
63
|
+
this.#current = {
|
|
64
|
+
snapshot: createSnapshotView(expectedGeneration + 1, prepared.snapshot),
|
|
65
|
+
dispose: prepared.dispose,
|
|
66
|
+
leases: 0,
|
|
67
|
+
retired: false,
|
|
68
|
+
};
|
|
69
|
+
previous.retired = true;
|
|
70
|
+
this.#retired.add(previous);
|
|
71
|
+
this.#disposeIfReady(previous);
|
|
72
|
+
return this.#current.snapshot;
|
|
73
|
+
}
|
|
74
|
+
async close() {
|
|
75
|
+
if (this.#closed)
|
|
76
|
+
return;
|
|
77
|
+
this.#closed = true;
|
|
78
|
+
this.#current.retired = true;
|
|
79
|
+
this.#retired.add(this.#current);
|
|
80
|
+
this.#disposeIfReady(this.#current);
|
|
81
|
+
// A Root is not stopped while any historical generation still owns live
|
|
82
|
+
// resources. This also surfaces deferred disposer failures to the caller.
|
|
83
|
+
await Promise.all([...this.#retired].map((record) => this.#waitForDisposal(record)));
|
|
84
|
+
this.#retired.clear();
|
|
85
|
+
}
|
|
86
|
+
#disposeIfReady(record) {
|
|
87
|
+
if (!record.retired || record.leases !== 0)
|
|
88
|
+
return;
|
|
89
|
+
if (!record.disposing) {
|
|
90
|
+
record.disposing = Promise.resolve().then(record.dispose);
|
|
91
|
+
void record.disposing.then(() => record.drain?.resolve(), (error) => record.drain?.reject(error));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
#waitForDisposal(record) {
|
|
95
|
+
if (record.disposing)
|
|
96
|
+
return record.disposing;
|
|
97
|
+
if (!record.drain) {
|
|
98
|
+
let resolve;
|
|
99
|
+
let reject;
|
|
100
|
+
const promise = new Promise((onResolve, onReject) => {
|
|
101
|
+
resolve = onResolve;
|
|
102
|
+
reject = onReject;
|
|
103
|
+
});
|
|
104
|
+
record.drain = { promise, resolve, reject };
|
|
105
|
+
}
|
|
106
|
+
return record.drain.promise;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export function createSnapshotView(generation, state) {
|
|
110
|
+
// Object.freeze(Map) does not disable Map#set. ReadonlyMapView removes the
|
|
111
|
+
// mutator interface at runtime, including from provider projection hooks.
|
|
112
|
+
return Object.freeze({
|
|
113
|
+
...state,
|
|
114
|
+
generation,
|
|
115
|
+
tree: readonlyMap(state.tree),
|
|
116
|
+
config: readonlyMap(state.config),
|
|
117
|
+
resources: readonlyMap([...state.resources].map(([owner, resources]) => [owner, readonlyMap(resources)])),
|
|
118
|
+
capabilities: readonlyMap(state.capabilities),
|
|
119
|
+
projections: readonlyMap(state.projections),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
class ReadonlyMapView {
|
|
123
|
+
#values;
|
|
124
|
+
constructor(entries) {
|
|
125
|
+
this.#values = new Map(entries);
|
|
126
|
+
Object.freeze(this);
|
|
127
|
+
}
|
|
128
|
+
get size() {
|
|
129
|
+
return this.#values.size;
|
|
130
|
+
}
|
|
131
|
+
get(key) {
|
|
132
|
+
return this.#values.get(key);
|
|
133
|
+
}
|
|
134
|
+
has(key) {
|
|
135
|
+
return this.#values.has(key);
|
|
136
|
+
}
|
|
137
|
+
entries() {
|
|
138
|
+
return this.#values.entries();
|
|
139
|
+
}
|
|
140
|
+
keys() {
|
|
141
|
+
return this.#values.keys();
|
|
142
|
+
}
|
|
143
|
+
values() {
|
|
144
|
+
return this.#values.values();
|
|
145
|
+
}
|
|
146
|
+
forEach(callback) {
|
|
147
|
+
this.#values.forEach((value, key) => callback(value, key, this));
|
|
148
|
+
}
|
|
149
|
+
[Symbol.iterator]() {
|
|
150
|
+
return this.entries();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function readonlyMap(values) {
|
|
154
|
+
return new ReadonlyMapView(values);
|
|
155
|
+
}
|
package/lib/token.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { DisposeStack, type Dispose } from './dispose.js';
|
|
2
|
+
import { type PluginId, type TokenId } from './identity.js';
|
|
3
|
+
export interface Token<T> {
|
|
4
|
+
readonly id: TokenId;
|
|
5
|
+
readonly description?: string;
|
|
6
|
+
readonly _type?: () => T;
|
|
7
|
+
}
|
|
8
|
+
export declare function createToken<T>(id: string, description?: string): Token<T>;
|
|
9
|
+
export declare class Scope {
|
|
10
|
+
#private;
|
|
11
|
+
readonly owner: PluginId;
|
|
12
|
+
readonly parent?: Scope | undefined;
|
|
13
|
+
readonly disposers: DisposeStack;
|
|
14
|
+
constructor(owner: PluginId, parent?: Scope | undefined, disposers?: DisposeStack);
|
|
15
|
+
provide<T>(token: Token<T>, value: T, dispose?: Dispose): void;
|
|
16
|
+
use<T>(token: Token<T>): T;
|
|
17
|
+
has<T>(token: Token<T>): boolean;
|
|
18
|
+
seal(): void;
|
|
19
|
+
snapshot(): ReadonlyMap<TokenId, unknown>;
|
|
20
|
+
}
|
package/lib/token.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { DisposeStack } from './dispose.js';
|
|
2
|
+
import { tokenId } from './identity.js';
|
|
3
|
+
export function createToken(id, description) {
|
|
4
|
+
return Object.freeze({ id: tokenId(id), description });
|
|
5
|
+
}
|
|
6
|
+
export class Scope {
|
|
7
|
+
owner;
|
|
8
|
+
parent;
|
|
9
|
+
disposers;
|
|
10
|
+
#bindings = new Map();
|
|
11
|
+
#sealed = false;
|
|
12
|
+
constructor(owner, parent, disposers = new DisposeStack()) {
|
|
13
|
+
this.owner = owner;
|
|
14
|
+
this.parent = parent;
|
|
15
|
+
this.disposers = disposers;
|
|
16
|
+
}
|
|
17
|
+
provide(token, value, dispose) {
|
|
18
|
+
if (this.#sealed)
|
|
19
|
+
throw new Error(`Scope is sealed: ${this.owner}`);
|
|
20
|
+
if (this.#bindings.has(token.id)) {
|
|
21
|
+
throw new Error(`Duplicate resource ${token.id} in ${this.owner}`);
|
|
22
|
+
}
|
|
23
|
+
this.#bindings.set(token.id, Object.freeze({ owner: this.owner, value }));
|
|
24
|
+
if (dispose)
|
|
25
|
+
this.disposers.add(dispose);
|
|
26
|
+
}
|
|
27
|
+
use(token) {
|
|
28
|
+
const binding = this.#bindings.get(token.id);
|
|
29
|
+
if (binding)
|
|
30
|
+
return binding.value;
|
|
31
|
+
if (this.parent)
|
|
32
|
+
return this.parent.use(token);
|
|
33
|
+
throw new Error(`Missing resource ${token.id} for ${this.owner}`);
|
|
34
|
+
}
|
|
35
|
+
has(token) {
|
|
36
|
+
return this.#bindings.has(token.id) || Boolean(this.parent?.has(token));
|
|
37
|
+
}
|
|
38
|
+
seal() {
|
|
39
|
+
this.#sealed = true;
|
|
40
|
+
this.disposers.seal();
|
|
41
|
+
}
|
|
42
|
+
snapshot() {
|
|
43
|
+
if (!this.#sealed)
|
|
44
|
+
throw new Error(`Scope is not sealed: ${this.owner}`);
|
|
45
|
+
const result = this.parent
|
|
46
|
+
? new Map(this.parent.snapshot())
|
|
47
|
+
: new Map();
|
|
48
|
+
for (const [id, binding] of this.#bindings)
|
|
49
|
+
result.set(id, binding.value);
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zhin.js/plugin-runtime",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Zero-dependency Plugin tree, Scope and atomic generation runtime for Zhin",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./lib/index.js",
|
|
7
|
+
"types": "./lib/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./lib/index.d.ts",
|
|
11
|
+
"development": "./src/index.ts",
|
|
12
|
+
"import": "./lib/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"lib"
|
|
17
|
+
],
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/node": "^26.1.0",
|
|
20
|
+
"typescript": "^6.0.3"
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": "^20.19.0 || >=22.12.0"
|
|
24
|
+
},
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/zhinjs/zhin.git",
|
|
28
|
+
"directory": "packages/im/plugin-runtime"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public",
|
|
32
|
+
"registry": "https://registry.npmjs.org"
|
|
33
|
+
},
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"private": false,
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsc",
|
|
38
|
+
"clean": "rimraf lib",
|
|
39
|
+
"test": "vitest run --root ../../.. packages/im/plugin-runtime/tests"
|
|
40
|
+
}
|
|
41
|
+
}
|