@norskvideo/ctl-sdk 0.1.53 → 0.1.55
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/fleet.d.ts +54 -0
- package/fleet.js +97 -3
- package/manifest-schema.d.ts +1 -1
- package/manifest-schema.js +1 -1
- package/package.json +1 -1
- package/product-service.d.ts +20 -1
- package/product-service.js +29 -0
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}`);
|
package/manifest-schema.d.ts
CHANGED
|
@@ -225,7 +225,7 @@ export declare const ManifestSchema: z.ZodObject<{
|
|
|
225
225
|
name: z.ZodString;
|
|
226
226
|
url: z.ZodString;
|
|
227
227
|
}, z.core.$strip>>>;
|
|
228
|
-
defaultDashboards: z.
|
|
228
|
+
defaultDashboards: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
229
229
|
name: z.ZodString;
|
|
230
230
|
displayName: z.ZodOptional<z.ZodString>;
|
|
231
231
|
url: z.ZodString;
|
package/manifest-schema.js
CHANGED
|
@@ -210,7 +210,7 @@ export const ManifestSchema = z.object({
|
|
|
210
210
|
defaultProductTemplates: z.array(DefaultProductTemplateSchema).default([]).meta({
|
|
211
211
|
description: "Product templates the runner auto-fetches at product-registration time. Each entry is GET'd against the product's base URL and stored under its `name` via the runner's product-template store. Lets a product offer 'zero-config' launch paths — operators add the product and immediately have a launchable product template without visiting configure.",
|
|
212
212
|
}),
|
|
213
|
-
defaultDashboards: z.array(DefaultDashboardSchema).
|
|
213
|
+
defaultDashboards: z.array(DefaultDashboardSchema).optional().meta({
|
|
214
214
|
description: "Fleet dashboards the product brings: norsk-mgr fetches each bundle from the product at registration and on every reload, registers it as the product's, and removes it with the product. norsk-ctl serving alone ignores them.",
|
|
215
215
|
}),
|
|
216
216
|
export: ExportSchema.optional(),
|
package/package.json
CHANGED
package/product-service.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type Result } from "@norskvideo/ctl-foundation";
|
|
2
2
|
import { type ProductPlacement } from "./docker-runner.js";
|
|
3
3
|
import { type LicenseStager } from "./license-registration.js";
|
|
4
|
-
import type { Manifest, ProductRoleDeclaration } from "./manifest-schema.js";
|
|
4
|
+
import type { DefaultDashboard, Manifest, ProductRoleDeclaration } from "./manifest-schema.js";
|
|
5
5
|
import { type ProductReach } from "./product-reach.js";
|
|
6
6
|
import type { ProductTemplateSource } from "./product-template-record.js";
|
|
7
7
|
import type { ProductLicense, ProductRegistration, ProductSpec, RoleDefault } from "./product-types.js";
|
|
@@ -99,8 +99,22 @@ export interface ProductContainerOps {
|
|
|
99
99
|
/** The real docker-runner wiring. Exported so a host can override one op
|
|
100
100
|
* (typically `pull`, onto its own docker adapter) and keep the rest. */
|
|
101
101
|
export declare const defaultProductContainerOps: ProductContainerOps;
|
|
102
|
+
/** A fleet dashboard a product brings, handed to the host to register or
|
|
103
|
+
* replace in place (norsk-ctl task 123 G). */
|
|
104
|
+
export type ImportDashboardBytesFn = (input: {
|
|
105
|
+
productName: string;
|
|
106
|
+
entry: DefaultDashboard;
|
|
107
|
+
bytes: Uint8Array;
|
|
108
|
+
}) => Promise<void>;
|
|
102
109
|
export interface ProductServiceOptions {
|
|
103
110
|
store: ProductStore;
|
|
111
|
+
/** Optional: called once per `manifest.defaultDashboards` entry at add and
|
|
112
|
+
* at every reload, with the bundle fetched from the product. Omit on hosts
|
|
113
|
+
* that host no dashboards (norsk-ctl serving alone): they are then skipped. */
|
|
114
|
+
importDashboardBytes?: ImportDashboardBytesFn;
|
|
115
|
+
/** Optional: told when a product is removed, so the host removes the
|
|
116
|
+
* dashboards it brought. */
|
|
117
|
+
removeProductDashboards?: (productName: string) => Promise<void>;
|
|
104
118
|
/** Optional: called once per `manifest.defaultProductTemplates` entry at
|
|
105
119
|
* registration time. Omit on hosts that don't store product templates
|
|
106
120
|
* (defaultProductTemplates is then silently skipped). */
|
|
@@ -211,6 +225,8 @@ export declare class ProductService {
|
|
|
211
225
|
private readonly store;
|
|
212
226
|
private readonly importProductTemplateBytes?;
|
|
213
227
|
private readonly refreshProductTemplateBytes?;
|
|
228
|
+
private readonly importDashboardBytes?;
|
|
229
|
+
private readonly removeProductDashboards?;
|
|
214
230
|
private readonly stageLicense?;
|
|
215
231
|
private readonly isDevUrlAlive;
|
|
216
232
|
private readonly containerOps;
|
|
@@ -255,6 +271,9 @@ export declare class ProductService {
|
|
|
255
271
|
private changed;
|
|
256
272
|
add(spec: ProductSpec, opts?: AddProductOpts): Promise<AddProductResult>;
|
|
257
273
|
private addSerialised;
|
|
274
|
+
/** Each dashboard the manifest declares, fetched from the product and handed
|
|
275
|
+
* to the host; the warnings for those that could not be. */
|
|
276
|
+
private bringDashboards;
|
|
258
277
|
remove(name: string): Promise<void>;
|
|
259
278
|
private removeSerialised;
|
|
260
279
|
/** Bring a registration up to date with what its image now serves. For a
|
package/product-service.js
CHANGED
|
@@ -55,6 +55,8 @@ export class ProductService {
|
|
|
55
55
|
store;
|
|
56
56
|
importProductTemplateBytes;
|
|
57
57
|
refreshProductTemplateBytes;
|
|
58
|
+
importDashboardBytes;
|
|
59
|
+
removeProductDashboards;
|
|
58
60
|
stageLicense;
|
|
59
61
|
isDevUrlAlive;
|
|
60
62
|
containerOps;
|
|
@@ -67,6 +69,8 @@ export class ProductService {
|
|
|
67
69
|
this.store = opts.store;
|
|
68
70
|
this.importProductTemplateBytes = opts.importProductTemplateBytes;
|
|
69
71
|
this.refreshProductTemplateBytes = opts.refreshProductTemplateBytes;
|
|
72
|
+
this.importDashboardBytes = opts.importDashboardBytes;
|
|
73
|
+
this.removeProductDashboards = opts.removeProductDashboards;
|
|
70
74
|
this.stageLicense = opts.stageLicense;
|
|
71
75
|
this.isDevUrlAlive = opts.isDevUrlAlive ?? isDevUrlAlive;
|
|
72
76
|
this.containerOps = opts.containerOps ?? defaultProductContainerOps;
|
|
@@ -374,10 +378,29 @@ export class ProductService {
|
|
|
374
378
|
}
|
|
375
379
|
}
|
|
376
380
|
}
|
|
381
|
+
warnings.push(...(await this.bringDashboards(manifest, baseUrl)));
|
|
377
382
|
for (const w of warnings)
|
|
378
383
|
logger.warn(`[product:${manifest.name}] ${w}`);
|
|
379
384
|
return { registration, warnings };
|
|
380
385
|
}
|
|
386
|
+
/** Each dashboard the manifest declares, fetched from the product and handed
|
|
387
|
+
* to the host; the warnings for those that could not be. */
|
|
388
|
+
async bringDashboards(manifest, baseUrl) {
|
|
389
|
+
if (!this.importDashboardBytes)
|
|
390
|
+
return [];
|
|
391
|
+
const warnings = [];
|
|
392
|
+
for (const entry of manifest.defaultDashboards ?? []) {
|
|
393
|
+
try {
|
|
394
|
+
const bytes = await fetchProductTemplateBytes(baseUrl, entry.url, this.probeHeadersFor?.(manifest.name) ?? {});
|
|
395
|
+
await this.importDashboardBytes({ productName: manifest.name, entry, bytes });
|
|
396
|
+
logger.info(`Product '${manifest.name}': dashboard '${entry.name}' from ${entry.url}`);
|
|
397
|
+
}
|
|
398
|
+
catch (e) {
|
|
399
|
+
warnings.push(`dashboard '${entry.name}' from ${entry.url} failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return warnings;
|
|
403
|
+
}
|
|
381
404
|
async remove(name) {
|
|
382
405
|
return this.changed(await this.mutations.run(() => this.removeSerialised(name)));
|
|
383
406
|
}
|
|
@@ -395,6 +418,11 @@ export class ProductService {
|
|
|
395
418
|
// so a re-added product of the same name is stamped anew.
|
|
396
419
|
this.forgetIdentity?.(name);
|
|
397
420
|
this.onRemoved?.(name);
|
|
421
|
+
// The product's dashboards go with it; a failure leaves them registered,
|
|
422
|
+
// for the operator to remove, rather than the product half-removed.
|
|
423
|
+
await this.removeProductDashboards?.(name).catch((e) => {
|
|
424
|
+
logger.warn(`[product:${name}] its dashboards were not removed: ${e instanceof Error ? e.message : String(e)}`);
|
|
425
|
+
});
|
|
398
426
|
logger.info(`Product '${name}' removed`);
|
|
399
427
|
}
|
|
400
428
|
/** Bring a registration up to date with what its image now serves. For a
|
|
@@ -478,6 +506,7 @@ export class ProductService {
|
|
|
478
506
|
}
|
|
479
507
|
}
|
|
480
508
|
}
|
|
509
|
+
warnings.push(...(await this.bringDashboards(manifest, baseUrl)));
|
|
481
510
|
for (const w of warnings)
|
|
482
511
|
logger.warn(`[product:${name}] ${w}`);
|
|
483
512
|
return { registration, warnings, containerRestarted, productTemplates };
|