@intentius/chant-k8s-client 0.57.0 → 0.59.0

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/dist/client.d.ts CHANGED
@@ -34,6 +34,7 @@
34
34
  * path that writes the `Authorization` header — runs for real, which is what
35
35
  * makes a test that injects one worth writing.
36
36
  */
37
+ import { type WatchFrame } from "./watch.js";
37
38
  import type { ApiResourceInfo, ClientProvenance, K8sClientOptions, K8sObject, ObjectRef, ResourceSelector, KubeconfigView } from "./types.js";
38
39
  type ClientNode = typeof import("@kubernetes/client-node");
39
40
  /**
@@ -136,6 +137,55 @@ export interface ReadLogOptions {
136
137
  timestamps?: boolean;
137
138
  signal?: AbortSignal;
138
139
  }
140
+ /**
141
+ * Options for {@link K8sClient.watch} (chant #1981).
142
+ *
143
+ * A watch is a trigger channel, so the interesting options are the ones that
144
+ * bound it (a namespace, a label selector) rather than anything about what
145
+ * comes back.
146
+ */
147
+ export interface WatchOptions {
148
+ /** Restrict to one namespace. Omitted watches across all of them. */
149
+ namespace?: string;
150
+ /** A label selector, e.g. `app.kubernetes.io/managed-by=chant`. */
151
+ labelSelector?: string;
152
+ /**
153
+ * Called once per event frame, with the frame the server sent.
154
+ *
155
+ * The frame is passed through verbatim rather than interpreted, because this
156
+ * package has no opinion about what a change means. Chant's own consumer
157
+ * (the k8s lexicon's `subscribeChanges`) ignores it entirely and calls a
158
+ * no-argument `onChange`, which is the rule that keeps a watch event from
159
+ * ever becoming an observation.
160
+ */
161
+ onEvent?(frame: WatchFrame): void;
162
+ /**
163
+ * The watch ended for a reason that is not "you closed it". Called at most
164
+ * once, and the watch is over when it is: re-establishing is the caller's
165
+ * decision, made with the caller's own backoff.
166
+ */
167
+ onError?(message: string): void;
168
+ /** Aborts the watch, exactly as {@link WatchHandle.close} does. */
169
+ signal?: AbortSignal;
170
+ /**
171
+ * How long to wait before reopening a stream the server closed cleanly.
172
+ * The API server ends a watch connection every few minutes by design, which
173
+ * is a reconnect rather than a failure. @default 1000
174
+ */
175
+ reopenDelayMs?: number;
176
+ }
177
+ /** A live watch. Returned by {@link K8sClient.watch}. */
178
+ export interface WatchHandle {
179
+ /**
180
+ * Stop the watch and release its connection. Idempotent, and safe to call
181
+ * after the watch has already ended on its own. Resolves once the reader
182
+ * has actually unwound, so a caller that closes and then asserts on request
183
+ * counts is not racing the stream.
184
+ */
185
+ close(): Promise<void>;
186
+ /** Resolves when the watch is over, however it ended. Never rejects. */
187
+ readonly done: Promise<void>;
188
+ }
139
189
  /** The client surface the k8s lexicon consumes. */
140
190
  export interface K8sClient {
141
191
  /** Where this client is pointed and what authorized it. */
@@ -166,6 +216,19 @@ export interface K8sClient {
166
216
  readIfPresent(ref: ObjectRef, options?: ReadOptions): Promise<K8sObject | undefined>;
167
217
  /** LIST a kind, optionally namespaced and label-filtered. Follows `continue` tokens. */
168
218
  list(selector: ResourceSelector, options?: ListOptions): Promise<K8sObject[]>;
219
+ /**
220
+ * WATCH a kind: a long-lived `GET ...?watch=1&resourceVersion=<rv>` whose
221
+ * NDJSON frames arrive at `options.onEvent` until somebody closes it (chant
222
+ * #1981).
223
+ *
224
+ * The `resourceVersion` comes from a LIST issued first, which is the only
225
+ * way to start a watch without a gap. A `410 Gone`, the server saying that
226
+ * version has aged out of its change history, re-LISTs and resumes from the
227
+ * new version rather than retrying the stale one; a stream the server closes
228
+ * cleanly is reopened from the last version seen. Anything else ends the
229
+ * watch through `options.onError`.
230
+ */
231
+ watch(selector: ResourceSelector, options?: WatchOptions): Promise<WatchHandle>;
169
232
  /**
170
233
  * GET a Pod's `/log` subresource — plain text, not JSON, which is why this
171
234
  * is its own method rather than a `read` variant. A snapshot only: the
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAeH,OAAO,EAAoE,KAAK,UAAU,EAAE,MAAM,SAAS,CAAC;AAC5G,OAAO,KAAK,EACV,eAAe,EACf,gBAAgB,EAChB,gBAAgB,EAChB,SAAS,EACT,SAAS,EAET,gBAAgB,EAGhB,cAAc,EACf,MAAM,SAAS,CAAC;AAEjB,KAAK,UAAU,GAAG,cAAc,yBAAyB,CAAC,CAAC;AAI3D;;;;;;;GAOG;AACH,wBAAsB,cAAc,IAAI,OAAO,CAAC,UAAU,CAAC,CAQ1D;AAED,yEAAyE;AACzE,wBAAsB,oBAAoB,IAAI,OAAO,CAAC,OAAO,CAAC,CAO7D;AAED;;;;;;;;;GASG;AACH,wBAAsB,kBAAkB,CACtC,OAAO,GAAE,IAAI,CAAC,gBAAgB,EAAE,YAAY,GAAG,gBAAgB,CAAM,GACpE,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAO7B;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAsB,kBAAkB,CACtC,OAAO,GAAE,IAAI,CAAC,gBAAgB,EAAE,YAAY,GAAG,gBAAgB,CAAM,GACpE,OAAO,CAAC,cAAc,CAAC,CAwBzB;AAED,wCAAwC;AACxC,MAAM,WAAW,WAAW;IAC1B,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,2CAA2C;AAC3C,MAAM,WAAW,YAAY;IAC3B;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;;OAKG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,gFAAgF;IAChF,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,4CAA4C;AAC5C,MAAM,WAAW,aAAa;IAC5B,mFAAmF;IACnF,iBAAiB,CAAC,EAAE,YAAY,GAAG,YAAY,GAAG,QAAQ,CAAC;IAC3D,wDAAwD;IACxD,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,0CAA0C;AAC1C,MAAM,WAAW,WAAW;IAC1B,mEAAmE;IACnE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,mEAAmE;IACnE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,2DAA2D;AAC3D,MAAM,WAAW,cAAc;IAC7B,+EAA+E;IAC/E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,sEAAsE;IACtE,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,6BAA6B;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,4CAA4C;IAC5C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,mDAAmD;IACnD,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,YAAY;IAC3B,qEAAqE;IACrE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,mEAAmE;IACnE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;;;;OAQG;IACH,OAAO,CAAC,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI,CAAC;IAClC;;;;OAIG;IACH,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,mEAAmE;IACnE,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,yDAAyD;AACzD,MAAM,WAAW,WAAW;IAC1B;;;;;OAKG;IACH,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,wEAAwE;IACxE,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;CAC9B;AAED,mDAAmD;AACnD,MAAM,WAAW,SAAS;IACxB,2DAA2D;IAC3D,QAAQ,CAAC,UAAU,EAAE,gBAAgB,CAAC;IACtC,kDAAkD;IAClD,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC;;;;OAIG;IACH,OAAO,CAAC,QAAQ,EAAE,gBAAgB,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,eAAe,GAAG,SAAS,CAAC,CAAC;IAChG,8EAA8E;IAC9E,IAAI,CAAC,GAAG,EAAE,SAAS,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC;IAChE;;;;;;;;;;OAUG;IACH,OAAO,CAAC,GAAG,EAAE,SAAS,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IAC3E,wEAAwE;IACxE,aAAa,CAAC,GAAG,EAAE,SAAS,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC,CAAC;IACrF,wFAAwF;IACxF,IAAI,CAAC,QAAQ,EAAE,gBAAgB,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC,CAAC;IAC9E;;;;;;;;;;;OAWG;IACH,KAAK,CAAC,QAAQ,EAAE,gBAAgB,EAAE,OAAO,CAAC,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC,CAAC;IAChF;;;;;;;;OAQG;IACH,OAAO,CAAC,GAAG,EAAE,SAAS,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACnE,4DAA4D;IAC5D,KAAK,CAAC,MAAM,EAAE,SAAS,EAAE,OAAO,CAAC,EAAE,YAAY,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC;IACrE,iFAAiF;IACjF,MAAM,CAAC,GAAG,EAAE,SAAS,EAAE,OAAO,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/D,oEAAoE;IACpE,YAAY,CAAC,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE,SAAS,CAAC,EAAE,EAAE,EAAE,EAAE,CAAC,IAAI,EAAE,CAAC,EAAE,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,EAAE,CAAC,CAAC;IAClG;;;;;;OAMG;IACH,SAAS,CAAC,UAAU,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC,CAAC;IAChF;;;;;;;OAOG;IACH,sBAAsB,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAChE;;;;;;;;;OASG;IACH,iBAAiB,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,eAAe,GAAG,SAAS,CAAC,CAAC;IAC9E,6FAA6F;IAC7F,kBAAkB,IAAI,MAAM,EAAE,CAAC;CAChC;AAED;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC9B,8EAA8E;IAC9E,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;CACnB;AA6BD;;;;GAIG;AACH,wBAAsB,eAAe,CAAC,OAAO,GAAE,gBAAqB,GAAG,OAAO,CAAC,SAAS,CAAC,CA2pBxF;AAED,qDAAqD;AACrD,wBAAgB,cAAc,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAEzD;AAED,yDAAyD;AACzD,wBAAgB,eAAe,CAAC,UAAU,EAAE,MAAM,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,CAGpF;AAED,wDAAwD;AACxD,wBAAgB,YAAY,CAAC,QAAQ,EAAE,gBAAgB,GAAG,MAAM,CAM/D;AAED,iEAAiE;AACjE,wBAAgB,OAAO,CAAC,GAAG,EAAE,SAAS,GAAG,MAAM,CAE9C"}
package/dist/index.d.ts CHANGED
@@ -12,7 +12,9 @@
12
12
  * it stays that way.
13
13
  */
14
14
  export { createK8sClient, readAmbientContext, readKubeconfigView, loadClientNode, isK8sClientAvailable, apiVersionPath, splitApiVersion, selectorText, refText, } from "./client.js";
15
- export type { K8sClient, ReadOptions, ApplyOptions, DeleteOptions, ListOptions, ReadLogOptions, SelfSubjectInfo } from "./client.js";
15
+ export type { K8sClient, ReadOptions, ApplyOptions, DeleteOptions, ListOptions, ReadLogOptions, SelfSubjectInfo, WatchOptions, WatchHandle, } from "./client.js";
16
+ export { parseWatchFrames, isExpiredFrame, resourceVersionOf, streamLines } from "./watch.js";
17
+ export type { WatchFrame, WatchEventType } from "./watch.js";
16
18
  export { K8sApiError, K8sTransportError, K8sClientUnavailableError, ExecCredentialNotAllowedError, FieldManagerError, KubeConfigError, UnknownResourceError, } from "./errors.js";
17
19
  export type { K8sStatus } from "./errors.js";
18
20
  export { CHANT_FIELD_MANAGER, FIELD_MANAGER_SEPARATOR, FIELD_MANAGER_MAX_LENGTH, fieldManagerFor, assertValidFieldManager, isChantFieldManager, chantStackOf, } from "./field-manager.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EACL,eAAe,EACf,kBAAkB,EAClB,kBAAkB,EAClB,cAAc,EACd,oBAAoB,EACpB,cAAc,EACd,eAAe,EACf,YAAY,EACZ,OAAO,GACR,MAAM,UAAU,CAAC;AAClB,YAAY,EAAE,SAAS,EAAE,WAAW,EAAE,YAAY,EAAE,aAAa,EAAE,WAAW,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,UAAU,CAAC;AAElI,OAAO,EACL,WAAW,EACX,iBAAiB,EACjB,yBAAyB,EACzB,6BAA6B,EAC7B,iBAAiB,EACjB,eAAe,EACf,oBAAoB,GACrB,MAAM,UAAU,CAAC;AAClB,YAAY,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAG1C,OAAO,EACL,mBAAmB,EACnB,uBAAuB,EACvB,wBAAwB,EACxB,eAAe,EACf,uBAAuB,EACvB,mBAAmB,EACnB,YAAY,GACb,MAAM,iBAAiB,CAAC;AACzB,YAAY,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AAE5D,OAAO,EACL,yBAAyB,EACzB,sBAAsB,EACtB,mBAAmB,EACnB,oBAAoB,EACpB,oBAAoB,GACrB,MAAM,YAAY,CAAC;AACpB,YAAY,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAGhE,OAAO,EACL,eAAe,EACf,WAAW,EACX,UAAU,EACV,aAAa,EACb,gBAAgB,EAChB,WAAW,EACX,YAAY,EACZ,aAAa,GACd,MAAM,kBAAkB,CAAC;AAC1B,YAAY,EAAE,kBAAkB,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAE5E,OAAO,EACL,sBAAsB,EACtB,2BAA2B,EAC3B,gBAAgB,EAChB,YAAY,EACZ,eAAe,GAChB,MAAM,eAAe,CAAC;AACvB,YAAY,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAEhE,OAAO,EAAE,aAAa,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AAEnE,YAAY,EACV,eAAe,EACf,gBAAgB,EAChB,cAAc,EACd,gBAAgB,EAChB,qBAAqB,EACrB,cAAc,EACd,SAAS,EACT,SAAS,EACT,kBAAkB,EAClB,YAAY,EACZ,gBAAgB,EAChB,mBAAmB,GACpB,MAAM,SAAS,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EACL,eAAe,EACf,kBAAkB,EAClB,kBAAkB,EAClB,cAAc,EACd,oBAAoB,EACpB,cAAc,EACd,eAAe,EACf,YAAY,EACZ,OAAO,GACR,MAAM,UAAU,CAAC;AAClB,YAAY,EACV,SAAS,EAAE,WAAW,EAAE,YAAY,EAAE,aAAa,EAAE,WAAW,EAAE,cAAc,EAChF,eAAe,EAAE,YAAY,EAAE,WAAW,GAC3C,MAAM,UAAU,CAAC;AAKlB,OAAO,EAAE,gBAAgB,EAAE,cAAc,EAAE,iBAAiB,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAC3F,YAAY,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAE1D,OAAO,EACL,WAAW,EACX,iBAAiB,EACjB,yBAAyB,EACzB,6BAA6B,EAC7B,iBAAiB,EACjB,eAAe,EACf,oBAAoB,GACrB,MAAM,UAAU,CAAC;AAClB,YAAY,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAG1C,OAAO,EACL,mBAAmB,EACnB,uBAAuB,EACvB,wBAAwB,EACxB,eAAe,EACf,uBAAuB,EACvB,mBAAmB,EACnB,YAAY,GACb,MAAM,iBAAiB,CAAC;AACzB,YAAY,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AAE5D,OAAO,EACL,yBAAyB,EACzB,sBAAsB,EACtB,mBAAmB,EACnB,oBAAoB,EACpB,oBAAoB,GACrB,MAAM,YAAY,CAAC;AACpB,YAAY,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAGhE,OAAO,EACL,eAAe,EACf,WAAW,EACX,UAAU,EACV,aAAa,EACb,gBAAgB,EAChB,WAAW,EACX,YAAY,EACZ,aAAa,GACd,MAAM,kBAAkB,CAAC;AAC1B,YAAY,EAAE,kBAAkB,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAE5E,OAAO,EACL,sBAAsB,EACtB,2BAA2B,EAC3B,gBAAgB,EAChB,YAAY,EACZ,eAAe,GAChB,MAAM,eAAe,CAAC;AACvB,YAAY,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAEhE,OAAO,EAAE,aAAa,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AAEnE,YAAY,EACV,eAAe,EACf,gBAAgB,EAChB,cAAc,EACd,gBAAgB,EAChB,qBAAqB,EACrB,cAAc,EACd,SAAS,EACT,SAAS,EACT,kBAAkB,EAClB,YAAY,EACZ,gBAAgB,EAChB,mBAAmB,GACpB,MAAM,SAAS,CAAC"}
package/dist/testing.d.ts CHANGED
@@ -29,6 +29,13 @@ export interface FakeResponse {
29
29
  status?: number;
30
30
  body?: unknown;
31
31
  headers?: Record<string, string>;
32
+ /**
33
+ * A streaming body, for a watch (chant #1981). Anything async-iterable will
34
+ * do, and {@link fakeWatchStream} is the usual source. Set it and the response
35
+ * exposes `body.stream()`, which is what the client reads instead of
36
+ * `text()` for a request it never expects to complete.
37
+ */
38
+ stream?: AsyncIterable<string | Uint8Array>;
32
39
  }
33
40
  export type FakeRequestHandler = (request: RecordedRequest) => FakeResponse | Promise<FakeResponse>;
34
41
  /** A recording {@link RequestLayer} driven by `handler`. */
@@ -81,4 +88,31 @@ export declare function apiResourceList(groupVersion: string, resources: Array<{
81
88
  shortNames?: string[];
82
89
  verbs?: string[];
83
90
  }>): Record<string, unknown>;
91
+ /** A watch stream a test drives by hand. */
92
+ export interface FakeWatchStream extends AsyncIterable<string> {
93
+ /**
94
+ * Send one NDJSON frame down the stream. Objects are stringified; a string
95
+ * is sent verbatim, which is how a test produces a malformed or half-written
96
+ * frame. A newline is appended unless the string already ends in one.
97
+ */
98
+ push(frame: unknown): void;
99
+ /** End the stream, as a server closing the connection does. */
100
+ close(): void;
101
+ /** How many frames have been consumed by the reader. */
102
+ readonly delivered: number;
103
+ }
104
+ /**
105
+ * A controllable NDJSON stream for driving {@link import("./client.js").K8sClient.watch}
106
+ * against the fake cluster (chant #1981).
107
+ *
108
+ * The client reads it exactly as it reads a live watch: one frame per line,
109
+ * for as long as the connection stays open. So a test can push an ADDED, an
110
+ * expired-`410` ERROR, or nothing at all, and assert on what the client does
111
+ * about it, without a cluster, a socket, or a timer.
112
+ */
113
+ export declare function fakeWatchStream(): FakeWatchStream;
114
+ /** A watch event frame, the shape the API server sends. */
115
+ export declare function watchFrame(type: string, object: Record<string, unknown>): Record<string, unknown>;
116
+ /** The `410 Gone` frame a watch gets when its resourceVersion has aged out. */
117
+ export declare function expiredWatchFrame(message?: string): Record<string, unknown>;
84
118
  //# sourceMappingURL=testing.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAsB,YAAY,EAAuB,MAAM,SAAS,CAAC;AAErF,8CAA8C;AAC9C,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,uCAAuC;IACvC,GAAG,EAAE,MAAM,CAAC;IACZ,iCAAiC;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,IAAI,EAAE,OAAO,CAAC;CACf;AAED,qFAAqF;AACrF,MAAM,WAAW,YAAY;IAC3B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClC;AAED,MAAM,MAAM,kBAAkB,GAAG,CAAC,OAAO,EAAE,eAAe,KAAK,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC;AAEpG,4DAA4D;AAC5D,MAAM,WAAW,gBAAiB,SAAQ,YAAY;IACpD,iDAAiD;IACjD,QAAQ,CAAC,QAAQ,EAAE,eAAe,EAAE,CAAC;IACrC,yDAAyD;IACzD,KAAK,IAAI,MAAM,EAAE,CAAC;CACnB;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,kBAAkB,GAAG,gBAAgB,CA8B9E;AAED,8EAA8E;AAC9E,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAUjG;AAED,0CAA0C;AAC1C,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACxF,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,uEAAuE;IACvE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yEAAyE;IACzE,IAAI,CAAC,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;QAAC,GAAG,CAAC,EAAE,KAAK,CAAC;YAAE,IAAI,EAAE,MAAM,CAAC;YAAC,KAAK,EAAE,MAAM,CAAA;SAAE,CAAC,CAAA;KAAE,CAAC;CAC3F;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,OAAO,GAAE,qBAA0B,GAAG,MAAM,CAkD1E;AAED,qFAAqF;AACrF,wBAAgB,eAAe,CAC7B,YAAY,EAAE,MAAM,EACpB,SAAS,EAAE,KAAK,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;CAClB,CAAC,GACD,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAczB"}
1
+ {"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"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"}
package/dist/types.d.ts CHANGED
@@ -169,6 +169,24 @@ export interface ResponseContextLike {
169
169
  headers: Record<string, string>;
170
170
  body: {
171
171
  text(): Promise<string>;
172
+ /**
173
+ * The body as it arrives, rather than once it is complete (chant #1981).
174
+ *
175
+ * `text()` reads a response to completion, which is the right shape for
176
+ * every request this client makes except one: a watch never completes, so
177
+ * a watch read through `text()` is a promise that resolves when the
178
+ * cluster hangs up and never before. client-node's own HTTP library
179
+ * already exposes `stream()` (its `undici` `fetch` response's web
180
+ * `ReadableStream`), so the seam widens rather than being invented.
181
+ *
182
+ * Typed `unknown` for the same reason the rest of this file avoids the
183
+ * library's classes: the decoder (`./watch.ts`'s `streamLines`) accepts a
184
+ * web `ReadableStream`, a Node `Readable`, or any async iterable. Optional
185
+ * because a transport without it is still a valid transport: the watch
186
+ * falls back to `text()`, which is exactly what a fake returning a
187
+ * complete NDJSON body wants.
188
+ */
189
+ stream?(): unknown;
172
190
  };
173
191
  }
174
192
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The Kubernetes watch, as much of it as can be written without a client
3
+ * (chant #1981).
4
+ *
5
+ * A watch is `GET <list path>?watch=1&resourceVersion=<rv>` answered with an
6
+ * open connection that emits one JSON document per line. NDJSON, forever,
7
+ * until somebody hangs up. Everything here is the frame side of that: parsing
8
+ * lines out of a byte stream, reading a frame's `resourceVersion`, and
9
+ * recognising the one failure a watch is expected to hit.
10
+ *
11
+ * ## `410 Gone`
12
+ *
13
+ * The API server keeps a bounded history of changes. A watch resuming from a
14
+ * `resourceVersion` older than that window cannot be told what it missed, and
15
+ * says so: an `ERROR` frame carrying a `Status` with `code: 410` / `reason:
16
+ * Expired`. There is exactly one correct response, and it is not to retry with
17
+ * the same `resourceVersion`. It is to LIST again, take the list's own
18
+ * `resourceVersion`, and watch from there. Anything else silently drops
19
+ * whatever happened during the gap.
20
+ *
21
+ * For chant's purposes the gap costs nothing anyway: the events are a trigger,
22
+ * never a fact, and the tick they wake re-observes the estate from scratch. A
23
+ * re-list is a re-list, not a reconciliation.
24
+ */
25
+ import type { K8sObject } from "./types.js";
26
+ /** The event types the watch API emits. `BOOKMARK` requires `allowWatchBookmarks`. */
27
+ export type WatchEventType = "ADDED" | "MODIFIED" | "DELETED" | "BOOKMARK" | "ERROR";
28
+ /**
29
+ * One decoded NDJSON frame. `object` is whatever the server put in the frame:
30
+ * the changed resource for the four ordinary types, a `Status` for `ERROR`.
31
+ */
32
+ export interface WatchFrame {
33
+ type: WatchEventType;
34
+ object: K8sObject;
35
+ }
36
+ /**
37
+ * Split a chunk of a watch stream into whole frames, carrying the trailing
38
+ * partial line over to the next call.
39
+ *
40
+ * Frames arrive split across TCP reads at arbitrary byte offsets, so a decoder
41
+ * that treats each chunk as a set of complete lines drops or corrupts every
42
+ * frame that straddles a boundary. `carry` is the fix and the reason this is a
43
+ * function rather than three lines inline.
44
+ *
45
+ * A line that is not JSON, or is JSON without a known `type`, is dropped
46
+ * rather than thrown: a watch is a trigger channel, and the correct response
47
+ * to a frame nobody can read is to keep watching.
48
+ */
49
+ export declare function parseWatchFrames(chunk: string, carry?: string): {
50
+ frames: WatchFrame[];
51
+ carry: string;
52
+ };
53
+ /**
54
+ * Whether this frame is the API server saying the watch's `resourceVersion` has
55
+ * aged out of its history: the `410 Gone` that means re-list, not retry.
56
+ *
57
+ * Matched on `code` first and `reason` second, because both are set on the
58
+ * `Status` the server sends and a cluster that sets only one of them is still
59
+ * telling us the same thing.
60
+ */
61
+ export declare function isExpiredFrame(frame: WatchFrame): boolean;
62
+ /** The `resourceVersion` to resume from after this frame, when it carries one. */
63
+ export declare function resourceVersionOf(frame: WatchFrame): string | undefined;
64
+ /**
65
+ * Decode whatever a response body's `stream()` returned into lines of text.
66
+ *
67
+ * Three shapes reach here and all three are real: a web `ReadableStream` (what
68
+ * `undici`'s `fetch` gives client-node's HTTP library, and so what a live
69
+ * cluster produces), a Node `Readable` or any other async iterable (what a
70
+ * hand-rolled transport gives), and `undefined` for a transport with no
71
+ * streaming seam at all, which is a fake that answered the whole body at once and
72
+ * is handled by the caller reading `text()` instead.
73
+ */
74
+ export declare function streamLines(source: unknown): AsyncGenerator<string>;
75
+ //# sourceMappingURL=watch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"watch.d.ts","sourceRoot":"","sources":["../src/watch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAEzC,sFAAsF;AACtF,MAAM,MAAM,cAAc,GAAG,OAAO,GAAG,UAAU,GAAG,SAAS,GAAG,UAAU,GAAG,OAAO,CAAC;AAErF;;;GAGG;AACH,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,cAAc,CAAC;IACrB,MAAM,EAAE,SAAS,CAAC;CACnB;AAID;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,MAAM,EACb,KAAK,SAAK,GACT;IAAE,MAAM,EAAE,UAAU,EAAE,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CA2BzC;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAKzD;AAED,kFAAkF;AAClF,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,GAAG,SAAS,CAGvE;AAED;;;;;;;;;GASG;AACH,wBAAuB,WAAW,CAAC,MAAM,EAAE,OAAO,GAAG,cAAc,CAAC,MAAM,CAAC,CAuC1E"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-k8s-client",
3
- "version": "0.57.0",
3
+ "version": "0.59.0",
4
4
  "description": "Typed Kubernetes API client for chant — the read/write path of the k8s lexicon, kept out of the build path",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant/lexicons/k8s/api-client/",
package/src/client.ts CHANGED
@@ -48,6 +48,7 @@ import { asFieldManagerConflict } from "./conflict";
48
48
  import { assertValidFieldManager, CHANT_FIELD_MANAGER } from "./field-manager";
49
49
  import { DEFAULT_CONCURRENCY, mapConcurrent } from "./concurrency";
50
50
  import { loadKubeConfig } from "./kubeconfig";
51
+ import { isExpiredFrame, parseWatchFrames, resourceVersionOf, streamLines, type WatchFrame } from "./watch";
51
52
  import type {
52
53
  ApiResourceInfo,
53
54
  ClientProvenance,
@@ -224,6 +225,57 @@ export interface ReadLogOptions {
224
225
  signal?: AbortSignal;
225
226
  }
226
227
 
228
+ /**
229
+ * Options for {@link K8sClient.watch} (chant #1981).
230
+ *
231
+ * A watch is a trigger channel, so the interesting options are the ones that
232
+ * bound it (a namespace, a label selector) rather than anything about what
233
+ * comes back.
234
+ */
235
+ export interface WatchOptions {
236
+ /** Restrict to one namespace. Omitted watches across all of them. */
237
+ namespace?: string;
238
+ /** A label selector, e.g. `app.kubernetes.io/managed-by=chant`. */
239
+ labelSelector?: string;
240
+ /**
241
+ * Called once per event frame, with the frame the server sent.
242
+ *
243
+ * The frame is passed through verbatim rather than interpreted, because this
244
+ * package has no opinion about what a change means. Chant's own consumer
245
+ * (the k8s lexicon's `subscribeChanges`) ignores it entirely and calls a
246
+ * no-argument `onChange`, which is the rule that keeps a watch event from
247
+ * ever becoming an observation.
248
+ */
249
+ onEvent?(frame: WatchFrame): void;
250
+ /**
251
+ * The watch ended for a reason that is not "you closed it". Called at most
252
+ * once, and the watch is over when it is: re-establishing is the caller's
253
+ * decision, made with the caller's own backoff.
254
+ */
255
+ onError?(message: string): void;
256
+ /** Aborts the watch, exactly as {@link WatchHandle.close} does. */
257
+ signal?: AbortSignal;
258
+ /**
259
+ * How long to wait before reopening a stream the server closed cleanly.
260
+ * The API server ends a watch connection every few minutes by design, which
261
+ * is a reconnect rather than a failure. @default 1000
262
+ */
263
+ reopenDelayMs?: number;
264
+ }
265
+
266
+ /** A live watch. Returned by {@link K8sClient.watch}. */
267
+ export interface WatchHandle {
268
+ /**
269
+ * Stop the watch and release its connection. Idempotent, and safe to call
270
+ * after the watch has already ended on its own. Resolves once the reader
271
+ * has actually unwound, so a caller that closes and then asserts on request
272
+ * counts is not racing the stream.
273
+ */
274
+ close(): Promise<void>;
275
+ /** Resolves when the watch is over, however it ended. Never rejects. */
276
+ readonly done: Promise<void>;
277
+ }
278
+
227
279
  /** The client surface the k8s lexicon consumes. */
228
280
  export interface K8sClient {
229
281
  /** Where this client is pointed and what authorized it. */
@@ -254,6 +306,19 @@ export interface K8sClient {
254
306
  readIfPresent(ref: ObjectRef, options?: ReadOptions): Promise<K8sObject | undefined>;
255
307
  /** LIST a kind, optionally namespaced and label-filtered. Follows `continue` tokens. */
256
308
  list(selector: ResourceSelector, options?: ListOptions): Promise<K8sObject[]>;
309
+ /**
310
+ * WATCH a kind: a long-lived `GET ...?watch=1&resourceVersion=<rv>` whose
311
+ * NDJSON frames arrive at `options.onEvent` until somebody closes it (chant
312
+ * #1981).
313
+ *
314
+ * The `resourceVersion` comes from a LIST issued first, which is the only
315
+ * way to start a watch without a gap. A `410 Gone`, the server saying that
316
+ * version has aged out of its change history, re-LISTs and resumes from the
317
+ * new version rather than retrying the stale one; a stream the server closes
318
+ * cleanly is reopened from the last version seen. Anything else ends the
319
+ * watch through `options.onError`.
320
+ */
321
+ watch(selector: ResourceSelector, options?: WatchOptions): Promise<WatchHandle>;
257
322
  /**
258
323
  * GET a Pod's `/log` subresource — plain text, not JSON, which is why this
259
324
  * is its own method rather than a `read` variant. A snapshot only: the
@@ -706,6 +771,172 @@ export async function createK8sClient(options: K8sClientOptions = {}): Promise<K
706
771
  return items;
707
772
  }
708
773
 
774
+ /**
775
+ * Open the watch stream itself: the same request-building, auth and
776
+ * transport path every other call takes, stopping short of reading the body
777
+ * to completion. `send` cannot be reused because its last act is
778
+ * `body.text()`, which for a watch is a promise that resolves when the
779
+ * cluster hangs up.
780
+ */
781
+ async function openStream(
782
+ path: string,
783
+ query: Record<string, string>,
784
+ signal: AbortSignal,
785
+ target: string,
786
+ ): Promise<{ status: number; lines: AsyncIterable<string> }> {
787
+ const ctx = configuration.baseServer.makeRequestContext(path, "GET" as k8s.HttpMethod);
788
+ ctx.setHeaderParam("Accept", "application/json");
789
+ for (const [key, value] of Object.entries(query)) ctx.setQueryParam(key, value);
790
+ ctx.setSignal(signal);
791
+ await kc.applySecurityAuthentication(ctx);
792
+
793
+ let response: ResponseContextLike;
794
+ try {
795
+ response = (await configuration.httpApi.send(ctx).toPromise()) as unknown as ResponseContextLike;
796
+ } catch (err) {
797
+ throw noted(
798
+ new K8sTransportError(err instanceof Error ? err.message : String(err), target, { cause: err }),
799
+ );
800
+ }
801
+
802
+ if (response.httpStatusCode < 200 || response.httpStatusCode > 299) {
803
+ throw noted(K8sApiError.fromResponse(response.httpStatusCode, await response.body.text(), target));
804
+ }
805
+
806
+ // A transport with no streaming seam answered the whole body at once,
807
+ // which a fake NDJSON fixture does, and a live watch never can.
808
+ const raw = response.body.stream?.();
809
+ if (raw === undefined || raw === null) {
810
+ const text = await response.body.text();
811
+ return { status: response.httpStatusCode, lines: (async function* () { yield* text.split("\n"); })() };
812
+ }
813
+ return { status: response.httpStatusCode, lines: streamLines(raw) };
814
+ }
815
+
816
+ async function watch(selector: ResourceSelector, opts: WatchOptions = {}): Promise<WatchHandle> {
817
+ const info = await resolveOrThrow(selector, opts.signal);
818
+ const target = `watch ${selectorText(selector)}`;
819
+ const reopenDelayMs = opts.reopenDelayMs ?? 1_000;
820
+ // Own controller so `close()` works whether or not the caller passed a
821
+ // signal, and so aborting the watch never touches the caller's.
822
+ const controller = new AbortController();
823
+ const stopOnCallerAbort = () => controller.abort();
824
+ if (opts.signal) {
825
+ if (opts.signal.aborted) controller.abort();
826
+ else opts.signal.addEventListener("abort", stopOnCallerAbort, { once: true });
827
+ }
828
+
829
+ const listPath = opts.namespace
830
+ ? objectPath(info, undefined, opts.namespace)
831
+ : `${apiVersionPath(info.apiVersion)}/${info.name}`;
832
+ const listQuery: Record<string, string> = {};
833
+ if (opts.labelSelector) listQuery.labelSelector = opts.labelSelector;
834
+
835
+ /** The list's own `resourceVersion`, where a watch with no gap starts. */
836
+ async function listResourceVersion(): Promise<string | undefined> {
837
+ const page = await sendJson<{ metadata?: { resourceVersion?: string } }>(listPath, "GET", {
838
+ signal: controller.signal,
839
+ // `limit=1` because nothing here wants the objects: the list exists
840
+ // for the `resourceVersion` in its metadata, which every page carries.
841
+ query: { ...listQuery, limit: "1" },
842
+ target: `${target} (initial list)`,
843
+ });
844
+ return page.metadata?.resourceVersion;
845
+ }
846
+
847
+ let ended = false;
848
+ const end = (message?: string) => {
849
+ if (ended) return;
850
+ ended = true;
851
+ if (message !== undefined) opts.onError?.(message);
852
+ };
853
+
854
+ const done = (async () => {
855
+ try {
856
+ let resourceVersion = await listResourceVersion();
857
+ while (!controller.signal.aborted) {
858
+ const query: Record<string, string> = {
859
+ ...listQuery,
860
+ watch: "1",
861
+ // Bookmarks are how a quiet watch keeps its resourceVersion fresh
862
+ // without any object changing, which is what keeps a reconnect
863
+ // from re-listing. A cluster that does not support them ignores it.
864
+ allowWatchBookmarks: "true",
865
+ };
866
+ if (resourceVersion) query.resourceVersion = resourceVersion;
867
+
868
+ const { lines } = await openStream(listPath, query, controller.signal, target);
869
+ let expired = false;
870
+ // Read the stream one line at a time, racing each read against the
871
+ // abort. `for await` would not do: a watch that is quiet is parked
872
+ // inside `next()` with nothing to wake it, so a `close()` that
873
+ // waited for the loop to notice would wait for the next event on a
874
+ // cluster that may never send one.
875
+ const iterator = lines[Symbol.asyncIterator]();
876
+ const stopped = new Promise<{ done: true; value: undefined }>((resolve) => {
877
+ const finish = () => resolve({ done: true, value: undefined });
878
+ if (controller.signal.aborted) finish();
879
+ else controller.signal.addEventListener("abort", finish, { once: true });
880
+ });
881
+ for (;;) {
882
+ const next = await Promise.race([iterator.next(), stopped]);
883
+ if (next.done || controller.signal.aborted) break;
884
+ // `lines` already yields whole lines, so every call here consumes
885
+ // its input completely and the carry comes back empty.
886
+ const { frames } = parseWatchFrames(`${next.value}\n`);
887
+ for (const frame of frames) {
888
+ if (isExpiredFrame(frame)) {
889
+ // The one failure a watch is expected to hit. Re-list, never
890
+ // retry the stale version. See ./watch.ts.
891
+ expired = true;
892
+ break;
893
+ }
894
+ const rv = resourceVersionOf(frame);
895
+ if (rv) resourceVersion = rv;
896
+ if (frame.type !== "ERROR") opts.onEvent?.(frame);
897
+ }
898
+ if (expired) break;
899
+ }
900
+ // Not awaited: a generator parked on a read that will never complete
901
+ // would make this the very wait the race above exists to avoid. The
902
+ // underlying connection is closed by the abort, not by this.
903
+ void iterator.return?.(undefined)?.catch?.(() => {});
904
+ if (controller.signal.aborted) break;
905
+
906
+ if (expired) {
907
+ resourceVersion = await listResourceVersion();
908
+ continue;
909
+ }
910
+ // The server closed the stream cleanly, which it does every few
911
+ // minutes by design. Reopen from where we left off, after a real
912
+ // timer even at zero delay: a server that closes instantly would
913
+ // otherwise spin this loop through microtasks alone and starve
914
+ // every timer in the process, the abort's included.
915
+ await new Promise((resolve) => {
916
+ const timer = setTimeout(resolve, reopenDelayMs);
917
+ controller.signal.addEventListener("abort", () => { clearTimeout(timer); resolve(undefined); }, { once: true });
918
+ });
919
+ }
920
+ end();
921
+ } catch (err) {
922
+ // An aborted watch is a closed watch, never a failure to report.
923
+ if (controller.signal.aborted) return end();
924
+ end(err instanceof Error ? err.message : String(err));
925
+ } finally {
926
+ opts.signal?.removeEventListener("abort", stopOnCallerAbort);
927
+ }
928
+ })();
929
+
930
+ return {
931
+ done,
932
+ async close() {
933
+ controller.abort();
934
+ ended = true;
935
+ await done;
936
+ },
937
+ };
938
+ }
939
+
709
940
  async function apply(object: K8sObject, opts: ApplyOptions = {}): Promise<K8sObject> {
710
941
  const apiVersion = object.apiVersion;
711
942
  const kind = object.kind;
@@ -837,6 +1068,7 @@ export async function createK8sClient(options: K8sClientOptions = {}): Promise<K
837
1068
  readIfPresent,
838
1069
  list,
839
1070
  readLog,
1071
+ watch,
840
1072
  apply,
841
1073
  delete: remove,
842
1074
  concurrently: (items, fn) => mapConcurrent(items, fn, concurrency),
package/src/index.ts CHANGED
@@ -23,7 +23,16 @@ export {
23
23
  selectorText,
24
24
  refText,
25
25
  } from "./client";
26
- export type { K8sClient, ReadOptions, ApplyOptions, DeleteOptions, ListOptions, ReadLogOptions, SelfSubjectInfo } from "./client";
26
+ export type {
27
+ K8sClient, ReadOptions, ApplyOptions, DeleteOptions, ListOptions, ReadLogOptions,
28
+ SelfSubjectInfo, WatchOptions, WatchHandle,
29
+ } from "./client";
30
+
31
+ // The watch (chant #1981): frame decoding, the `410 Gone` rule, and the
32
+ // stream-to-lines adapter. The client owns the connection; these are the
33
+ // pieces a consumer or a test may need to name.
34
+ export { parseWatchFrames, isExpiredFrame, resourceVersionOf, streamLines } from "./watch";
35
+ export type { WatchFrame, WatchEventType } from "./watch";
27
36
 
28
37
  export {
29
38
  K8sApiError,
package/src/testing.ts CHANGED
@@ -32,6 +32,13 @@ export interface FakeResponse {
32
32
  status?: number;
33
33
  body?: unknown;
34
34
  headers?: Record<string, string>;
35
+ /**
36
+ * A streaming body, for a watch (chant #1981). Anything async-iterable will
37
+ * do, and {@link fakeWatchStream} is the usual source. Set it and the response
38
+ * exposes `body.stream()`, which is what the client reads instead of
39
+ * `text()` for a request it never expects to complete.
40
+ */
41
+ stream?: AsyncIterable<string | Uint8Array>;
35
42
  }
36
43
 
37
44
  export type FakeRequestHandler = (request: RecordedRequest) => FakeResponse | Promise<FakeResponse>;
@@ -74,7 +81,10 @@ export function fakeRequestLayer(handler: FakeRequestHandler): FakeRequestLayer
74
81
  return {
75
82
  httpStatusCode: status,
76
83
  headers: { "content-type": "application/json", ...(result.headers ?? {}) },
77
- body: { text: async () => body },
84
+ body: {
85
+ text: async () => body,
86
+ ...(result.stream ? { stream: () => result.stream } : {}),
87
+ },
78
88
  };
79
89
  },
80
90
  };
@@ -186,3 +196,77 @@ export function apiResourceList(
186
196
  })),
187
197
  };
188
198
  }
199
+
200
+ /** A watch stream a test drives by hand. */
201
+ export interface FakeWatchStream extends AsyncIterable<string> {
202
+ /**
203
+ * Send one NDJSON frame down the stream. Objects are stringified; a string
204
+ * is sent verbatim, which is how a test produces a malformed or half-written
205
+ * frame. A newline is appended unless the string already ends in one.
206
+ */
207
+ push(frame: unknown): void;
208
+ /** End the stream, as a server closing the connection does. */
209
+ close(): void;
210
+ /** How many frames have been consumed by the reader. */
211
+ readonly delivered: number;
212
+ }
213
+
214
+ /**
215
+ * A controllable NDJSON stream for driving {@link import("./client").K8sClient.watch}
216
+ * against the fake cluster (chant #1981).
217
+ *
218
+ * The client reads it exactly as it reads a live watch: one frame per line,
219
+ * for as long as the connection stays open. So a test can push an ADDED, an
220
+ * expired-`410` ERROR, or nothing at all, and assert on what the client does
221
+ * about it, without a cluster, a socket, or a timer.
222
+ */
223
+ export function fakeWatchStream(): FakeWatchStream {
224
+ const queued: string[] = [];
225
+ let waiting: (() => void) | undefined;
226
+ let closed = false;
227
+ let delivered = 0;
228
+
229
+ const wake = () => {
230
+ const resume = waiting;
231
+ waiting = undefined;
232
+ resume?.();
233
+ };
234
+
235
+ return {
236
+ get delivered() {
237
+ return delivered;
238
+ },
239
+ push(frame: unknown) {
240
+ if (closed) return;
241
+ const text = typeof frame === "string" ? frame : JSON.stringify(frame);
242
+ queued.push(text.endsWith("\n") ? text : `${text}\n`);
243
+ wake();
244
+ },
245
+ close() {
246
+ closed = true;
247
+ wake();
248
+ },
249
+ async *[Symbol.asyncIterator]() {
250
+ for (;;) {
251
+ while (queued.length > 0) {
252
+ delivered++;
253
+ yield queued.shift()!;
254
+ }
255
+ if (closed) return;
256
+ await new Promise<void>((resolve) => {
257
+ waiting = resolve;
258
+ });
259
+ }
260
+ },
261
+ };
262
+ }
263
+
264
+ /** A watch event frame, the shape the API server sends. */
265
+ export function watchFrame(type: string, object: Record<string, unknown>): Record<string, unknown> {
266
+ return { type, object };
267
+ }
268
+
269
+ /** The `410 Gone` frame a watch gets when its resourceVersion has aged out. */
270
+ export function expiredWatchFrame(message = "too old resource version: 1 (5000)"): Record<string, unknown> {
271
+ return watchFrame("ERROR", statusBody(410, "Expired", message));
272
+ }
package/src/types.ts CHANGED
@@ -182,5 +182,25 @@ export interface RequestContextLike {
182
182
  export interface ResponseContextLike {
183
183
  httpStatusCode: number;
184
184
  headers: Record<string, string>;
185
- body: { text(): Promise<string> };
185
+ body: {
186
+ text(): Promise<string>;
187
+ /**
188
+ * The body as it arrives, rather than once it is complete (chant #1981).
189
+ *
190
+ * `text()` reads a response to completion, which is the right shape for
191
+ * every request this client makes except one: a watch never completes, so
192
+ * a watch read through `text()` is a promise that resolves when the
193
+ * cluster hangs up and never before. client-node's own HTTP library
194
+ * already exposes `stream()` (its `undici` `fetch` response's web
195
+ * `ReadableStream`), so the seam widens rather than being invented.
196
+ *
197
+ * Typed `unknown` for the same reason the rest of this file avoids the
198
+ * library's classes: the decoder (`./watch.ts`'s `streamLines`) accepts a
199
+ * web `ReadableStream`, a Node `Readable`, or any async iterable. Optional
200
+ * because a transport without it is still a valid transport: the watch
201
+ * falls back to `text()`, which is exactly what a fake returning a
202
+ * complete NDJSON body wants.
203
+ */
204
+ stream?(): unknown;
205
+ };
186
206
  }
@@ -0,0 +1,366 @@
1
+ /**
2
+ * The watch (chant #1981): frame decoding on its own, then the whole watch
3
+ * driven against the fake cluster, on the same request layer every other client
4
+ * test uses, so kubeconfig parsing, discovery, auth and URL construction all
5
+ * run for real and only the socket is fake. No cluster, no k3d, no timer past
6
+ * a few milliseconds.
7
+ */
8
+
9
+ import { describe, test, expect } from "vitest";
10
+ import { createK8sClient } from "./client";
11
+ import { isExpiredFrame, parseWatchFrames, resourceVersionOf, streamLines } from "./watch";
12
+ import {
13
+ apiResourceList,
14
+ expiredWatchFrame,
15
+ fakeKubeconfig,
16
+ fakeRequestLayer,
17
+ fakeWatchStream,
18
+ statusBody,
19
+ watchFrame,
20
+ } from "./testing";
21
+ import type { RecordedRequest } from "./testing";
22
+
23
+ const CORE_V1 = apiResourceList("v1", [
24
+ { name: "configmaps", kind: "ConfigMap" },
25
+ { name: "namespaces", kind: "Namespace", namespaced: false },
26
+ ]);
27
+ const APPS_V1 = apiResourceList("apps/v1", [{ name: "deployments", kind: "Deployment" }]);
28
+
29
+ const DISCOVERY: Record<string, unknown> = {
30
+ "/api": { kind: "APIVersions", versions: ["v1"] },
31
+ "/apis": {
32
+ kind: "APIGroupList",
33
+ groups: [{ name: "apps", preferredVersion: { groupVersion: "apps/v1", version: "v1" }, versions: [{ groupVersion: "apps/v1" }] }],
34
+ },
35
+ "/api/v1": CORE_V1,
36
+ "/apis/apps/v1": APPS_V1,
37
+ };
38
+
39
+ function deployment(name: string, resourceVersion: string): Record<string, unknown> {
40
+ return {
41
+ apiVersion: "apps/v1",
42
+ kind: "Deployment",
43
+ metadata: { name, namespace: "prod", resourceVersion },
44
+ };
45
+ }
46
+
47
+ /** Wait for `predicate`, or give up. Never a bare sleep. */
48
+ async function waitFor(predicate: () => boolean, maxWaitMs = 3_000): Promise<void> {
49
+ const deadline = Date.now() + maxWaitMs;
50
+ while (!predicate() && Date.now() < deadline) {
51
+ await new Promise((r) => setTimeout(r, 5));
52
+ }
53
+ }
54
+
55
+ /**
56
+ * A fake cluster that answers discovery, one LIST per call (with an
57
+ * incrementing `resourceVersion`), and hands each watch request the next
58
+ * stream the test queued.
59
+ */
60
+ function watchableCluster(streams: Array<ReturnType<typeof fakeWatchStream>>) {
61
+ const state = { lists: 0, watches: [] as RecordedRequest[] };
62
+ const layer = fakeRequestLayer((req) => {
63
+ if (req.path in DISCOVERY) return { body: DISCOVERY[req.path] };
64
+ if (req.query.watch === "1") {
65
+ state.watches.push(req);
66
+ const stream = streams.shift();
67
+ if (!stream) return { status: 500, body: statusBody(500, "InternalError", "no stream queued") };
68
+ return { stream };
69
+ }
70
+ if (req.path.endsWith("/deployments") || req.path.endsWith("/configmaps")) {
71
+ state.lists++;
72
+ return { body: { kind: "List", metadata: { resourceVersion: `rv-${state.lists}` }, items: [] } };
73
+ }
74
+ return { status: 404, body: statusBody(404, "NotFound", `${req.path} not found`) };
75
+ });
76
+ return { layer, state };
77
+ }
78
+
79
+ async function watchingClient(layer: ReturnType<typeof fakeRequestLayer>) {
80
+ return createK8sClient({ kubeconfig: fakeKubeconfig(), requestLayer: layer });
81
+ }
82
+
83
+ describe("parseWatchFrames", () => {
84
+ test("decodes whole NDJSON lines and carries the partial one over", () => {
85
+ const first = parseWatchFrames('{"type":"ADDED","object":{"kind":"Pod"}}\n{"type":"MODI');
86
+ expect(first.frames).toHaveLength(1);
87
+ expect(first.frames[0].type).toBe("ADDED");
88
+ expect(first.carry).toBe('{"type":"MODI');
89
+
90
+ const second = parseWatchFrames('FIED","object":{"kind":"Pod"}}\n', first.carry);
91
+ expect(second.frames).toHaveLength(1);
92
+ expect(second.frames[0].type).toBe("MODIFIED");
93
+ expect(second.carry).toBe("");
94
+ });
95
+
96
+ test("drops a line that is not JSON, or JSON without a known type, and keeps going", () => {
97
+ const { frames } = parseWatchFrames(
98
+ ['not json at all', '{"type":"WAT","object":{}}', '{"nope":1}', '{"type":"DELETED","object":{}}', ""].join("\n"),
99
+ );
100
+ expect(frames.map((f) => f.type)).toEqual(["DELETED"]);
101
+ });
102
+
103
+ test("a frame with no object still decodes, rather than throwing", () => {
104
+ const { frames } = parseWatchFrames('{"type":"BOOKMARK"}\n');
105
+ expect(frames).toEqual([{ type: "BOOKMARK", object: {} }]);
106
+ });
107
+ });
108
+
109
+ describe("isExpiredFrame / resourceVersionOf", () => {
110
+ test("410 by code, and by reason for a cluster that sends only one", () => {
111
+ expect(isExpiredFrame({ type: "ERROR", object: statusBody(410, "Expired", "too old") })).toBe(true);
112
+ expect(isExpiredFrame({ type: "ERROR", object: { reason: "Gone" } })).toBe(true);
113
+ expect(isExpiredFrame({ type: "ERROR", object: statusBody(500, "InternalError", "boom") })).toBe(false);
114
+ // An ordinary event is never the expiry signal, whatever it carries.
115
+ expect(isExpiredFrame({ type: "MODIFIED", object: { code: 410 } as never })).toBe(false);
116
+ });
117
+
118
+ test("reads the resumable resourceVersion, and nothing when there is none", () => {
119
+ expect(resourceVersionOf({ type: "ADDED", object: deployment("api", "77") })).toBe("77");
120
+ expect(resourceVersionOf({ type: "ADDED", object: { metadata: {} } })).toBeUndefined();
121
+ });
122
+ });
123
+
124
+ describe("streamLines", () => {
125
+ test("reassembles lines split across chunks, from bytes or strings", async () => {
126
+ const encoder = new TextEncoder();
127
+ const chunks = [encoder.encode('{"a":'), encoder.encode("1}\n{\"b\":2}\n"), "tail-without-newline"];
128
+ const source = (async function* () {
129
+ yield* chunks;
130
+ })();
131
+ const lines: string[] = [];
132
+ for await (const line of streamLines(source)) lines.push(line);
133
+ expect(lines).toEqual(['{"a":1}', '{"b":2}', "tail-without-newline"]);
134
+ });
135
+
136
+ test("a web ReadableStream reads the same way", async () => {
137
+ const encoder = new TextEncoder();
138
+ const stream = new ReadableStream<Uint8Array>({
139
+ start(controller) {
140
+ controller.enqueue(encoder.encode("one\ntw"));
141
+ controller.enqueue(encoder.encode("o\n"));
142
+ controller.close();
143
+ },
144
+ });
145
+ const lines: string[] = [];
146
+ for await (const line of streamLines(stream)) lines.push(line);
147
+ expect(lines).toEqual(["one", "two"]);
148
+ });
149
+
150
+ test("no stream at all yields nothing rather than throwing", async () => {
151
+ const lines: string[] = [];
152
+ for await (const line of streamLines(undefined)) lines.push(line);
153
+ expect(lines).toEqual([]);
154
+ });
155
+ });
156
+
157
+ describe("K8sClient.watch, against the fake cluster", () => {
158
+ test("LISTs first for a resourceVersion, then opens the watch from it", async () => {
159
+ const stream = fakeWatchStream();
160
+ const { layer, state } = watchableCluster([stream]);
161
+ const c = await watchingClient(layer);
162
+
163
+ const seen: string[] = [];
164
+ const handle = await c.watch(
165
+ { apiVersion: "apps/v1", kind: "Deployment" },
166
+ { namespace: "prod", onEvent: (f) => seen.push(f.type) },
167
+ );
168
+
169
+ await waitFor(() => state.watches.length > 0);
170
+ const request = state.watches[0];
171
+ expect(request.path).toBe("/apis/apps/v1/namespaces/prod/deployments");
172
+ expect(request.query.watch).toBe("1");
173
+ expect(request.query.resourceVersion).toBe("rv-1");
174
+ expect(request.query.allowWatchBookmarks).toBe("true");
175
+ // The auth path ran for real, above the seam.
176
+ expect(request.headers.Authorization).toBe("Bearer test-token");
177
+
178
+ stream.push(watchFrame("ADDED", deployment("api", "43")));
179
+ stream.push(watchFrame("MODIFIED", deployment("api", "44")));
180
+ await waitFor(() => seen.length >= 2);
181
+ expect(seen).toEqual(["ADDED", "MODIFIED"]);
182
+
183
+ await handle.close();
184
+ });
185
+
186
+ test("no namespace watches across all of them, and a label selector rides along", async () => {
187
+ const stream = fakeWatchStream();
188
+ const { layer, state } = watchableCluster([stream]);
189
+ const c = await watchingClient(layer);
190
+ const handle = await c.watch(
191
+ { apiVersion: "apps/v1", kind: "Deployment" },
192
+ { labelSelector: "app.kubernetes.io/managed-by=chant" },
193
+ );
194
+ await waitFor(() => state.watches.length > 0);
195
+ expect(state.watches[0].path).toBe("/apis/apps/v1/deployments");
196
+ expect(state.watches[0].query.labelSelector).toBe("app.kubernetes.io/managed-by=chant");
197
+ await handle.close();
198
+ });
199
+
200
+ test("a 410 Gone re-LISTs and resumes from the new resourceVersion, never retrying the stale one", async () => {
201
+ const first = fakeWatchStream();
202
+ const second = fakeWatchStream();
203
+ const { layer, state } = watchableCluster([first, second]);
204
+ const c = await watchingClient(layer);
205
+
206
+ const seen: string[] = [];
207
+ const errors: string[] = [];
208
+ const handle = await c.watch(
209
+ { apiVersion: "apps/v1", kind: "Deployment" },
210
+ { namespace: "prod", reopenDelayMs: 0, onEvent: (f) => seen.push(f.type), onError: (m) => errors.push(m) },
211
+ );
212
+
213
+ await waitFor(() => state.watches.length >= 1);
214
+ first.push(expiredWatchFrame());
215
+
216
+ await waitFor(() => state.watches.length >= 2);
217
+ expect(state.watches[1].query.resourceVersion).toBe("rv-2"); // a fresh list, not "rv-1"
218
+ expect(state.lists).toBe(2);
219
+
220
+ // And the watch is still live: the expiry was a re-list, not an ending.
221
+ second.push(watchFrame("ADDED", deployment("api", "99")));
222
+ await waitFor(() => seen.length >= 1);
223
+ expect(seen).toEqual(["ADDED"]);
224
+ expect(errors).toEqual([]);
225
+
226
+ await handle.close();
227
+ });
228
+
229
+ test("a stream the server closes cleanly is reopened from the last version seen", async () => {
230
+ const first = fakeWatchStream();
231
+ const second = fakeWatchStream();
232
+ const { layer, state } = watchableCluster([first, second]);
233
+ const c = await watchingClient(layer);
234
+
235
+ const errors: string[] = [];
236
+ const handle = await c.watch(
237
+ { apiVersion: "apps/v1", kind: "Deployment" },
238
+ { namespace: "prod", reopenDelayMs: 0, onError: (m) => errors.push(m) },
239
+ );
240
+
241
+ await waitFor(() => state.watches.length >= 1);
242
+ first.push(watchFrame("MODIFIED", deployment("api", "555")));
243
+ await waitFor(() => first.delivered >= 1);
244
+ first.close();
245
+
246
+ await waitFor(() => state.watches.length >= 2);
247
+ // Resumed from the frame, without paying for another LIST.
248
+ expect(state.watches[1].query.resourceVersion).toBe("555");
249
+ expect(state.lists).toBe(1);
250
+ expect(errors).toEqual([]);
251
+
252
+ await handle.close();
253
+ });
254
+
255
+ test("a watch that cannot be opened reports once through onError and ends, never throwing into the caller", async () => {
256
+ const layer = fakeRequestLayer((req) => {
257
+ if (req.path in DISCOVERY) return { body: DISCOVERY[req.path] };
258
+ if (req.query.watch === "1") return { status: 403, body: statusBody(403, "Forbidden", "watch denied") };
259
+ return { body: { kind: "List", metadata: { resourceVersion: "rv-1" }, items: [] } };
260
+ });
261
+ const c = await watchingClient(layer);
262
+
263
+ const errors: string[] = [];
264
+ const handle = await c.watch(
265
+ { apiVersion: "apps/v1", kind: "Deployment" },
266
+ { namespace: "prod", onError: (m) => errors.push(m) },
267
+ );
268
+
269
+ await handle.done;
270
+ expect(errors).toHaveLength(1);
271
+ expect(errors[0]).toContain("watch denied");
272
+ await handle.close(); // idempotent
273
+ expect(errors).toHaveLength(1);
274
+ });
275
+
276
+ test("close() stops the watch, reports nothing, and is safe to call twice", async () => {
277
+ const stream = fakeWatchStream();
278
+ const { layer, state } = watchableCluster([stream]);
279
+ const c = await watchingClient(layer);
280
+
281
+ const errors: string[] = [];
282
+ const handle = await c.watch(
283
+ { apiVersion: "apps/v1", kind: "Deployment" },
284
+ { namespace: "prod", reopenDelayMs: 0, onError: (m) => errors.push(m) },
285
+ );
286
+ await waitFor(() => state.watches.length >= 1);
287
+
288
+ await handle.close();
289
+ await handle.close();
290
+ await handle.done;
291
+ // A closed watch is not a failed watch.
292
+ expect(errors).toEqual([]);
293
+
294
+ // And nothing reopens after the close.
295
+ const watchesAtClose = state.watches.length;
296
+ stream.close();
297
+ await new Promise((r) => setTimeout(r, 50));
298
+ expect(state.watches.length).toBe(watchesAtClose);
299
+ });
300
+
301
+ test("an aborted caller signal closes the watch without reporting a failure", async () => {
302
+ const stream = fakeWatchStream();
303
+ const { layer, state } = watchableCluster([stream]);
304
+ const c = await watchingClient(layer);
305
+ const controller = new AbortController();
306
+
307
+ const errors: string[] = [];
308
+ const handle = await c.watch(
309
+ { apiVersion: "apps/v1", kind: "Deployment" },
310
+ { namespace: "prod", reopenDelayMs: 0, signal: controller.signal, onError: (m) => errors.push(m) },
311
+ );
312
+ await waitFor(() => state.watches.length >= 1);
313
+
314
+ controller.abort();
315
+ await handle.done;
316
+ expect(errors).toEqual([]);
317
+ });
318
+
319
+ test("a kind the cluster's discovery does not serve refuses by name, before any stream", async () => {
320
+ const { layer } = watchableCluster([]);
321
+ const c = await watchingClient(layer);
322
+ await expect(c.watch({ apiVersion: "ray.io/v1", kind: "RayCluster" })).rejects.toThrow(/ray.io\/v1 RayCluster/);
323
+ });
324
+
325
+ test("a cluster-scoped kind watches without a namespace segment", async () => {
326
+ const stream = fakeWatchStream();
327
+ const layer = fakeRequestLayer((req) => {
328
+ if (req.path in DISCOVERY) return { body: DISCOVERY[req.path] };
329
+ if (req.query.watch === "1") return { stream };
330
+ return { body: { kind: "List", metadata: { resourceVersion: "rv-1" }, items: [] } };
331
+ });
332
+ const c = await watchingClient(layer);
333
+ const handle = await c.watch({ apiVersion: "v1", kind: "Namespace" }, { namespace: "ignored" });
334
+ await waitFor(() => layer.requests.some((r) => r.query.watch === "1"));
335
+ expect(layer.requests.find((r) => r.query.watch === "1")!.path).toBe("/api/v1/namespaces");
336
+ await handle.close();
337
+ });
338
+
339
+ test("a transport with no streaming seam falls back to the complete body", async () => {
340
+ // What a fixture returning a canned NDJSON document looks like: no
341
+ // `stream`, just text. The watch reads it as frames and then ends the
342
+ // stream, exactly as a server closing the connection would.
343
+ const ndjson =
344
+ [JSON.stringify(watchFrame("ADDED", deployment("api", "43"))), JSON.stringify(watchFrame("DELETED", deployment("api", "44")))].join("\n") + "\n";
345
+ const held = fakeWatchStream(); // keeps the reopened watch open, as a server does
346
+ let watches = 0;
347
+ const layer = fakeRequestLayer((req) => {
348
+ if (req.path in DISCOVERY) return { body: DISCOVERY[req.path] };
349
+ if (req.query.watch === "1") {
350
+ watches++;
351
+ return watches === 1 ? { body: ndjson } : { stream: held };
352
+ }
353
+ return { body: { kind: "List", metadata: { resourceVersion: "rv-1" }, items: [] } };
354
+ });
355
+ const c = await watchingClient(layer);
356
+
357
+ const seen: string[] = [];
358
+ const handle = await c.watch(
359
+ { apiVersion: "apps/v1", kind: "Deployment" },
360
+ { namespace: "prod", reopenDelayMs: 0, onEvent: (f) => seen.push(f.type) },
361
+ );
362
+ await waitFor(() => seen.length >= 2);
363
+ expect(seen).toEqual(["ADDED", "DELETED"]);
364
+ await handle.close();
365
+ });
366
+ });
package/src/watch.ts ADDED
@@ -0,0 +1,157 @@
1
+ /**
2
+ * The Kubernetes watch, as much of it as can be written without a client
3
+ * (chant #1981).
4
+ *
5
+ * A watch is `GET <list path>?watch=1&resourceVersion=<rv>` answered with an
6
+ * open connection that emits one JSON document per line. NDJSON, forever,
7
+ * until somebody hangs up. Everything here is the frame side of that: parsing
8
+ * lines out of a byte stream, reading a frame's `resourceVersion`, and
9
+ * recognising the one failure a watch is expected to hit.
10
+ *
11
+ * ## `410 Gone`
12
+ *
13
+ * The API server keeps a bounded history of changes. A watch resuming from a
14
+ * `resourceVersion` older than that window cannot be told what it missed, and
15
+ * says so: an `ERROR` frame carrying a `Status` with `code: 410` / `reason:
16
+ * Expired`. There is exactly one correct response, and it is not to retry with
17
+ * the same `resourceVersion`. It is to LIST again, take the list's own
18
+ * `resourceVersion`, and watch from there. Anything else silently drops
19
+ * whatever happened during the gap.
20
+ *
21
+ * For chant's purposes the gap costs nothing anyway: the events are a trigger,
22
+ * never a fact, and the tick they wake re-observes the estate from scratch. A
23
+ * re-list is a re-list, not a reconciliation.
24
+ */
25
+
26
+ import type { K8sObject } from "./types";
27
+
28
+ /** The event types the watch API emits. `BOOKMARK` requires `allowWatchBookmarks`. */
29
+ export type WatchEventType = "ADDED" | "MODIFIED" | "DELETED" | "BOOKMARK" | "ERROR";
30
+
31
+ /**
32
+ * One decoded NDJSON frame. `object` is whatever the server put in the frame:
33
+ * the changed resource for the four ordinary types, a `Status` for `ERROR`.
34
+ */
35
+ export interface WatchFrame {
36
+ type: WatchEventType;
37
+ object: K8sObject;
38
+ }
39
+
40
+ const EVENT_TYPES = new Set<string>(["ADDED", "MODIFIED", "DELETED", "BOOKMARK", "ERROR"]);
41
+
42
+ /**
43
+ * Split a chunk of a watch stream into whole frames, carrying the trailing
44
+ * partial line over to the next call.
45
+ *
46
+ * Frames arrive split across TCP reads at arbitrary byte offsets, so a decoder
47
+ * that treats each chunk as a set of complete lines drops or corrupts every
48
+ * frame that straddles a boundary. `carry` is the fix and the reason this is a
49
+ * function rather than three lines inline.
50
+ *
51
+ * A line that is not JSON, or is JSON without a known `type`, is dropped
52
+ * rather than thrown: a watch is a trigger channel, and the correct response
53
+ * to a frame nobody can read is to keep watching.
54
+ */
55
+ export function parseWatchFrames(
56
+ chunk: string,
57
+ carry = "",
58
+ ): { frames: WatchFrame[]; carry: string } {
59
+ const combined = carry + chunk;
60
+ const lines = combined.split("\n");
61
+ // The last element is either "" (the chunk ended on a newline) or a partial
62
+ // line still waiting for its remainder.
63
+ const rest = lines.pop() ?? "";
64
+ const frames: WatchFrame[] = [];
65
+
66
+ for (const line of lines) {
67
+ const trimmed = line.trim();
68
+ if (!trimmed) continue;
69
+ let parsed: unknown;
70
+ try {
71
+ parsed = JSON.parse(trimmed);
72
+ } catch {
73
+ continue;
74
+ }
75
+ if (!parsed || typeof parsed !== "object") continue;
76
+ const { type, object } = parsed as { type?: unknown; object?: unknown };
77
+ if (typeof type !== "string" || !EVENT_TYPES.has(type)) continue;
78
+ frames.push({
79
+ type: type as WatchEventType,
80
+ object: (object && typeof object === "object" ? object : {}) as K8sObject,
81
+ });
82
+ }
83
+
84
+ return { frames, carry: rest };
85
+ }
86
+
87
+ /**
88
+ * Whether this frame is the API server saying the watch's `resourceVersion` has
89
+ * aged out of its history: the `410 Gone` that means re-list, not retry.
90
+ *
91
+ * Matched on `code` first and `reason` second, because both are set on the
92
+ * `Status` the server sends and a cluster that sets only one of them is still
93
+ * telling us the same thing.
94
+ */
95
+ export function isExpiredFrame(frame: WatchFrame): boolean {
96
+ if (frame.type !== "ERROR") return false;
97
+ const status = frame.object as { code?: unknown; reason?: unknown };
98
+ if (status.code === 410) return true;
99
+ return status.reason === "Expired" || status.reason === "Gone";
100
+ }
101
+
102
+ /** The `resourceVersion` to resume from after this frame, when it carries one. */
103
+ export function resourceVersionOf(frame: WatchFrame): string | undefined {
104
+ const rv = frame.object?.metadata?.resourceVersion;
105
+ return typeof rv === "string" && rv.length > 0 ? rv : undefined;
106
+ }
107
+
108
+ /**
109
+ * Decode whatever a response body's `stream()` returned into lines of text.
110
+ *
111
+ * Three shapes reach here and all three are real: a web `ReadableStream` (what
112
+ * `undici`'s `fetch` gives client-node's HTTP library, and so what a live
113
+ * cluster produces), a Node `Readable` or any other async iterable (what a
114
+ * hand-rolled transport gives), and `undefined` for a transport with no
115
+ * streaming seam at all, which is a fake that answered the whole body at once and
116
+ * is handled by the caller reading `text()` instead.
117
+ */
118
+ export async function* streamLines(source: unknown): AsyncGenerator<string> {
119
+ const decoder = new TextDecoder();
120
+ let carry = "";
121
+
122
+ const emit = function* (chunk: string): Generator<string> {
123
+ const lines = (carry + chunk).split("\n");
124
+ carry = lines.pop() ?? "";
125
+ for (const line of lines) yield line;
126
+ };
127
+
128
+ const decode = (value: unknown): string =>
129
+ typeof value === "string" ? value : decoder.decode(value as Uint8Array, { stream: true });
130
+
131
+ if (source && typeof (source as { getReader?: unknown }).getReader === "function") {
132
+ const reader = (source as ReadableStream<Uint8Array>).getReader();
133
+ try {
134
+ for (;;) {
135
+ const { done, value } = await reader.read();
136
+ if (done) break;
137
+ yield* emit(decode(value));
138
+ }
139
+ } finally {
140
+ // Releasing matters: an un-released reader keeps the connection's
141
+ // backpressure machinery alive after the watch is done with it.
142
+ try {
143
+ reader.releaseLock();
144
+ } catch {
145
+ // Already released, or the stream is gone. Either is fine.
146
+ }
147
+ }
148
+ } else if (source && typeof (source as AsyncIterable<unknown>)[Symbol.asyncIterator] === "function") {
149
+ for await (const value of source as AsyncIterable<unknown>) {
150
+ yield* emit(decode(value));
151
+ }
152
+ } else {
153
+ return;
154
+ }
155
+
156
+ if (carry.trim()) yield carry;
157
+ }