@norskvideo/ctl-sdk 0.1.32 → 0.1.33
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 +1 -0
- package/index.js +1 -0
- package/migration-client.d.ts +60 -0
- package/migration-client.js +267 -0
- package/package.json +8 -3
- package/product-template-record.d.ts +2 -1
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,60 @@
|
|
|
1
|
+
export interface MigrationHandlers {
|
|
2
|
+
/** Source side: the target is ready at `payload`. Stop output at or after
|
|
3
|
+
* it, then `reportStoppedOutput`. */
|
|
4
|
+
onCutoverReady?(payload: unknown, migrationId: string): void | Promise<void>;
|
|
5
|
+
/** Target side: the source stopped at `payload`. Take over from it, then
|
|
6
|
+
* `reportApplied`. */
|
|
7
|
+
onCutoverApply?(payload: unknown, migrationId: string): void | Promise<void>;
|
|
8
|
+
/** Either side. A source resumes output; a target expects to be stopped. */
|
|
9
|
+
onAbort?(reason: string, migrationId: string): void | Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
export interface MigrationClient {
|
|
12
|
+
/** "target" iff the worker's hello-ack said so (the instance was launched
|
|
13
|
+
* as a migration target); "none" when there is no socket at all. A source
|
|
14
|
+
* is "source" — every connected instance that is not a target may be
|
|
15
|
+
* asked to hand over. */
|
|
16
|
+
readonly role: "source" | "target" | "none";
|
|
17
|
+
/** The migration a target belongs to, or the one a source is in once a
|
|
18
|
+
* cutover-ready has named it. */
|
|
19
|
+
readonly migrationId?: string;
|
|
20
|
+
/** True while the socket is open and hello-acked. */
|
|
21
|
+
readonly connected: boolean;
|
|
22
|
+
/** Target: ready to take over at `payload` (a position). Once. */
|
|
23
|
+
reportReady(payload: unknown): void;
|
|
24
|
+
/** Source: output stopped at `payload` (a position). */
|
|
25
|
+
reportStoppedOutput(payload: unknown): void;
|
|
26
|
+
/** Target: the handover has been applied. */
|
|
27
|
+
reportApplied(): void;
|
|
28
|
+
/** Either side: give up on the migration. */
|
|
29
|
+
abort(reason: string): void;
|
|
30
|
+
/** Stop reconnecting and close. */
|
|
31
|
+
close(): void;
|
|
32
|
+
}
|
|
33
|
+
/** The subset of the WHATWG WebSocket API the client uses. `ws`'s WebSocket
|
|
34
|
+
* and the Node/Bun/browser globals all satisfy it. */
|
|
35
|
+
export interface MigrationSocket {
|
|
36
|
+
send(data: string): void;
|
|
37
|
+
close(code?: number, reason?: string): void;
|
|
38
|
+
onopen: ((ev: unknown) => void) | null;
|
|
39
|
+
onmessage: ((ev: {
|
|
40
|
+
data: unknown;
|
|
41
|
+
}) => void) | null;
|
|
42
|
+
onclose: ((ev: {
|
|
43
|
+
code: number;
|
|
44
|
+
reason: string;
|
|
45
|
+
}) => void) | null;
|
|
46
|
+
onerror: ((ev: unknown) => void) | null;
|
|
47
|
+
}
|
|
48
|
+
export type MigrationSocketCtor = new (url: string) => MigrationSocket;
|
|
49
|
+
export interface ConnectMigrationOptions {
|
|
50
|
+
/** Override the socket implementation (tests; a runtime with neither a
|
|
51
|
+
* global WebSocket nor `ws`). */
|
|
52
|
+
WebSocket?: MigrationSocketCtor;
|
|
53
|
+
/** Backoff bounds. */
|
|
54
|
+
reconnectMinMs?: number;
|
|
55
|
+
reconnectMaxMs?: number;
|
|
56
|
+
/** Where the client reports what it does; default: console.error, one
|
|
57
|
+
* line per event, prefixed. */
|
|
58
|
+
log?: (line: string) => void;
|
|
59
|
+
}
|
|
60
|
+
export declare function connectMigration(handlers: MigrationHandlers, env?: NodeJS.ProcessEnv, opts?: ConnectMigrationOptions): MigrationClient;
|
|
@@ -0,0 +1,267 @@
|
|
|
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
|
+
// onCutoverReady: async (payload) => { /* source: stop at >= payload */ migration.reportStoppedOutput({...}) },
|
|
7
|
+
// onCutoverApply: async (payload) => { /* target: take over from payload */ migration.reportApplied() },
|
|
8
|
+
// onAbort: (reason) => { /* source: resume output */ },
|
|
9
|
+
// });
|
|
10
|
+
// if (migration.role === "target") migration.reportReady({...});
|
|
11
|
+
//
|
|
12
|
+
// Reads NORSK_WORKER_WS_URL / NORSK_WORKER_TOKEN. When they are absent — a
|
|
13
|
+
// worker with `--no-job-socket`, a local `norsk-ctl` launch, a test — this is
|
|
14
|
+
// a no-op client with role "none", so a product calls it unconditionally.
|
|
15
|
+
//
|
|
16
|
+
// Transport: a WHATWG-shaped WebSocket. Node ≥ 22 and Bun have one as a
|
|
17
|
+
// global; on older Node the `ws` package supplies it. Reconnects with backoff
|
|
18
|
+
// (1 s doubling to 30 s) after any drop, re-sends hello, and holds at most
|
|
19
|
+
// the LATEST unsent report while disconnected — a newer position supersedes
|
|
20
|
+
// an older one, and the worker acts on the first it hears.
|
|
21
|
+
import { JOB_SOCKET_ENV, parseWorkerToJobFrame, } from "@norskvideo/ctl-product-template-schema/migration-socket";
|
|
22
|
+
const DEFAULT_MIN_BACKOFF_MS = 1_000;
|
|
23
|
+
const DEFAULT_MAX_BACKOFF_MS = 30_000;
|
|
24
|
+
export function connectMigration(handlers, env = process.env, opts = {}) {
|
|
25
|
+
const url = env[JOB_SOCKET_ENV.url];
|
|
26
|
+
const token = env[JOB_SOCKET_ENV.token];
|
|
27
|
+
if (!url || !token)
|
|
28
|
+
return noopClient();
|
|
29
|
+
return new SocketMigrationClient(url, token, handlers, opts);
|
|
30
|
+
}
|
|
31
|
+
function noopClient() {
|
|
32
|
+
return {
|
|
33
|
+
role: "none",
|
|
34
|
+
migrationId: undefined,
|
|
35
|
+
connected: false,
|
|
36
|
+
reportReady: () => { },
|
|
37
|
+
reportStoppedOutput: () => { },
|
|
38
|
+
reportApplied: () => { },
|
|
39
|
+
abort: () => { },
|
|
40
|
+
close: () => { },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
class SocketMigrationClient {
|
|
44
|
+
url;
|
|
45
|
+
token;
|
|
46
|
+
handlers;
|
|
47
|
+
opts;
|
|
48
|
+
role = "source";
|
|
49
|
+
migrationId;
|
|
50
|
+
connected = false;
|
|
51
|
+
socket;
|
|
52
|
+
closed = false;
|
|
53
|
+
attempt = 0;
|
|
54
|
+
reconnectTimer;
|
|
55
|
+
pending;
|
|
56
|
+
minBackoffMs;
|
|
57
|
+
maxBackoffMs;
|
|
58
|
+
log;
|
|
59
|
+
constructor(url, token, handlers, opts) {
|
|
60
|
+
this.url = url;
|
|
61
|
+
this.token = token;
|
|
62
|
+
this.handlers = handlers;
|
|
63
|
+
this.opts = opts;
|
|
64
|
+
this.minBackoffMs = opts.reconnectMinMs ?? DEFAULT_MIN_BACKOFF_MS;
|
|
65
|
+
this.maxBackoffMs = opts.reconnectMaxMs ?? DEFAULT_MAX_BACKOFF_MS;
|
|
66
|
+
this.log = opts.log ?? ((line) => console.error(`[migration] ${line}`));
|
|
67
|
+
void this.connect();
|
|
68
|
+
}
|
|
69
|
+
reportReady(payload) {
|
|
70
|
+
this.report({ type: "target-ready", payload: asPayload(payload) });
|
|
71
|
+
}
|
|
72
|
+
reportStoppedOutput(payload) {
|
|
73
|
+
this.report({ type: "source-stopped-output", payload: asPayload(payload) });
|
|
74
|
+
}
|
|
75
|
+
reportApplied() {
|
|
76
|
+
this.report({ type: "target-applied" });
|
|
77
|
+
}
|
|
78
|
+
abort(reason) {
|
|
79
|
+
this.report({ type: "abort", reason });
|
|
80
|
+
}
|
|
81
|
+
close() {
|
|
82
|
+
this.closed = true;
|
|
83
|
+
if (this.reconnectTimer)
|
|
84
|
+
clearTimeout(this.reconnectTimer);
|
|
85
|
+
this.reconnectTimer = undefined;
|
|
86
|
+
const s = this.socket;
|
|
87
|
+
this.socket = undefined;
|
|
88
|
+
this.connected = false;
|
|
89
|
+
try {
|
|
90
|
+
s?.close(1000, "job closing");
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// already gone
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// ── private ─────────────────────────────────────────────────────────
|
|
97
|
+
report(msg) {
|
|
98
|
+
if (this.closed)
|
|
99
|
+
return;
|
|
100
|
+
if (this.connected && this.socket) {
|
|
101
|
+
this.send(msg);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
// Disconnected: the latest report wins. A position reported twice is
|
|
105
|
+
// the newer position; the worker acts on whichever it hears first.
|
|
106
|
+
this.pending = msg;
|
|
107
|
+
}
|
|
108
|
+
send(msg) {
|
|
109
|
+
try {
|
|
110
|
+
this.socket?.send(JSON.stringify(msg));
|
|
111
|
+
}
|
|
112
|
+
catch (e) {
|
|
113
|
+
this.log(`send of '${msg.type}' failed: ${String(e)}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async connect() {
|
|
117
|
+
if (this.closed)
|
|
118
|
+
return;
|
|
119
|
+
let Ctor;
|
|
120
|
+
try {
|
|
121
|
+
Ctor = await resolveWebSocket(this.opts.WebSocket);
|
|
122
|
+
}
|
|
123
|
+
catch (e) {
|
|
124
|
+
this.log(`no WebSocket implementation: ${String(e)} — migration disabled`);
|
|
125
|
+
this.role = "none";
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (this.closed)
|
|
129
|
+
return;
|
|
130
|
+
let socket;
|
|
131
|
+
try {
|
|
132
|
+
socket = new Ctor(this.url);
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
this.log(`connect to ${this.url} failed: ${String(e)}`);
|
|
136
|
+
this.scheduleReconnect();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
this.socket = socket;
|
|
140
|
+
let acked = false;
|
|
141
|
+
socket.onopen = () => {
|
|
142
|
+
if (this.socket !== socket)
|
|
143
|
+
return;
|
|
144
|
+
this.send({ type: "hello", token: this.token });
|
|
145
|
+
};
|
|
146
|
+
socket.onmessage = (ev) => {
|
|
147
|
+
if (this.socket !== socket)
|
|
148
|
+
return;
|
|
149
|
+
const text = typeof ev.data === "string" ? ev.data : String(ev.data);
|
|
150
|
+
const parsed = parseWorkerToJobFrame(text);
|
|
151
|
+
if (!parsed.ok) {
|
|
152
|
+
this.log(`ignoring frame from the worker: ${parsed.reason}`);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const msg = parsed.message;
|
|
156
|
+
if (!acked) {
|
|
157
|
+
if (msg.type !== "hello-ack") {
|
|
158
|
+
this.log(`expected hello-ack, got '${msg.type}'; ignoring`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
acked = true;
|
|
162
|
+
this.onAcked(msg);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
void this.onMessage(msg);
|
|
166
|
+
};
|
|
167
|
+
socket.onclose = (ev) => {
|
|
168
|
+
if (this.socket !== socket)
|
|
169
|
+
return;
|
|
170
|
+
this.socket = undefined;
|
|
171
|
+
this.connected = false;
|
|
172
|
+
if (this.closed)
|
|
173
|
+
return;
|
|
174
|
+
this.log(`socket closed (${ev.code}${ev.reason ? ` ${ev.reason}` : ""})`);
|
|
175
|
+
this.scheduleReconnect();
|
|
176
|
+
};
|
|
177
|
+
socket.onerror = () => {
|
|
178
|
+
// Node's WebSocket (undici, Node 22) fires `error` and NOT `close` when
|
|
179
|
+
// the connection itself fails — the socket stays CONNECTING for ever.
|
|
180
|
+
// Waiting for a close that never comes stranded every job whose worker
|
|
181
|
+
// restarted: the first refused reconnect ended the client's life
|
|
182
|
+
// (seen live 2026-09-07). Treat an error on the current socket as its
|
|
183
|
+
// end; a `close` that does follow finds `this.socket` moved on and is
|
|
184
|
+
// ignored by the guard above.
|
|
185
|
+
if (this.socket !== socket)
|
|
186
|
+
return;
|
|
187
|
+
this.socket = undefined;
|
|
188
|
+
this.connected = false;
|
|
189
|
+
if (this.closed)
|
|
190
|
+
return;
|
|
191
|
+
this.log("socket error; will reconnect");
|
|
192
|
+
try {
|
|
193
|
+
socket.close();
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
// never opened
|
|
197
|
+
}
|
|
198
|
+
this.scheduleReconnect();
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
onAcked(ack) {
|
|
202
|
+
this.attempt = 0;
|
|
203
|
+
this.connected = true;
|
|
204
|
+
if (ack.migration) {
|
|
205
|
+
this.role = "target";
|
|
206
|
+
this.migrationId = ack.migration.id;
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
this.role = "source";
|
|
210
|
+
}
|
|
211
|
+
this.log(`connected as ${this.role}${this.migrationId ? ` (migration ${this.migrationId})` : ""}`);
|
|
212
|
+
const pending = this.pending;
|
|
213
|
+
this.pending = undefined;
|
|
214
|
+
if (pending)
|
|
215
|
+
this.send(pending);
|
|
216
|
+
}
|
|
217
|
+
async onMessage(msg) {
|
|
218
|
+
try {
|
|
219
|
+
switch (msg.type) {
|
|
220
|
+
case "hello-ack":
|
|
221
|
+
return;
|
|
222
|
+
case "cutover-ready":
|
|
223
|
+
this.migrationId = msg.migrationId;
|
|
224
|
+
await this.handlers.onCutoverReady?.(msg.payload, msg.migrationId);
|
|
225
|
+
return;
|
|
226
|
+
case "cutover-apply":
|
|
227
|
+
this.migrationId = msg.migrationId;
|
|
228
|
+
await this.handlers.onCutoverApply?.(msg.payload, msg.migrationId);
|
|
229
|
+
return;
|
|
230
|
+
case "abort":
|
|
231
|
+
await this.handlers.onAbort?.(msg.reason, msg.migrationId);
|
|
232
|
+
if (this.role === "source")
|
|
233
|
+
this.migrationId = undefined;
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
catch (e) {
|
|
238
|
+
this.log(`handler for '${msg.type}' threw: ${String(e)}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
scheduleReconnect() {
|
|
242
|
+
if (this.closed || this.reconnectTimer)
|
|
243
|
+
return;
|
|
244
|
+
const delay = Math.min(this.maxBackoffMs, this.minBackoffMs * 2 ** this.attempt);
|
|
245
|
+
this.attempt = Math.min(this.attempt + 1, 30);
|
|
246
|
+
this.reconnectTimer = setTimeout(() => {
|
|
247
|
+
this.reconnectTimer = undefined;
|
|
248
|
+
void this.connect();
|
|
249
|
+
}, delay);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
function asPayload(payload) {
|
|
253
|
+
// Anything JSON.stringify can carry. A non-JSON value (undefined, a
|
|
254
|
+
// function) becomes null rather than an invalid frame.
|
|
255
|
+
return payload === undefined ? null : JSON.parse(JSON.stringify(payload));
|
|
256
|
+
}
|
|
257
|
+
async function resolveWebSocket(explicit) {
|
|
258
|
+
if (explicit)
|
|
259
|
+
return explicit;
|
|
260
|
+
const global = globalThis.WebSocket;
|
|
261
|
+
if (global)
|
|
262
|
+
return global;
|
|
263
|
+
// Older Node: the `ws` package's WebSocket carries the same handler
|
|
264
|
+
// properties. A dynamic import so a runtime with the global never loads it.
|
|
265
|
+
const ws = await import("ws");
|
|
266
|
+
return ws.WebSocket;
|
|
267
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@norskvideo/ctl-sdk",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.33",
|
|
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,
|
|
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
|