@zihanw/pi-forge 0.5.0 → 0.5.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/CHANGELOG.md +38 -0
- package/README.md +2 -1
- package/README.zh-CN.md +1 -1
- package/dist/context-diff-history.d.ts +61 -0
- package/dist/context-diff-history.d.ts.map +1 -0
- package/dist/context-diff-history.js +84 -0
- package/dist/context-diff-history.js.map +1 -0
- package/dist/context-diff-snapshot.d.ts +19 -0
- package/dist/context-diff-snapshot.d.ts.map +1 -0
- package/dist/context-diff-snapshot.js +146 -0
- package/dist/context-diff-snapshot.js.map +1 -0
- package/dist/context-diff.d.ts +70 -0
- package/dist/context-diff.d.ts.map +1 -0
- package/dist/context-diff.js +259 -0
- package/dist/context-diff.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/lifecycle.d.ts +2 -0
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +2 -0
- package/dist/lifecycle.js.map +1 -1
- package/dist/payload-capture.d.ts +10 -0
- package/dist/payload-capture.d.ts.map +1 -1
- package/dist/payload-capture.js +39 -9
- package/dist/payload-capture.js.map +1 -1
- package/dist/payload-command.d.ts +2 -0
- package/dist/payload-command.d.ts.map +1 -1
- package/dist/payload-command.js +36 -6
- package/dist/payload-command.js.map +1 -1
- package/dist/payload-state.d.ts +3 -0
- package/dist/payload-state.d.ts.map +1 -1
- package/dist/payload-state.js +5 -0
- package/dist/payload-state.js.map +1 -1
- package/dist/preview.d.ts.map +1 -1
- package/dist/preview.js +17 -3
- package/dist/preview.js.map +1 -1
- package/dist/runtime/tool-policy-runtime.d.ts.map +1 -1
- package/dist/runtime/tool-policy-runtime.js +13 -3
- package/dist/runtime/tool-policy-runtime.js.map +1 -1
- package/dist/runtime/web-editor-runtime.d.ts +2 -1
- package/dist/runtime/web-editor-runtime.d.ts.map +1 -1
- package/dist/runtime/web-editor-runtime.js +8 -3
- package/dist/runtime/web-editor-runtime.js.map +1 -1
- package/dist/ui-contribution/contrib-port.d.ts +170 -0
- package/dist/ui-contribution/contrib-port.d.ts.map +1 -0
- package/dist/ui-contribution/contrib-port.js +640 -0
- package/dist/ui-contribution/contrib-port.js.map +1 -0
- package/dist/ui-contribution/index.d.ts +3 -0
- package/dist/ui-contribution/index.d.ts.map +1 -0
- package/dist/ui-contribution/index.js +2 -0
- package/dist/ui-contribution/index.js.map +1 -0
- package/dist/web-editor/client-script.generated.d.ts.map +1 -1
- package/dist/web-editor/client-script.generated.js +1 -1
- package/dist/web-editor/client-script.generated.js.map +1 -1
- package/dist/web-editor/client-styles.generated.d.ts.map +1 -1
- package/dist/web-editor/client-styles.generated.js +1 -1
- package/dist/web-editor/client-styles.generated.js.map +1 -1
- package/dist/web-editor/contrib-service.d.ts +41 -0
- package/dist/web-editor/contrib-service.d.ts.map +1 -0
- package/dist/web-editor/contrib-service.js +174 -0
- package/dist/web-editor/contrib-service.js.map +1 -0
- package/dist/web-editor/line-diff.d.ts +28 -0
- package/dist/web-editor/line-diff.d.ts.map +1 -0
- package/dist/web-editor/line-diff.js +216 -0
- package/dist/web-editor/line-diff.js.map +1 -0
- package/dist/web-editor/schema-form.d.ts +63 -0
- package/dist/web-editor/schema-form.d.ts.map +1 -0
- package/dist/web-editor/schema-form.js +213 -0
- package/dist/web-editor/schema-form.js.map +1 -0
- package/dist/web-editor/server.d.ts.map +1 -1
- package/dist/web-editor/server.js +71 -6
- package/dist/web-editor/server.js.map +1 -1
- package/dist/web-editor/styles.d.ts.map +1 -1
- package/dist/web-editor/styles.js +188 -21
- package/dist/web-editor/styles.js.map +1 -1
- package/dist/web-editor/types.d.ts +12 -0
- package/dist/web-editor/types.d.ts.map +1 -1
- package/dist/web-host.d.ts +2 -0
- package/dist/web-host.d.ts.map +1 -1
- package/dist/web-host.js +1 -0
- package/dist/web-host.js.map +1 -1
- package/docs/README.md +1 -0
- package/docs/concepts/agent-profiles.md +1 -1
- package/docs/design/0.5.1-plan.md +237 -0
- package/docs/design/architecture-0.5.md +12 -1
- package/docs/design/context-diff-plan.md +8 -2
- package/docs/development/release.md +7 -1
- package/docs/guides/delegation.md +3 -3
- package/docs/guides/web-editor.md +1 -1
- package/docs/reference/commands.md +1 -1
- package/docs/reference/configuration.md +2 -2
- package/docs/reference/public-api.md +14 -2
- package/docs/reference/stack-schema.md +1 -1
- package/docs/reference/ui-contribution-port.md +51 -0
- package/docs/zh-CN/concepts/agent-profiles.md +1 -1
- package/docs/zh-CN/concepts/prompt-stacks.md +1 -1
- package/docs/zh-CN/guides/delegation.md +2 -2
- package/docs/zh-CN/reference/commands.md +1 -1
- package/package.json +6 -1
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Cross-extension UI contribution port v1 over the Pi event bus.
|
|
4
|
+
*
|
|
5
|
+
* This is the generic, versioned RPC contract used by optional packages to
|
|
6
|
+
* contribute schema-driven tabs to the pi-forge web editor. Messages are plain,
|
|
7
|
+
* recursively validated, JSON-compatible data only — no functions, components,
|
|
8
|
+
* live contexts, or internal registries cross the bus. The port itself is not a
|
|
9
|
+
* trust boundary.
|
|
10
|
+
*
|
|
11
|
+
* Ownership: the contributing package owns the schema, current values, and
|
|
12
|
+
* persistence/validation for its tab. The pi-forge web server only proxies
|
|
13
|
+
* descriptors to the browser and writes submitted values back over the bus.
|
|
14
|
+
*/
|
|
15
|
+
export const UI_CONTRIBUTION_PORT_VERSION = 1;
|
|
16
|
+
export const UI_CONTRIBUTION_PORT_NAMESPACE = "@zihanw/pi-forge/ui-contribution/v1";
|
|
17
|
+
export const UI_CONTRIBUTION_PORT_OPERATIONS = ["listContributions", "writeValues"];
|
|
18
|
+
export const UI_CONTRIBUTION_CHANNEL = {
|
|
19
|
+
discover: `${UI_CONTRIBUTION_PORT_NAMESPACE}/discover`,
|
|
20
|
+
available: `${UI_CONTRIBUTION_PORT_NAMESPACE}/available`,
|
|
21
|
+
request: `${UI_CONTRIBUTION_PORT_NAMESPACE}/request`,
|
|
22
|
+
reply: `${UI_CONTRIBUTION_PORT_NAMESPACE}/reply`,
|
|
23
|
+
unavailable: `${UI_CONTRIBUTION_PORT_NAMESPACE}/unavailable`,
|
|
24
|
+
};
|
|
25
|
+
export class UiContributionPortError extends Error {
|
|
26
|
+
code;
|
|
27
|
+
constructor(code, message) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.code = code;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Validation.
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
const FIELD_TYPES = new Set(["boolean", "number", "enum", "string", "record"]);
|
|
36
|
+
const LIST_FIELDS = new Set(["tabs"]);
|
|
37
|
+
const WRITE_REQUEST_FIELDS = new Set(["tabId", "patch"]);
|
|
38
|
+
export function validateListContributionsRequest(value) {
|
|
39
|
+
if (value === undefined || value === null)
|
|
40
|
+
return { ok: true, data: {} };
|
|
41
|
+
if (!isRecord(value))
|
|
42
|
+
return { ok: false, error: "listContributions request must be an empty object." };
|
|
43
|
+
if (Object.keys(value).length > 0) {
|
|
44
|
+
return { ok: false, error: `listContributions request must be empty; unexpected fields: ${Object.keys(value).join(", ")}` };
|
|
45
|
+
}
|
|
46
|
+
return { ok: true, data: {} };
|
|
47
|
+
}
|
|
48
|
+
export function validateListContributionsResponse(value) {
|
|
49
|
+
if (!isRecord(value) || !Array.isArray(value.tabs)) {
|
|
50
|
+
return { ok: false, error: "listContributions response must contain a tabs array." };
|
|
51
|
+
}
|
|
52
|
+
const unknown = Object.keys(value).filter((key) => !LIST_FIELDS.has(key));
|
|
53
|
+
if (unknown.length > 0) {
|
|
54
|
+
return { ok: false, error: `listContributions response contains unsupported fields: ${unknown.join(", ")}.` };
|
|
55
|
+
}
|
|
56
|
+
for (const [index, tab] of value.tabs.entries()) {
|
|
57
|
+
const tabResult = validateUiContributionTabDescriptor(tab);
|
|
58
|
+
if (!tabResult.ok) {
|
|
59
|
+
return { ok: false, error: `listContributions response tabs[${index}]: ${tabResult.error}` };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (!isJsonCompatible(value))
|
|
63
|
+
return { ok: false, error: "listContributions response is not JSON-compatible." };
|
|
64
|
+
return { ok: true, data: value };
|
|
65
|
+
}
|
|
66
|
+
export function validateUiContributionTabDescriptor(value) {
|
|
67
|
+
if (!isRecord(value))
|
|
68
|
+
return { ok: false, error: "tab descriptor must be an object." };
|
|
69
|
+
const allowed = new Set(["tabId", "title", "icon", "schema", "values"]);
|
|
70
|
+
const unknown = Object.keys(value).filter((key) => !allowed.has(key));
|
|
71
|
+
if (unknown.length > 0) {
|
|
72
|
+
return { ok: false, error: `tab descriptor contains unsupported fields: ${unknown.join(", ")}.` };
|
|
73
|
+
}
|
|
74
|
+
if (typeof value.tabId !== "string" || !value.tabId.trim()) {
|
|
75
|
+
return { ok: false, error: "tab descriptor tabId must be a non-empty string." };
|
|
76
|
+
}
|
|
77
|
+
if (typeof value.title !== "string") {
|
|
78
|
+
return { ok: false, error: "tab descriptor title must be a string." };
|
|
79
|
+
}
|
|
80
|
+
if (typeof value.icon !== "string") {
|
|
81
|
+
return { ok: false, error: "tab descriptor icon must be a string." };
|
|
82
|
+
}
|
|
83
|
+
const schema = validateFormSchema(value.schema);
|
|
84
|
+
if (!schema.ok)
|
|
85
|
+
return { ok: false, error: `tab descriptor schema: ${schema.error}` };
|
|
86
|
+
if (!isRecord(value.values))
|
|
87
|
+
return { ok: false, error: "tab descriptor values must be an object." };
|
|
88
|
+
if (!isJsonCompatible(value))
|
|
89
|
+
return { ok: false, error: "tab descriptor is not JSON-compatible." };
|
|
90
|
+
return { ok: true, data: value };
|
|
91
|
+
}
|
|
92
|
+
export function validateWriteValuesRequest(value) {
|
|
93
|
+
if (!isRecord(value))
|
|
94
|
+
return { ok: false, error: "writeValues request must be an object." };
|
|
95
|
+
const unknown = Object.keys(value).filter((key) => !WRITE_REQUEST_FIELDS.has(key));
|
|
96
|
+
if (unknown.length > 0) {
|
|
97
|
+
return { ok: false, error: `writeValues request contains unsupported fields: ${unknown.join(", ")}.` };
|
|
98
|
+
}
|
|
99
|
+
if (typeof value.tabId !== "string" || !value.tabId.trim()) {
|
|
100
|
+
return { ok: false, error: "writeValues request requires a non-empty tabId." };
|
|
101
|
+
}
|
|
102
|
+
if (!isRecord(value.patch))
|
|
103
|
+
return { ok: false, error: "writeValues request requires patch to be an object." };
|
|
104
|
+
if (!isJsonCompatible(value))
|
|
105
|
+
return { ok: false, error: "writeValues request is not JSON-compatible." };
|
|
106
|
+
return { ok: true, data: value };
|
|
107
|
+
}
|
|
108
|
+
export function validateWriteValuesResponse(value) {
|
|
109
|
+
if (!isRecord(value) || typeof value.ok !== "boolean") {
|
|
110
|
+
return { ok: false, error: "writeValues response must contain an ok boolean." };
|
|
111
|
+
}
|
|
112
|
+
if (value.ok) {
|
|
113
|
+
if (value.values !== undefined && !isRecord(value.values)) {
|
|
114
|
+
return { ok: false, error: "writeValues success values must be an object when provided." };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
const errors = value.errors;
|
|
119
|
+
if (!isRecord(errors) || Object.keys(errors).some((key) => typeof errors[key] !== "string")) {
|
|
120
|
+
return { ok: false, error: "writeValues failure must contain an errors object of strings." };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (!isJsonCompatible(value))
|
|
124
|
+
return { ok: false, error: "writeValues response is not JSON-compatible." };
|
|
125
|
+
return { ok: true, data: value };
|
|
126
|
+
}
|
|
127
|
+
export function validateFormSchema(value) {
|
|
128
|
+
if (!isRecord(value))
|
|
129
|
+
return { ok: false, error: "FormSchema must be an object." };
|
|
130
|
+
if (value.title !== undefined && typeof value.title !== "string")
|
|
131
|
+
return { ok: false, error: "FormSchema title must be a string when provided." };
|
|
132
|
+
if (value.description !== undefined && typeof value.description !== "string")
|
|
133
|
+
return { ok: false, error: "FormSchema description must be a string when provided." };
|
|
134
|
+
if (!Array.isArray(value.fields))
|
|
135
|
+
return { ok: false, error: "FormSchema fields must be an array." };
|
|
136
|
+
for (const [index, field] of value.fields.entries()) {
|
|
137
|
+
const fieldResult = validateSchemaField(field);
|
|
138
|
+
if (!fieldResult.ok)
|
|
139
|
+
return { ok: false, error: `FormSchema fields[${index}]: ${fieldResult.error}` };
|
|
140
|
+
}
|
|
141
|
+
if (!isJsonCompatible(value))
|
|
142
|
+
return { ok: false, error: "FormSchema is not JSON-compatible." };
|
|
143
|
+
return { ok: true, data: value };
|
|
144
|
+
}
|
|
145
|
+
function validateSchemaField(value) {
|
|
146
|
+
if (!isRecord(value))
|
|
147
|
+
return { ok: false, error: "field must be an object." };
|
|
148
|
+
const allowed = new Set([
|
|
149
|
+
"key", "label", "type", "description", "required", "default", "options",
|
|
150
|
+
"min", "max", "maxLength", "pattern", "placeholder", "recordFields", "keyLabel", "keyPlaceholder", "keyOptions",
|
|
151
|
+
]);
|
|
152
|
+
const unknown = Object.keys(value).filter((key) => !allowed.has(key));
|
|
153
|
+
if (unknown.length > 0) {
|
|
154
|
+
return { ok: false, error: `field contains unsupported fields: ${unknown.join(", ")}.` };
|
|
155
|
+
}
|
|
156
|
+
if (typeof value.key !== "string" || !value.key.trim())
|
|
157
|
+
return { ok: false, error: "field key must be a non-empty string." };
|
|
158
|
+
if (typeof value.label !== "string")
|
|
159
|
+
return { ok: false, error: "field label must be a string." };
|
|
160
|
+
if (typeof value.type !== "string" || !FIELD_TYPES.has(value.type)) {
|
|
161
|
+
return { ok: false, error: `field type must be one of ${[...FIELD_TYPES].join("/")}.` };
|
|
162
|
+
}
|
|
163
|
+
if (value.description !== undefined && typeof value.description !== "string")
|
|
164
|
+
return { ok: false, error: "field description must be a string when provided." };
|
|
165
|
+
if (value.required !== undefined && typeof value.required !== "boolean")
|
|
166
|
+
return { ok: false, error: "field required must be a boolean when provided." };
|
|
167
|
+
if (value.default !== undefined && !isJsonCompatible(value.default))
|
|
168
|
+
return { ok: false, error: "field default must be JSON-compatible when provided." };
|
|
169
|
+
if (value.min !== undefined && (typeof value.min !== "number" || !Number.isFinite(value.min)))
|
|
170
|
+
return { ok: false, error: "field min must be a finite number when provided." };
|
|
171
|
+
if (value.max !== undefined && (typeof value.max !== "number" || !Number.isFinite(value.max)))
|
|
172
|
+
return { ok: false, error: "field max must be a finite number when provided." };
|
|
173
|
+
if (value.maxLength !== undefined && (typeof value.maxLength !== "number" || !Number.isInteger(value.maxLength)))
|
|
174
|
+
return { ok: false, error: "field maxLength must be an integer when provided." };
|
|
175
|
+
if (value.pattern !== undefined && typeof value.pattern !== "string")
|
|
176
|
+
return { ok: false, error: "field pattern must be a string when provided." };
|
|
177
|
+
if (value.placeholder !== undefined && typeof value.placeholder !== "string")
|
|
178
|
+
return { ok: false, error: "field placeholder must be a string when provided." };
|
|
179
|
+
if (value.keyLabel !== undefined && typeof value.keyLabel !== "string")
|
|
180
|
+
return { ok: false, error: "field keyLabel must be a string when provided." };
|
|
181
|
+
if (value.keyPlaceholder !== undefined && typeof value.keyPlaceholder !== "string")
|
|
182
|
+
return { ok: false, error: "field keyPlaceholder must be a string when provided." };
|
|
183
|
+
if (value.type === "enum") {
|
|
184
|
+
if (value.options !== undefined) {
|
|
185
|
+
if (!Array.isArray(value.options))
|
|
186
|
+
return { ok: false, error: "enum options must be an array when provided." };
|
|
187
|
+
for (const [index, option] of value.options.entries()) {
|
|
188
|
+
const optionResult = validateEnumOption(option);
|
|
189
|
+
if (!optionResult.ok)
|
|
190
|
+
return { ok: false, error: `options[${index}]: ${optionResult.error}` };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (value.type === "number" && value.min !== undefined && value.max !== undefined && value.min > value.max) {
|
|
195
|
+
return { ok: false, error: "field min cannot be greater than max." };
|
|
196
|
+
}
|
|
197
|
+
if (value.type === "record") {
|
|
198
|
+
if (value.keyOptions !== undefined) {
|
|
199
|
+
if (!Array.isArray(value.keyOptions))
|
|
200
|
+
return { ok: false, error: "keyOptions must be an array when provided." };
|
|
201
|
+
const values = new Set();
|
|
202
|
+
for (const [index, option] of value.keyOptions.entries()) {
|
|
203
|
+
const optionResult = validateEnumOption(option);
|
|
204
|
+
if (!optionResult.ok)
|
|
205
|
+
return { ok: false, error: `keyOptions[${index}]: ${optionResult.error}` };
|
|
206
|
+
const optionValue = typeof option === "string" ? option : option.value;
|
|
207
|
+
if (values.has(optionValue))
|
|
208
|
+
return { ok: false, error: `keyOptions[${index}]: option values must be unique.` };
|
|
209
|
+
values.add(optionValue);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (value.recordFields !== undefined) {
|
|
213
|
+
if (!Array.isArray(value.recordFields))
|
|
214
|
+
return { ok: false, error: "recordFields must be an array when provided." };
|
|
215
|
+
for (const [index, rowField] of value.recordFields.entries()) {
|
|
216
|
+
const rowResult = validateSchemaField(rowField);
|
|
217
|
+
if (!rowResult.ok)
|
|
218
|
+
return { ok: false, error: `recordFields[${index}]: ${rowResult.error}` };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return { ok: true, data: value };
|
|
223
|
+
}
|
|
224
|
+
function validateEnumOption(value) {
|
|
225
|
+
if (typeof value === "string")
|
|
226
|
+
return value
|
|
227
|
+
? { ok: true, data: value }
|
|
228
|
+
: { ok: false, error: "enum option string must be non-empty." };
|
|
229
|
+
if (!isRecord(value))
|
|
230
|
+
return { ok: false, error: "enum option must be a string or an object." };
|
|
231
|
+
if (typeof value.value !== "string" || !value.value)
|
|
232
|
+
return { ok: false, error: "enum option value must be a non-empty string." };
|
|
233
|
+
if (value.label !== undefined && typeof value.label !== "string")
|
|
234
|
+
return { ok: false, error: "enum option label must be a string when provided." };
|
|
235
|
+
return { ok: true, data: value };
|
|
236
|
+
}
|
|
237
|
+
function isRecord(value) {
|
|
238
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
239
|
+
}
|
|
240
|
+
/** Reject functions, symbols, bigint, undefined, non-finite numbers, and cycles. */
|
|
241
|
+
function isJsonCompatible(value, seen = new Set()) {
|
|
242
|
+
if (value === null)
|
|
243
|
+
return true;
|
|
244
|
+
if (typeof value === "string" || typeof value === "boolean")
|
|
245
|
+
return true;
|
|
246
|
+
if (typeof value === "number")
|
|
247
|
+
return Number.isFinite(value);
|
|
248
|
+
if (typeof value !== "object")
|
|
249
|
+
return false;
|
|
250
|
+
if (seen.has(value))
|
|
251
|
+
return false;
|
|
252
|
+
seen.add(value);
|
|
253
|
+
if (Array.isArray(value))
|
|
254
|
+
return value.every((item) => isJsonCompatible(item, seen));
|
|
255
|
+
const prototype = Object.getPrototypeOf(value);
|
|
256
|
+
if (prototype !== Object.prototype && prototype !== null)
|
|
257
|
+
return false;
|
|
258
|
+
return Object.values(value).every((item) => isJsonCompatible(item, seen));
|
|
259
|
+
}
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// Wire parsing.
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
function parseDiscover(data) {
|
|
264
|
+
if (!isRecord(data) || data.type !== "discover")
|
|
265
|
+
return undefined;
|
|
266
|
+
const { protocolVersion, minVersion, maxVersion, clientId } = data;
|
|
267
|
+
if (typeof protocolVersion !== "number" || typeof minVersion !== "number" || typeof maxVersion !== "number" || typeof clientId !== "string")
|
|
268
|
+
return undefined;
|
|
269
|
+
return { type: "discover", protocolVersion, minVersion, maxVersion, clientId };
|
|
270
|
+
}
|
|
271
|
+
function parseAvailable(data) {
|
|
272
|
+
if (!isRecord(data) || data.type !== "available")
|
|
273
|
+
return undefined;
|
|
274
|
+
const { hostId, protocolVersion, minVersion, maxVersion, capabilities, generation } = data;
|
|
275
|
+
if (typeof hostId !== "string" || typeof protocolVersion !== "number" || typeof minVersion !== "number"
|
|
276
|
+
|| typeof maxVersion !== "number" || !Array.isArray(capabilities) || capabilities.some((cap) => typeof cap !== "string") || typeof generation !== "number") {
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
return { type: "available", hostId, protocolVersion, minVersion, maxVersion, capabilities, generation };
|
|
280
|
+
}
|
|
281
|
+
function parseRequest(data) {
|
|
282
|
+
if (!isRecord(data) || data.type !== "request")
|
|
283
|
+
return undefined;
|
|
284
|
+
const { requestId, hostId, generation, operation, payload } = data;
|
|
285
|
+
if (typeof requestId !== "string" || typeof hostId !== "string" || typeof generation !== "number" || typeof operation !== "string")
|
|
286
|
+
return undefined;
|
|
287
|
+
return { type: "request", requestId, hostId, generation, operation, payload };
|
|
288
|
+
}
|
|
289
|
+
function parseReply(data) {
|
|
290
|
+
if (!isRecord(data) || data.type !== "reply")
|
|
291
|
+
return undefined;
|
|
292
|
+
const { requestId, hostId, generation, ok } = data;
|
|
293
|
+
if (typeof requestId !== "string" || typeof hostId !== "string" || typeof generation !== "number" || typeof ok !== "boolean")
|
|
294
|
+
return undefined;
|
|
295
|
+
return {
|
|
296
|
+
type: "reply",
|
|
297
|
+
requestId,
|
|
298
|
+
hostId,
|
|
299
|
+
generation,
|
|
300
|
+
ok,
|
|
301
|
+
data: ok ? data.data : undefined,
|
|
302
|
+
error: ok ? undefined : typeof data.error === "string" ? data.error : undefined,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
function parseUnavailable(data) {
|
|
306
|
+
if (!isRecord(data) || data.type !== "unavailable")
|
|
307
|
+
return undefined;
|
|
308
|
+
const { hostId, generation } = data;
|
|
309
|
+
if (typeof hostId !== "string" || typeof generation !== "number")
|
|
310
|
+
return undefined;
|
|
311
|
+
return { type: "unavailable", hostId, generation };
|
|
312
|
+
}
|
|
313
|
+
function versionCompatible(hostMin, hostMax, wantMin, wantMax) {
|
|
314
|
+
return hostMax >= wantMin && wantMax >= hostMin;
|
|
315
|
+
}
|
|
316
|
+
function normalizeProviderResult(result) {
|
|
317
|
+
if (isRecord(result) && result.ok === true)
|
|
318
|
+
return { ok: true, data: result.data };
|
|
319
|
+
if (isRecord(result) && result.ok === false) {
|
|
320
|
+
return { ok: false, error: typeof result.error === "string" && result.error ? result.error : "UI contribution provider operation failed." };
|
|
321
|
+
}
|
|
322
|
+
return { ok: false, error: "UI contribution provider returned a malformed operation result." };
|
|
323
|
+
}
|
|
324
|
+
const DEFAULT_CLIENT_TIMEOUT_MS = 2_000;
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
// Provider side.
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
export class UiContributionProvider {
|
|
329
|
+
transport;
|
|
330
|
+
options;
|
|
331
|
+
hostId;
|
|
332
|
+
generationId = 1;
|
|
333
|
+
started = false;
|
|
334
|
+
unsubscribers = [];
|
|
335
|
+
pendingRequests = new Set();
|
|
336
|
+
constructor(transport, options) {
|
|
337
|
+
this.transport = transport;
|
|
338
|
+
this.options = options;
|
|
339
|
+
this.hostId = options.providerId ?? `ui-contribution-${randomUUID()}`;
|
|
340
|
+
}
|
|
341
|
+
get generation() {
|
|
342
|
+
return this.generationId;
|
|
343
|
+
}
|
|
344
|
+
get isLive() {
|
|
345
|
+
return this.started;
|
|
346
|
+
}
|
|
347
|
+
availableMessage() {
|
|
348
|
+
return {
|
|
349
|
+
type: "available",
|
|
350
|
+
hostId: this.hostId,
|
|
351
|
+
protocolVersion: UI_CONTRIBUTION_PORT_VERSION,
|
|
352
|
+
minVersion: this.options.minVersion ?? UI_CONTRIBUTION_PORT_VERSION,
|
|
353
|
+
maxVersion: this.options.maxVersion ?? UI_CONTRIBUTION_PORT_VERSION,
|
|
354
|
+
capabilities: [...(this.options.capabilities ?? UI_CONTRIBUTION_PORT_OPERATIONS)],
|
|
355
|
+
generation: this.generationId,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
start() {
|
|
359
|
+
if (this.started)
|
|
360
|
+
return;
|
|
361
|
+
this.started = true;
|
|
362
|
+
this.unsubscribers.push(this.transport.on(UI_CONTRIBUTION_CHANNEL.discover, (data) => this.onDiscover(data)));
|
|
363
|
+
this.unsubscribers.push(this.transport.on(UI_CONTRIBUTION_CHANNEL.request, (data) => void this.onRequest(data)));
|
|
364
|
+
this.transport.emit(UI_CONTRIBUTION_CHANNEL.available, this.availableMessage());
|
|
365
|
+
}
|
|
366
|
+
stop() {
|
|
367
|
+
if (!this.started)
|
|
368
|
+
return;
|
|
369
|
+
this.started = false;
|
|
370
|
+
for (const controller of this.pendingRequests)
|
|
371
|
+
controller.abort();
|
|
372
|
+
this.pendingRequests.clear();
|
|
373
|
+
for (const unsubscribe of this.unsubscribers.splice(0))
|
|
374
|
+
unsubscribe();
|
|
375
|
+
this.transport.emit(UI_CONTRIBUTION_CHANNEL.unavailable, {
|
|
376
|
+
type: "unavailable",
|
|
377
|
+
hostId: this.hostId,
|
|
378
|
+
generation: this.generationId,
|
|
379
|
+
});
|
|
380
|
+
this.generationId += 1;
|
|
381
|
+
}
|
|
382
|
+
onDiscover(data) {
|
|
383
|
+
if (!this.started)
|
|
384
|
+
return;
|
|
385
|
+
const message = parseDiscover(data);
|
|
386
|
+
if (!message || message.protocolVersion !== UI_CONTRIBUTION_PORT_VERSION)
|
|
387
|
+
return;
|
|
388
|
+
if (!versionCompatible(this.options.minVersion ?? UI_CONTRIBUTION_PORT_VERSION, this.options.maxVersion ?? UI_CONTRIBUTION_PORT_VERSION, message.minVersion, message.maxVersion)) {
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
this.transport.emit(UI_CONTRIBUTION_CHANNEL.available, this.availableMessage());
|
|
392
|
+
}
|
|
393
|
+
async onRequest(data) {
|
|
394
|
+
if (!this.started)
|
|
395
|
+
return;
|
|
396
|
+
const message = parseRequest(data);
|
|
397
|
+
if (!message)
|
|
398
|
+
return;
|
|
399
|
+
if (message.hostId !== this.hostId || message.generation !== this.generationId)
|
|
400
|
+
return;
|
|
401
|
+
const generation = this.generationId;
|
|
402
|
+
const controller = new AbortController();
|
|
403
|
+
this.pendingRequests.add(controller);
|
|
404
|
+
let result;
|
|
405
|
+
try {
|
|
406
|
+
result = typeof this.options.handle === "function"
|
|
407
|
+
? await this.options.handle(message.operation, message.payload, { signal: controller.signal, generation })
|
|
408
|
+
: { ok: false, error: "UI contribution provider has no operation handler." };
|
|
409
|
+
}
|
|
410
|
+
catch (error) {
|
|
411
|
+
result = { ok: false, error: `UI contribution provider operation threw: ${error instanceof Error ? error.message : String(error)}` };
|
|
412
|
+
}
|
|
413
|
+
finally {
|
|
414
|
+
this.pendingRequests.delete(controller);
|
|
415
|
+
}
|
|
416
|
+
if (controller.signal.aborted || !this.started || generation !== this.generationId)
|
|
417
|
+
return;
|
|
418
|
+
this.transport.emit(UI_CONTRIBUTION_CHANNEL.reply, {
|
|
419
|
+
...normalizeProviderResult(result),
|
|
420
|
+
type: "reply",
|
|
421
|
+
requestId: message.requestId,
|
|
422
|
+
hostId: this.hostId,
|
|
423
|
+
generation: this.generationId,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
export class UiContributionClient {
|
|
428
|
+
transport;
|
|
429
|
+
clientId;
|
|
430
|
+
defaultTimeoutMs;
|
|
431
|
+
discoverSettleMs;
|
|
432
|
+
connectionUnsubscribe;
|
|
433
|
+
unavailableHandlers = [];
|
|
434
|
+
activeConnection;
|
|
435
|
+
constructor(transport, options = {}) {
|
|
436
|
+
this.transport = transport;
|
|
437
|
+
this.clientId = options.clientId ?? `ui-contribution-client-${randomUUID()}`;
|
|
438
|
+
this.defaultTimeoutMs = options.defaultTimeoutMs ?? DEFAULT_CLIENT_TIMEOUT_MS;
|
|
439
|
+
this.discoverSettleMs = options.discoverSettleMs ?? 25;
|
|
440
|
+
}
|
|
441
|
+
onUnavailable(handler) {
|
|
442
|
+
if (this.connectionUnsubscribe === undefined) {
|
|
443
|
+
this.connectionUnsubscribe = this.transport.on(UI_CONTRIBUTION_CHANNEL.unavailable, (data) => this.onUnavailableMessage(data));
|
|
444
|
+
}
|
|
445
|
+
this.unavailableHandlers.push(handler);
|
|
446
|
+
let removed = false;
|
|
447
|
+
return () => {
|
|
448
|
+
if (removed)
|
|
449
|
+
return;
|
|
450
|
+
removed = true;
|
|
451
|
+
const index = this.unavailableHandlers.indexOf(handler);
|
|
452
|
+
if (index !== -1)
|
|
453
|
+
this.unavailableHandlers.splice(index, 1);
|
|
454
|
+
if (this.unavailableHandlers.length === 0 && this.activeConnection === undefined) {
|
|
455
|
+
this.teardownPersistentListener();
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
teardownPersistentListener() {
|
|
460
|
+
if (this.connectionUnsubscribe !== undefined) {
|
|
461
|
+
this.connectionUnsubscribe();
|
|
462
|
+
this.connectionUnsubscribe = undefined;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
onUnavailableMessage(data) {
|
|
466
|
+
const message = parseUnavailable(data);
|
|
467
|
+
if (!message)
|
|
468
|
+
return;
|
|
469
|
+
if (this.activeConnection && message.hostId === this.activeConnection.hostId && message.generation === this.activeConnection.generation) {
|
|
470
|
+
this.activeConnection = undefined;
|
|
471
|
+
for (const handler of [...this.unavailableHandlers]) {
|
|
472
|
+
try {
|
|
473
|
+
handler();
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
// A throwing client handler must not break provider disposal dispatch.
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
async discover(timeoutMs) {
|
|
482
|
+
const expectedTimeout = timeoutMs ?? this.defaultTimeoutMs;
|
|
483
|
+
return new Promise((resolve, reject) => {
|
|
484
|
+
const hosts = new Map();
|
|
485
|
+
let settled = false;
|
|
486
|
+
let timer;
|
|
487
|
+
let settleTimer;
|
|
488
|
+
const settle = (error, connection) => {
|
|
489
|
+
if (settled)
|
|
490
|
+
return;
|
|
491
|
+
settled = true;
|
|
492
|
+
if (timer !== undefined)
|
|
493
|
+
clearTimeout(timer);
|
|
494
|
+
if (settleTimer !== undefined)
|
|
495
|
+
clearTimeout(settleTimer);
|
|
496
|
+
unsubscribeAll();
|
|
497
|
+
if (error)
|
|
498
|
+
reject(error);
|
|
499
|
+
else
|
|
500
|
+
resolve(connection);
|
|
501
|
+
};
|
|
502
|
+
const onAvailable = (data) => {
|
|
503
|
+
const message = parseAvailable(data);
|
|
504
|
+
if (!message || message.protocolVersion !== UI_CONTRIBUTION_PORT_VERSION)
|
|
505
|
+
return;
|
|
506
|
+
if (!versionCompatible(UI_CONTRIBUTION_PORT_VERSION, UI_CONTRIBUTION_PORT_VERSION, message.minVersion, message.maxVersion))
|
|
507
|
+
return;
|
|
508
|
+
const existing = hosts.get(message.hostId);
|
|
509
|
+
if (!existing || existing.generation !== message.generation) {
|
|
510
|
+
hosts.set(message.hostId, {
|
|
511
|
+
hostId: message.hostId,
|
|
512
|
+
protocolVersion: message.protocolVersion,
|
|
513
|
+
capabilities: message.capabilities,
|
|
514
|
+
generation: message.generation,
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
if (hosts.size > 1) {
|
|
518
|
+
settle(new UiContributionPortError("duplicate", "Multiple compatible UI contribution providers are live."));
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
if (settleTimer === undefined) {
|
|
522
|
+
settleTimer = setTimeout(() => settle(undefined, hosts.values().next().value), this.discoverSettleMs);
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
const unsubAvailable = this.transport.on(UI_CONTRIBUTION_CHANNEL.available, onAvailable);
|
|
526
|
+
const unsubUnavailable = this.transport.on(UI_CONTRIBUTION_CHANNEL.unavailable, (data) => {
|
|
527
|
+
const message = parseUnavailable(data);
|
|
528
|
+
const known = message ? hosts.get(message.hostId) : undefined;
|
|
529
|
+
if (known && message && message.generation === known.generation) {
|
|
530
|
+
settle(new UiContributionPortError("unavailable", `UI contribution provider ${message.hostId} became unavailable during discovery.`));
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
const unsubscribeAll = () => {
|
|
534
|
+
unsubAvailable();
|
|
535
|
+
unsubUnavailable();
|
|
536
|
+
};
|
|
537
|
+
timer = setTimeout(() => settle(new UiContributionPortError("timeout", "UI contribution provider discovery timed out.")), expectedTimeout);
|
|
538
|
+
this.transport.emit(UI_CONTRIBUTION_CHANNEL.discover, {
|
|
539
|
+
type: "discover",
|
|
540
|
+
protocolVersion: UI_CONTRIBUTION_PORT_VERSION,
|
|
541
|
+
minVersion: UI_CONTRIBUTION_PORT_VERSION,
|
|
542
|
+
maxVersion: UI_CONTRIBUTION_PORT_VERSION,
|
|
543
|
+
clientId: this.clientId,
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
connect(connection) {
|
|
548
|
+
if (this.activeConnection && this.activeConnection.hostId !== connection.hostId) {
|
|
549
|
+
throw new UiContributionPortError("duplicate", "This client is already connected to another UI contribution provider.");
|
|
550
|
+
}
|
|
551
|
+
this.activeConnection = connection;
|
|
552
|
+
if (this.connectionUnsubscribe === undefined) {
|
|
553
|
+
this.connectionUnsubscribe = this.transport.on(UI_CONTRIBUTION_CHANNEL.unavailable, (data) => this.onUnavailableMessage(data));
|
|
554
|
+
}
|
|
555
|
+
return connection;
|
|
556
|
+
}
|
|
557
|
+
async request(connection, operation, payload, timeoutMs) {
|
|
558
|
+
if (this.activeConnection !== connection) {
|
|
559
|
+
return { ok: false, error: `UI contribution provider ${connection.hostId} is not the client's active connection.` };
|
|
560
|
+
}
|
|
561
|
+
if (!UI_CONTRIBUTION_PORT_OPERATIONS.includes(operation)) {
|
|
562
|
+
return { ok: false, error: `Unknown UI contribution operation: ${operation}` };
|
|
563
|
+
}
|
|
564
|
+
const requestId = randomUUID();
|
|
565
|
+
const expectedTimeout = timeoutMs ?? this.defaultTimeoutMs;
|
|
566
|
+
return new Promise((resolve) => {
|
|
567
|
+
const unsubscribeReply = this.transport.on(UI_CONTRIBUTION_CHANNEL.reply, (data) => {
|
|
568
|
+
const message = parseReply(data);
|
|
569
|
+
if (!message
|
|
570
|
+
|| message.requestId !== requestId
|
|
571
|
+
|| message.hostId !== connection.hostId
|
|
572
|
+
|| message.generation !== connection.generation) {
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
cleanup();
|
|
576
|
+
if (message.ok)
|
|
577
|
+
resolve({ ok: true, data: message.data });
|
|
578
|
+
else
|
|
579
|
+
resolve({ ok: false, error: message.error ?? "UI contribution provider operation failed." });
|
|
580
|
+
});
|
|
581
|
+
const unsubscribeUnavailable = this.transport.on(UI_CONTRIBUTION_CHANNEL.unavailable, (data) => {
|
|
582
|
+
const message = parseUnavailable(data);
|
|
583
|
+
if (message && message.hostId === connection.hostId && message.generation === connection.generation) {
|
|
584
|
+
cleanup();
|
|
585
|
+
resolve({ ok: false, error: `UI contribution provider ${connection.hostId} became unavailable.` });
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
let timer;
|
|
589
|
+
const cleanup = () => {
|
|
590
|
+
if (timer !== undefined)
|
|
591
|
+
clearTimeout(timer);
|
|
592
|
+
unsubscribeReply();
|
|
593
|
+
unsubscribeUnavailable();
|
|
594
|
+
};
|
|
595
|
+
timer = setTimeout(() => {
|
|
596
|
+
cleanup();
|
|
597
|
+
resolve({ ok: false, error: `UI contribution operation ${operation} timed out.` });
|
|
598
|
+
}, expectedTimeout);
|
|
599
|
+
this.transport.emit(UI_CONTRIBUTION_CHANNEL.request, {
|
|
600
|
+
type: "request",
|
|
601
|
+
requestId,
|
|
602
|
+
hostId: connection.hostId,
|
|
603
|
+
generation: connection.generation,
|
|
604
|
+
operation,
|
|
605
|
+
payload,
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
async listContributions(connection, timeoutMs) {
|
|
610
|
+
const result = await this.request(connection, "listContributions", {}, timeoutMs);
|
|
611
|
+
if (!result.ok)
|
|
612
|
+
return result;
|
|
613
|
+
const validated = validateListContributionsResponse(result.data);
|
|
614
|
+
if (!validated.ok)
|
|
615
|
+
return { ok: false, error: validated.error };
|
|
616
|
+
return { ok: true, data: validated.data };
|
|
617
|
+
}
|
|
618
|
+
async writeValues(connection, tabId, patch, timeoutMs) {
|
|
619
|
+
const request = { tabId, patch };
|
|
620
|
+
const validatedRequest = validateWriteValuesRequest(request);
|
|
621
|
+
if (!validatedRequest.ok)
|
|
622
|
+
return { ok: false, error: validatedRequest.error };
|
|
623
|
+
const result = await this.request(connection, "writeValues", validatedRequest.data, timeoutMs);
|
|
624
|
+
if (!result.ok)
|
|
625
|
+
return result;
|
|
626
|
+
const validated = validateWriteValuesResponse(result.data);
|
|
627
|
+
if (!validated.ok)
|
|
628
|
+
return { ok: false, error: validated.error };
|
|
629
|
+
return { ok: true, data: validated.data };
|
|
630
|
+
}
|
|
631
|
+
disconnect() {
|
|
632
|
+
this.activeConnection = undefined;
|
|
633
|
+
this.teardownPersistentListener();
|
|
634
|
+
this.unavailableHandlers = [];
|
|
635
|
+
}
|
|
636
|
+
get subscriptionCount() {
|
|
637
|
+
return this.connectionUnsubscribe === undefined ? 0 : 1;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
//# sourceMappingURL=contrib-port.js.map
|