@rebon/cli-darwin-arm64 0.17.2 → 0.18.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/bin/compose-runtime/package.json +17 -0
- package/bin/compose-runtime/payload/compose/credentials-runtime.js +38 -0
- package/bin/compose-runtime/payload/compose/llm-runtime.js +330 -0
- package/bin/compose-runtime/payload/compose/loop-assembly.js +149 -0
- package/bin/compose-runtime/payload/compose/serve-dispatch.js +82 -0
- package/bin/compose-runtime/payload/compose/shims/dsh-anonymous-user-id.js +10 -0
- package/bin/compose-runtime/payload/compose/shims/dsh-credentials.js +13 -0
- package/bin/compose-runtime/payload/compose/shims/dsh-launch-environment.js +21 -0
- package/bin/compose-runtime/payload/compose/shims/dsh-llm.js +313 -0
- package/bin/compose-runtime/payload/compose/shims/dsh-settings.js +43 -0
- package/bin/compose-runtime/payload/compose/shims/dsh-tools.js +20 -0
- package/bin/compose-runtime/payload/compose/shims/dsh-web.js +10 -0
- package/bin/compose-runtime/payload/compose/shims/llm-adapter.js +32 -0
- package/bin/compose-runtime/payload/compose/shims/zod-lite.js +49 -0
- package/bin/compose-runtime/payload/compose/systemprompt-runtime.js +74 -0
- package/bin/compose-runtime/payload/compose/tools-runtime.js +228 -0
- package/bin/compose-runtime/payload/compose/web-runtime.js +204 -0
- package/bin/compose-runtime/payload/vendor/cordis/index.js +1530 -0
- package/bin/compose-runtime/payload/vendor/cosmokit/LICENSE +21 -0
- package/bin/compose-runtime/payload/vendor/cosmokit/index.mjs +357 -0
- package/bin/compose-runtime/payload/vendor/dsh/LICENSE-agent +21 -0
- package/bin/compose-runtime/payload/vendor/dsh/LICENSE-agent-loop +21 -0
- package/bin/compose-runtime/payload/vendor/dsh/LICENSE-llm-core +21 -0
- package/bin/compose-runtime/payload/vendor/dsh/LICENSE-llm-deepseek +21 -0
- package/bin/compose-runtime/payload/vendor/dsh/LICENSE-logger-console +21 -0
- package/bin/compose-runtime/payload/vendor/dsh/LICENSE-scope +21 -0
- package/bin/compose-runtime/payload/vendor/dsh/LICENSE-session +21 -0
- package/bin/compose-runtime/payload/vendor/dsh/LICENSE-system-prompt +21 -0
- package/bin/compose-runtime/payload/vendor/dsh/LICENSE-timeout +21 -0
- package/bin/compose-runtime/payload/vendor/dsh/LICENSE-timer +21 -0
- package/bin/compose-runtime/payload/vendor/dsh/LICENSE-tool-todo +21 -0
- package/bin/compose-runtime/payload/vendor/dsh/LICENSE-tool-web +21 -0
- package/bin/compose-runtime/payload/vendor/dsh/LICENSE-tools-schema +21 -0
- package/bin/compose-runtime/payload/vendor/dsh/LICENSE-web-search-exa +21 -0
- package/bin/compose-runtime/payload/vendor/dsh/agent-loop.js +1193 -0
- package/bin/compose-runtime/payload/vendor/dsh/agent.js +714 -0
- package/bin/compose-runtime/payload/vendor/dsh/llm-core.js +269 -0
- package/bin/compose-runtime/payload/vendor/dsh/llm-deepseek.js +672 -0
- package/bin/compose-runtime/payload/vendor/dsh/logger-console.js +83 -0
- package/bin/compose-runtime/payload/vendor/dsh/scope.js +287 -0
- package/bin/compose-runtime/payload/vendor/dsh/session.js +1668 -0
- package/bin/compose-runtime/payload/vendor/dsh/system-prompt.js +309 -0
- package/bin/compose-runtime/payload/vendor/dsh/timeout.js +100 -0
- package/bin/compose-runtime/payload/vendor/dsh/timer.js +128 -0
- package/bin/compose-runtime/payload/vendor/dsh/tool-todo.js +143 -0
- package/bin/compose-runtime/payload/vendor/dsh/tool-web.js +1527 -0
- package/bin/compose-runtime/payload/vendor/dsh/tools-schema.js +849 -0
- package/bin/compose-runtime/payload/vendor/dsh/web-search-exa.js +122 -0
- package/bin/compose-runtime/payload/vendor/eventsource-parser/LICENSE +21 -0
- package/bin/compose-runtime/payload/vendor/eventsource-parser/index.js +177 -0
- package/bin/compose-runtime/payload/vendor/eventsource-parser/stream.js +48 -0
- package/bin/compose-runtime/payload/vendor/schemastery/index.mjs +656 -0
- package/bin/compose-runtime/payload-manifests.json +48 -0
- package/bin/compose-runtime/src/bridge.mjs +142 -0
- package/bin/compose-runtime/src/credentials-runtime.mjs +50 -0
- package/bin/compose-runtime/src/eventsource-stream.mjs +48 -0
- package/bin/compose-runtime/src/index.mjs +7 -0
- package/bin/compose-runtime/src/llm-runtime.mjs +326 -0
- package/bin/compose-runtime/src/loader.mjs +78 -0
- package/bin/compose-runtime/src/loop-assembly.mjs +237 -0
- package/bin/compose-runtime/src/payload.mjs +49 -0
- package/bin/compose-runtime/src/plugin.mjs +59 -0
- package/bin/compose-runtime/src/realm.mjs +223 -0
- package/bin/compose-runtime/src/registry.mjs +191 -0
- package/bin/compose-runtime/src/resolve.mjs +115 -0
- package/bin/compose-runtime/src/systemprompt-runtime.mjs +67 -0
- package/bin/compose-runtime/src/tools-runtime.mjs +239 -0
- package/bin/compose-runtime/src/web-runtime.mjs +209 -0
- package/bin/plugin-host/package.json +11 -0
- package/bin/plugin-host/src/bridge.mjs +64 -0
- package/bin/plugin-host/src/cli.mjs +105 -0
- package/bin/plugin-host/src/framing.mjs +113 -0
- package/bin/plugin-host/src/host.mjs +624 -0
- package/bin/plugin-host/src/json.mjs +196 -0
- package/bin/plugin-host/src/lifecycle.mjs +114 -0
- package/bin/plugin-host/src/loader.mjs +142 -0
- package/bin/plugin-host/src/methods.mjs +256 -0
- package/bin/plugin-host/src/protocol.mjs +129 -0
- package/bin/plugin-host/src/sdk.mjs +7 -0
- package/bin/rebon +0 -0
- package/bin/rebon-boa-helper +0 -0
- package/package.json +4 -1
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// The composition's `ctx.web` seat, on the plugin plane.
|
|
2
|
+
//
|
|
3
|
+
// Ported from `js/compose/web-runtime.js`. The dsh-facing surface is
|
|
4
|
+
// unchanged and verbatim: providers register with
|
|
5
|
+
// `ctx.web.registerSearchProvider` / `registerFetchProvider` (duplicate ids
|
|
6
|
+
// throw WEB_DUPLICATE_PROVIDER, the exact disposer comes back), and
|
|
7
|
+
// `search`/`fetch` resolve their provider at EXECUTION time with dsh's
|
|
8
|
+
// five-state rules — a configured id wins or fails loudly, never falls back;
|
|
9
|
+
// otherwise exactly one usable provider auto-selects. `maxResults` truncation
|
|
10
|
+
// (capSources) is enforced on the way out.
|
|
11
|
+
//
|
|
12
|
+
// Deployment default: the `rebon` builtin provider, backed by rebon's own
|
|
13
|
+
// WebSearch/WebFetch tools through `tool/invoke`, is always registered, and the
|
|
14
|
+
// configured id DEFAULTS to `"rebon"`. So a plugin provider takes over only
|
|
15
|
+
// when configured, and dsh's AMBIGUOUS state is unreachable unless the default
|
|
16
|
+
// is explicitly cleared. **A plugin whose tool reaches `ctx.web` therefore
|
|
17
|
+
// causes a `tool/invoke` of rebon's WebSearch, attributed to that plugin** —
|
|
18
|
+
// which is why such a plugin declares `WebSearch`/`WebFetch` in
|
|
19
|
+
// `invokableTools`. The attribution is the point: rebon can always answer who
|
|
20
|
+
// caused a tool to run.
|
|
21
|
+
//
|
|
22
|
+
// What changed: `emit('web/provider-registered', …)` and the shared serve pump
|
|
23
|
+
// are gone. A registered provider is *reported* — it appears in the plugin's
|
|
24
|
+
// load report as a service named `web:<kind>:<id>`, which is also how rebon
|
|
25
|
+
// dispatches into it.
|
|
26
|
+
import { Service } from 'cordis';
|
|
27
|
+
import { WebError } from '@deepseek-ai/dsh-web';
|
|
28
|
+
import { invokeTool, withCall } from './bridge.mjs';
|
|
29
|
+
import { sinkOf } from './registry.mjs';
|
|
30
|
+
|
|
31
|
+
/** The service-name namespace a plugin web provider is served under. */
|
|
32
|
+
export const webTarget = (kind, id) => `web:${kind}:${id}`;
|
|
33
|
+
|
|
34
|
+
/** dsh WebRuntime.resolveProvider, transcribed. */
|
|
35
|
+
function resolveProvider(configuredId, providers) {
|
|
36
|
+
if (configuredId !== undefined) {
|
|
37
|
+
const provider = providers.get(configuredId);
|
|
38
|
+
if (!provider) {
|
|
39
|
+
throw new WebError(
|
|
40
|
+
`configured web provider "${configuredId}" is not registered`,
|
|
41
|
+
'WEB_PROVIDER_CONFIGURED_MISSING',
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
if (!provider.available()) {
|
|
45
|
+
throw new WebError(
|
|
46
|
+
`configured web provider "${configuredId}" is registered but unavailable`,
|
|
47
|
+
'WEB_PROVIDER_CONFIGURED_UNAVAILABLE',
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return provider;
|
|
51
|
+
}
|
|
52
|
+
const usable = [...providers.values()].filter((provider) => {
|
|
53
|
+
try {
|
|
54
|
+
return !!provider.available();
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
if (usable.length === 0) {
|
|
60
|
+
throw new WebError('no usable web provider is registered', 'WEB_PROVIDER_UNAVAILABLE');
|
|
61
|
+
}
|
|
62
|
+
if (usable.length > 1) {
|
|
63
|
+
const ids = usable.map((provider) => provider.id).join(', ');
|
|
64
|
+
throw new WebError(
|
|
65
|
+
`multiple usable web providers are registered (${ids}); configure one explicitly`,
|
|
66
|
+
'WEB_PROVIDER_AMBIGUOUS',
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return usable[0];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** dsh capSources: enforce `maxResults` on the way back. */
|
|
73
|
+
function capSources(result, maxResults) {
|
|
74
|
+
if (maxResults === undefined || (result.sources?.length ?? 0) <= maxResults) return result;
|
|
75
|
+
return { ...result, sources: result.sources.slice(0, maxResults), truncated: true };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The rebon builtin search provider: reaches rebon's WebSearch tool and maps
|
|
80
|
+
* the `{query, answer, results}` contract into dsh's result vocabulary.
|
|
81
|
+
*/
|
|
82
|
+
const rebonBuiltinSearch = {
|
|
83
|
+
id: 'rebon',
|
|
84
|
+
available: () => true,
|
|
85
|
+
async search(request, _signal) {
|
|
86
|
+
const out = await invokeTool('WebSearch', { query: String(request?.query ?? '') });
|
|
87
|
+
const sources = (out?.results ?? [])
|
|
88
|
+
.map((entry) => ({
|
|
89
|
+
url: String(entry?.url ?? ''),
|
|
90
|
+
...(entry?.title ? { title: String(entry.title) } : {}),
|
|
91
|
+
...(entry?.snippet ? { snippet: String(entry.snippet) } : {}),
|
|
92
|
+
}))
|
|
93
|
+
.filter((source) => source.url.length > 0);
|
|
94
|
+
return {
|
|
95
|
+
...(out?.answer ? { content: String(out.answer) } : {}),
|
|
96
|
+
sources,
|
|
97
|
+
truncated: false,
|
|
98
|
+
};
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The rebon builtin fetch provider over rebon's WebFetch tool. The tool
|
|
104
|
+
* returns extracted text, not raw transport facts, so `statusCode` is the
|
|
105
|
+
* 200 the extraction implies and the body is `text` — a documented v1
|
|
106
|
+
* approximation, not a transport claim.
|
|
107
|
+
*/
|
|
108
|
+
const rebonBuiltinFetch = {
|
|
109
|
+
id: 'rebon',
|
|
110
|
+
available: () => true,
|
|
111
|
+
async fetch(request, _signal) {
|
|
112
|
+
const out = await invokeTool('WebFetch', { url: String(request?.url ?? '') });
|
|
113
|
+
const content =
|
|
114
|
+
typeof out === 'string' ? out : String(out?.content ?? out?.text ?? JSON.stringify(out));
|
|
115
|
+
return {
|
|
116
|
+
url: String(request?.url ?? ''),
|
|
117
|
+
statusCode: 200,
|
|
118
|
+
body: { kind: 'text', content },
|
|
119
|
+
truncated: false,
|
|
120
|
+
};
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export default class RebonWebRuntime extends Service {
|
|
125
|
+
constructor(ctx, options) {
|
|
126
|
+
super(ctx, 'web');
|
|
127
|
+
const config = options?.web ?? {};
|
|
128
|
+
const configured = (value) =>
|
|
129
|
+
typeof value === 'string' && value.trim().length > 0 ? value.trim() : 'rebon';
|
|
130
|
+
this.searchProviderId = configured(config?.searchProvider);
|
|
131
|
+
this.fetchProviderId = configured(config?.fetchProvider);
|
|
132
|
+
this.searchProviders = new Map([['rebon', rebonBuiltinSearch]]);
|
|
133
|
+
this.fetchProviders = new Map([['rebon', rebonBuiltinFetch]]);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
registerSearchProvider(provider) {
|
|
137
|
+
return this._register(this.searchProviders, 'search', provider);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
registerFetchProvider(provider) {
|
|
141
|
+
return this._register(this.fetchProviders, 'fetch', provider);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
_register(store, kind, provider) {
|
|
145
|
+
if (typeof provider?.id !== 'string' || provider.id.length === 0) {
|
|
146
|
+
throw new TypeError('a web provider must declare a non-empty string `id`');
|
|
147
|
+
}
|
|
148
|
+
if (store.has(provider.id)) {
|
|
149
|
+
throw new WebError(
|
|
150
|
+
`a web provider with id "${provider.id}" is already registered`,
|
|
151
|
+
'WEB_DUPLICATE_PROVIDER',
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
const runtime = this;
|
|
155
|
+
let available = false;
|
|
156
|
+
try {
|
|
157
|
+
available = !!provider.available();
|
|
158
|
+
} catch {}
|
|
159
|
+
// `available` is a registration-time snapshot: a static composition's
|
|
160
|
+
// configuration does not change under it. Documented v1 semantics,
|
|
161
|
+
// carried over unchanged.
|
|
162
|
+
const sink = sinkOf(this.ctx);
|
|
163
|
+
sink?.service(webTarget(kind, provider.id), (input, callCtx) =>
|
|
164
|
+
withCall(callCtx, () => runtime._serve(kind, provider.id, input, callCtx.signal)));
|
|
165
|
+
sink?.webProvider(kind, provider.id, available);
|
|
166
|
+
// Caller-fork RAII (the Service proxy binds this.ctx to the registrant):
|
|
167
|
+
// plugin disposal removes the provider, and a call arriving afterwards is
|
|
168
|
+
// refused rather than answered by a provider nobody can revoke.
|
|
169
|
+
return this.ctx.effect(() => {
|
|
170
|
+
store.set(provider.id, provider);
|
|
171
|
+
return () => {
|
|
172
|
+
store.delete(provider.id);
|
|
173
|
+
};
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** rebon-dispatched execution of one plugin provider (seat rules applied). */
|
|
178
|
+
async _serve(kind, id, input, signal) {
|
|
179
|
+
const store = kind === 'search' ? this.searchProviders : this.fetchProviders;
|
|
180
|
+
const provider = store.get(id);
|
|
181
|
+
if (provider === undefined) {
|
|
182
|
+
throw new WebError(
|
|
183
|
+
`web provider "${id}" is no longer registered in this composition`,
|
|
184
|
+
'WEB_PROVIDER_UNAVAILABLE',
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
if (kind === 'search') {
|
|
188
|
+
const request = {
|
|
189
|
+
query: String(input?.query ?? ''),
|
|
190
|
+
...(input?.maxResults !== undefined ? { maxResults: input.maxResults } : {}),
|
|
191
|
+
};
|
|
192
|
+
return capSources(await provider.search(request, signal), request.maxResults);
|
|
193
|
+
}
|
|
194
|
+
return provider.fetch({ url: String(input?.url ?? '') }, signal);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** dsh consumer API: run one search through the selected provider. */
|
|
198
|
+
async search(request, signal) {
|
|
199
|
+
const provider = resolveProvider(this.searchProviderId, this.searchProviders);
|
|
200
|
+
const result = await provider.search(request, signal);
|
|
201
|
+
return capSources(result, request?.maxResults);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** dsh consumer API: retrieve one URL through the selected provider. */
|
|
205
|
+
async fetch(request, signal) {
|
|
206
|
+
const provider = resolveProvider(this.fetchProviderId, this.fetchProviders);
|
|
207
|
+
return provider.fetch(request, signal);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rebon/plugin-host",
|
|
3
|
+
"version": "0.0.0-private",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT OR Apache-2.0",
|
|
7
|
+
"engines": { "node": ">=24.19.0 <25" },
|
|
8
|
+
"scripts": { "test": "node --test test/*.test.mjs" },
|
|
9
|
+
"bin": { "rebon-plugin-host": "./src/cli.mjs" },
|
|
10
|
+
"exports": { ".": "./src/sdk.mjs" }
|
|
11
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { cancel, notification, request, ProtocolError } from './protocol.mjs';
|
|
3
|
+
|
|
4
|
+
export function createPluginBridge({ trustedPluginId, scopeBinding, transport, ledger, idFactory = randomUUID }) {
|
|
5
|
+
if (typeof trustedPluginId !== 'string') throw new TypeError('trustedPluginId must be a string');
|
|
6
|
+
if (typeof idFactory !== 'function') throw new TypeError('idFactory must be a function');
|
|
7
|
+
const pending = new Map();
|
|
8
|
+
const makeIdentity = () => Object.freeze({ host_epoch: scopeBinding.hostEpoch, plugin_id: trustedPluginId,
|
|
9
|
+
scope_id: scopeBinding.scopeId, scope_generation: scopeBinding.scopeGeneration, call_id: idFactory() });
|
|
10
|
+
const call = (method, payload, { signal } = {}) => {
|
|
11
|
+
if (signal !== undefined && !(signal instanceof AbortSignal)) throw new TypeError('signal must be an AbortSignal');
|
|
12
|
+
const identity = makeIdentity();
|
|
13
|
+
ledger.register(identity, 'outbound', trustedPluginId);
|
|
14
|
+
let resolvePromise, rejectPromise;
|
|
15
|
+
const promise = new Promise((resolve, reject) => { resolvePromise = resolve; rejectPromise = reject; });
|
|
16
|
+
let settled = false; let cancelSent = false; let requestSent = false; let abortWanted = signal?.aborted ?? false; let listenerAdded = false;
|
|
17
|
+
const cleanup = () => {
|
|
18
|
+
if (listenerAdded) { signal.removeEventListener('abort', onAbort); listenerAdded = false; }
|
|
19
|
+
pending.delete(identity.call_id);
|
|
20
|
+
};
|
|
21
|
+
const settle = (action, value) => {
|
|
22
|
+
if (settled) return;
|
|
23
|
+
settled = true; cleanup(); action(value);
|
|
24
|
+
};
|
|
25
|
+
const sendCancel = () => {
|
|
26
|
+
if (settled || cancelSent || !requestSent) return Promise.resolve();
|
|
27
|
+
cancelSent = true;
|
|
28
|
+
return Promise.resolve().then(() => transport.send(cancel(identity))).catch((error) => settle(rejectPromise, error));
|
|
29
|
+
};
|
|
30
|
+
const onAbort = () => {
|
|
31
|
+
if (settled) return;
|
|
32
|
+
abortWanted = true;
|
|
33
|
+
if (requestSent) void sendCancel();
|
|
34
|
+
};
|
|
35
|
+
pending.set(identity.call_id, { identity, resolve: (value) => settle(resolvePromise, value), reject: (error) => settle(rejectPromise, error) });
|
|
36
|
+
if (signal && !signal.aborted) {
|
|
37
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
38
|
+
listenerAdded = true;
|
|
39
|
+
}
|
|
40
|
+
Promise.resolve().then(() => transport.send(request(identity, method, payload))).then(() => {
|
|
41
|
+
if (settled) return;
|
|
42
|
+
requestSent = true;
|
|
43
|
+
if (abortWanted) return sendCancel();
|
|
44
|
+
}).catch((error) => {
|
|
45
|
+
if (!settled) ledger.retire(identity);
|
|
46
|
+
settle(rejectPromise, error);
|
|
47
|
+
});
|
|
48
|
+
return promise;
|
|
49
|
+
};
|
|
50
|
+
const notify = (method, payload) => Promise.resolve().then(() => transport.send(notification(makeIdentity(), method, payload)));
|
|
51
|
+
const receive = (envelope) => {
|
|
52
|
+
const disposition = ledger.terminal(envelope, 'outbound');
|
|
53
|
+
if (disposition.stale) return;
|
|
54
|
+
const item = pending.get(envelope.call_id);
|
|
55
|
+
if (!item) throw new ProtocolError('unknown_call', 'no pending bridge call');
|
|
56
|
+
if (envelope.message.status === 'success') item.resolve(envelope.message.payload);
|
|
57
|
+
else {
|
|
58
|
+
const error = new ProtocolError(envelope.message.status, `call ended with ${envelope.message.status}`);
|
|
59
|
+
error.payload = envelope.message.payload;
|
|
60
|
+
item.reject(error);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
return Object.freeze({ call, notify, receive, pendingCount: () => pending.size });
|
|
64
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import { NdjsonDecoder, SerializedWriter } from './framing.mjs';
|
|
4
|
+
import { PluginHost } from './host.mjs';
|
|
5
|
+
import { loadPlugin } from './loader.mjs';
|
|
6
|
+
import { ProtocolError } from './protocol.mjs';
|
|
7
|
+
|
|
8
|
+
export function diagnostic(stream, code, message) {
|
|
9
|
+
const bounded = String(message).replace(/[\r\n]+/g, ' ').slice(0, 512);
|
|
10
|
+
stream.write(`${JSON.stringify({ level: 'error', code, message: bounded })}\n`);
|
|
11
|
+
}
|
|
12
|
+
export function installHostConsole(stream) {
|
|
13
|
+
const write = (level, values) => stream.write(`${JSON.stringify({ level, message: values.map(String).join(' ').slice(0, 512) })}\n`);
|
|
14
|
+
return Object.freeze({ log: (...v) => write('info', v), info: (...v) => write('info', v), warn: (...v) => write('warn', v), error: (...v) => write('error', v) });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Frames are not handled one-at-a-time.
|
|
18
|
+
//
|
|
19
|
+
// A request handler may itself call rebon — that is the whole Node → rebon
|
|
20
|
+
// direction — and the answer to that call arrives as a frame on this very
|
|
21
|
+
// stream. Awaiting each dispatch before reading the next would mean the handler
|
|
22
|
+
// waits for a frame the loop cannot read until the handler returns: a deadlock
|
|
23
|
+
// that no amount of care inside a plugin can avoid. So dispatches are tracked
|
|
24
|
+
// rather than awaited, and everything that needs them finished waits here.
|
|
25
|
+
/// Loads the module that decides what a plugin package *is*.
|
|
26
|
+
///
|
|
27
|
+
/// The host itself knows one shape: a module exporting `activate`. Anything
|
|
28
|
+
/// else — a Cordis plugin mounted into a shared composition realm, say — is a
|
|
29
|
+
/// different answer to the same question, and the host has no business knowing
|
|
30
|
+
/// which answers exist. So rebon names the module that does, and this loads it.
|
|
31
|
+
///
|
|
32
|
+
/// The module exports `createLoader({ next })` and returns `{ load, unload }`;
|
|
33
|
+
/// `next` is the built-in loader, so an adapter that does not recognise a
|
|
34
|
+
/// package hands it back rather than reimplementing it.
|
|
35
|
+
export async function resolveLoader(specifier, error) {
|
|
36
|
+
if (!specifier) return {};
|
|
37
|
+
const url = specifier.startsWith('file:') ? specifier : pathToFileURL(specifier).href;
|
|
38
|
+
const module = await import(url);
|
|
39
|
+
if (typeof module.createLoader !== 'function') {
|
|
40
|
+
throw new ProtocolError('loader_invalid', `${specifier} exports no createLoader`);
|
|
41
|
+
}
|
|
42
|
+
const seams = await module.createLoader({ next: loadPlugin });
|
|
43
|
+
if (typeof seams?.load !== 'function') {
|
|
44
|
+
throw new ProtocolError('loader_invalid', `${specifier} produced no load function`);
|
|
45
|
+
}
|
|
46
|
+
return seams;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function runHost({ input, output, error, loader }) {
|
|
50
|
+
const decoder = new NdjsonDecoder();
|
|
51
|
+
const writer = new SerializedWriter(output);
|
|
52
|
+
const host = new PluginHost(writer, loader ?? {});
|
|
53
|
+
const running = new Set();
|
|
54
|
+
let fatal = null;
|
|
55
|
+
const track = (promise) => {
|
|
56
|
+
const tracked = promise.catch((cause) => { fatal ??= cause; }).finally(() => running.delete(tracked));
|
|
57
|
+
running.add(tracked);
|
|
58
|
+
};
|
|
59
|
+
// Draining rather than a single `Promise.all`: a dispatch that is still
|
|
60
|
+
// running can schedule another one, and the process must not exit under it.
|
|
61
|
+
const quiesce = async () => { while (running.size > 0) await Promise.all([...running]); };
|
|
62
|
+
try {
|
|
63
|
+
for await (const chunk of input) {
|
|
64
|
+
for (const frame of decoder.push(chunk)) {
|
|
65
|
+
if (fatal) throw fatal;
|
|
66
|
+
if (host.shuttingDown) throw new ProtocolError('frame_after_shutdown', 'frame received after shutdown');
|
|
67
|
+
track(host.accept(frame));
|
|
68
|
+
}
|
|
69
|
+
if (fatal) throw fatal;
|
|
70
|
+
if (host.shuttingDown) { await quiesce(); if (fatal) throw fatal; return 0; }
|
|
71
|
+
}
|
|
72
|
+
decoder.finish();
|
|
73
|
+
await quiesce();
|
|
74
|
+
if (fatal) throw fatal;
|
|
75
|
+
diagnostic(error, 'connection_lost', 'stdin reached EOF before successful shutdown');
|
|
76
|
+
return 3;
|
|
77
|
+
} catch (cause) {
|
|
78
|
+
// Terminals already owed still have to reach stdout: a caller waiting on
|
|
79
|
+
// one is not made whole by the host exiting with a diagnosis.
|
|
80
|
+
await quiesce();
|
|
81
|
+
const code = cause?.code ?? 'host_failure';
|
|
82
|
+
diagnostic(error, code, cause?.message ?? 'host failed');
|
|
83
|
+
return code === 'write_error' ? 4 : 2;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
88
|
+
const flag = process.argv.indexOf('--loader');
|
|
89
|
+
const specifier = flag >= 0 ? process.argv[flag + 1] : process.env.REBON_PLUGIN_LOADER;
|
|
90
|
+
let loader;
|
|
91
|
+
try {
|
|
92
|
+
loader = await resolveLoader(specifier, process.stderr);
|
|
93
|
+
} catch (cause) {
|
|
94
|
+
// A host that cannot build the loader rebon asked for must not fall back to
|
|
95
|
+
// the built-in one: it would load a different set of plugins than the one
|
|
96
|
+
// rebon believes it asked for, and say nothing.
|
|
97
|
+
diagnostic(process.stderr, cause?.code ?? 'loader_failure', cause?.message ?? 'loader failed');
|
|
98
|
+
process.exitCode = 2;
|
|
99
|
+
}
|
|
100
|
+
if (loader !== undefined) {
|
|
101
|
+
process.exitCode = await runHost({
|
|
102
|
+
input: process.stdin, output: process.stdout, error: process.stderr, loader,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { MAX_FRAME_BYTES, ProtocolError, validateEnvelope } from './protocol.mjs';
|
|
2
|
+
import { parseEnvelopeJson } from './json.mjs';
|
|
3
|
+
|
|
4
|
+
export class FramingError extends ProtocolError {}
|
|
5
|
+
|
|
6
|
+
export class NdjsonDecoder {
|
|
7
|
+
#segments = []; #byteCount = 0; #failed = false; #copiedBytes = 0;
|
|
8
|
+
constructor(maxFrameBytes = MAX_FRAME_BYTES) { this.maxFrameBytes = maxFrameBytes; }
|
|
9
|
+
get stats() { return Object.freeze({ retained_bytes: this.#byteCount, copied_bytes: this.#copiedBytes }); }
|
|
10
|
+
push(chunk) {
|
|
11
|
+
if (this.#failed) throw new FramingError('poisoned', 'decoder is poisoned');
|
|
12
|
+
if (!Buffer.isBuffer(chunk) && !(chunk instanceof Uint8Array)) return this.#fail('wrong_chunk', 'input chunk must be bytes');
|
|
13
|
+
const input = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
|
14
|
+
const frames = [];
|
|
15
|
+
let start = 0;
|
|
16
|
+
for (let index = 0; index < input.length; index++) {
|
|
17
|
+
if (input[index] !== 0x0a) continue;
|
|
18
|
+
this.#append(input.subarray(start, index));
|
|
19
|
+
let bytes = this.#join();
|
|
20
|
+
this.#segments = []; this.#byteCount = 0;
|
|
21
|
+
if (bytes.at(-1) === 0x0d) bytes = bytes.subarray(0, -1);
|
|
22
|
+
if (bytes.length > this.maxFrameBytes) return this.#fail('frame_too_large', `frame exceeds ${this.maxFrameBytes} bytes`);
|
|
23
|
+
if (bytes.length === 0) return this.#fail('empty_frame', 'NDJSON frame is empty');
|
|
24
|
+
if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
|
|
25
|
+
return this.#fail('utf8_bom', 'UTF-8 BOM is not permitted');
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
29
|
+
frames.push(parseEnvelopeJson(text));
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (error instanceof ProtocolError) return this.#fail(error.code, error.message);
|
|
32
|
+
return this.#fail('invalid_utf8', 'frame is not valid UTF-8');
|
|
33
|
+
}
|
|
34
|
+
start = index + 1;
|
|
35
|
+
}
|
|
36
|
+
this.#append(input.subarray(start));
|
|
37
|
+
if (this.#byteCount > this.maxFrameBytes
|
|
38
|
+
&& !(this.#byteCount === this.maxFrameBytes + 1 && this.#lastByte() === 0x0d)) {
|
|
39
|
+
return this.#fail('frame_too_large', `frame exceeds ${this.maxFrameBytes} bytes`);
|
|
40
|
+
}
|
|
41
|
+
return frames;
|
|
42
|
+
}
|
|
43
|
+
finish() {
|
|
44
|
+
if (this.#failed) throw new FramingError('poisoned', 'decoder is poisoned');
|
|
45
|
+
if (this.#byteCount) return this.#fail('incomplete_frame', `EOF with unterminated ${this.#byteCount}-byte frame`);
|
|
46
|
+
}
|
|
47
|
+
#append(bytes) {
|
|
48
|
+
if (!bytes.length) return;
|
|
49
|
+
const copy = Buffer.from(bytes);
|
|
50
|
+
this.#segments.push(copy); this.#byteCount += copy.length; this.#copiedBytes += copy.length;
|
|
51
|
+
}
|
|
52
|
+
#join() {
|
|
53
|
+
if (this.#segments.length === 1) return this.#segments[0];
|
|
54
|
+
this.#copiedBytes += this.#byteCount;
|
|
55
|
+
return Buffer.concat(this.#segments, this.#byteCount);
|
|
56
|
+
}
|
|
57
|
+
#lastByte() { return this.#segments.at(-1)?.at(-1); }
|
|
58
|
+
#fail(code, message) { this.#failed = true; this.#segments = []; this.#byteCount = 0; throw new FramingError(code, message); }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function encodeFrame(value, maxFrameBytes = MAX_FRAME_BYTES) {
|
|
62
|
+
const normalized = validateEnvelope(value);
|
|
63
|
+
let text;
|
|
64
|
+
try { text = JSON.stringify(normalized); } catch { throw new FramingError('encode_error', 'envelope cannot be encoded'); }
|
|
65
|
+
const bytes = Buffer.from(text);
|
|
66
|
+
if (bytes.length > maxFrameBytes) throw new FramingError('frame_too_large', `frame exceeds ${maxFrameBytes} bytes`);
|
|
67
|
+
return Buffer.concat([bytes, Buffer.from('\n')]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class SerializedWriter {
|
|
71
|
+
#tail = Promise.resolve(); #error;
|
|
72
|
+
constructor(stream, maxFrameBytes = MAX_FRAME_BYTES) { this.stream = stream; this.maxFrameBytes = maxFrameBytes; }
|
|
73
|
+
send(value) {
|
|
74
|
+
if (this.#error) return Promise.reject(this.#error);
|
|
75
|
+
const bytes = encodeFrame(value, this.maxFrameBytes);
|
|
76
|
+
const operation = this.#tail.then(() => {
|
|
77
|
+
if (this.#error) throw this.#error;
|
|
78
|
+
return this.#write(bytes);
|
|
79
|
+
});
|
|
80
|
+
this.#tail = operation.catch((error) => { this.#error ??= error; });
|
|
81
|
+
return operation;
|
|
82
|
+
}
|
|
83
|
+
async flush() { await this.#tail; if (this.#error) throw this.#error; }
|
|
84
|
+
#write(bytes) {
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
let callbackDone = false; let accepted; let drainSeen = false; let settled = false;
|
|
87
|
+
const cleanup = () => { this.stream.removeListener('error', onError); this.stream.removeListener('drain', onDrain); };
|
|
88
|
+
const fail = (cause) => {
|
|
89
|
+
if (settled) return;
|
|
90
|
+
settled = true; cleanup();
|
|
91
|
+
const error = cause instanceof FramingError ? cause
|
|
92
|
+
: new FramingError('write_error', `protocol output failed: ${cause?.code ?? 'stream error'}`);
|
|
93
|
+
this.#error ??= error; reject(error);
|
|
94
|
+
};
|
|
95
|
+
const complete = () => {
|
|
96
|
+
if (settled || accepted === undefined || !callbackDone || (!accepted && !drainSeen)) return;
|
|
97
|
+
settled = true; cleanup(); resolve();
|
|
98
|
+
};
|
|
99
|
+
const onError = (error) => fail(error);
|
|
100
|
+
const onDrain = () => { drainSeen = true; complete(); };
|
|
101
|
+
this.stream.once('error', onError);
|
|
102
|
+
this.stream.once('drain', onDrain);
|
|
103
|
+
try {
|
|
104
|
+
accepted = this.stream.write(bytes, (error) => {
|
|
105
|
+
if (error) { setImmediate(() => fail(error)); return; }
|
|
106
|
+
callbackDone = true; complete();
|
|
107
|
+
});
|
|
108
|
+
if (accepted) this.stream.removeListener('drain', onDrain);
|
|
109
|
+
complete();
|
|
110
|
+
} catch (error) { fail(error); }
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|