@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 +1 -0
- package/index.js +1 -0
- package/migration-client.d.ts +93 -0
- package/migration-client.js +378 -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,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.
|
|
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,
|
|
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
|