@standardagents/code-plugin-sdk 0.0.0-stub.0 → 1.0.0-alpha.1
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 +174 -3
- package/bin/standard-plugin.mjs +149 -0
- package/package.json +17 -4
- package/src/collection.mjs +0 -0
- package/src/index.d.ts +310 -0
- package/src/index.mjs +13 -0
- package/src/internal.d.ts +39 -0
- package/src/internal.mjs +5 -0
- package/src/manifest.mjs +86 -0
- package/src/protocol.mjs +166 -0
- package/src/publish.mjs +62 -0
- package/src/runtime.mjs +354 -0
- package/src/testing.d.ts +24 -0
- package/src/testing.mjs +104 -0
package/src/index.mjs
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { ensure, identifier, validateManifest, PluginError } from './manifest.mjs'
|
|
2
|
+
import { COLLECTION_FILE, resolveCollection, validateCollection } from './collection.mjs'
|
|
3
|
+
import { SDK_PACKAGE, checkPackageForPublish, lockfileRequirement } from './publish.mjs'
|
|
4
|
+
|
|
5
|
+
export { validateManifest, PluginError }
|
|
6
|
+
export { COLLECTION_FILE, resolveCollection, validateCollection }
|
|
7
|
+
export { SDK_PACKAGE, checkPackageForPublish, lockfileRequirement }
|
|
8
|
+
|
|
9
|
+
export function definePlugin(definition) {
|
|
10
|
+
ensure(definition && identifier(definition.id) && typeof definition.activate === 'function',
|
|
11
|
+
'invalid_plugin', 'definePlugin requires an id and activate function')
|
|
12
|
+
return Object.freeze({ id: definition.id, activate: definition.activate })
|
|
13
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ContributionKey, SurfaceContent, Producer, Operation, Json, PluginManifest, PluginContext, PluginDefinition, RequestOptions, Condition } from './index.js';
|
|
2
|
+
export * from './index.js';
|
|
3
|
+
export const RUNNER_PROTOCOL_VERSION: 1;
|
|
4
|
+
export const PLUGIN_API_VERSION: 1;
|
|
5
|
+
export const PLUGIN_PEER_CAPABILITY: 'plugins.v1';
|
|
6
|
+
export const LIMITS: Readonly<Record<string, number>>;
|
|
7
|
+
export interface PreparedPlugin {
|
|
8
|
+
root: string;
|
|
9
|
+
entry: string;
|
|
10
|
+
manifest: PluginManifest;
|
|
11
|
+
manifestDigest: string;
|
|
12
|
+
}
|
|
13
|
+
export type HostOperation =
|
|
14
|
+
{ op: 'runner.start' | 'runner.reload'; args: { root: string; manifestDigest: string; capabilities: string[]; linked?: boolean } } |
|
|
15
|
+
{ op: 'runner.stop' | 'runner.diagnose' | 'runner.ping'; args: Record<string, never> } |
|
|
16
|
+
{ op: 'runtime.invoke'; args: { subscriptionId: string; event: Json } } |
|
|
17
|
+
{ op: 'runtime.visibility'; args: { conditions: Condition[] } };
|
|
18
|
+
export type Envelope = { version: 1; producer: Producer } & (
|
|
19
|
+
{ kind: 'request'; id: string; operation: Operation | HostOperation; timeoutMs: number } |
|
|
20
|
+
{ kind: 'response'; id: string; result: { ok: true; value: Json } | { ok: false; error: { code: string; message: string } } } |
|
|
21
|
+
{ kind: 'cancel'; id: string } |
|
|
22
|
+
{ kind: 'surface'; key: ContributionKey; sequence: string; content: SurfaceContent | null }
|
|
23
|
+
);
|
|
24
|
+
export interface Clock { now(): number; setTimeout(callback: () => void | Promise<void>, ms: number): unknown; clearTimeout(id: unknown): void }
|
|
25
|
+
export interface Runtime {
|
|
26
|
+
context: PluginContext;
|
|
27
|
+
receive(frame: Envelope): void;
|
|
28
|
+
activate(definition: PluginDefinition): Promise<void>;
|
|
29
|
+
dispose(): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
export function createRuntime(options: { manifest: PluginManifest; producer: Producer; send(frame: Envelope): void; clock?: Clock; instanceId?: string; onError?(error: Error): void }): Runtime;
|
|
32
|
+
export function authorize(operation: Operation, capabilities: string[]): void;
|
|
33
|
+
export function validateEnvelope(frame: unknown): Envelope;
|
|
34
|
+
export class RpcPeer {
|
|
35
|
+
constructor(options: { producer: Producer; send(frame: Envelope): void; clock?: Clock; idPrefix?: string; onRequest?(operation: Operation | HostOperation, options: { signal: AbortSignal; timeoutMs: number }): Promise<Json> | Json; onSurface?(frame: Envelope): void; onError?(error: Error): void });
|
|
36
|
+
request(operation: Operation | HostOperation, options?: RequestOptions): Promise<Json>;
|
|
37
|
+
receive(frame: Envelope): void;
|
|
38
|
+
dispose(): void;
|
|
39
|
+
}
|
package/src/internal.mjs
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Re-exports for the plugin runner, which imports this file by relative path.
|
|
2
|
+
// The package does not export this module; plugin code uses the public entry.
|
|
3
|
+
export * from './manifest.mjs'
|
|
4
|
+
export * from './protocol.mjs'
|
|
5
|
+
export { createRuntime } from './runtime.mjs'
|
package/src/manifest.mjs
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export const CAPABILITIES = Object.freeze(['surfaces', 'events', 'hooks', 'panes', 'projects',
|
|
2
|
+
'notifications', 'url', 'fetch', 'secrets', 'webhook'])
|
|
3
|
+
export const SURFACE_KINDS = Object.freeze(['section', 'slot', 'badge', 'panel', 'overlay',
|
|
4
|
+
'menu', 'command', 'key', 'link'])
|
|
5
|
+
export const ANCHORS = Object.freeze(['plugins', 'machine.before', 'machine.after',
|
|
6
|
+
'project.before', 'project.after', 'pane.header', 'pane.footer',
|
|
7
|
+
'account', 'machine', 'project', 'pane', 'section'])
|
|
8
|
+
export const LIMITS = Object.freeze({ manifestBytes: 65536, frameBytes: 262144,
|
|
9
|
+
pendingRequests: 128, subscriptions: 256, schedules: 128, contributions: 256,
|
|
10
|
+
queuedBytes: 4 * 1024 * 1024, hookTimeoutMs: 60000, requestTimeoutMs: 30000,
|
|
11
|
+
canvasColumns: 512, canvasRows: 256 })
|
|
12
|
+
|
|
13
|
+
export class PluginError extends Error {
|
|
14
|
+
constructor(code, message) { super(message); this.name = 'PluginError'; this.code = code }
|
|
15
|
+
}
|
|
16
|
+
export function ensure(condition, code, message) {
|
|
17
|
+
if (!condition) throw new PluginError(code, message)
|
|
18
|
+
}
|
|
19
|
+
export function object(value) { return value !== null && typeof value === 'object' && !Array.isArray(value) }
|
|
20
|
+
export function identifier(value) { return typeof value === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(value) }
|
|
21
|
+
export function jsonBytes(value, limit = LIMITS.frameBytes) {
|
|
22
|
+
// Reject non-JSON values before serialization can silently change their meaning.
|
|
23
|
+
const seen = new Set()
|
|
24
|
+
function visit(item, depth) {
|
|
25
|
+
ensure(depth <= 32, 'invalid_payload', 'JSON nesting exceeds 32 levels')
|
|
26
|
+
if (item === null || typeof item === 'string' || typeof item === 'boolean') return
|
|
27
|
+
if (typeof item === 'number') { ensure(Number.isFinite(item), 'invalid_payload', 'JSON numbers must be finite'); return }
|
|
28
|
+
ensure(object(item) || Array.isArray(item), 'invalid_payload', 'Payload must contain JSON values')
|
|
29
|
+
ensure(!seen.has(item), 'invalid_payload', 'Payload contains a cycle')
|
|
30
|
+
ensure(Array.isArray(item) || [Object.prototype, null].includes(Object.getPrototypeOf(item)), 'invalid_payload', 'Payload must contain plain objects')
|
|
31
|
+
seen.add(item)
|
|
32
|
+
for (const child of Object.values(item)) visit(child, depth + 1)
|
|
33
|
+
seen.delete(item)
|
|
34
|
+
}
|
|
35
|
+
visit(value, 0)
|
|
36
|
+
const text = JSON.stringify(value)
|
|
37
|
+
ensure(Buffer.byteLength(text) <= limit, 'payload_too_large', `Payload exceeds ${limit} bytes`)
|
|
38
|
+
return text
|
|
39
|
+
}
|
|
40
|
+
function freeze(value) {
|
|
41
|
+
if (value && typeof value === 'object') { Object.values(value).forEach(freeze); Object.freeze(value) }
|
|
42
|
+
return value
|
|
43
|
+
}
|
|
44
|
+
export function validateManifest(value) {
|
|
45
|
+
ensure(object(value), 'invalid_manifest', 'package.json must contain a standardPlugin object')
|
|
46
|
+
jsonBytes(value, LIMITS.manifestBytes)
|
|
47
|
+
const allowed = ['apiVersion', 'id', 'name', 'version', 'entry', 'singleton', 'order',
|
|
48
|
+
'capabilities', 'contributions', 'configSchema', 'hookTimeoutMs']
|
|
49
|
+
ensure(Object.keys(value).every(key => allowed.includes(key)), 'invalid_manifest', 'Unknown manifest field')
|
|
50
|
+
const manifest = structuredClone({ singleton: false, order: 0, capabilities: [], contributions: [], hookTimeoutMs: 5000, ...value })
|
|
51
|
+
ensure(manifest.apiVersion === 1, 'incompatible_version', 'Plugin requires a different SDK API version')
|
|
52
|
+
ensure(identifier(manifest.id), 'invalid_manifest', 'Plugin id must be a stable identifier')
|
|
53
|
+
ensure(typeof manifest.name === 'string' && manifest.name.trim().length > 0 && manifest.name.length <= 128,
|
|
54
|
+
'invalid_manifest', 'Plugin name is required and bounded to 128 characters')
|
|
55
|
+
ensure(typeof manifest.version === 'string' && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/.test(manifest.version),
|
|
56
|
+
'invalid_manifest', 'Plugin version must be semantic')
|
|
57
|
+
ensure(typeof manifest.entry === 'string' && manifest.entry.length <= 512 &&
|
|
58
|
+
!/[\\\u0000?#]/.test(manifest.entry) && !manifest.entry.startsWith('/') &&
|
|
59
|
+
manifest.entry.split('/').every(part => part && part !== '..') && /\.(?:mjs|cjs|js|mts|ts)$/.test(manifest.entry),
|
|
60
|
+
'invalid_manifest', 'Plugin entry must be a relative JavaScript or TypeScript file')
|
|
61
|
+
ensure(typeof manifest.singleton === 'boolean' && Number.isSafeInteger(manifest.order) && Math.abs(manifest.order) <= 2147483647,
|
|
62
|
+
'invalid_manifest', 'Invalid singleton or order')
|
|
63
|
+
ensure(Number.isSafeInteger(manifest.hookTimeoutMs) && manifest.hookTimeoutMs > 0 && manifest.hookTimeoutMs <= LIMITS.hookTimeoutMs,
|
|
64
|
+
'invalid_manifest', 'Hook timeout must be between 1 and 60000 ms')
|
|
65
|
+
ensure(Array.isArray(manifest.capabilities) && manifest.capabilities.every(item => CAPABILITIES.includes(item)) &&
|
|
66
|
+
new Set(manifest.capabilities).size === manifest.capabilities.length, 'invalid_manifest', 'Unknown or repeated capability')
|
|
67
|
+
ensure(Array.isArray(manifest.contributions) && manifest.contributions.length <= LIMITS.contributions,
|
|
68
|
+
'invalid_manifest', 'Too many contributions')
|
|
69
|
+
const ids = new Set()
|
|
70
|
+
for (const declaration of manifest.contributions) {
|
|
71
|
+
ensure(object(declaration) && identifier(declaration.id) && !ids.has(declaration.id) &&
|
|
72
|
+
SURFACE_KINDS.includes(declaration.kind) && ANCHORS.includes(declaration.anchor), 'invalid_manifest', 'Invalid contribution declaration')
|
|
73
|
+
ids.add(declaration.id)
|
|
74
|
+
for (const [key, choices] of Object.entries({ merge: ['by-machine', 'by-identity'], width: ['full', 'half'],
|
|
75
|
+
position: ['top', 'after-open', 'before-danger', 'bottom'] })) {
|
|
76
|
+
ensure(declaration[key] === undefined || choices.includes(declaration[key]), 'invalid_manifest', `Invalid contribution ${key}`)
|
|
77
|
+
}
|
|
78
|
+
for (const key of ['title', 'group', 'chord', 'pattern', 'actionId']) {
|
|
79
|
+
ensure(declaration[key] === undefined || (typeof declaration[key] === 'string' && declaration[key].length <= 512),
|
|
80
|
+
'invalid_manifest', `Invalid contribution ${key}`)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
ensure(!manifest.contributions.length || manifest.capabilities.includes('surfaces'), 'invalid_manifest', 'Contributions require surfaces capability')
|
|
84
|
+
ensure(manifest.configSchema === undefined || object(manifest.configSchema), 'invalid_manifest', 'Configuration schema must be an object')
|
|
85
|
+
return freeze(manifest)
|
|
86
|
+
}
|
package/src/protocol.mjs
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { ensure, identifier, object, jsonBytes, LIMITS, PluginError } from './manifest.mjs'
|
|
2
|
+
|
|
3
|
+
export const RUNNER_PROTOCOL_VERSION = 1
|
|
4
|
+
export const PLUGIN_API_VERSION = 1
|
|
5
|
+
export const PLUGIN_PEER_CAPABILITY = 'plugins.v1'
|
|
6
|
+
export const OPERATIONS = Object.freeze({
|
|
7
|
+
'pane.create': 'panes', 'pane.close': 'panes', 'pane.restart': 'panes',
|
|
8
|
+
'pane.input': 'panes', 'pane.focus': 'panes', 'pane.wait': 'panes',
|
|
9
|
+
'project.create': 'projects', 'project.remove': 'projects',
|
|
10
|
+
'notification.show': 'notifications', 'url.open': 'url', fetch: 'fetch',
|
|
11
|
+
'secret.get': 'secrets', 'config.get': null, 'state.get': null, 'state.set': null,
|
|
12
|
+
'context.get': null, 'popover.open': 'surfaces', 'canvas.write': 'surfaces',
|
|
13
|
+
'canvas.focus': 'surfaces', 'subscription.add': null, 'subscription.remove': null,
|
|
14
|
+
'health.set': null, 'webhook.ack': 'webhook',
|
|
15
|
+
})
|
|
16
|
+
export const HOST_OPERATIONS = Object.freeze(['runner.start', 'runner.stop', 'runner.reload',
|
|
17
|
+
'runner.diagnose', 'runner.ping', 'runtime.invoke', 'runtime.visibility'])
|
|
18
|
+
|
|
19
|
+
export function authorize(operation, capabilities) {
|
|
20
|
+
ensure(object(operation) && Object.hasOwn(OPERATIONS, operation.op) && object(operation.args),
|
|
21
|
+
'unsupported_operation', 'Unknown SDK operation or invalid arguments')
|
|
22
|
+
let capability = OPERATIONS[operation.op]
|
|
23
|
+
if (operation.op === 'subscription.add') {
|
|
24
|
+
const { id, kind, name } = operation.args
|
|
25
|
+
ensure(identifier(id) && typeof name === 'string' && name.length <= 256 &&
|
|
26
|
+
['event', 'hook', 'action', 'input', 'select', 'resize', 'activate', 'deactivate', 'visibility'].includes(kind),
|
|
27
|
+
'invalid_payload', 'Invalid subscription')
|
|
28
|
+
capability = kind === 'hook' ? 'hooks' : kind === 'event' ? (name === 'webhook' ? 'webhook' : 'events') : 'surfaces'
|
|
29
|
+
}
|
|
30
|
+
ensure(!capability || capabilities.includes(capability), 'capability_denied', `Operation requires ${capability} capability`)
|
|
31
|
+
jsonBytes(operation)
|
|
32
|
+
}
|
|
33
|
+
export function validateProducer(producer) {
|
|
34
|
+
ensure(object(producer) && identifier(producer.pluginId) && typeof producer.machineId === 'string' &&
|
|
35
|
+
producer.machineId.length > 0 && producer.machineId.length <= 128 && typeof producer.epoch === 'string' &&
|
|
36
|
+
/^(0|[1-9]\d{0,19})$/.test(producer.epoch) && BigInt(producer.epoch) <= 18446744073709551615n,
|
|
37
|
+
'invalid_payload', 'Invalid plugin producer identity')
|
|
38
|
+
return producer
|
|
39
|
+
}
|
|
40
|
+
export function sameProducer(a, b) {
|
|
41
|
+
return a.pluginId === b.pluginId && a.machineId === b.machineId && a.epoch === b.epoch
|
|
42
|
+
}
|
|
43
|
+
export function validateEnvelope(frame) {
|
|
44
|
+
jsonBytes(frame)
|
|
45
|
+
ensure(object(frame) && frame.version === RUNNER_PROTOCOL_VERSION, 'incompatible_version', 'Daemon and runner protocol versions differ')
|
|
46
|
+
validateProducer(frame.producer)
|
|
47
|
+
ensure(['request', 'response', 'cancel', 'surface'].includes(frame.kind), 'invalid_payload', 'Unknown runner frame kind')
|
|
48
|
+
if (frame.kind === 'surface') {
|
|
49
|
+
ensure(object(frame.key) && identifier(frame.key.contributionId) && typeof frame.sequence === 'string' &&
|
|
50
|
+
/^(0|[1-9]\d{0,19})$/.test(frame.sequence) && BigInt(frame.sequence) <= 18446744073709551615n &&
|
|
51
|
+
Object.hasOwn(frame, 'content'), 'invalid_payload', 'Invalid surface replacement')
|
|
52
|
+
} else {
|
|
53
|
+
ensure(typeof frame.id === 'string' && frame.id.length > 0 && frame.id.length <= 128, 'invalid_payload', 'Missing correlation id')
|
|
54
|
+
if (frame.kind === 'request') {
|
|
55
|
+
ensure(object(frame.operation) && (Object.hasOwn(OPERATIONS, frame.operation.op) || HOST_OPERATIONS.includes(frame.operation.op)) &&
|
|
56
|
+
object(frame.operation.args) && Number.isSafeInteger(frame.timeoutMs) && frame.timeoutMs > 0 && frame.timeoutMs <= LIMITS.hookTimeoutMs,
|
|
57
|
+
'invalid_payload', 'Invalid runner request')
|
|
58
|
+
}
|
|
59
|
+
if (frame.kind === 'response') {
|
|
60
|
+
ensure(object(frame.result) && typeof frame.result.ok === 'boolean' &&
|
|
61
|
+
(frame.result.ok ? Object.hasOwn(frame.result, 'value') :
|
|
62
|
+
object(frame.result.error) && typeof frame.result.error.code === 'string' && typeof frame.result.error.message === 'string'),
|
|
63
|
+
'invalid_payload', 'Invalid runner response')
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return frame
|
|
67
|
+
}
|
|
68
|
+
export function wireError(error) {
|
|
69
|
+
return { code: error instanceof PluginError ? error.code : 'plugin_error',
|
|
70
|
+
message: String(error?.message ?? 'Plugin operation failed').slice(0, 1024) }
|
|
71
|
+
}
|
|
72
|
+
export const realClock = Object.freeze({ now: () => Date.now(), setTimeout: (fn, ms) => setTimeout(fn, ms), clearTimeout: id => clearTimeout(id) })
|
|
73
|
+
|
|
74
|
+
/** A single correlation path for commands, actions and hooks. Send must refuse overflow synchronously. */
|
|
75
|
+
export class RpcPeer {
|
|
76
|
+
constructor({ producer, send, clock = realClock, onRequest, onSurface, onError = () => {}, idPrefix = 'host' }) {
|
|
77
|
+
this.producer = Object.freeze({ ...validateProducer(producer) })
|
|
78
|
+
this.send = send
|
|
79
|
+
this.clock = clock
|
|
80
|
+
this.onRequest = onRequest
|
|
81
|
+
this.onSurface = onSurface
|
|
82
|
+
this.onError = onError
|
|
83
|
+
this.pending = new Map()
|
|
84
|
+
this.incoming = new Map()
|
|
85
|
+
this.nextId = 0
|
|
86
|
+
this.idPrefix = idPrefix
|
|
87
|
+
this.closed = false
|
|
88
|
+
}
|
|
89
|
+
frame(kind, values) { return { version: RUNNER_PROTOCOL_VERSION, producer: this.producer, kind, ...values } }
|
|
90
|
+
transmit(frame) { validateEnvelope(frame); this.send(frame) }
|
|
91
|
+
request(operation, { signal, timeoutMs = LIMITS.requestTimeoutMs } = {}) {
|
|
92
|
+
if (this.closed) return Promise.reject(new PluginError('disposed', 'Plugin runtime is disposed'))
|
|
93
|
+
if (signal?.aborted) return Promise.reject(new PluginError('cancelled', 'Request was cancelled'))
|
|
94
|
+
if (this.pending.size >= LIMITS.pendingRequests) return Promise.reject(new PluginError('queue_full', 'Too many pending requests'))
|
|
95
|
+
const id = `${this.idPrefix}-${++this.nextId}`
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
let timer
|
|
98
|
+
const finish = (error, value) => {
|
|
99
|
+
if (!this.pending.delete(id)) return
|
|
100
|
+
this.clock.clearTimeout(timer)
|
|
101
|
+
signal?.removeEventListener('abort', cancel)
|
|
102
|
+
error ? reject(error) : resolve(value)
|
|
103
|
+
}
|
|
104
|
+
const cancelWith = code => {
|
|
105
|
+
finish(new PluginError(code, code === 'timeout' ? 'Request deadline elapsed' : 'Request was cancelled'))
|
|
106
|
+
try { this.transmit(this.frame('cancel', { id })) } catch (error) { this.onError(error) }
|
|
107
|
+
}
|
|
108
|
+
const cancel = () => cancelWith('cancelled')
|
|
109
|
+
this.pending.set(id, { finish })
|
|
110
|
+
signal?.addEventListener('abort', cancel, { once: true })
|
|
111
|
+
timer = this.clock.setTimeout(() => cancelWith('timeout'), timeoutMs)
|
|
112
|
+
try { this.transmit(this.frame('request', { id, operation, timeoutMs })) }
|
|
113
|
+
catch (error) { finish(error) }
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
receive(frame) {
|
|
117
|
+
if (this.closed) return
|
|
118
|
+
validateEnvelope(frame)
|
|
119
|
+
ensure(sameProducer(frame.producer, this.producer), 'stale_producer', 'Frame belongs to another plugin instance')
|
|
120
|
+
if (frame.kind === 'surface') { this.onSurface?.(frame); return }
|
|
121
|
+
if (frame.kind === 'response') {
|
|
122
|
+
const result = frame.result
|
|
123
|
+
this.pending.get(frame.id)?.finish(result.ok ? null : new PluginError(result.error.code, result.error.message), result.value)
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
if (frame.kind === 'cancel') { this.incoming.get(frame.id)?.cancel(); return }
|
|
127
|
+
ensure(!this.incoming.has(frame.id), 'duplicate_request', 'Request id is still in flight')
|
|
128
|
+
const respond = result => {
|
|
129
|
+
if (!this.closed) {
|
|
130
|
+
try { this.transmit(this.frame('response', { id: frame.id, result })) }
|
|
131
|
+
catch (error) { this.onError(error) }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (this.incoming.size >= LIMITS.pendingRequests) {
|
|
135
|
+
respond({ ok: false, error: { code: 'queue_full', message: 'Too many incoming requests' } })
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
const controller = new AbortController()
|
|
139
|
+
// Retain the occupied slot until a cancelled handler settles. An uncooperative
|
|
140
|
+
// handler cannot turn repeated cancellation into unbounded concurrent work.
|
|
141
|
+
const cancel = () => { controller.abort(); this.clock.clearTimeout(timer) }
|
|
142
|
+
const timer = this.clock.setTimeout(() => {
|
|
143
|
+
cancel()
|
|
144
|
+
respond({ ok: false, error: { code: 'timeout', message: 'Handler deadline elapsed' } })
|
|
145
|
+
}, frame.timeoutMs)
|
|
146
|
+
this.incoming.set(frame.id, { cancel })
|
|
147
|
+
Promise.resolve().then(() => {
|
|
148
|
+
ensure(!controller.signal.aborted, 'cancelled', 'Request was cancelled')
|
|
149
|
+
ensure(typeof this.onRequest === 'function', 'unsupported_operation', 'No request handler')
|
|
150
|
+
return this.onRequest(frame.operation, { signal: controller.signal, timeoutMs: frame.timeoutMs })
|
|
151
|
+
}).then(value => {
|
|
152
|
+
if (!controller.signal.aborted) respond({ ok: true, value: value ?? null })
|
|
153
|
+
}, error => {
|
|
154
|
+
if (!controller.signal.aborted) respond({ ok: false, error: wireError(error) })
|
|
155
|
+
}).finally(() => { this.clock.clearTimeout(timer); this.incoming.delete(frame.id) })
|
|
156
|
+
}
|
|
157
|
+
dispose() {
|
|
158
|
+
if (this.closed) return
|
|
159
|
+
for (const [id, request] of this.pending) {
|
|
160
|
+
request.finish(new PluginError('disposed', 'Plugin runtime was disposed'))
|
|
161
|
+
try { this.transmit(this.frame('cancel', { id })) } catch (error) { this.onError(error) }
|
|
162
|
+
}
|
|
163
|
+
this.closed = true
|
|
164
|
+
for (const request of this.incoming.values()) request.cancel()
|
|
165
|
+
}
|
|
166
|
+
}
|
package/src/publish.mjs
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { PluginError, ensure, object, validateManifest } from './manifest.mjs'
|
|
2
|
+
|
|
3
|
+
export const SDK_PACKAGE = '@standardagents/code-plugin-sdk'
|
|
4
|
+
export const SOURCE_KINDS = Object.freeze(['git', 'npm'])
|
|
5
|
+
const LOCKFILES = Object.freeze({ git: Object.freeze(['package-lock.json', 'npm-shrinkwrap.json']), npm: Object.freeze(['npm-shrinkwrap.json']) })
|
|
6
|
+
|
|
7
|
+
function names(section) { return object(section) ? Object.keys(section) : [] }
|
|
8
|
+
|
|
9
|
+
/** Runtime dependencies are the ones npm installs for a consumer of the package. */
|
|
10
|
+
export function runtimeDependencies(packageJson) {
|
|
11
|
+
ensure(object(packageJson), 'invalid_package', 'package.json must contain an object')
|
|
12
|
+
return [...new Set([...names(packageJson.dependencies), ...names(packageJson.optionalDependencies)])]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A plugin with runtime dependencies ships a lockfile. npm publishes
|
|
17
|
+
* npm-shrinkwrap.json and drops package-lock.json, so an npm source needs the
|
|
18
|
+
* shrinkwrap; a Git source may keep either file.
|
|
19
|
+
*/
|
|
20
|
+
export function lockfileRequirement({ packageJson, sourceKind }) {
|
|
21
|
+
ensure(SOURCE_KINDS.includes(sourceKind), 'invalid_source', 'Source kind must be git or npm')
|
|
22
|
+
const dependencies = runtimeDependencies(packageJson)
|
|
23
|
+
return Object.freeze({ required: dependencies.length > 0, dependencies: Object.freeze(dependencies), lockfiles: LOCKFILES[sourceKind] })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function normalize(path) { return path.replace(/^\.\//, '') }
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Pure publish checks for one plugin directory. `files` lists the relative
|
|
30
|
+
* POSIX paths that ship with the package. Returns a list of problems; an empty
|
|
31
|
+
* list means the package passes.
|
|
32
|
+
*/
|
|
33
|
+
export function checkPackageForPublish({ packageJson, files, sourceKind = 'npm', requireManifest = true, expectedId } = {}) {
|
|
34
|
+
ensure(Array.isArray(files) && files.every(file => typeof file === 'string'), 'invalid_source', 'files must list relative paths')
|
|
35
|
+
const problems = []
|
|
36
|
+
const problem = (code, message) => problems.push(Object.freeze({ code, message }))
|
|
37
|
+
if (!object(packageJson)) return Object.freeze([Object.freeze({ code: 'invalid_package', message: 'package.json must contain an object' })])
|
|
38
|
+
const present = new Set(files.map(normalize))
|
|
39
|
+
if (packageJson.private === true && sourceKind === 'npm') problem('package_private', 'package.json marks the package private, so npm refuses to publish it')
|
|
40
|
+
const requirement = lockfileRequirement({ packageJson, sourceKind })
|
|
41
|
+
if (requirement.required && !requirement.lockfiles.some(name => present.has(name))) {
|
|
42
|
+
problem('lockfile_missing', `Dependencies (${requirement.dependencies.join(', ')}) need ${requirement.lockfiles.join(' or ')} beside package.json`)
|
|
43
|
+
}
|
|
44
|
+
for (const section of ['dependencies', 'optionalDependencies']) {
|
|
45
|
+
if (names(packageJson[section]).includes(SDK_PACKAGE)) problem('sdk_dependency', `${SDK_PACKAGE} belongs under peerDependencies, not ${section}`)
|
|
46
|
+
}
|
|
47
|
+
let manifest = null
|
|
48
|
+
if (packageJson.standardPlugin === undefined) {
|
|
49
|
+
if (requireManifest) problem('manifest_missing', 'package.json has no standardPlugin manifest')
|
|
50
|
+
} else {
|
|
51
|
+
try { manifest = validateManifest(packageJson.standardPlugin) } catch (error) {
|
|
52
|
+
if (!(error instanceof PluginError)) throw error
|
|
53
|
+
problem(error.code, error.message)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (manifest) {
|
|
57
|
+
if (!names(packageJson.peerDependencies).includes(SDK_PACKAGE)) problem('sdk_peer_missing', `${SDK_PACKAGE} must be listed under peerDependencies`)
|
|
58
|
+
if (!present.has(normalize(manifest.entry))) problem('entry_missing', `Entry ${manifest.entry} is not among the package files`)
|
|
59
|
+
if (expectedId !== undefined && manifest.id !== expectedId) problem('id_mismatch', `Manifest id ${manifest.id} differs from collection entry ${expectedId}`)
|
|
60
|
+
}
|
|
61
|
+
return Object.freeze(problems)
|
|
62
|
+
}
|