@openchamber/sdk 2.5.0 → 2.5.2
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/API.md +33 -4
- package/DOCUMENTATION.md +41 -7
- package/GUEST_SERVICES.md +129 -3
- package/dist/index.d.ts +7 -5
- package/dist/index.js +3 -2
- package/dist/manifest.d.ts +188 -5
- package/dist/manifest.js +39 -5
- package/dist/parse.d.ts +230 -2
- package/dist/parse.js +198 -7
- package/dist/protocol.d.ts +30 -0
- package/dist/schemas.d.ts +2 -1
- package/dist/schemas.js +2 -1
- package/dist/service-provider-schemas.d.ts +510 -0
- package/dist/service-provider-schemas.js +221 -1
- package/dist/service-providers.d.ts +377 -2
- package/dist/service-providers.js +216 -0
- package/dist/status-view-schemas.d.ts +166 -0
- package/dist/status-view-schemas.js +113 -0
- package/dist/status-view.d.ts +129 -0
- package/dist/status-view.js +15 -0
- package/dist/ui/style.js +16 -9
- package/dist/workspace-schemas.d.ts +9 -0
- package/dist/workspace-schemas.js +1 -0
- package/dist/workspace.d.ts +13 -0
- package/package.json +2 -2
|
@@ -15,6 +15,7 @@ export const BROWSER_CONTROL_ACTIONS = [
|
|
|
15
15
|
'browser.snapshot',
|
|
16
16
|
'browser.click',
|
|
17
17
|
'browser.type',
|
|
18
|
+
'browser.select',
|
|
18
19
|
'browser.scroll',
|
|
19
20
|
'browser.back',
|
|
20
21
|
'browser.forward',
|
|
@@ -72,3 +73,218 @@ export const readBrowserProviderRequest = (body) => {
|
|
|
72
73
|
// and the host validated the parameters for this action before posting.
|
|
73
74
|
return { requestId, action, parameters, context: readContext(context) };
|
|
74
75
|
};
|
|
76
|
+
/*
|
|
77
|
+
* Source control: a service with `provides: ["source-control"]` is the Git
|
|
78
|
+
* host `contributes.sourceControl` describes. OpenChamber keeps the user's
|
|
79
|
+
* accounts and tokens, the bindings, the idempotent mutation records and the
|
|
80
|
+
* caches; it forwards the resource reads and the three mutation steps to the
|
|
81
|
+
* service at `SOURCE_CONTROL_PROVIDER_PATH`. The service never sees a token: it
|
|
82
|
+
* reaches the forge through OpenChamber's outbound proxy
|
|
83
|
+
* (`createSourceControlForge`), which pins the instance origin and adds the
|
|
84
|
+
* stored token itself.
|
|
85
|
+
*/
|
|
86
|
+
/** Route on the service loopback OpenChamber posts every source-control call to. */
|
|
87
|
+
export const SOURCE_CONTROL_PROVIDER_PATH = '/source-control';
|
|
88
|
+
/** The `v` of every request; a service answers a version it does not know with HTTP 400. */
|
|
89
|
+
export const SOURCE_CONTROL_PROVIDER_WIRE_VERSION = 1;
|
|
90
|
+
/**
|
|
91
|
+
* Every call OpenChamber forwards. `auth.verify` reads who a candidate token
|
|
92
|
+
* belongs to and `auth.probe` whether the instance is this forge; the fifteen
|
|
93
|
+
* reads take the shell's own input without the session; the three
|
|
94
|
+
* `mutation.*` steps are the part of a write the service owns, while
|
|
95
|
+
* OpenChamber owns the kind, the normalized input, its digest, the
|
|
96
|
+
* idempotency record and when each step runs.
|
|
97
|
+
*/
|
|
98
|
+
export const SOURCE_CONTROL_PROVIDER_METHODS = [
|
|
99
|
+
'auth.verify',
|
|
100
|
+
'auth.probe',
|
|
101
|
+
'changeRequestStatus',
|
|
102
|
+
'listChangeRequests',
|
|
103
|
+
'changeRequestContext',
|
|
104
|
+
'listIssues',
|
|
105
|
+
'getIssue',
|
|
106
|
+
'issueComments',
|
|
107
|
+
'projectUpstream',
|
|
108
|
+
'projectBranches',
|
|
109
|
+
'listLabels',
|
|
110
|
+
'listReviewerCandidates',
|
|
111
|
+
'readChangeRequestPatch',
|
|
112
|
+
'readChangeRequestFile',
|
|
113
|
+
'liveSummaries',
|
|
114
|
+
'readChangeRequestHead',
|
|
115
|
+
'resolveChangeRequestSource',
|
|
116
|
+
'mutation.resolveTarget',
|
|
117
|
+
'mutation.perform',
|
|
118
|
+
'mutation.reconcile',
|
|
119
|
+
];
|
|
120
|
+
/** `changeRequestStatus` answers within the badge's own deadline; past it the last known status shows. */
|
|
121
|
+
export const SOURCE_CONTROL_PROVIDER_STATUS_TIMEOUT_MS = 12_000;
|
|
122
|
+
export const SOURCE_CONTROL_PROVIDER_READ_TIMEOUT_MS = 20_000;
|
|
123
|
+
export const SOURCE_CONTROL_PROVIDER_MUTATION_TIMEOUT_MS = 30_000;
|
|
124
|
+
export const SOURCE_CONTROL_PROVIDER_AUTH_TIMEOUT_MS = 15_000;
|
|
125
|
+
/** Answers are read up to this many bytes, enough for a change request's patch or one file. */
|
|
126
|
+
export const SOURCE_CONTROL_PROVIDER_RESPONSE_MAX = 8_000_000;
|
|
127
|
+
/** Characters of `error.message` OpenChamber reads. */
|
|
128
|
+
export const SOURCE_CONTROL_PROVIDER_ERROR_MESSAGE_MAX = 2_000;
|
|
129
|
+
/** No call for this long stops the service; the next call starts it again. */
|
|
130
|
+
export const SOURCE_CONTROL_PROVIDER_IDLE_MS = 10 * 60_000;
|
|
131
|
+
/** Characters of a mutation's `providerTarget` JSON, which OpenChamber stores with the record. */
|
|
132
|
+
export const SOURCE_CONTROL_PROVIDER_TARGET_MAX = 4_096;
|
|
133
|
+
/** How long OpenChamber waits for the service to answer `method`. */
|
|
134
|
+
export const sourceControlProviderTimeoutMs = (method) => {
|
|
135
|
+
if (method === 'changeRequestStatus')
|
|
136
|
+
return SOURCE_CONTROL_PROVIDER_STATUS_TIMEOUT_MS;
|
|
137
|
+
if (method === 'auth.verify' || method === 'auth.probe')
|
|
138
|
+
return SOURCE_CONTROL_PROVIDER_AUTH_TIMEOUT_MS;
|
|
139
|
+
if (method.startsWith('mutation.'))
|
|
140
|
+
return SOURCE_CONTROL_PROVIDER_MUTATION_TIMEOUT_MS;
|
|
141
|
+
return SOURCE_CONTROL_PROVIDER_READ_TIMEOUT_MS;
|
|
142
|
+
};
|
|
143
|
+
/**
|
|
144
|
+
* Environment variable with the base URL of OpenChamber's callback listener
|
|
145
|
+
* (`http://127.0.0.1:<port>`). Set only for a service that provides
|
|
146
|
+
* `source-control`; `createSourceControlForge` reads it.
|
|
147
|
+
*/
|
|
148
|
+
export const OPENCHAMBER_HOST_CALLBACK_URL = 'OPENCHAMBER_HOST_CALLBACK_URL';
|
|
149
|
+
/** Route on the callback listener a service sends a forge request to. */
|
|
150
|
+
export const SOURCE_CONTROL_OUTBOUND_PATH = '/source-control/outbound';
|
|
151
|
+
export const SOURCE_CONTROL_OUTBOUND_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
152
|
+
/** Request headers OpenChamber passes to the forge; it sets every other header itself. */
|
|
153
|
+
export const SOURCE_CONTROL_OUTBOUND_REQUEST_HEADERS = ['accept', 'content-type', 'if-none-match', 'range'];
|
|
154
|
+
/** Response headers OpenChamber hands back, plus any starting with `SOURCE_CONTROL_OUTBOUND_RESPONSE_HEADER_PREFIX`. */
|
|
155
|
+
export const SOURCE_CONTROL_OUTBOUND_RESPONSE_HEADERS = ['content-type', 'link', 'etag', 'x-total-count', 'x-total', 'retry-after'];
|
|
156
|
+
export const SOURCE_CONTROL_OUTBOUND_RESPONSE_HEADER_PREFIX = 'x-ratelimit-';
|
|
157
|
+
/** Characters of an outbound request body. */
|
|
158
|
+
export const SOURCE_CONTROL_OUTBOUND_BODY_MAX = 1_000_000;
|
|
159
|
+
/** Bytes of a forge answer OpenChamber reads; a longer one is refused whole. */
|
|
160
|
+
export const SOURCE_CONTROL_OUTBOUND_RESPONSE_MAX = 8_000_000;
|
|
161
|
+
/** One forge request may take this long; the call's own deadline may end it sooner. */
|
|
162
|
+
export const SOURCE_CONTROL_OUTBOUND_TIMEOUT_MS = 20_000;
|
|
163
|
+
export const isSourceControlProviderMethod = (value) => (SOURCE_CONTROL_PROVIDER_METHODS.some((method) => method === value));
|
|
164
|
+
/** `undefined` is a user that is neither `null` nor `{ id, login }`. */
|
|
165
|
+
const readSessionUser = (user) => {
|
|
166
|
+
if (user === null)
|
|
167
|
+
return null;
|
|
168
|
+
if (Object(user) !== user || user === undefined)
|
|
169
|
+
return undefined;
|
|
170
|
+
const { id, login } = user;
|
|
171
|
+
if (!Number.isSafeInteger(id) || String(login) !== login || login.length === 0)
|
|
172
|
+
return undefined;
|
|
173
|
+
return { id: Number(id), login };
|
|
174
|
+
};
|
|
175
|
+
/** `undefined` is a session that is neither `null` nor `{ instance, handle, user }`. */
|
|
176
|
+
const readSession = (session) => {
|
|
177
|
+
if (session === null)
|
|
178
|
+
return null;
|
|
179
|
+
if (Object(session) !== session || session === undefined)
|
|
180
|
+
return undefined;
|
|
181
|
+
const { instance, handle } = session;
|
|
182
|
+
if (String(instance) !== instance || instance.length === 0 || String(handle) !== handle || handle.length === 0)
|
|
183
|
+
return undefined;
|
|
184
|
+
const user = readSessionUser(session.user);
|
|
185
|
+
if (user === undefined)
|
|
186
|
+
return undefined;
|
|
187
|
+
return { instance, handle, user };
|
|
188
|
+
};
|
|
189
|
+
/**
|
|
190
|
+
* A service's read of the body OpenChamber posted to
|
|
191
|
+
* `SOURCE_CONTROL_PROVIDER_PATH`. OpenChamber built and validated `input`, so
|
|
192
|
+
* this checks the envelope, not every field, and the service bundle needs no
|
|
193
|
+
* schema library. `null` is a body that is not a source-control call of this
|
|
194
|
+
* wire version; answer it with HTTP 400.
|
|
195
|
+
*/
|
|
196
|
+
export const readSourceControlProviderRequest = (body) => {
|
|
197
|
+
let wire;
|
|
198
|
+
try {
|
|
199
|
+
const parsed = JSON.parse(body);
|
|
200
|
+
if (Object(parsed) !== parsed || parsed === null)
|
|
201
|
+
return null;
|
|
202
|
+
wire = parsed;
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
const { v, requestId, method, provider, input } = wire;
|
|
208
|
+
if (v !== SOURCE_CONTROL_PROVIDER_WIRE_VERSION)
|
|
209
|
+
return null;
|
|
210
|
+
if (String(requestId) !== requestId || requestId.length === 0)
|
|
211
|
+
return null;
|
|
212
|
+
if (String(provider) !== provider || provider.length === 0)
|
|
213
|
+
return null;
|
|
214
|
+
if (String(method) !== method || !isSourceControlProviderMethod(method))
|
|
215
|
+
return null;
|
|
216
|
+
if (Object(input) !== input || Array.isArray(input))
|
|
217
|
+
return null;
|
|
218
|
+
const session = readSession(wire.session);
|
|
219
|
+
if (session === undefined)
|
|
220
|
+
return null;
|
|
221
|
+
// SAFETY: `input` is a plain object parsed from JSON, so every value in it is JSON.
|
|
222
|
+
return { v: SOURCE_CONTROL_PROVIDER_WIRE_VERSION, requestId, method, provider, session, input: input };
|
|
223
|
+
};
|
|
224
|
+
/**
|
|
225
|
+
* OpenChamber refused the request (an expired handle, a path off the
|
|
226
|
+
* instance, a body too large), could not reach the forge, or did not answer.
|
|
227
|
+
* `status` is the callback listener's HTTP status, `0` when it gave none;
|
|
228
|
+
* `code` is its error code (`HOST_UNAVAILABLE` when there was no usable answer).
|
|
229
|
+
*/
|
|
230
|
+
export class SourceControlForgeError extends Error {
|
|
231
|
+
code;
|
|
232
|
+
status;
|
|
233
|
+
constructor(message, code, status) {
|
|
234
|
+
super(message);
|
|
235
|
+
this.name = 'SourceControlForgeError';
|
|
236
|
+
this.code = code;
|
|
237
|
+
this.status = status;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* The service's way to the forge: OpenChamber's callback listener
|
|
242
|
+
* (`OPENCHAMBER_HOST_CALLBACK_URL`), which checks the handle, keeps the request
|
|
243
|
+
* on the instance origin and adds the token. Plain `fetch`, no dependencies.
|
|
244
|
+
* Throws `SourceControlForgeError` when OpenChamber refuses the request; a
|
|
245
|
+
* forge answer of any status, 401 included, resolves.
|
|
246
|
+
*/
|
|
247
|
+
export const createSourceControlForge = ({ env }) => {
|
|
248
|
+
const base = env[OPENCHAMBER_HOST_CALLBACK_URL];
|
|
249
|
+
if (!base)
|
|
250
|
+
throw new Error(`${OPENCHAMBER_HOST_CALLBACK_URL} is not set: only a service that provides "source-control" can reach a forge.`);
|
|
251
|
+
const url = `${base.replace(/\/+$/, '')}${SOURCE_CONTROL_OUTBOUND_PATH}`;
|
|
252
|
+
return {
|
|
253
|
+
async request(handle, request) {
|
|
254
|
+
let response;
|
|
255
|
+
try {
|
|
256
|
+
response = await fetch(url, {
|
|
257
|
+
method: 'POST',
|
|
258
|
+
headers: { authorization: `Bearer ${handle}`, 'content-type': 'application/json' },
|
|
259
|
+
body: JSON.stringify(request),
|
|
260
|
+
// A little past OpenChamber's own deadline, so its answer wins.
|
|
261
|
+
signal: AbortSignal.timeout(SOURCE_CONTROL_OUTBOUND_TIMEOUT_MS + 5_000),
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
throw new SourceControlForgeError(`OpenChamber did not answer: ${error instanceof Error ? error.message : String(error)}`, 'HOST_UNAVAILABLE', 0);
|
|
266
|
+
}
|
|
267
|
+
let answer;
|
|
268
|
+
try {
|
|
269
|
+
answer = Object(await response.json());
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
throw new SourceControlForgeError('OpenChamber answered with something other than JSON', 'HOST_UNAVAILABLE', response.status);
|
|
273
|
+
}
|
|
274
|
+
if (!response.ok) {
|
|
275
|
+
const message = answer.error?.message;
|
|
276
|
+
const code = answer.error?.code;
|
|
277
|
+
throw new SourceControlForgeError(String(message) === message && message.length > 0 ? message : `OpenChamber refused the request (${response.status})`, String(code) === code && code.length > 0 ? code : 'OUTBOUND_REFUSED', response.status);
|
|
278
|
+
}
|
|
279
|
+
if (!Number.isSafeInteger(answer.status) || String(answer.body) !== answer.body) {
|
|
280
|
+
throw new SourceControlForgeError('OpenChamber relayed a malformed forge answer', 'HOST_UNAVAILABLE', response.status);
|
|
281
|
+
}
|
|
282
|
+
const headers = {};
|
|
283
|
+
for (const [name, text] of Object.entries(Object(answer.headers ?? {}))) {
|
|
284
|
+
if (String(text) === text)
|
|
285
|
+
headers[name] = text;
|
|
286
|
+
}
|
|
287
|
+
return { status: Number(answer.status), headers, body: answer.body };
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/** A service's answer to `GET <source.path>`. Row ids and control ids are each unique. */
|
|
3
|
+
export declare const guestStatusViewSchema: z.ZodObject<{
|
|
4
|
+
rows: z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
|
|
5
|
+
kind: z.ZodLiteral<"line">;
|
|
6
|
+
id: z.ZodString;
|
|
7
|
+
label: z.ZodString;
|
|
8
|
+
icon: z.ZodOptional<z.ZodString>;
|
|
9
|
+
dot: z.ZodOptional<z.ZodEnum<{
|
|
10
|
+
accent: "accent";
|
|
11
|
+
default: "default";
|
|
12
|
+
error: "error";
|
|
13
|
+
info: "info";
|
|
14
|
+
muted: "muted";
|
|
15
|
+
success: "success";
|
|
16
|
+
warning: "warning";
|
|
17
|
+
}>>;
|
|
18
|
+
content: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
|
|
19
|
+
kind: z.ZodLiteral<"bar">;
|
|
20
|
+
value: z.ZodNumber;
|
|
21
|
+
tone: z.ZodOptional<z.ZodEnum<{
|
|
22
|
+
accent: "accent";
|
|
23
|
+
default: "default";
|
|
24
|
+
error: "error";
|
|
25
|
+
info: "info";
|
|
26
|
+
muted: "muted";
|
|
27
|
+
success: "success";
|
|
28
|
+
warning: "warning";
|
|
29
|
+
}>>;
|
|
30
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
31
|
+
kind: z.ZodLiteral<"chart">;
|
|
32
|
+
values: z.ZodArray<z.ZodNumber>;
|
|
33
|
+
max: z.ZodOptional<z.ZodNumber>;
|
|
34
|
+
tone: z.ZodOptional<z.ZodEnum<{
|
|
35
|
+
accent: "accent";
|
|
36
|
+
default: "default";
|
|
37
|
+
error: "error";
|
|
38
|
+
info: "info";
|
|
39
|
+
muted: "muted";
|
|
40
|
+
success: "success";
|
|
41
|
+
warning: "warning";
|
|
42
|
+
}>>;
|
|
43
|
+
unit: z.ZodOptional<z.ZodEnum<{
|
|
44
|
+
bytes: "bytes";
|
|
45
|
+
count: "count";
|
|
46
|
+
percent: "percent";
|
|
47
|
+
}>>;
|
|
48
|
+
step: z.ZodOptional<z.ZodNumber>;
|
|
49
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
50
|
+
kind: z.ZodLiteral<"chips">;
|
|
51
|
+
chips: z.ZodArray<z.ZodObject<{
|
|
52
|
+
id: z.ZodString;
|
|
53
|
+
label: z.ZodString;
|
|
54
|
+
tone: z.ZodOptional<z.ZodEnum<{
|
|
55
|
+
accent: "accent";
|
|
56
|
+
default: "default";
|
|
57
|
+
error: "error";
|
|
58
|
+
info: "info";
|
|
59
|
+
muted: "muted";
|
|
60
|
+
success: "success";
|
|
61
|
+
warning: "warning";
|
|
62
|
+
}>>;
|
|
63
|
+
url: z.ZodOptional<z.ZodString>;
|
|
64
|
+
}, z.core.$strict>>;
|
|
65
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
66
|
+
kind: z.ZodLiteral<"text">;
|
|
67
|
+
text: z.ZodString;
|
|
68
|
+
tone: z.ZodOptional<z.ZodEnum<{
|
|
69
|
+
accent: "accent";
|
|
70
|
+
default: "default";
|
|
71
|
+
error: "error";
|
|
72
|
+
info: "info";
|
|
73
|
+
muted: "muted";
|
|
74
|
+
success: "success";
|
|
75
|
+
warning: "warning";
|
|
76
|
+
}>>;
|
|
77
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
78
|
+
kind: z.ZodLiteral<"segmented">;
|
|
79
|
+
id: z.ZodString;
|
|
80
|
+
label: z.ZodString;
|
|
81
|
+
value: z.ZodString;
|
|
82
|
+
options: z.ZodArray<z.ZodObject<{
|
|
83
|
+
value: z.ZodString;
|
|
84
|
+
label: z.ZodString;
|
|
85
|
+
}, z.core.$strict>>;
|
|
86
|
+
disabled: z.ZodOptional<z.ZodBoolean>;
|
|
87
|
+
}, z.core.$strict>]>>;
|
|
88
|
+
value: z.ZodOptional<z.ZodString>;
|
|
89
|
+
valueTone: z.ZodOptional<z.ZodEnum<{
|
|
90
|
+
accent: "accent";
|
|
91
|
+
default: "default";
|
|
92
|
+
error: "error";
|
|
93
|
+
info: "info";
|
|
94
|
+
muted: "muted";
|
|
95
|
+
success: "success";
|
|
96
|
+
warning: "warning";
|
|
97
|
+
}>>;
|
|
98
|
+
badge: z.ZodOptional<z.ZodObject<{
|
|
99
|
+
label: z.ZodString;
|
|
100
|
+
tone: z.ZodOptional<z.ZodEnum<{
|
|
101
|
+
accent: "accent";
|
|
102
|
+
default: "default";
|
|
103
|
+
error: "error";
|
|
104
|
+
info: "info";
|
|
105
|
+
muted: "muted";
|
|
106
|
+
success: "success";
|
|
107
|
+
warning: "warning";
|
|
108
|
+
}>>;
|
|
109
|
+
}, z.core.$strict>>;
|
|
110
|
+
tooltip: z.ZodOptional<z.ZodString>;
|
|
111
|
+
actions: z.ZodOptional<z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
|
|
112
|
+
kind: z.ZodLiteral<"button">;
|
|
113
|
+
id: z.ZodString;
|
|
114
|
+
label: z.ZodString;
|
|
115
|
+
variant: z.ZodOptional<z.ZodEnum<{
|
|
116
|
+
default: "default";
|
|
117
|
+
destructive: "destructive";
|
|
118
|
+
outline: "outline";
|
|
119
|
+
}>>;
|
|
120
|
+
confirm: z.ZodOptional<z.ZodString>;
|
|
121
|
+
disabled: z.ZodOptional<z.ZodBoolean>;
|
|
122
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
123
|
+
kind: z.ZodLiteral<"switch">;
|
|
124
|
+
id: z.ZodString;
|
|
125
|
+
label: z.ZodString;
|
|
126
|
+
checked: z.ZodBoolean;
|
|
127
|
+
disabled: z.ZodOptional<z.ZodBoolean>;
|
|
128
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
129
|
+
kind: z.ZodLiteral<"select">;
|
|
130
|
+
id: z.ZodString;
|
|
131
|
+
label: z.ZodString;
|
|
132
|
+
value: z.ZodString;
|
|
133
|
+
options: z.ZodArray<z.ZodObject<{
|
|
134
|
+
value: z.ZodString;
|
|
135
|
+
label: z.ZodString;
|
|
136
|
+
}, z.core.$strict>>;
|
|
137
|
+
disabled: z.ZodOptional<z.ZodBoolean>;
|
|
138
|
+
}, z.core.$strict>]>>>;
|
|
139
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
140
|
+
kind: z.ZodLiteral<"note">;
|
|
141
|
+
id: z.ZodString;
|
|
142
|
+
text: z.ZodString;
|
|
143
|
+
tone: z.ZodOptional<z.ZodEnum<{
|
|
144
|
+
error: "error";
|
|
145
|
+
info: "info";
|
|
146
|
+
muted: "muted";
|
|
147
|
+
warning: "warning";
|
|
148
|
+
}>>;
|
|
149
|
+
}, z.core.$strict>]>>;
|
|
150
|
+
controls: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
151
|
+
kind: z.ZodLiteral<"button">;
|
|
152
|
+
id: z.ZodString;
|
|
153
|
+
label: z.ZodString;
|
|
154
|
+
disabled: z.ZodOptional<z.ZodBoolean>;
|
|
155
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
156
|
+
kind: z.ZodLiteral<"select">;
|
|
157
|
+
id: z.ZodString;
|
|
158
|
+
label: z.ZodString;
|
|
159
|
+
value: z.ZodString;
|
|
160
|
+
options: z.ZodArray<z.ZodObject<{
|
|
161
|
+
value: z.ZodString;
|
|
162
|
+
label: z.ZodString;
|
|
163
|
+
}, z.core.$strict>>;
|
|
164
|
+
disabled: z.ZodOptional<z.ZodBoolean>;
|
|
165
|
+
}, z.core.$strict>], "kind">>>;
|
|
166
|
+
}, z.core.$strict>;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { guestStatusControlsSchema } from './status-control-schemas.js';
|
|
3
|
+
import { GUEST_STATUS_CONTROL_ID, GUEST_STATUS_CONTROL_LABEL_MAX, GUEST_STATUS_CONTROL_OPTIONS_MAX, GUEST_STATUS_CONTROL_VALUE_MAX, } from './status-controls.js';
|
|
4
|
+
import { GUEST_STATUS_VIEW_ACTIONS_MAX, GUEST_STATUS_VIEW_CHART_POINTS_MAX, GUEST_STATUS_VIEW_CHIPS_MAX, GUEST_STATUS_VIEW_ICON, GUEST_STATUS_VIEW_NOTE_MAX, GUEST_STATUS_VIEW_ROWS_MAX, GUEST_STATUS_VIEW_SEGMENTS_MAX, GUEST_STATUS_VIEW_SEGMENTS_MIN, GUEST_STATUS_VIEW_TEXT_MAX, } from './status-view.js';
|
|
5
|
+
const idSchema = z.string().regex(GUEST_STATUS_CONTROL_ID);
|
|
6
|
+
const labelSchema = z.string().min(1).max(GUEST_STATUS_CONTROL_LABEL_MAX);
|
|
7
|
+
const valueSchema = z.string().min(1).max(GUEST_STATUS_CONTROL_VALUE_MAX);
|
|
8
|
+
const textSchema = z.string().min(1).max(GUEST_STATUS_VIEW_TEXT_MAX);
|
|
9
|
+
const toneSchema = z.enum(['default', 'muted', 'accent', 'success', 'warning', 'error', 'info']);
|
|
10
|
+
const finite = z.number().finite();
|
|
11
|
+
const isWebUrl = (value) => {
|
|
12
|
+
try {
|
|
13
|
+
const url = new URL(value);
|
|
14
|
+
return url.protocol === 'http:' || url.protocol === 'https:';
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
const optionSchema = z.object({ value: valueSchema, label: labelSchema }).strict();
|
|
21
|
+
/** One to `max` options with unique values, and `value` among them. */
|
|
22
|
+
const choiceSchema = (kind, min, max) => z.object({
|
|
23
|
+
kind: z.literal(kind),
|
|
24
|
+
id: idSchema,
|
|
25
|
+
label: labelSchema,
|
|
26
|
+
value: valueSchema,
|
|
27
|
+
options: z.array(optionSchema).min(min).max(max),
|
|
28
|
+
disabled: z.boolean().optional(),
|
|
29
|
+
}).strict().refine((choice) => new Set(choice.options.map((option) => option.value)).size === choice.options.length, { message: 'option values must be unique', path: ['options'] }).refine((choice) => choice.options.some((option) => option.value === choice.value), { message: 'value must be one of its options', path: ['value'] });
|
|
30
|
+
const chipSchema = z.object({
|
|
31
|
+
id: idSchema,
|
|
32
|
+
label: labelSchema,
|
|
33
|
+
tone: toneSchema.optional(),
|
|
34
|
+
url: z.string().max(2_000).refine(isWebUrl, { message: 'chip url must be http(s)' }).optional(),
|
|
35
|
+
}).strict();
|
|
36
|
+
const contentSchema = z.union([
|
|
37
|
+
z.object({ kind: z.literal('bar'), value: finite.min(0).max(100), tone: toneSchema.optional() }).strict(),
|
|
38
|
+
z.object({
|
|
39
|
+
kind: z.literal('chart'),
|
|
40
|
+
values: z.array(finite.min(0)).max(GUEST_STATUS_VIEW_CHART_POINTS_MAX),
|
|
41
|
+
max: finite.positive().optional(),
|
|
42
|
+
tone: toneSchema.optional(),
|
|
43
|
+
unit: z.enum(['bytes', 'percent', 'count']).optional(),
|
|
44
|
+
step: finite.positive().max(3_600).optional(),
|
|
45
|
+
}).strict(),
|
|
46
|
+
z.object({
|
|
47
|
+
kind: z.literal('chips'),
|
|
48
|
+
chips: z.array(chipSchema).min(1).max(GUEST_STATUS_VIEW_CHIPS_MAX),
|
|
49
|
+
}).strict(),
|
|
50
|
+
z.object({ kind: z.literal('text'), text: textSchema, tone: toneSchema.optional() }).strict(),
|
|
51
|
+
choiceSchema('segmented', GUEST_STATUS_VIEW_SEGMENTS_MIN, GUEST_STATUS_VIEW_SEGMENTS_MAX),
|
|
52
|
+
]);
|
|
53
|
+
const actionSchema = z.union([
|
|
54
|
+
z.object({
|
|
55
|
+
kind: z.literal('button'),
|
|
56
|
+
id: idSchema,
|
|
57
|
+
label: labelSchema,
|
|
58
|
+
variant: z.enum(['default', 'outline', 'destructive']).optional(),
|
|
59
|
+
confirm: labelSchema.optional(),
|
|
60
|
+
disabled: z.boolean().optional(),
|
|
61
|
+
}).strict(),
|
|
62
|
+
z.object({
|
|
63
|
+
kind: z.literal('switch'),
|
|
64
|
+
id: idSchema,
|
|
65
|
+
label: labelSchema,
|
|
66
|
+
checked: z.boolean(),
|
|
67
|
+
disabled: z.boolean().optional(),
|
|
68
|
+
}).strict(),
|
|
69
|
+
choiceSchema('select', 1, GUEST_STATUS_CONTROL_OPTIONS_MAX),
|
|
70
|
+
]);
|
|
71
|
+
const rowSchema = z.union([
|
|
72
|
+
z.object({
|
|
73
|
+
kind: z.literal('line'),
|
|
74
|
+
id: idSchema,
|
|
75
|
+
label: labelSchema,
|
|
76
|
+
icon: z.string().regex(GUEST_STATUS_VIEW_ICON).optional(),
|
|
77
|
+
dot: toneSchema.optional(),
|
|
78
|
+
content: contentSchema.optional(),
|
|
79
|
+
value: valueSchema.optional(),
|
|
80
|
+
valueTone: toneSchema.optional(),
|
|
81
|
+
badge: z.object({ label: labelSchema, tone: toneSchema.optional() }).strict().optional(),
|
|
82
|
+
tooltip: textSchema.optional(),
|
|
83
|
+
actions: z.array(actionSchema).max(GUEST_STATUS_VIEW_ACTIONS_MAX).optional(),
|
|
84
|
+
}).strict(),
|
|
85
|
+
z.object({
|
|
86
|
+
kind: z.literal('note'),
|
|
87
|
+
id: idSchema,
|
|
88
|
+
text: z.string().min(1).max(GUEST_STATUS_VIEW_NOTE_MAX),
|
|
89
|
+
tone: z.enum(['muted', 'info', 'warning', 'error']).optional(),
|
|
90
|
+
}).strict(),
|
|
91
|
+
]);
|
|
92
|
+
/** Every id a view's controls answer to: header controls, line actions, segmented choices. */
|
|
93
|
+
const controlIds = (view) => [
|
|
94
|
+
...(view.controls ?? []).map((control) => control.id),
|
|
95
|
+
...view.rows.flatMap((row) => (row.kind === 'line'
|
|
96
|
+
? [
|
|
97
|
+
...(row.actions ?? []).map((action) => action.id),
|
|
98
|
+
...(row.content?.kind === 'segmented' ? [row.content.id] : []),
|
|
99
|
+
]
|
|
100
|
+
: [])),
|
|
101
|
+
];
|
|
102
|
+
/** A service's answer to `GET <source.path>`. Row ids and control ids are each unique. */
|
|
103
|
+
export const guestStatusViewSchema = z.object({
|
|
104
|
+
rows: z.array(rowSchema).max(GUEST_STATUS_VIEW_ROWS_MAX),
|
|
105
|
+
controls: guestStatusControlsSchema.optional(),
|
|
106
|
+
}).strict().refine((view) => new Set(view.rows.map((row) => row.id)).size === view.rows.length, { message: 'row ids must be unique', path: ['rows'] }).refine((view) => {
|
|
107
|
+
const ids = controlIds(view);
|
|
108
|
+
return new Set(ids).size === ids.length;
|
|
109
|
+
}, { message: 'control ids must be unique across the view' });
|
|
110
|
+
const assertViewParsed = (value) => value;
|
|
111
|
+
const assertViewContract = (value) => value;
|
|
112
|
+
void assertViewParsed;
|
|
113
|
+
void assertViewContract;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { GuestStatusControl, GuestStatusControlOption } from './status-controls.ts';
|
|
2
|
+
/** Fastest the host asks a service for its section, in ms. */
|
|
3
|
+
export declare const GUEST_STATUS_SOURCE_INTERVAL_MIN = 1000;
|
|
4
|
+
/** Slowest polling a source may ask for, in ms. */
|
|
5
|
+
export declare const GUEST_STATUS_SOURCE_INTERVAL_MAX = 60000;
|
|
6
|
+
/** Polling interval when the manifest names none, in ms. */
|
|
7
|
+
export declare const GUEST_STATUS_SOURCE_INTERVAL_DEFAULT = 2000;
|
|
8
|
+
export declare const GUEST_STATUS_VIEW_ROWS_MAX = 16;
|
|
9
|
+
export declare const GUEST_STATUS_VIEW_ACTIONS_MAX = 2;
|
|
10
|
+
export declare const GUEST_STATUS_VIEW_CHIPS_MAX = 6;
|
|
11
|
+
export declare const GUEST_STATUS_VIEW_CHART_POINTS_MAX = 120;
|
|
12
|
+
export declare const GUEST_STATUS_VIEW_SEGMENTS_MIN = 2;
|
|
13
|
+
export declare const GUEST_STATUS_VIEW_SEGMENTS_MAX = 4;
|
|
14
|
+
export declare const GUEST_STATUS_VIEW_TEXT_MAX = 200;
|
|
15
|
+
export declare const GUEST_STATUS_VIEW_NOTE_MAX = 300;
|
|
16
|
+
export declare const GUEST_STATUS_VIEW_ICON: RegExp;
|
|
17
|
+
/** Where the host reads a source-backed section and how often. */
|
|
18
|
+
export type StatusSectionSource = {
|
|
19
|
+
/** Service path, such as `/status`. */
|
|
20
|
+
path: string;
|
|
21
|
+
/** Milliseconds between reads while the section is on screen. */
|
|
22
|
+
interval?: number;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Colours come from the theme, never from the extension: `accent` is the
|
|
26
|
+
* theme's primary, the status tones are its status colours, `muted` is
|
|
27
|
+
* secondary text.
|
|
28
|
+
*/
|
|
29
|
+
export type GuestStatusTone = 'default' | 'muted' | 'accent' | 'success' | 'warning' | 'error' | 'info';
|
|
30
|
+
/** How a chart's hover label formats a point. */
|
|
31
|
+
export type GuestStatusChartUnit = 'bytes' | 'percent' | 'count';
|
|
32
|
+
export type GuestStatusChip = {
|
|
33
|
+
id: string;
|
|
34
|
+
label: string;
|
|
35
|
+
tone?: GuestStatusTone;
|
|
36
|
+
/** An `http(s)` URL the chip opens; without one the chip is a label. */
|
|
37
|
+
url?: string;
|
|
38
|
+
};
|
|
39
|
+
/** What sits between a line's label and its value. */
|
|
40
|
+
export type GuestStatusContent = {
|
|
41
|
+
kind: 'bar';
|
|
42
|
+
value: number;
|
|
43
|
+
tone?: GuestStatusTone;
|
|
44
|
+
} | {
|
|
45
|
+
kind: 'chart';
|
|
46
|
+
/** Oldest first; the newest point sits at the right edge. */
|
|
47
|
+
values: number[];
|
|
48
|
+
/** Top of the chart; the largest value when omitted. */
|
|
49
|
+
max?: number;
|
|
50
|
+
tone?: GuestStatusTone;
|
|
51
|
+
unit?: GuestStatusChartUnit;
|
|
52
|
+
/** Seconds between points, for the hover's "12 s ago". */
|
|
53
|
+
step?: number;
|
|
54
|
+
} | {
|
|
55
|
+
kind: 'chips';
|
|
56
|
+
chips: GuestStatusChip[];
|
|
57
|
+
} | {
|
|
58
|
+
kind: 'text';
|
|
59
|
+
text: string;
|
|
60
|
+
tone?: GuestStatusTone;
|
|
61
|
+
} | {
|
|
62
|
+
kind: 'segmented';
|
|
63
|
+
id: string;
|
|
64
|
+
label: string;
|
|
65
|
+
value: string;
|
|
66
|
+
options: GuestStatusControlOption[];
|
|
67
|
+
disabled?: boolean;
|
|
68
|
+
};
|
|
69
|
+
/** A control at the end of a line. Activating it posts `{ id, value? }` to the source path. */
|
|
70
|
+
export type GuestStatusAction = {
|
|
71
|
+
kind: 'button';
|
|
72
|
+
id: string;
|
|
73
|
+
label: string;
|
|
74
|
+
variant?: 'default' | 'outline' | 'destructive';
|
|
75
|
+
/** Asked on the first click; the second click acts. */
|
|
76
|
+
confirm?: string;
|
|
77
|
+
disabled?: boolean;
|
|
78
|
+
} | {
|
|
79
|
+
kind: 'switch';
|
|
80
|
+
id: string;
|
|
81
|
+
label: string;
|
|
82
|
+
checked: boolean;
|
|
83
|
+
disabled?: boolean;
|
|
84
|
+
} | {
|
|
85
|
+
kind: 'select';
|
|
86
|
+
id: string;
|
|
87
|
+
label: string;
|
|
88
|
+
value: string;
|
|
89
|
+
options: GuestStatusControlOption[];
|
|
90
|
+
disabled?: boolean;
|
|
91
|
+
};
|
|
92
|
+
export type GuestStatusRow = {
|
|
93
|
+
kind: 'line';
|
|
94
|
+
id: string;
|
|
95
|
+
label: string;
|
|
96
|
+
/** A Remixicon name, as in `panel.icon`. */
|
|
97
|
+
icon?: string;
|
|
98
|
+
/** A status dot before the label. */
|
|
99
|
+
dot?: GuestStatusTone;
|
|
100
|
+
content?: GuestStatusContent;
|
|
101
|
+
value?: string;
|
|
102
|
+
valueTone?: GuestStatusTone;
|
|
103
|
+
badge?: {
|
|
104
|
+
label: string;
|
|
105
|
+
tone?: GuestStatusTone;
|
|
106
|
+
};
|
|
107
|
+
tooltip?: string;
|
|
108
|
+
actions?: GuestStatusAction[];
|
|
109
|
+
} | {
|
|
110
|
+
kind: 'note';
|
|
111
|
+
id: string;
|
|
112
|
+
text: string;
|
|
113
|
+
tone?: 'muted' | 'info' | 'warning' | 'error';
|
|
114
|
+
};
|
|
115
|
+
/** The answer to `GET <source.path>`. */
|
|
116
|
+
export type GuestStatusView = {
|
|
117
|
+
rows: GuestStatusRow[];
|
|
118
|
+
/** Header controls, the same declarations `setStatusControls` takes. */
|
|
119
|
+
controls?: GuestStatusControl[];
|
|
120
|
+
};
|
|
121
|
+
/**
|
|
122
|
+
* The body of `POST <source.path>` when the user activates a control. A switch
|
|
123
|
+
* sends `on` or `off`; a select or segmented control sends the chosen value; a
|
|
124
|
+
* button sends no value.
|
|
125
|
+
*/
|
|
126
|
+
export type GuestStatusActionEvent = {
|
|
127
|
+
id: string;
|
|
128
|
+
value?: string;
|
|
129
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Fastest the host asks a service for its section, in ms. */
|
|
2
|
+
export const GUEST_STATUS_SOURCE_INTERVAL_MIN = 1_000;
|
|
3
|
+
/** Slowest polling a source may ask for, in ms. */
|
|
4
|
+
export const GUEST_STATUS_SOURCE_INTERVAL_MAX = 60_000;
|
|
5
|
+
/** Polling interval when the manifest names none, in ms. */
|
|
6
|
+
export const GUEST_STATUS_SOURCE_INTERVAL_DEFAULT = 2_000;
|
|
7
|
+
export const GUEST_STATUS_VIEW_ROWS_MAX = 16;
|
|
8
|
+
export const GUEST_STATUS_VIEW_ACTIONS_MAX = 2;
|
|
9
|
+
export const GUEST_STATUS_VIEW_CHIPS_MAX = 6;
|
|
10
|
+
export const GUEST_STATUS_VIEW_CHART_POINTS_MAX = 120;
|
|
11
|
+
export const GUEST_STATUS_VIEW_SEGMENTS_MIN = 2;
|
|
12
|
+
export const GUEST_STATUS_VIEW_SEGMENTS_MAX = 4;
|
|
13
|
+
export const GUEST_STATUS_VIEW_TEXT_MAX = 200;
|
|
14
|
+
export const GUEST_STATUS_VIEW_NOTE_MAX = 300;
|
|
15
|
+
export const GUEST_STATUS_VIEW_ICON = /^[a-z0-9][a-z0-9-]{0,39}$/;
|