@norskvideo/ctl-sdk 0.1.32 → 0.1.34

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/index.d.ts CHANGED
@@ -11,6 +11,7 @@ export * from "./license-stager.js";
11
11
  export * from "./license-v2.js";
12
12
  export * from "./manifest-fetch.js";
13
13
  export * from "./manifest-router.js";
14
+ export * from "./migration-client.js";
14
15
  export * from "./openapi-router.js";
15
16
  export * from "./product-health-monitor.js";
16
17
  export * from "./product-hub.js";
package/index.js CHANGED
@@ -13,6 +13,7 @@ export * from "./license-stager.js";
13
13
  export * from "./license-v2.js";
14
14
  export * from "./manifest-fetch.js";
15
15
  export * from "./manifest-router.js";
16
+ export * from "./migration-client.js";
16
17
  export * from "./openapi-router.js";
17
18
  export * from "./product-health-monitor.js";
18
19
  export * from "./product-hub.js";
@@ -0,0 +1,93 @@
1
+ /** A source declining the propose. Return it from `onGetState` (see
2
+ * {@link stateRefusal}); it goes to the worker as `state-refused{reason}`
3
+ * and the migration ends with no target ever placed. */
4
+ export interface StateRefusal {
5
+ readonly refused: true;
6
+ readonly reason: string;
7
+ }
8
+ export declare function stateRefusal(reason: string): StateRefusal;
9
+ export declare function isStateRefusal(value: unknown): value is StateRefusal;
10
+ export interface MigrationHandlers {
11
+ /** Source side, first on every handover: the propose. Return the state a
12
+ * target would need to take over (any JSON; `undefined`/`null` is a yes
13
+ * with nothing to hand over), or {@link stateRefusal} to decline. A throw
14
+ * is a refusal too. The answer goes back as `state` / `state-refused`;
15
+ * a product that answers from elsewhere calls `reportState` /
16
+ * `refuseState` inside the handler instead, and the return value is
17
+ * then ignored. Without this handler the client refuses every propose,
18
+ * naming the product — a product that cannot hand over is told so at
19
+ * once rather than timing out the migration. */
20
+ onGetState?(migrationId: string): unknown | Promise<unknown>;
21
+ /** Target side, first after the hello-ack when the source handed
22
+ * something over: adopt `payload` before reporting ready. Nothing is sent
23
+ * back for it. */
24
+ onInitialState?(payload: unknown, migrationId: string): void | Promise<void>;
25
+ /** Source side: the target is ready at `payload`. Stop output at or after
26
+ * it, then `reportStoppedOutput`. */
27
+ onCutoverReady?(payload: unknown, migrationId: string): void | Promise<void>;
28
+ /** Target side: the source stopped at `payload`. Take over from it, then
29
+ * `reportApplied`. */
30
+ onCutoverApply?(payload: unknown, migrationId: string): void | Promise<void>;
31
+ /** Either side. A source resumes output; a target expects to be stopped. */
32
+ onAbort?(reason: string, migrationId: string): void | Promise<void>;
33
+ }
34
+ export interface MigrationClient {
35
+ /** "target" iff the worker's hello-ack said so (the instance was launched
36
+ * as a migration target); "none" when there is no socket at all. A source
37
+ * is "source" — every connected instance that is not a target may be
38
+ * asked to hand over. */
39
+ readonly role: "source" | "target" | "none";
40
+ /** The migration a target belongs to, or the one a source is in once a
41
+ * cutover-ready has named it. */
42
+ readonly migrationId?: string;
43
+ /** True while the socket is open and hello-acked. */
44
+ readonly connected: boolean;
45
+ /** Source: answer the propose with `payload` (any JSON; null is a yes with
46
+ * nothing to hand over). Only meaningful while a `get-state` is in
47
+ * flight — i.e. from inside `onGetState`, before it returns or resolves;
48
+ * otherwise dropped with a log line. */
49
+ reportState(payload: unknown): void;
50
+ /** Source: decline the propose. Same window as `reportState`. */
51
+ refuseState(reason: string): void;
52
+ /** Target: ready to take over at `payload` (a position). Once. */
53
+ reportReady(payload: unknown): void;
54
+ /** Source: output stopped at `payload` (a position). */
55
+ reportStoppedOutput(payload: unknown): void;
56
+ /** Target: the handover has been applied. */
57
+ reportApplied(): void;
58
+ /** Either side: give up on the migration. */
59
+ abort(reason: string): void;
60
+ /** Stop reconnecting and close. */
61
+ close(): void;
62
+ }
63
+ /** The subset of the WHATWG WebSocket API the client uses. `ws`'s WebSocket
64
+ * and the Node/Bun/browser globals all satisfy it. */
65
+ export interface MigrationSocket {
66
+ send(data: string): void;
67
+ close(code?: number, reason?: string): void;
68
+ onopen: ((ev: unknown) => void) | null;
69
+ onmessage: ((ev: {
70
+ data: unknown;
71
+ }) => void) | null;
72
+ onclose: ((ev: {
73
+ code: number;
74
+ reason: string;
75
+ }) => void) | null;
76
+ onerror: ((ev: unknown) => void) | null;
77
+ }
78
+ export type MigrationSocketCtor = new (url: string) => MigrationSocket;
79
+ export interface ConnectMigrationOptions {
80
+ /** Override the socket implementation (tests; a runtime with neither a
81
+ * global WebSocket nor `ws`). */
82
+ WebSocket?: MigrationSocketCtor;
83
+ /** Backoff bounds. */
84
+ reconnectMinMs?: number;
85
+ reconnectMaxMs?: number;
86
+ /** Where the client reports what it does; default: console.error, one
87
+ * line per event, prefixed. */
88
+ log?: (line: string) => void;
89
+ /** How the product is named in a refusal the client makes on its behalf;
90
+ * default: `NORSK_RUNTIME_ID` from the env, else "this product". */
91
+ name?: string;
92
+ }
93
+ export declare function connectMigration(handlers: MigrationHandlers, env?: NodeJS.ProcessEnv, opts?: ConnectMigrationOptions): MigrationClient;
@@ -0,0 +1,378 @@
1
+ // The job side of the worker's job socket (norsk-ctl
2
+ // packages/norsk-mgr/docs/internal/design/job-migration.md §5): what a
3
+ // handover-migratable product calls at startup.
4
+ //
5
+ // const migration = connectMigration({
6
+ // onGetState: async () => currentPosition(), // source: the propose (or stateRefusal("why"))
7
+ // onInitialState: async (payload) => { /* target: adopt the source's state before ready */ },
8
+ // onCutoverReady: async (payload) => { /* source: stop at >= payload */ migration.reportStoppedOutput({...}) },
9
+ // onCutoverApply: async (payload) => { /* target: take over from payload */ migration.reportApplied() },
10
+ // onAbort: (reason) => { /* source: resume output */ },
11
+ // });
12
+ // if (migration.role === "target") migration.reportReady({...});
13
+ //
14
+ // The worker asks `get-state` FIRST on every handover and waits for `state`
15
+ // or `state-refused`; nothing on the worker times that out, so a client that
16
+ // answered neither parked every migration in `proposing` until the
17
+ // AutoManager's deadline (review 2026-09-16). This client therefore ALWAYS
18
+ // answers: the handler's return value, its refusal, its exception, or — with
19
+ // no handler at all — a refusal that names the product. A refusal costs
20
+ // nothing; a park costs the deadline.
21
+ //
22
+ // Reads NORSK_WORKER_WS_URL / NORSK_WORKER_TOKEN. When they are absent — a
23
+ // worker with `--no-job-socket`, a local `norsk-ctl` launch, a test — this is
24
+ // a no-op client with role "none", so a product calls it unconditionally.
25
+ //
26
+ // Transport: a WHATWG-shaped WebSocket. Node ≥ 22 and Bun have one as a
27
+ // global; on older Node the `ws` package supplies it. Reconnects with backoff
28
+ // (1 s doubling to 30 s) after any drop, re-sends hello, and holds at most
29
+ // the LATEST unsent report while disconnected — a newer position supersedes
30
+ // an older one, and the worker acts on the first it hears.
31
+ import { JOB_SOCKET_ENV, parseWorkerToJobFrame, } from "@norskvideo/ctl-product-template-schema/migration-socket";
32
+ export function stateRefusal(reason) {
33
+ return { refused: true, reason };
34
+ }
35
+ export function isStateRefusal(value) {
36
+ return (typeof value === "object" &&
37
+ value !== null &&
38
+ value.refused === true &&
39
+ typeof value.reason === "string");
40
+ }
41
+ const DEFAULT_MIN_BACKOFF_MS = 1_000;
42
+ const DEFAULT_MAX_BACKOFF_MS = 30_000;
43
+ export function connectMigration(handlers, env = process.env, opts = {}) {
44
+ const url = env[JOB_SOCKET_ENV.url];
45
+ const token = env[JOB_SOCKET_ENV.token];
46
+ if (!url || !token)
47
+ return noopClient();
48
+ const name = opts.name ?? env.NORSK_RUNTIME_ID ?? "this product";
49
+ return new SocketMigrationClient(url, token, handlers, { ...opts, name });
50
+ }
51
+ function noopClient() {
52
+ return {
53
+ role: "none",
54
+ migrationId: undefined,
55
+ connected: false,
56
+ reportState: () => { },
57
+ refuseState: () => { },
58
+ reportReady: () => { },
59
+ reportStoppedOutput: () => { },
60
+ reportApplied: () => { },
61
+ abort: () => { },
62
+ close: () => { },
63
+ };
64
+ }
65
+ class SocketMigrationClient {
66
+ url;
67
+ token;
68
+ handlers;
69
+ opts;
70
+ role = "source";
71
+ migrationId;
72
+ connected = false;
73
+ socket;
74
+ closed = false;
75
+ attempt = 0;
76
+ reconnectTimer;
77
+ pending;
78
+ /** The `get-state` being answered, while one is. `answered` flips when
79
+ * the product calls reportState/refuseState from inside its handler, so
80
+ * the handler's return value is not sent as a second answer. */
81
+ stateRequest;
82
+ minBackoffMs;
83
+ maxBackoffMs;
84
+ log;
85
+ constructor(url, token, handlers, opts) {
86
+ this.url = url;
87
+ this.token = token;
88
+ this.handlers = handlers;
89
+ this.opts = opts;
90
+ this.minBackoffMs = opts.reconnectMinMs ?? DEFAULT_MIN_BACKOFF_MS;
91
+ this.maxBackoffMs = opts.reconnectMaxMs ?? DEFAULT_MAX_BACKOFF_MS;
92
+ this.log = opts.log ?? ((line) => console.error(`[migration] ${line}`));
93
+ void this.connect();
94
+ }
95
+ reportState(payload) {
96
+ this.answerState({ type: "state", payload: asPayload(payload) });
97
+ }
98
+ refuseState(reason) {
99
+ this.answerState({ type: "state-refused", reason });
100
+ }
101
+ reportReady(payload) {
102
+ this.report({ type: "target-ready", payload: asPayload(payload) });
103
+ }
104
+ reportStoppedOutput(payload) {
105
+ this.report({ type: "source-stopped-output", payload: asPayload(payload) });
106
+ }
107
+ reportApplied() {
108
+ this.report({ type: "target-applied" });
109
+ }
110
+ abort(reason) {
111
+ this.report({ type: "abort", reason });
112
+ }
113
+ close() {
114
+ this.closed = true;
115
+ if (this.reconnectTimer)
116
+ clearTimeout(this.reconnectTimer);
117
+ this.reconnectTimer = undefined;
118
+ const s = this.socket;
119
+ this.socket = undefined;
120
+ this.connected = false;
121
+ try {
122
+ s?.close(1000, "job closing");
123
+ }
124
+ catch {
125
+ // already gone
126
+ }
127
+ }
128
+ // ── private ─────────────────────────────────────────────────────────
129
+ /** The one answer to the in-flight `get-state`. The worker closes the
130
+ * socket 4400 on a `state` with no propose in flight, so an answer with
131
+ * none is dropped here rather than sent. Not held while disconnected: a
132
+ * socket that drops mid-propose is the source aborting, and the worker
133
+ * asks again on the next migration. */
134
+ answerState(msg) {
135
+ if (this.closed)
136
+ return;
137
+ const req = this.stateRequest;
138
+ if (!req) {
139
+ this.log(`'${msg.type}' with no get-state in flight; dropped`);
140
+ return;
141
+ }
142
+ if (req.answered) {
143
+ this.log(`'${msg.type}' after get-state for ${req.migrationId} was already answered; dropped`);
144
+ return;
145
+ }
146
+ req.answered = true;
147
+ if (msg.type === "state-refused") {
148
+ // A source that refused is not in the migration; it may be asked again.
149
+ if (this.migrationId === req.migrationId)
150
+ this.migrationId = undefined;
151
+ }
152
+ if (!this.connected || !this.socket) {
153
+ this.log(`'${msg.type}' for ${req.migrationId} while disconnected; dropped — the worker will ask again`);
154
+ return;
155
+ }
156
+ this.send(msg);
157
+ }
158
+ /** The propose. Every path ends in exactly one `state` | `state-refused`. */
159
+ async onGetState(migrationId) {
160
+ if (this.stateRequest && !this.stateRequest.answered) {
161
+ // The worker never sends a second get-state while one is in flight;
162
+ // if it does, the newer one is the one it is waiting on.
163
+ this.log(`get-state for ${migrationId} while ${this.stateRequest.migrationId} is unanswered; superseded`);
164
+ }
165
+ this.stateRequest = { migrationId, answered: false };
166
+ this.migrationId = migrationId;
167
+ const handler = this.handlers.onGetState;
168
+ if (!handler) {
169
+ const reason = `${this.opts.name} registered no onGetState handler (connectMigration without onGetState); it cannot hand over`;
170
+ this.log(`get-state for ${migrationId}: ${reason}; refusing`);
171
+ this.refuseState(reason);
172
+ return;
173
+ }
174
+ let answer;
175
+ try {
176
+ answer = await handler.call(this.handlers, migrationId);
177
+ }
178
+ catch (e) {
179
+ this.log(`onGetState for ${migrationId} threw: ${String(e)}; refusing`);
180
+ this.refuseState(`onGetState threw: ${String(e)}`);
181
+ return;
182
+ }
183
+ if (this.stateRequest?.migrationId !== migrationId || this.stateRequest.answered)
184
+ return;
185
+ if (isStateRefusal(answer))
186
+ this.refuseState(answer.reason);
187
+ else
188
+ this.reportState(answer);
189
+ }
190
+ report(msg) {
191
+ if (this.closed)
192
+ return;
193
+ if (this.connected && this.socket) {
194
+ this.send(msg);
195
+ return;
196
+ }
197
+ // Disconnected: the latest report wins. A position reported twice is
198
+ // the newer position; the worker acts on whichever it hears first.
199
+ this.pending = msg;
200
+ }
201
+ send(msg) {
202
+ try {
203
+ this.socket?.send(JSON.stringify(msg));
204
+ }
205
+ catch (e) {
206
+ this.log(`send of '${msg.type}' failed: ${String(e)}`);
207
+ }
208
+ }
209
+ async connect() {
210
+ if (this.closed)
211
+ return;
212
+ let Ctor;
213
+ try {
214
+ Ctor = await resolveWebSocket(this.opts.WebSocket);
215
+ }
216
+ catch (e) {
217
+ this.log(`no WebSocket implementation: ${String(e)} — migration disabled`);
218
+ this.role = "none";
219
+ return;
220
+ }
221
+ if (this.closed)
222
+ return;
223
+ let socket;
224
+ try {
225
+ socket = new Ctor(this.url);
226
+ }
227
+ catch (e) {
228
+ this.log(`connect to ${this.url} failed: ${String(e)}`);
229
+ this.scheduleReconnect();
230
+ return;
231
+ }
232
+ this.socket = socket;
233
+ let acked = false;
234
+ socket.onopen = () => {
235
+ if (this.socket !== socket)
236
+ return;
237
+ this.send({ type: "hello", token: this.token });
238
+ };
239
+ socket.onmessage = (ev) => {
240
+ if (this.socket !== socket)
241
+ return;
242
+ const text = typeof ev.data === "string" ? ev.data : String(ev.data);
243
+ const parsed = parseWorkerToJobFrame(text);
244
+ if (!parsed.ok) {
245
+ this.log(`ignoring frame from the worker: ${parsed.reason}`);
246
+ return;
247
+ }
248
+ const msg = parsed.message;
249
+ if (!acked) {
250
+ if (msg.type !== "hello-ack") {
251
+ this.log(`expected hello-ack, got '${msg.type}'; ignoring`);
252
+ return;
253
+ }
254
+ acked = true;
255
+ this.onAcked(msg);
256
+ return;
257
+ }
258
+ void this.onMessage(msg);
259
+ };
260
+ socket.onclose = (ev) => {
261
+ if (this.socket !== socket)
262
+ return;
263
+ this.socket = undefined;
264
+ this.connected = false;
265
+ // A drop mid-propose is the source aborting; the worker asks afresh.
266
+ this.stateRequest = undefined;
267
+ if (this.closed)
268
+ return;
269
+ this.log(`socket closed (${ev.code}${ev.reason ? ` ${ev.reason}` : ""})`);
270
+ this.scheduleReconnect();
271
+ };
272
+ socket.onerror = () => {
273
+ // Node's WebSocket (undici, Node 22) fires `error` and NOT `close` when
274
+ // the connection itself fails — the socket stays CONNECTING for ever.
275
+ // Waiting for a close that never comes stranded every job whose worker
276
+ // restarted: the first refused reconnect ended the client's life
277
+ // (seen live 2026-09-07). Treat an error on the current socket as its
278
+ // end; a `close` that does follow finds `this.socket` moved on and is
279
+ // ignored by the guard above.
280
+ if (this.socket !== socket)
281
+ return;
282
+ this.socket = undefined;
283
+ this.connected = false;
284
+ this.stateRequest = undefined;
285
+ if (this.closed)
286
+ return;
287
+ this.log("socket error; will reconnect");
288
+ try {
289
+ socket.close();
290
+ }
291
+ catch {
292
+ // never opened
293
+ }
294
+ this.scheduleReconnect();
295
+ };
296
+ }
297
+ onAcked(ack) {
298
+ this.attempt = 0;
299
+ this.connected = true;
300
+ if (ack.migration) {
301
+ this.role = "target";
302
+ this.migrationId = ack.migration.id;
303
+ }
304
+ else {
305
+ this.role = "source";
306
+ }
307
+ this.log(`connected as ${this.role}${this.migrationId ? ` (migration ${this.migrationId})` : ""}`);
308
+ const pending = this.pending;
309
+ this.pending = undefined;
310
+ if (pending)
311
+ this.send(pending);
312
+ }
313
+ async onMessage(msg) {
314
+ try {
315
+ switch (msg.type) {
316
+ case "hello-ack":
317
+ return;
318
+ case "get-state":
319
+ await this.onGetState(msg.migrationId);
320
+ return;
321
+ case "initial-state":
322
+ this.migrationId = msg.migrationId;
323
+ if (!this.handlers.onInitialState) {
324
+ this.log(`initial-state for ${msg.migrationId} arrived but no onInitialState handler is registered; the source's state is dropped`);
325
+ return;
326
+ }
327
+ await this.handlers.onInitialState(msg.payload, msg.migrationId);
328
+ return;
329
+ case "cutover-ready":
330
+ this.migrationId = msg.migrationId;
331
+ await this.handlers.onCutoverReady?.(msg.payload, msg.migrationId);
332
+ return;
333
+ case "cutover-apply":
334
+ this.migrationId = msg.migrationId;
335
+ await this.handlers.onCutoverApply?.(msg.payload, msg.migrationId);
336
+ return;
337
+ case "abort":
338
+ // An abort ends an unanswered propose too: an answer after it
339
+ // would meet a worker with nothing in flight (4400).
340
+ if (this.stateRequest?.migrationId === msg.migrationId)
341
+ this.stateRequest = undefined;
342
+ await this.handlers.onAbort?.(msg.reason, msg.migrationId);
343
+ if (this.role === "source")
344
+ this.migrationId = undefined;
345
+ return;
346
+ }
347
+ }
348
+ catch (e) {
349
+ this.log(`handler for '${msg.type}' threw: ${String(e)}`);
350
+ }
351
+ }
352
+ scheduleReconnect() {
353
+ if (this.closed || this.reconnectTimer)
354
+ return;
355
+ const delay = Math.min(this.maxBackoffMs, this.minBackoffMs * 2 ** this.attempt);
356
+ this.attempt = Math.min(this.attempt + 1, 30);
357
+ this.reconnectTimer = setTimeout(() => {
358
+ this.reconnectTimer = undefined;
359
+ void this.connect();
360
+ }, delay);
361
+ }
362
+ }
363
+ function asPayload(payload) {
364
+ // Anything JSON.stringify can carry. A non-JSON value (undefined, a
365
+ // function) becomes null rather than an invalid frame.
366
+ return payload === undefined ? null : JSON.parse(JSON.stringify(payload));
367
+ }
368
+ async function resolveWebSocket(explicit) {
369
+ if (explicit)
370
+ return explicit;
371
+ const global = globalThis.WebSocket;
372
+ if (global)
373
+ return global;
374
+ // Older Node: the `ws` package's WebSocket carries the same handler
375
+ // properties. A dynamic import so a runtime with the global never loads it.
376
+ const ws = await import("ws");
377
+ return ws.WebSocket;
378
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-sdk",
3
- "version": "0.1.32",
3
+ "version": "0.1.34",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -31,7 +31,11 @@
31
31
  "types": "./workflow.d.ts",
32
32
  "default": "./workflow.js"
33
33
  },
34
- "./base.css": "./base.css"
34
+ "./base.css": "./base.css",
35
+ "./migration": {
36
+ "types": "./migration-client.d.ts",
37
+ "default": "./migration-client.js"
38
+ }
35
39
  },
36
40
  "main": "./index.js",
37
41
  "types": "./index.d.ts",
@@ -42,7 +46,8 @@
42
46
  "lucide-react": "^0.483.0",
43
47
  "react": "^19.0.0",
44
48
  "react-hot-toast": "^2.4.1",
45
- "zod": "4.4.3"
49
+ "zod": "4.4.3",
50
+ "ws": "^8.20.1"
46
51
  },
47
52
  "publishConfig": {
48
53
  "access": "public"
@@ -56,7 +56,8 @@ export interface ProductTemplateRecord {
56
56
  * launch's parameter map beneath the operator's instance values —
57
57
  * instance wins, template default wins over manifest default. Names
58
58
  * must be env-var shaped and never `NORSK_`-prefixed (that namespace is
59
- * infra-injected: NORSK_RUNTIME_ROLE, NORSK_MATCHED_CAPABILITIES, …). */
59
+ * infra-injected: NORSK_RUNTIME_ROLE, NORSK_MIGRATION_ID,
60
+ * NORSK_MATCHED_CAPABILITIES, …). */
60
61
  parameterDefaults?: Record<string, string>;
61
62
  /** Template-author security-group suggestions, by discovery-tag LABEL
62
63
  * (portable across deployments; ids are not). The launch UI pre-ticks