@norskvideo/ctl-sdk 0.1.50 → 0.1.52

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 ADDED
@@ -0,0 +1,76 @@
1
+ export interface FleetEndpoint {
2
+ transport: string;
3
+ address: string;
4
+ params: Record<string, string>;
5
+ }
6
+ /** What norsk-discovery holds. */
7
+ export interface FleetResource {
8
+ name: string;
9
+ kind: string;
10
+ labels: Record<string, string>;
11
+ endpoints: FleetEndpoint[];
12
+ }
13
+ /** A runtime as the platform knows it: under norsk-mgr its job id,
14
+ * production, replica and job name; under norsk-ctl alone its instance id
15
+ * and product. */
16
+ export interface FleetRuntime {
17
+ id: string;
18
+ namespace: string;
19
+ [field: string]: unknown;
20
+ }
21
+ export interface FleetItem {
22
+ runtime: FleetRuntime;
23
+ /** The platform's own registration of the runtime (`norsk.runtime`), with
24
+ * its `norsk.state`. */
25
+ presence?: FleetResource;
26
+ entries: FleetResource[];
27
+ }
28
+ export interface FleetDirectory {
29
+ items: FleetItem[];
30
+ /** Whether discovery's replay has landed: before it, entries are partial. */
31
+ synced: boolean;
32
+ }
33
+ export interface FleetSelection {
34
+ /** The kinds to read, as literals. `norsk.runtime` lists runtimes by their
35
+ * own registration. */
36
+ kind: string | readonly string[];
37
+ /** Every one present on an entry, with the same value. */
38
+ labels?: Readonly<Record<string, string>>;
39
+ }
40
+ export interface FleetWatchHandlers {
41
+ /** The whole current set, after the snapshot and after every change. */
42
+ onChange?: (items: FleetItem[]) => void;
43
+ onSynced?: (synced: boolean) => void;
44
+ /** The stream failed; the browser retries it. */
45
+ onError?: () => void;
46
+ }
47
+ export interface FleetWatch {
48
+ current(): FleetItem[];
49
+ synced(): boolean;
50
+ close(): void;
51
+ }
52
+ interface EventSourceLike {
53
+ addEventListener(name: string, listener: (e: {
54
+ data: string;
55
+ }) => void): void;
56
+ close(): void;
57
+ }
58
+ export interface FleetOptions {
59
+ /** Where the directory is: `/_fleet` on a dashboard host (the default),
60
+ * `/api/v1/fleet` on the platform's own origin. */
61
+ base?: string;
62
+ fetch?: (url: string, init?: {
63
+ credentials?: "same-origin";
64
+ }) => Promise<Response>;
65
+ EventSource?: new (url: string, init?: {
66
+ withCredentials?: boolean;
67
+ }) => EventSourceLike;
68
+ }
69
+ export interface Fleet {
70
+ query(selection: FleetSelection): Promise<FleetDirectory>;
71
+ watch(selection: FleetSelection, handlers?: FleetWatchHandlers): FleetWatch;
72
+ }
73
+ export declare function fleetClient(options?: FleetOptions): Fleet;
74
+ /** The directory on the page's own origin, as a dashboard host serves it. */
75
+ export declare const fleet: Fleet;
76
+ export {};
package/fleet.js ADDED
@@ -0,0 +1,70 @@
1
+ // The directory, for a dashboard (RFC 0006 §4.5, task 122 K): the runtimes
2
+ // the signed-in user may see, each with what discovery holds under it, read
3
+ // from the page's own origin and filtered by the platform, never by the
4
+ // dashboard.
5
+ //
6
+ // On a dashboard host the directory is `/_fleet/`; in a product's own UI or a
7
+ // dashboard's dev loop it is the platform's API, `/api/v1/fleet/`. The calls
8
+ // are the same either way.
9
+ //
10
+ // Name the kinds a call reads with literals: ctl-dev-kit's dashboard build
11
+ // derives the registration's `uses` from them, and refuses one it cannot read.
12
+ //
13
+ // Browser-only, with no imports, so a dashboard's bundle carries nothing else.
14
+ function queryString(selection) {
15
+ const kinds = typeof selection.kind === "string" ? [selection.kind] : [...selection.kind];
16
+ const params = new URLSearchParams({ kinds: kinds.join(",") });
17
+ const labels = Object.entries(selection.labels ?? {}).map(([k, v]) => `${k}=${v}`);
18
+ if (labels.length > 0)
19
+ params.set("labels", labels.join(","));
20
+ return params.toString();
21
+ }
22
+ export function fleetClient(options = {}) {
23
+ const base = (options.base ?? "/_fleet").replace(/\/+$/, "");
24
+ return {
25
+ async query(selection) {
26
+ const doFetch = options.fetch ?? ((url, init) => globalThis.fetch(url, init));
27
+ const res = await doFetch(`${base}/directory?${queryString(selection)}`, { credentials: "same-origin" });
28
+ if (!res.ok)
29
+ throw new Error(`the directory answered ${res.status}`);
30
+ return (await res.json());
31
+ },
32
+ watch(selection, handlers = {}) {
33
+ const Source = options.EventSource ?? globalThis.EventSource;
34
+ if (Source === undefined)
35
+ throw new Error("this environment has no EventSource");
36
+ const items = new Map();
37
+ let isSynced = false;
38
+ const source = new Source(`${base}/directory/watch?${queryString(selection)}`, { withCredentials: true });
39
+ const changed = () => handlers.onChange?.([...items.values()]);
40
+ const on = (name, apply) => source.addEventListener(name, (e) => apply(JSON.parse(e.data)));
41
+ on("snapshot", (data) => {
42
+ items.clear();
43
+ for (const item of data.items)
44
+ items.set(item.runtime.id, item);
45
+ isSynced = data.synced;
46
+ changed();
47
+ });
48
+ on("upsert", (item) => {
49
+ items.set(item.runtime.id, item);
50
+ changed();
51
+ });
52
+ on("remove", (data) => {
53
+ items.delete(data.id);
54
+ changed();
55
+ });
56
+ on("synced", (data) => {
57
+ isSynced = data.synced;
58
+ handlers.onSynced?.(isSynced);
59
+ });
60
+ source.addEventListener("error", () => handlers.onError?.());
61
+ return {
62
+ current: () => [...items.values()],
63
+ synced: () => isSynced,
64
+ close: () => source.close(),
65
+ };
66
+ },
67
+ };
68
+ }
69
+ /** The directory on the page's own origin, as a dashboard host serves it. */
70
+ export const fleet = fleetClient();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-sdk",
3
- "version": "0.1.50",
3
+ "version": "0.1.52",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -15,6 +15,10 @@
15
15
  "types": "./components/index.d.ts",
16
16
  "default": "./components/index.js"
17
17
  },
18
+ "./fleet": {
19
+ "types": "./fleet.d.ts",
20
+ "default": "./fleet.js"
21
+ },
18
22
  "./frontend-spec": {
19
23
  "types": "./frontend-spec.d.ts",
20
24
  "default": "./frontend-spec.js"
@@ -171,6 +171,9 @@ export interface ProductServiceOptions {
171
171
  /** Told once a registration is removed, so the host can drop whatever it
172
172
  * holds against the product (role assignments on `product/<name>`). */
173
173
  onRemoved?: (productName: string) => void;
174
+ /** Told after every add, remove and reload that succeeds: the host keeps a
175
+ * view of its products elsewhere (norsk-ctl's discovery entries). */
176
+ onChanged?: () => void;
174
177
  /**
175
178
  * What the daemon asserts when it dials a REGISTERED product's own routes —
176
179
  * the config-screen probe and the product-template fetches (RFC 0003 D7's
@@ -228,6 +231,7 @@ export declare class ProductService {
228
231
  private reachHostFor;
229
232
  private readonly onRolesDeclared?;
230
233
  private readonly onRemoved?;
234
+ private readonly onChanged?;
231
235
  private readonly reservedRoleNames?;
232
236
  /**
233
237
  * Product roles travel bare, so a name means one thing on a host. Two
@@ -248,6 +252,7 @@ export declare class ProductService {
248
252
  * tracked containerId means running. Dev-mode: externally owned, so probe
249
253
  * the dev URL — registered no longer implies running. */
250
254
  isRunning(reg: ProductRegistration): Promise<boolean>;
255
+ private changed;
251
256
  add(spec: ProductSpec, opts?: AddProductOpts): Promise<AddProductResult>;
252
257
  private addSerialised;
253
258
  remove(name: string): Promise<void>;
@@ -76,6 +76,7 @@ export class ProductService {
76
76
  this.onRolesDeclared = opts.onRolesDeclared;
77
77
  this.reservedRoleNames = opts.reservedRoleNames;
78
78
  this.onRemoved = opts.onRemoved;
79
+ this.onChanged = opts.onChanged;
79
80
  this.forgetIdentity = opts.forgetIdentity;
80
81
  this.probeHeadersFor = opts.probeHeadersFor;
81
82
  }
@@ -99,6 +100,7 @@ export class ProductService {
99
100
  }
100
101
  onRolesDeclared;
101
102
  onRemoved;
103
+ onChanged;
102
104
  reservedRoleNames;
103
105
  /**
104
106
  * Product roles travel bare, so a name means one thing on a host. Two
@@ -165,8 +167,12 @@ export class ProductService {
165
167
  return reg.containerId !== undefined;
166
168
  return this.isDevUrlAlive(productBaseUrl(reg));
167
169
  }
170
+ changed(result) {
171
+ this.onChanged?.();
172
+ return result;
173
+ }
168
174
  async add(spec, opts = {}) {
169
- return this.mutations.run(() => this.addSerialised(spec, opts));
175
+ return this.changed(await this.mutations.run(() => this.addSerialised(spec, opts)));
170
176
  }
171
177
  async addSerialised(spec, opts) {
172
178
  const existing = this.store.read();
@@ -373,7 +379,7 @@ export class ProductService {
373
379
  return { registration, warnings };
374
380
  }
375
381
  async remove(name) {
376
- return this.mutations.run(() => this.removeSerialised(name));
382
+ return this.changed(await this.mutations.run(() => this.removeSerialised(name)));
377
383
  }
378
384
  async removeSerialised(name) {
379
385
  const existing = this.store.read();
@@ -400,7 +406,7 @@ export class ProductService {
400
406
  * stood in for. Like restart, the new container id is persisted before the
401
407
  * readiness wait so a timed-out reload leaves a tracked container. */
402
408
  async reload(name) {
403
- return this.mutations.run(() => this.reloadSerialised(name));
409
+ return this.changed(await this.mutations.run(() => this.reloadSerialised(name)));
404
410
  }
405
411
  async reloadSerialised(name) {
406
412
  const target = this.store.read().find((p) => p.name === name);