@norskvideo/ctl-sdk 0.1.54 → 0.1.56
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/components/ProductTemplateBuildForm.d.ts +6 -1
- package/components/ProductTemplateBuildForm.js +16 -4
- package/components/index.d.ts +1 -0
- package/components/index.js +1 -0
- package/components/labels-text.d.ts +9 -0
- package/components/labels-text.js +26 -0
- package/fleet.d.ts +54 -0
- package/fleet.js +97 -3
- package/package.json +1 -1
|
@@ -22,6 +22,9 @@ export type ProductTemplateBuildRequest = {
|
|
|
22
22
|
* dev-kit-generated product splits off before its strict config parse
|
|
23
23
|
* and bakes into the manifest's `advanced` block. */
|
|
24
24
|
formValues: Record<string, unknown>;
|
|
25
|
+
/** The template's labels (ADR-0015, norsk-ctl task 124), when the form was
|
|
26
|
+
* asked for them (`withLabels`): the caller records them on the template. */
|
|
27
|
+
labels?: Record<string, string>;
|
|
25
28
|
};
|
|
26
29
|
export type ProductTemplateBuildOutcome<TProductTemplate> = {
|
|
27
30
|
ok: true;
|
|
@@ -30,8 +33,10 @@ export type ProductTemplateBuildOutcome<TProductTemplate> = {
|
|
|
30
33
|
ok: false;
|
|
31
34
|
error: string;
|
|
32
35
|
};
|
|
33
|
-
export declare function ProductTemplateBuildForm<TProductTemplate>({ productName, configScreenUrl, onClose, sections, onSubmit, onSaved, onSavedAndLaunch, onRequirements, }: {
|
|
36
|
+
export declare function ProductTemplateBuildForm<TProductTemplate>({ productName, configScreenUrl, onClose, sections, onSubmit, onSaved, onSavedAndLaunch, onRequirements, withLabels, }: {
|
|
34
37
|
productName: string;
|
|
38
|
+
/** Ask for the template's labels too; the save carries them as `labels`. */
|
|
39
|
+
withLabels?: boolean;
|
|
35
40
|
configScreenUrl: string | null;
|
|
36
41
|
onClose: () => void;
|
|
37
42
|
/** Forwarded from the config-screen iframe: the product's suggested placement
|
|
@@ -3,6 +3,7 @@ import { ChevronDown, ChevronRight, X } from "lucide-react";
|
|
|
3
3
|
import { useRef, useState } from "react";
|
|
4
4
|
import toast from "react-hot-toast";
|
|
5
5
|
import { validateInstanceId } from "../validate.js";
|
|
6
|
+
import { parseLabelsText } from "./labels-text.js";
|
|
6
7
|
import { ProductIframe } from "./ProductIframe.js";
|
|
7
8
|
import { useUIPrimitives } from "./ui-primitives.js";
|
|
8
9
|
const emptyAdvancedDefaults = {
|
|
@@ -34,9 +35,11 @@ function assembleAdvancedDefaults(d) {
|
|
|
34
35
|
out.openFileLimit = { default: limit };
|
|
35
36
|
return Object.keys(out).length > 0 ? out : undefined;
|
|
36
37
|
}
|
|
37
|
-
export function ProductTemplateBuildForm({ productName, configScreenUrl, onClose, sections, onSubmit, onSaved, onSavedAndLaunch, onRequirements, }) {
|
|
38
|
+
export function ProductTemplateBuildForm({ productName, configScreenUrl, onClose, sections, onSubmit, onSaved, onSavedAndLaunch, onRequirements, withLabels = false, }) {
|
|
38
39
|
const { Button, Input, Label } = useUIPrimitives();
|
|
39
40
|
const [productTemplateName, setProductTemplateName] = useState("");
|
|
41
|
+
const [labelsDraft, setLabelsDraft] = useState("");
|
|
42
|
+
const labels = parseLabelsText(labelsDraft);
|
|
40
43
|
// The iframe can suggest a name derived from its current choices (#317).
|
|
41
44
|
// We prefill the field with it while the operator hasn't typed anything,
|
|
42
45
|
// so changing template/release keeps the suggestion live; once they edit
|
|
@@ -58,7 +61,7 @@ export function ProductTemplateBuildForm({ productName, configScreenUrl, onClose
|
|
|
58
61
|
};
|
|
59
62
|
const nameValidation = productTemplateName ? validateInstanceId(productTemplateName) : null;
|
|
60
63
|
const productTemplateNameError = nameValidation?.status === "error" ? nameValidation.message : null;
|
|
61
|
-
const canSubmit = !!productTemplateName && !productTemplateNameError && !submitting && iframeValid;
|
|
64
|
+
const canSubmit = !!productTemplateName && !productTemplateNameError && !submitting && iframeValid && (!withLabels || labels.ok);
|
|
62
65
|
// Internal helper. Returns the saved product template on success (so the
|
|
63
66
|
// caller can hand it off to its launch flow without waiting for a refetch),
|
|
64
67
|
// null on failure.
|
|
@@ -81,7 +84,16 @@ export function ProductTemplateBuildForm({ productName, configScreenUrl, onClose
|
|
|
81
84
|
const advanced = assembleAdvancedDefaults(advancedDraft);
|
|
82
85
|
if (advanced)
|
|
83
86
|
formValues = { ...formValues, $advancedDefaults: advanced };
|
|
84
|
-
|
|
87
|
+
if (withLabels && !labels.ok) {
|
|
88
|
+
toast.error(labels.error);
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const outcome = await onSubmit({
|
|
92
|
+
productName,
|
|
93
|
+
productTemplateName,
|
|
94
|
+
formValues,
|
|
95
|
+
...(withLabels && labels.ok && Object.keys(labels.labels).length > 0 ? { labels: labels.labels } : {}),
|
|
96
|
+
});
|
|
85
97
|
if (!outcome.ok) {
|
|
86
98
|
toast.error(`Save product template failed: ${outcome.error}`);
|
|
87
99
|
return null;
|
|
@@ -118,5 +130,5 @@ export function ProductTemplateBuildForm({ productName, configScreenUrl, onClose
|
|
|
118
130
|
return (_jsxs("div", { className: "mb-6 rounded-lg border border-zinc-700 bg-zinc-900 p-4", children: [_jsxs("div", { className: "flex items-center justify-between mb-4", children: [_jsxs("div", { children: [_jsxs("h4", { className: "text-sm font-semibold text-zinc-100", children: ["Create ", productName, " product template"] }), _jsx("p", { className: "text-xs text-zinc-500 mt-0.5", children: "Saves a reusable product template. Launch instances from it later." })] }), _jsx("button", { type: "button", onClick: onClose, className: "text-zinc-500 hover:text-zinc-300", children: _jsx(X, { size: 14 }) })] }), _jsxs("form", { onSubmit: handleSave, className: "space-y-4", children: [_jsxs("div", { children: [_jsx(Label, { children: "Product template name *" }), _jsx(Input, { value: productTemplateName, onChange: (e) => {
|
|
119
131
|
setNameTouched(true);
|
|
120
132
|
setProductTemplateName(e.target.value);
|
|
121
|
-
}, placeholder: suggestedName || `e.g. ${productName}-prod`, required: true }), productTemplateNameError ? (_jsx("p", { className: "text-xs text-red-400 mt-1", children: productTemplateNameError })) : (_jsx("p", { className: "text-xs text-zinc-500 mt-1", children: "Lowercase letters, numbers, and hyphens only. Used to launch instances later." }))] }), configScreenUrl ? (_jsx(ProductIframe, { ref: iframeRef, productName: productName, configScreenUrl: configScreenUrl, onValidityChange: (v) => setIframeValid(v), onSuggestName: handleSuggestName, ...(onRequirements ? { onRequirements } : {}) })) : (_jsxs("p", { className: "text-xs text-zinc-500", children: ["Product manifest does not declare ", _jsx("code", { children: "ui.configScreenUrl" }), " \u2014 nothing to configure."] })), sections, _jsxs("div", { children: [_jsxs("button", { type: "button", onClick: () => setDefaultsOpen((v) => !v), className: "flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-widest text-zinc-500 hover:text-zinc-300", children: [defaultsOpen ? _jsx(ChevronDown, { size: 12 }) : _jsx(ChevronRight, { size: 12 }), "Custom defaults"] }), defaultsOpen && (_jsxs("div", { className: "mt-2", children: [_jsx("p", { className: "text-xs text-zinc-500 mb-2", children: "Launch-form defaults baked into this template's manifest. Operators can still override them per instance." }), _jsxs("div", { className: "flex flex-wrap gap-3", children: [_jsxs("div", { children: [_jsx(Label, { children: "Network mode" }), _jsxs("select", { "aria-label": "Network mode", value: advancedDraft.networkMode, onChange: (e) => patchDraft({ networkMode: e.target.value }), className: "block rounded border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-sm text-zinc-100", children: [_jsx("option", { value: "", children: "product default" }), _jsx("option", { value: "docker", children: "docker" }), _jsx("option", { value: "hybrid", children: "hybrid" })] })] }), _jsxs("div", { className: "min-w-[14rem] flex-1", children: [_jsx(Label, { children: "Host ports" }), _jsx(Input, { "aria-label": "Host ports", placeholder: "e.g. 1935, 5001/udp, 9000:80@svc", value: advancedDraft.hostPorts, onChange: (e) => patchDraft({ hostPorts: e.target.value }) })] }), _jsxs("div", { children: [_jsx(Label, { children: "Container user" }), _jsx(Input, { "aria-label": "Container user", placeholder: "uid:gid", value: advancedDraft.containerUser, onChange: (e) => patchDraft({ containerUser: e.target.value }), className: "w-28" })] }), _jsxs("div", { children: [_jsx(Label, { children: "Shared memory size" }), _jsx(Input, { "aria-label": "Shared memory size", placeholder: "e.g. 8gb", value: advancedDraft.shmSize, onChange: (e) => patchDraft({ shmSize: e.target.value }), className: "w-24" })] }), _jsxs("div", { children: [_jsx(Label, { children: "Open file limit" }), _jsx(Input, { "aria-label": "Open file limit", type: "number", min: 1024, max: 1048576, placeholder: "runner default", value: advancedDraft.openFileLimit, onChange: (e) => patchDraft({ openFileLimit: e.target.value }), className: "w-32" })] })] })] }))] }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { type: "button", variant: "primary", disabled: !canSubmit, onClick: handleSaveAndLaunch, children: submitting ? "Working…" : "Save & Launch…" }), _jsx(Button, { type: "submit", variant: "default", disabled: !canSubmit, children: "Save" }), _jsx(Button, { type: "button", variant: "ghost", onClick: onClose, children: "Cancel" })] })] })] }));
|
|
133
|
+
}, placeholder: suggestedName || `e.g. ${productName}-prod`, required: true }), productTemplateNameError ? (_jsx("p", { className: "text-xs text-red-400 mt-1", children: productTemplateNameError })) : (_jsx("p", { className: "text-xs text-zinc-500 mt-1", children: "Lowercase letters, numbers, and hyphens only. Used to launch instances later." }))] }), withLabels && (_jsxs("div", { children: [_jsx(Label, { children: "Labels" }), _jsx(Input, { "aria-label": "Labels", value: labelsDraft, onChange: (e) => setLabelsDraft(e.target.value), placeholder: "e.g. env=qa,team=news" }), labels.ok ? (_jsx("p", { className: "text-xs text-zinc-500 mt-1", children: "name=value pairs joined by commas. Instances launched from it start with them; they decide who may see it, and what walls show it." })) : (_jsx("p", { className: "text-xs text-red-400 mt-1", children: labels.error }))] })), configScreenUrl ? (_jsx(ProductIframe, { ref: iframeRef, productName: productName, configScreenUrl: configScreenUrl, onValidityChange: (v) => setIframeValid(v), onSuggestName: handleSuggestName, ...(onRequirements ? { onRequirements } : {}) })) : (_jsxs("p", { className: "text-xs text-zinc-500", children: ["Product manifest does not declare ", _jsx("code", { children: "ui.configScreenUrl" }), " \u2014 nothing to configure."] })), sections, _jsxs("div", { children: [_jsxs("button", { type: "button", onClick: () => setDefaultsOpen((v) => !v), className: "flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-widest text-zinc-500 hover:text-zinc-300", children: [defaultsOpen ? _jsx(ChevronDown, { size: 12 }) : _jsx(ChevronRight, { size: 12 }), "Custom defaults"] }), defaultsOpen && (_jsxs("div", { className: "mt-2", children: [_jsx("p", { className: "text-xs text-zinc-500 mb-2", children: "Launch-form defaults baked into this template's manifest. Operators can still override them per instance." }), _jsxs("div", { className: "flex flex-wrap gap-3", children: [_jsxs("div", { children: [_jsx(Label, { children: "Network mode" }), _jsxs("select", { "aria-label": "Network mode", value: advancedDraft.networkMode, onChange: (e) => patchDraft({ networkMode: e.target.value }), className: "block rounded border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-sm text-zinc-100", children: [_jsx("option", { value: "", children: "product default" }), _jsx("option", { value: "docker", children: "docker" }), _jsx("option", { value: "hybrid", children: "hybrid" })] })] }), _jsxs("div", { className: "min-w-[14rem] flex-1", children: [_jsx(Label, { children: "Host ports" }), _jsx(Input, { "aria-label": "Host ports", placeholder: "e.g. 1935, 5001/udp, 9000:80@svc", value: advancedDraft.hostPorts, onChange: (e) => patchDraft({ hostPorts: e.target.value }) })] }), _jsxs("div", { children: [_jsx(Label, { children: "Container user" }), _jsx(Input, { "aria-label": "Container user", placeholder: "uid:gid", value: advancedDraft.containerUser, onChange: (e) => patchDraft({ containerUser: e.target.value }), className: "w-28" })] }), _jsxs("div", { children: [_jsx(Label, { children: "Shared memory size" }), _jsx(Input, { "aria-label": "Shared memory size", placeholder: "e.g. 8gb", value: advancedDraft.shmSize, onChange: (e) => patchDraft({ shmSize: e.target.value }), className: "w-24" })] }), _jsxs("div", { children: [_jsx(Label, { children: "Open file limit" }), _jsx(Input, { "aria-label": "Open file limit", type: "number", min: 1024, max: 1048576, placeholder: "runner default", value: advancedDraft.openFileLimit, onChange: (e) => patchDraft({ openFileLimit: e.target.value }), className: "w-32" })] })] })] }))] }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { type: "button", variant: "primary", disabled: !canSubmit, onClick: handleSaveAndLaunch, children: submitting ? "Working…" : "Save & Launch…" }), _jsx(Button, { type: "submit", variant: "default", disabled: !canSubmit, children: "Save" }), _jsx(Button, { type: "button", variant: "ghost", onClick: onClose, children: "Cancel" })] })] })] }));
|
|
122
134
|
}
|
package/components/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { type ComposeLayer, ComposeLayerPreview, type ComposeLayerPreviewProps } from "./ComposeLayerPreview.js";
|
|
2
|
+
export { type LabelsTextResult, labelsText, parseLabelsText } from "./labels-text.js";
|
|
2
3
|
export { ProductIframe, type ProductIframeHandle, type SubmitResult } from "./ProductIframe.js";
|
|
3
4
|
export { ProductTemplateBuildForm, type ProductTemplateBuildOutcome, type ProductTemplateBuildRequest, } from "./ProductTemplateBuildForm.js";
|
|
4
5
|
export { SectionFold, type SectionFoldProps } from "./SectionFold.js";
|
package/components/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { ComposeLayerPreview } from "./ComposeLayerPreview.js";
|
|
2
|
+
export { labelsText, parseLabelsText } from "./labels-text.js";
|
|
2
3
|
export { ProductIframe } from "./ProductIframe.js";
|
|
3
4
|
export { ProductTemplateBuildForm, } from "./ProductTemplateBuildForm.js";
|
|
4
5
|
export { SectionFold } from "./SectionFold.js";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type LabelsTextResult = {
|
|
2
|
+
ok: true;
|
|
3
|
+
labels: Record<string, string>;
|
|
4
|
+
} | {
|
|
5
|
+
ok: false;
|
|
6
|
+
error: string;
|
|
7
|
+
};
|
|
8
|
+
export declare function parseLabelsText(text: string): LabelsTextResult;
|
|
9
|
+
export declare function labelsText(labels: Readonly<Record<string, string>>): string;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Labels as an operator types them (norsk-ctl task 124): `name=value` pairs
|
|
2
|
+
// joined by commas, the same form a runtime receives as NORSK_LABELS. The
|
|
3
|
+
// platform still checks each set (ADR-0015's limits); this only reads the box.
|
|
4
|
+
export function parseLabelsText(text) {
|
|
5
|
+
const labels = {};
|
|
6
|
+
for (const raw of text.split(",")) {
|
|
7
|
+
const pair = raw.trim();
|
|
8
|
+
if (pair === "")
|
|
9
|
+
continue;
|
|
10
|
+
const at = pair.indexOf("=");
|
|
11
|
+
const name = pair.slice(0, at).trim();
|
|
12
|
+
const value = pair.slice(at + 1).trim();
|
|
13
|
+
if (at <= 0 || name === "" || value === "")
|
|
14
|
+
return { ok: false, error: `'${pair}' is not name=value` };
|
|
15
|
+
if (name.startsWith("norsk."))
|
|
16
|
+
return { ok: false, error: `'${name}': names under norsk. are the platform's` };
|
|
17
|
+
labels[name] = value;
|
|
18
|
+
}
|
|
19
|
+
return { ok: true, labels };
|
|
20
|
+
}
|
|
21
|
+
export function labelsText(labels) {
|
|
22
|
+
return Object.entries(labels)
|
|
23
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
24
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
25
|
+
.join(",");
|
|
26
|
+
}
|
package/fleet.d.ts
CHANGED
|
@@ -49,6 +49,44 @@ export interface FleetWatch {
|
|
|
49
49
|
synced(): boolean;
|
|
50
50
|
close(): void;
|
|
51
51
|
}
|
|
52
|
+
/** A declared operation the runtime offers this dashboard. */
|
|
53
|
+
export interface FleetOperation {
|
|
54
|
+
operationId: string;
|
|
55
|
+
method: string;
|
|
56
|
+
/** Under the runtime's instance root, with `{param}`s. */
|
|
57
|
+
path: string;
|
|
58
|
+
live: boolean;
|
|
59
|
+
}
|
|
60
|
+
export interface FleetCallInput {
|
|
61
|
+
params?: Readonly<Record<string, string | number>>;
|
|
62
|
+
query?: Readonly<Record<string, string>>;
|
|
63
|
+
body?: unknown;
|
|
64
|
+
}
|
|
65
|
+
export interface FleetLiveHandlers {
|
|
66
|
+
onOpen?: () => void;
|
|
67
|
+
onMessage?: (data: unknown) => void;
|
|
68
|
+
onClose?: () => void;
|
|
69
|
+
}
|
|
70
|
+
export interface FleetLive {
|
|
71
|
+
send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void;
|
|
72
|
+
close(): void;
|
|
73
|
+
}
|
|
74
|
+
export interface FleetStateDocument<T = unknown> {
|
|
75
|
+
value: T;
|
|
76
|
+
revision: number;
|
|
77
|
+
}
|
|
78
|
+
/** Someone else wrote the document since it was read: `current` is theirs. */
|
|
79
|
+
export declare class FleetStateConflict extends Error {
|
|
80
|
+
readonly current: FleetStateDocument;
|
|
81
|
+
constructor(current: FleetStateDocument);
|
|
82
|
+
}
|
|
83
|
+
interface WebSocketLike {
|
|
84
|
+
addEventListener(name: string, listener: (e: {
|
|
85
|
+
data?: unknown;
|
|
86
|
+
}) => void): void;
|
|
87
|
+
send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void;
|
|
88
|
+
close(): void;
|
|
89
|
+
}
|
|
52
90
|
interface EventSourceLike {
|
|
53
91
|
addEventListener(name: string, listener: (e: {
|
|
54
92
|
data: string;
|
|
@@ -60,15 +98,31 @@ export interface FleetOptions {
|
|
|
60
98
|
* `/api/v1/fleet` on the platform's own origin. */
|
|
61
99
|
base?: string;
|
|
62
100
|
fetch?: (url: string, init?: {
|
|
101
|
+
method?: string;
|
|
102
|
+
headers?: Record<string, string>;
|
|
103
|
+
body?: string;
|
|
63
104
|
credentials?: "same-origin";
|
|
64
105
|
}) => Promise<Response>;
|
|
65
106
|
EventSource?: new (url: string, init?: {
|
|
66
107
|
withCredentials?: boolean;
|
|
67
108
|
}) => EventSourceLike;
|
|
109
|
+
WebSocket?: new (url: string) => WebSocketLike;
|
|
110
|
+
/** The page's origin, for a live operation's ws(s) URL; the page's own by default. */
|
|
111
|
+
origin?: string;
|
|
68
112
|
}
|
|
69
113
|
export interface Fleet {
|
|
70
114
|
query(selection: FleetSelection): Promise<FleetDirectory>;
|
|
71
115
|
watch(selection: FleetSelection, handlers?: FleetWatchHandlers): FleetWatch;
|
|
116
|
+
/** Call a runtime's declared operation; its JSON answer, or its text. */
|
|
117
|
+
call<T = unknown>(runtimeId: string, operationId: string, input?: FleetCallInput): Promise<T>;
|
|
118
|
+
/** Open a runtime's declared live operation. */
|
|
119
|
+
live(runtimeId: string, operationId: string, handlers: FleetLiveHandlers, input?: FleetCallInput): Promise<FleetLive>;
|
|
120
|
+
state: {
|
|
121
|
+
get<T = unknown>(key: string): Promise<FleetStateDocument<T> | undefined>;
|
|
122
|
+
/** Write at `revision` (omit for a new key); a FleetStateConflict when
|
|
123
|
+
* someone else wrote since. */
|
|
124
|
+
put<T = unknown>(key: string, value: T, revision?: number): Promise<FleetStateDocument<T>>;
|
|
125
|
+
};
|
|
72
126
|
}
|
|
73
127
|
export declare function fleetClient(options?: FleetOptions): Fleet;
|
|
74
128
|
/** The directory on the page's own origin, as a dashboard host serves it. */
|
package/fleet.js
CHANGED
|
@@ -7,10 +7,34 @@
|
|
|
7
7
|
// dashboard's dev loop it is the platform's API, `/api/v1/fleet/`. The calls
|
|
8
8
|
// are the same either way.
|
|
9
9
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
10
|
+
// A dashboard also calls a runtime's declared operations (`fleet.call`),
|
|
11
|
+
// opens its live ones (`fleet.live`), and keeps shared state on its host
|
|
12
|
+
// (`fleet.state`); the platform decides each for the signed-in user.
|
|
13
|
+
//
|
|
14
|
+
// Name the kinds a query or watch reads, and the operation a call or live
|
|
15
|
+
// opens, with literals: ctl-dev-kit's dashboard build derives the
|
|
16
|
+
// registration's `uses` from them, and refuses one it cannot read.
|
|
12
17
|
//
|
|
13
18
|
// Browser-only, with no imports, so a dashboard's bundle carries nothing else.
|
|
19
|
+
/** Someone else wrote the document since it was read: `current` is theirs. */
|
|
20
|
+
export class FleetStateConflict extends Error {
|
|
21
|
+
current;
|
|
22
|
+
constructor(current) {
|
|
23
|
+
super("the dashboard state changed since it was read");
|
|
24
|
+
this.current = current;
|
|
25
|
+
this.name = "FleetStateConflict";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function resolvePath(template, input) {
|
|
29
|
+
const path = template.replace(/\{([^}]+)\}/g, (_m, name) => {
|
|
30
|
+
const value = input.params?.[name];
|
|
31
|
+
if (value === undefined)
|
|
32
|
+
throw new Error(`the operation needs parameter ${name}`);
|
|
33
|
+
return encodeURIComponent(String(value));
|
|
34
|
+
});
|
|
35
|
+
const query = input.query && Object.keys(input.query).length > 0 ? `?${new URLSearchParams(input.query)}` : "";
|
|
36
|
+
return `${path}${query}`;
|
|
37
|
+
}
|
|
14
38
|
function queryString(selection) {
|
|
15
39
|
const kinds = typeof selection.kind === "string" ? [selection.kind] : [...selection.kind];
|
|
16
40
|
const params = new URLSearchParams({ kinds: kinds.join(",") });
|
|
@@ -21,9 +45,79 @@ function queryString(selection) {
|
|
|
21
45
|
}
|
|
22
46
|
export function fleetClient(options = {}) {
|
|
23
47
|
const base = (options.base ?? "/_fleet").replace(/\/+$/, "");
|
|
48
|
+
const doFetch = options.fetch ?? ((url, init) => globalThis.fetch(url, init));
|
|
49
|
+
const tables = new Map();
|
|
50
|
+
const operationOf = async (runtimeId, operationId) => {
|
|
51
|
+
let table = tables.get(runtimeId);
|
|
52
|
+
if (table === undefined) {
|
|
53
|
+
table = doFetch(`${base}/operations/${encodeURIComponent(runtimeId)}`, { credentials: "same-origin" }).then(async (res) => {
|
|
54
|
+
if (!res.ok)
|
|
55
|
+
throw new Error(`the runtime's operations answered ${res.status}`);
|
|
56
|
+
return (await res.json()).operations;
|
|
57
|
+
});
|
|
58
|
+
tables.set(runtimeId, table);
|
|
59
|
+
table.catch(() => tables.delete(runtimeId));
|
|
60
|
+
}
|
|
61
|
+
const op = (await table).find((o) => o.operationId === operationId);
|
|
62
|
+
if (op === undefined)
|
|
63
|
+
throw new Error(`runtime ${runtimeId} offers this dashboard no operation ${operationId}`);
|
|
64
|
+
return op;
|
|
65
|
+
};
|
|
24
66
|
return {
|
|
67
|
+
async call(runtimeId, operationId, input = {}) {
|
|
68
|
+
const op = await operationOf(runtimeId, operationId);
|
|
69
|
+
if (op.live)
|
|
70
|
+
throw new Error(`${operationId} is a live operation: open it with fleet.live`);
|
|
71
|
+
const hasBody = input.body !== undefined && op.method !== "GET" && op.method !== "HEAD";
|
|
72
|
+
const res = await doFetch(`${base}/api/${encodeURIComponent(runtimeId)}${resolvePath(op.path, input)}`, {
|
|
73
|
+
method: op.method,
|
|
74
|
+
credentials: "same-origin",
|
|
75
|
+
...(hasBody ? { headers: { "content-type": "application/json" }, body: JSON.stringify(input.body) } : {}),
|
|
76
|
+
});
|
|
77
|
+
if (!res.ok)
|
|
78
|
+
throw new Error(`${operationId} answered ${res.status}: ${await res.text()}`);
|
|
79
|
+
const type = res.headers.get("content-type") ?? "";
|
|
80
|
+
return (type.includes("json") ? await res.json() : await res.text());
|
|
81
|
+
},
|
|
82
|
+
async live(runtimeId, operationId, handlers, input = {}) {
|
|
83
|
+
const op = await operationOf(runtimeId, operationId);
|
|
84
|
+
if (!op.live)
|
|
85
|
+
throw new Error(`${operationId} is not a live operation: call it with fleet.call`);
|
|
86
|
+
const Socket = options.WebSocket ?? globalThis.WebSocket;
|
|
87
|
+
if (Socket === undefined)
|
|
88
|
+
throw new Error("this environment has no WebSocket");
|
|
89
|
+
const origin = options.origin ?? globalThis.location?.origin ?? "";
|
|
90
|
+
const ws = origin.replace(/^http/, "ws");
|
|
91
|
+
const socket = new Socket(`${ws}${base}/live/${encodeURIComponent(runtimeId)}${resolvePath(op.path, input)}`);
|
|
92
|
+
socket.addEventListener("open", () => handlers.onOpen?.());
|
|
93
|
+
socket.addEventListener("message", (e) => handlers.onMessage?.(e.data));
|
|
94
|
+
socket.addEventListener("close", () => handlers.onClose?.());
|
|
95
|
+
return { send: (data) => socket.send(data), close: () => socket.close() };
|
|
96
|
+
},
|
|
97
|
+
state: {
|
|
98
|
+
async get(key) {
|
|
99
|
+
const res = await doFetch(`${base}/state/${encodeURIComponent(key)}`, { credentials: "same-origin" });
|
|
100
|
+
if (res.status === 404)
|
|
101
|
+
return undefined;
|
|
102
|
+
if (!res.ok)
|
|
103
|
+
throw new Error(`the dashboard state answered ${res.status}`);
|
|
104
|
+
return (await res.json());
|
|
105
|
+
},
|
|
106
|
+
async put(key, value, revision) {
|
|
107
|
+
const res = await doFetch(`${base}/state/${encodeURIComponent(key)}`, {
|
|
108
|
+
method: "PUT",
|
|
109
|
+
credentials: "same-origin",
|
|
110
|
+
headers: { "content-type": "application/json" },
|
|
111
|
+
body: JSON.stringify({ value, ...(revision !== undefined ? { revision } : {}) }),
|
|
112
|
+
});
|
|
113
|
+
if (res.status === 409)
|
|
114
|
+
throw new FleetStateConflict((await res.json()).current);
|
|
115
|
+
if (!res.ok)
|
|
116
|
+
throw new Error(`the dashboard state answered ${res.status}: ${await res.text()}`);
|
|
117
|
+
return (await res.json());
|
|
118
|
+
},
|
|
119
|
+
},
|
|
25
120
|
async query(selection) {
|
|
26
|
-
const doFetch = options.fetch ?? ((url, init) => globalThis.fetch(url, init));
|
|
27
121
|
const res = await doFetch(`${base}/directory?${queryString(selection)}`, { credentials: "same-origin" });
|
|
28
122
|
if (!res.ok)
|
|
29
123
|
throw new Error(`the directory answered ${res.status}`);
|