@configbutler/krm-stream 0.1.0 → 0.2.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/README.md +26 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/krm-stream.js +862 -0
- package/dist/sse.d.ts +27 -5
- package/dist/sse.js +19 -13
- package/dist/store.d.ts +11 -0
- package/dist/store.js +12 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +13 -5
package/README.md
CHANGED
|
@@ -21,6 +21,30 @@ connectWithEventSource(
|
|
|
21
21
|
);
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
## Vendoring it without a bundler
|
|
25
|
+
|
|
26
|
+
The default entry point is the per-module build: `dist/index.js` imports `./store.js`, which imports
|
|
27
|
+
`./merge.js`, and so on. A bundler resolves that graph and tree-shakes it, and a browser resolves it
|
|
28
|
+
at runtime, so every file has to be there.
|
|
29
|
+
|
|
30
|
+
If you have no bundler and no `node_modules` — you copy the library in and serve it yourself — import
|
|
31
|
+
the flattened build instead. Same public API, one file, no relative imports left to resolve:
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { LiveResourceStore } from "@configbutler/krm-stream/bundle";
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Vendored, that is one file to copy (`dist/krm-stream.js`) and one path to serve:
|
|
38
|
+
|
|
39
|
+
```js
|
|
40
|
+
import { LiveResourceStore, connectWithEventSource } from "/krm-stream/krm-stream.js";
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Both entry points are built from the same source and are exercised by the same browser test suite
|
|
44
|
+
against a real `EventSource`. Neither has a runtime dependency.
|
|
45
|
+
|
|
46
|
+
## Status
|
|
47
|
+
|
|
48
|
+
The unscoped `krm-stream` package is a compatibility forwarder. This project is pre-1.0: the protocol
|
|
49
|
+
and the API may still change before 1.0. See the repository [README](../../README.md),
|
|
26
50
|
[client state model](../../docs/client-state-model.md), and [release guide](../../docs/releasing.md).
|
package/dist/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export { get, has, isPrefix, parsePointer, pathKey } from "./path.ts";
|
|
|
3
3
|
export { DEFAULT_EDITABLE_REGIONS, defaultPolicy, readOnlyPolicy, regionPolicy } from "./policy.ts";
|
|
4
4
|
export type { KubernetesStructuralSchema } from "./schema.ts";
|
|
5
5
|
export { withOpenAPIKeyedLists } from "./schema.ts";
|
|
6
|
-
export type { StreamHandle, StreamOptions } from "./sse.ts";
|
|
6
|
+
export type { StreamChange, StreamHandle, StreamOptions } from "./sse.ts";
|
|
7
7
|
export { applyStreamEvent, connectResourceStream, connectWithEventSource, SSEDecoder, StreamSequence } from "./sse.ts";
|
|
8
8
|
export type { ApplyOptions, ApplyResult } from "./store.ts";
|
|
9
9
|
export { LiveResourceStore } from "./store.ts";
|
package/dist/index.js
CHANGED
|
@@ -0,0 +1,862 @@
|
|
|
1
|
+
// dist/deep.js
|
|
2
|
+
function isPlainObject(v) {
|
|
3
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
4
|
+
}
|
|
5
|
+
function deepEqual(a, b) {
|
|
6
|
+
if (Object.is(a, b))
|
|
7
|
+
return true;
|
|
8
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
9
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
|
|
10
|
+
return false;
|
|
11
|
+
return a.every((x, i) => deepEqual(x, b[i]));
|
|
12
|
+
}
|
|
13
|
+
if (isPlainObject(a) && isPlainObject(b)) {
|
|
14
|
+
const ka = Object.keys(a);
|
|
15
|
+
const kb = Object.keys(b);
|
|
16
|
+
if (ka.length !== kb.length)
|
|
17
|
+
return false;
|
|
18
|
+
return ka.every((k) => Object.hasOwn(b, k) && deepEqual(a[k], b[k]));
|
|
19
|
+
}
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
function clone(v) {
|
|
23
|
+
if (Array.isArray(v))
|
|
24
|
+
return v.map(clone);
|
|
25
|
+
if (isPlainObject(v)) {
|
|
26
|
+
const out = {};
|
|
27
|
+
for (const [k, x] of Object.entries(v))
|
|
28
|
+
out[k] = clone(x);
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
return v;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// dist/path.js
|
|
35
|
+
function pathKey(path) {
|
|
36
|
+
return JSON.stringify(path);
|
|
37
|
+
}
|
|
38
|
+
function isPrefix(prefix, path) {
|
|
39
|
+
if (prefix.length > path.length)
|
|
40
|
+
return false;
|
|
41
|
+
return prefix.every((seg, i) => String(seg) === String(path[i]));
|
|
42
|
+
}
|
|
43
|
+
function at(value, seg) {
|
|
44
|
+
if (Array.isArray(value))
|
|
45
|
+
return value[Number(seg)];
|
|
46
|
+
if (isPlainObject(value))
|
|
47
|
+
return value[String(seg)];
|
|
48
|
+
return void 0;
|
|
49
|
+
}
|
|
50
|
+
function get(value, path) {
|
|
51
|
+
let cur = value;
|
|
52
|
+
for (const seg of path)
|
|
53
|
+
cur = at(cur, seg);
|
|
54
|
+
return cur;
|
|
55
|
+
}
|
|
56
|
+
function has(value, path) {
|
|
57
|
+
let cur = value;
|
|
58
|
+
for (const seg of path) {
|
|
59
|
+
if (Array.isArray(cur)) {
|
|
60
|
+
if (Number(seg) >= cur.length)
|
|
61
|
+
return false;
|
|
62
|
+
} else if (isPlainObject(cur)) {
|
|
63
|
+
if (!Object.hasOwn(cur, String(seg)))
|
|
64
|
+
return false;
|
|
65
|
+
} else {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
cur = at(cur, seg);
|
|
69
|
+
}
|
|
70
|
+
return cur !== void 0;
|
|
71
|
+
}
|
|
72
|
+
function setAt(root, path, value) {
|
|
73
|
+
if (path.length === 0)
|
|
74
|
+
throw new Error("krm-stream: cannot set the root of an object");
|
|
75
|
+
let cur = root;
|
|
76
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
77
|
+
const seg = path[i];
|
|
78
|
+
let next = at(cur, seg);
|
|
79
|
+
if (!isPlainObject(next) && !Array.isArray(next)) {
|
|
80
|
+
next = typeof path[i + 1] === "number" ? [] : {};
|
|
81
|
+
assign(cur, seg, next);
|
|
82
|
+
}
|
|
83
|
+
cur = next;
|
|
84
|
+
}
|
|
85
|
+
assign(cur, path[path.length - 1], value);
|
|
86
|
+
}
|
|
87
|
+
function removeAt(root, path) {
|
|
88
|
+
if (path.length === 0)
|
|
89
|
+
throw new Error("krm-stream: cannot remove the root of an object");
|
|
90
|
+
const parent = get(root, path.slice(0, -1));
|
|
91
|
+
const last = path[path.length - 1];
|
|
92
|
+
if (Array.isArray(parent))
|
|
93
|
+
parent.splice(Number(last), 1);
|
|
94
|
+
else if (isPlainObject(parent))
|
|
95
|
+
delete parent[String(last)];
|
|
96
|
+
}
|
|
97
|
+
function assign(container, seg, value) {
|
|
98
|
+
if (Array.isArray(container))
|
|
99
|
+
container[Number(seg)] = value;
|
|
100
|
+
else
|
|
101
|
+
container[String(seg)] = value;
|
|
102
|
+
}
|
|
103
|
+
function parsePointer(pointer) {
|
|
104
|
+
if (pointer === "")
|
|
105
|
+
return [];
|
|
106
|
+
if (!pointer.startsWith("/"))
|
|
107
|
+
throw new Error(`krm-stream: not an RFC 6901 pointer: ${JSON.stringify(pointer)}`);
|
|
108
|
+
return pointer.slice(1).split("/").map((seg) => seg.replaceAll("~1", "/").replaceAll("~0", "~"));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// dist/policy.js
|
|
112
|
+
var DEFAULT_EDITABLE_REGIONS = [
|
|
113
|
+
["spec"],
|
|
114
|
+
["metadata", "labels"],
|
|
115
|
+
["metadata", "annotations"],
|
|
116
|
+
["data"],
|
|
117
|
+
["stringData"]
|
|
118
|
+
];
|
|
119
|
+
function regionPolicy(roots) {
|
|
120
|
+
return {
|
|
121
|
+
isEditable: (_obj, path) => roots.some((root) => isPrefix(root, path)),
|
|
122
|
+
// A path that is not editable itself but that an editable region lives UNDER — `[]` and
|
|
123
|
+
// `["metadata"]` for the defaults. The merge must recurse through these rather than replacing
|
|
124
|
+
// them wholesale, or an edit to `metadata.labels` would be clobbered by the read-only handling
|
|
125
|
+
// of `metadata.name` sitting beside it.
|
|
126
|
+
containsEditable: (_obj, path) => roots.some((root) => root.length > path.length && isPrefix(path, root))
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
var defaultPolicy = regionPolicy(DEFAULT_EDITABLE_REGIONS);
|
|
130
|
+
var readOnlyPolicy = regionPolicy([]);
|
|
131
|
+
|
|
132
|
+
// dist/schema.js
|
|
133
|
+
function withOpenAPIKeyedLists(policy, schema) {
|
|
134
|
+
return {
|
|
135
|
+
...policy,
|
|
136
|
+
listMapKeys: (object, path) => policy.listMapKeys?.(object, path) ?? mapKeysAt(schema, path)
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function mapKeysAt(root, path) {
|
|
140
|
+
let schema = root;
|
|
141
|
+
for (const segment of path) {
|
|
142
|
+
schema = typeof segment === "number" ? schema?.items : schema?.properties?.[segment];
|
|
143
|
+
if (!schema)
|
|
144
|
+
return void 0;
|
|
145
|
+
}
|
|
146
|
+
const keys = schema["x-kubernetes-list-map-keys"];
|
|
147
|
+
if (schema["x-kubernetes-list-type"] !== "map" || !keys || keys.length === 0)
|
|
148
|
+
return void 0;
|
|
149
|
+
return keys;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// dist/sse.js
|
|
153
|
+
var SSEDecoder = class {
|
|
154
|
+
#buffer = "";
|
|
155
|
+
/** Feed a chunk of the stream; get back the events that completed with it. */
|
|
156
|
+
push(chunk) {
|
|
157
|
+
this.#buffer += chunk;
|
|
158
|
+
const out = [];
|
|
159
|
+
const trailingCR = this.#buffer.endsWith("\r");
|
|
160
|
+
const complete = trailingCR ? this.#buffer.slice(0, -1) : this.#buffer;
|
|
161
|
+
this.#buffer = complete.replace(/\r\n|\r/g, "\n") + (trailingCR ? "\r" : "");
|
|
162
|
+
for (; ; ) {
|
|
163
|
+
const sep = this.#buffer.indexOf("\n\n");
|
|
164
|
+
if (sep === -1)
|
|
165
|
+
break;
|
|
166
|
+
const frame = this.#buffer.slice(0, sep);
|
|
167
|
+
this.#buffer = this.#buffer.slice(sep + 2);
|
|
168
|
+
const ev = parseFrame(frame);
|
|
169
|
+
if (ev)
|
|
170
|
+
out.push(ev);
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
var StreamSequence = class {
|
|
176
|
+
#next = 1;
|
|
177
|
+
observe(event) {
|
|
178
|
+
if (!Number.isSafeInteger(event.seq) || event.seq !== this.#next) {
|
|
179
|
+
return { expected: this.#next, received: event.seq };
|
|
180
|
+
}
|
|
181
|
+
this.#next++;
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
function parseFrame(frame) {
|
|
186
|
+
const data = [];
|
|
187
|
+
for (const line of frame.split("\n")) {
|
|
188
|
+
if (line === "" || line.startsWith(":"))
|
|
189
|
+
continue;
|
|
190
|
+
const colon = line.indexOf(":");
|
|
191
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
192
|
+
let value = colon === -1 ? "" : line.slice(colon + 1);
|
|
193
|
+
if (value.startsWith(" "))
|
|
194
|
+
value = value.slice(1);
|
|
195
|
+
if (field === "data")
|
|
196
|
+
data.push(value);
|
|
197
|
+
}
|
|
198
|
+
if (data.length === 0)
|
|
199
|
+
return null;
|
|
200
|
+
try {
|
|
201
|
+
return JSON.parse(data.join("\n"));
|
|
202
|
+
} catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function applyStreamEvent(store, ev) {
|
|
207
|
+
switch (ev.type) {
|
|
208
|
+
case "reset":
|
|
209
|
+
store.beginSnapshot();
|
|
210
|
+
return { type: ev.type, added: false, structural: false, flashed: [], conflicts: [] };
|
|
211
|
+
case "added":
|
|
212
|
+
case "modified": {
|
|
213
|
+
if (!ev.object)
|
|
214
|
+
return { type: ev.type, added: false, structural: false, flashed: [], conflicts: [] };
|
|
215
|
+
const result = store.applyServerEvent(ev.object, { redacted: ev.redacted });
|
|
216
|
+
return { type: ev.type, uid: ev.object.metadata.uid, ...result };
|
|
217
|
+
}
|
|
218
|
+
case "deleted": {
|
|
219
|
+
const uid = ev.identity?.uid;
|
|
220
|
+
if (uid)
|
|
221
|
+
store.removeResource(uid);
|
|
222
|
+
return { type: ev.type, uid, added: false, structural: true, flashed: [], conflicts: [] };
|
|
223
|
+
}
|
|
224
|
+
case "synced":
|
|
225
|
+
store.endSnapshot();
|
|
226
|
+
return { type: ev.type, added: false, structural: false, flashed: [], conflicts: [] };
|
|
227
|
+
default:
|
|
228
|
+
return { type: ev.type, added: false, structural: false, flashed: [], conflicts: [] };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function connectResourceStream(url, store, opts = {}) {
|
|
232
|
+
if (opts.signal?.aborted)
|
|
233
|
+
return { close: () => {
|
|
234
|
+
}, closed: Promise.resolve() };
|
|
235
|
+
const controller = new AbortController();
|
|
236
|
+
const fetchImpl = opts.fetch ?? globalThis.fetch;
|
|
237
|
+
opts.signal?.addEventListener("abort", () => controller.abort(), { once: true });
|
|
238
|
+
const closed = (async () => {
|
|
239
|
+
const res = await fetchImpl(url, {
|
|
240
|
+
signal: controller.signal,
|
|
241
|
+
headers: { Accept: "text/event-stream", ...opts.headers },
|
|
242
|
+
// The stream IS the response body; a cached one is a stream that never moves.
|
|
243
|
+
cache: "no-store"
|
|
244
|
+
});
|
|
245
|
+
if (!res.ok || !res.body) {
|
|
246
|
+
opts.onError?.("INTERNAL", `stream: HTTP ${res.status}`, true);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
|
|
250
|
+
const decoder = new SSEDecoder();
|
|
251
|
+
const sequence = new StreamSequence();
|
|
252
|
+
try {
|
|
253
|
+
for (; ; ) {
|
|
254
|
+
const { done, value } = await reader.read();
|
|
255
|
+
if (done)
|
|
256
|
+
return;
|
|
257
|
+
for (const ev of decoder.push(value)) {
|
|
258
|
+
if (feed(store, sequence, ev, opts)) {
|
|
259
|
+
controller.abort();
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
} catch (err) {
|
|
265
|
+
if (!controller.signal.aborted)
|
|
266
|
+
throw err;
|
|
267
|
+
} finally {
|
|
268
|
+
reader.cancel().catch(() => {
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
})();
|
|
272
|
+
return {
|
|
273
|
+
close: () => controller.abort(),
|
|
274
|
+
closed: closed.catch(() => {
|
|
275
|
+
})
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
function connectWithEventSource(url, store, opts = {}) {
|
|
279
|
+
if (opts.signal?.aborted)
|
|
280
|
+
return { close: () => {
|
|
281
|
+
}, closed: Promise.resolve() };
|
|
282
|
+
const es = new EventSource(url, { withCredentials: true });
|
|
283
|
+
const sequence = new StreamSequence();
|
|
284
|
+
let resolve;
|
|
285
|
+
const closed = new Promise((r) => {
|
|
286
|
+
resolve = r;
|
|
287
|
+
});
|
|
288
|
+
const shut = () => {
|
|
289
|
+
es.close();
|
|
290
|
+
resolve();
|
|
291
|
+
};
|
|
292
|
+
es.onmessage = (e) => {
|
|
293
|
+
let ev;
|
|
294
|
+
try {
|
|
295
|
+
ev = JSON.parse(e.data);
|
|
296
|
+
} catch {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (feed(store, sequence, ev, opts))
|
|
300
|
+
shut();
|
|
301
|
+
};
|
|
302
|
+
es.onerror = () => {
|
|
303
|
+
if (es.readyState === EventSource.CLOSED)
|
|
304
|
+
resolve();
|
|
305
|
+
};
|
|
306
|
+
opts.signal?.addEventListener("abort", shut, { once: true });
|
|
307
|
+
return { close: shut, closed };
|
|
308
|
+
}
|
|
309
|
+
function feed(store, sequence, ev, opts) {
|
|
310
|
+
const gap = sequence.observe(ev);
|
|
311
|
+
if (gap) {
|
|
312
|
+
opts.onGap?.(gap.expected, gap.received);
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
if (ev.type === "error") {
|
|
316
|
+
opts.onError?.(ev.code ?? "INTERNAL", ev.message ?? "", ev.terminal ?? false);
|
|
317
|
+
return ev.terminal === true;
|
|
318
|
+
}
|
|
319
|
+
const change = applyStreamEvent(store, ev);
|
|
320
|
+
if (ev.type === "synced")
|
|
321
|
+
opts.onSynced?.();
|
|
322
|
+
opts.onChange?.(change);
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// dist/merge.js
|
|
327
|
+
function reconcile(s, base, ours, theirs) {
|
|
328
|
+
return node(s, [], base, ours, theirs);
|
|
329
|
+
}
|
|
330
|
+
function node(s, path, base, ours, theirs) {
|
|
331
|
+
if (s.regions.editable(path))
|
|
332
|
+
return mergeEditable(s, path, base, ours, theirs);
|
|
333
|
+
if (s.regions.container(path))
|
|
334
|
+
return mergeContainer(s, path, base, ours, theirs);
|
|
335
|
+
return follow(s, path, base, theirs);
|
|
336
|
+
}
|
|
337
|
+
function follow(s, path, base, theirs) {
|
|
338
|
+
if (isPlainObject(base) && isPlainObject(theirs)) {
|
|
339
|
+
const out = {};
|
|
340
|
+
for (const k of unionKeys(base, theirs)) {
|
|
341
|
+
const v = follow(s, [...path, k], at(base, k), at(theirs, k));
|
|
342
|
+
if (v !== void 0)
|
|
343
|
+
out[k] = v;
|
|
344
|
+
}
|
|
345
|
+
return out;
|
|
346
|
+
}
|
|
347
|
+
if (!deepEqual(base, theirs))
|
|
348
|
+
s.flashed.push(path);
|
|
349
|
+
return clone(theirs);
|
|
350
|
+
}
|
|
351
|
+
function mergeContainer(s, path, base, ours, theirs) {
|
|
352
|
+
if (!isPlainObject(base) && !isPlainObject(ours) && !isPlainObject(theirs)) {
|
|
353
|
+
return follow(s, path, base, theirs);
|
|
354
|
+
}
|
|
355
|
+
return recurse(s, path, base, ours, theirs);
|
|
356
|
+
}
|
|
357
|
+
function mergeEditable(s, path, base, ours, theirs) {
|
|
358
|
+
const keys = s.regions.listMapKeys(path);
|
|
359
|
+
if (keys && isAssociativeList(base, keys) && isAssociativeList(ours, keys) && isAssociativeList(theirs, keys)) {
|
|
360
|
+
return mergeAssociativeList(s, path, keys, base, ours, theirs);
|
|
361
|
+
}
|
|
362
|
+
const defined = [base, ours, theirs].filter((v) => v !== void 0);
|
|
363
|
+
if (defined.length > 0 && defined.every(isPlainObject))
|
|
364
|
+
return recurse(s, path, base, ours, theirs);
|
|
365
|
+
if (deepEqual(base, ours)) {
|
|
366
|
+
if (!deepEqual(base, theirs))
|
|
367
|
+
s.flashed.push(path);
|
|
368
|
+
clearConflict(s, path);
|
|
369
|
+
return clone(theirs);
|
|
370
|
+
}
|
|
371
|
+
if (deepEqual(base, theirs))
|
|
372
|
+
return clone(ours);
|
|
373
|
+
if (deepEqual(ours, theirs)) {
|
|
374
|
+
clearConflict(s, path);
|
|
375
|
+
return clone(ours);
|
|
376
|
+
}
|
|
377
|
+
setConflict(s, path, theirs);
|
|
378
|
+
return clone(ours);
|
|
379
|
+
}
|
|
380
|
+
function mergeAssociativeList(s, path, keys, base, ours, theirs) {
|
|
381
|
+
const baseByKey = indexAssociativeList(base, keys);
|
|
382
|
+
const oursByKey = indexAssociativeList(ours, keys);
|
|
383
|
+
const theirsByKey = indexAssociativeList(theirs, keys);
|
|
384
|
+
if (!baseByKey || !oursByKey || !theirsByKey)
|
|
385
|
+
return mergeAtomic(s, path, base, ours, theirs);
|
|
386
|
+
const order = unique([...theirsByKey.order, ...oursByKey.order, ...baseByKey.order]);
|
|
387
|
+
const outputKeys = order.filter((key) => associativeEntrySurvives(oursByKey.values.get(key), theirsByKey.values.get(key)));
|
|
388
|
+
remapListConflicts(s, path, oursByKey.order, outputKeys);
|
|
389
|
+
const out = [];
|
|
390
|
+
for (const key of outputKeys) {
|
|
391
|
+
const value = mergeAssociativeEntry(s, [...path, out.length], baseByKey.values.get(key), oursByKey.values.get(key), theirsByKey.values.get(key));
|
|
392
|
+
if (value !== void 0)
|
|
393
|
+
out.push(value);
|
|
394
|
+
}
|
|
395
|
+
return out;
|
|
396
|
+
}
|
|
397
|
+
function associativeEntrySurvives(ours, theirs) {
|
|
398
|
+
return ours !== void 0 || theirs !== void 0;
|
|
399
|
+
}
|
|
400
|
+
function remapListConflicts(s, path, previousOrder, outputKeys) {
|
|
401
|
+
const outputIndex = new Map(outputKeys.map((key, index) => [key, index]));
|
|
402
|
+
const moved = [];
|
|
403
|
+
for (const [encoded, conflict] of s.conflicts) {
|
|
404
|
+
const segment = conflict.path[path.length];
|
|
405
|
+
if (!isPrefix(path, conflict.path) || typeof segment !== "number")
|
|
406
|
+
continue;
|
|
407
|
+
const key = previousOrder[segment];
|
|
408
|
+
const nextIndex = key === void 0 ? void 0 : outputIndex.get(key);
|
|
409
|
+
if (nextIndex === void 0 || nextIndex === segment)
|
|
410
|
+
continue;
|
|
411
|
+
s.conflicts.delete(encoded);
|
|
412
|
+
moved.push({ ...conflict, path: [...path, nextIndex, ...conflict.path.slice(path.length + 1)] });
|
|
413
|
+
}
|
|
414
|
+
for (const conflict of moved)
|
|
415
|
+
s.conflicts.set(pathKey(conflict.path), conflict);
|
|
416
|
+
}
|
|
417
|
+
function mergeAssociativeEntry(s, path, base, ours, theirs) {
|
|
418
|
+
if (base === void 0 || ours === void 0 || theirs === void 0)
|
|
419
|
+
return mergeAtomic(s, path, base, ours, theirs);
|
|
420
|
+
return node(s, path, base, ours, theirs);
|
|
421
|
+
}
|
|
422
|
+
function mergeAtomic(s, path, base, ours, theirs) {
|
|
423
|
+
if (deepEqual(base, ours)) {
|
|
424
|
+
if (!deepEqual(base, theirs))
|
|
425
|
+
s.flashed.push(path);
|
|
426
|
+
clearConflict(s, path);
|
|
427
|
+
return clone(theirs);
|
|
428
|
+
}
|
|
429
|
+
if (deepEqual(base, theirs))
|
|
430
|
+
return clone(ours);
|
|
431
|
+
if (deepEqual(ours, theirs)) {
|
|
432
|
+
clearConflict(s, path);
|
|
433
|
+
return clone(ours);
|
|
434
|
+
}
|
|
435
|
+
setConflict(s, path, theirs);
|
|
436
|
+
return clone(ours);
|
|
437
|
+
}
|
|
438
|
+
function isAssociativeList(value, keys) {
|
|
439
|
+
return Array.isArray(value) && indexAssociativeList(value, keys) !== void 0;
|
|
440
|
+
}
|
|
441
|
+
function indexAssociativeList(values, keys) {
|
|
442
|
+
const indexed = { order: [], values: /* @__PURE__ */ new Map() };
|
|
443
|
+
for (const value of values) {
|
|
444
|
+
if (!isPlainObject(value))
|
|
445
|
+
return void 0;
|
|
446
|
+
const identity = keys.map((key) => value[key]);
|
|
447
|
+
if (identity.some((part) => part === void 0 || part === null || typeof part === "object"))
|
|
448
|
+
return void 0;
|
|
449
|
+
const encoded = JSON.stringify(identity);
|
|
450
|
+
if (indexed.values.has(encoded))
|
|
451
|
+
return void 0;
|
|
452
|
+
indexed.order.push(encoded);
|
|
453
|
+
indexed.values.set(encoded, value);
|
|
454
|
+
}
|
|
455
|
+
return indexed;
|
|
456
|
+
}
|
|
457
|
+
function unique(values) {
|
|
458
|
+
return [...new Set(values)];
|
|
459
|
+
}
|
|
460
|
+
function recurse(s, path, base, ours, theirs) {
|
|
461
|
+
const out = {};
|
|
462
|
+
for (const k of unionKeys(base, ours, theirs)) {
|
|
463
|
+
const v = node(s, [...path, k], at(base, k), at(ours, k), at(theirs, k));
|
|
464
|
+
if (v !== void 0)
|
|
465
|
+
out[k] = v;
|
|
466
|
+
}
|
|
467
|
+
if (theirs === void 0 && Object.keys(out).length === 0)
|
|
468
|
+
return void 0;
|
|
469
|
+
return out;
|
|
470
|
+
}
|
|
471
|
+
function unionKeys(...values) {
|
|
472
|
+
const keys = [];
|
|
473
|
+
const seen = /* @__PURE__ */ new Set();
|
|
474
|
+
for (const v of values) {
|
|
475
|
+
if (!isPlainObject(v))
|
|
476
|
+
continue;
|
|
477
|
+
for (const k of Object.keys(v)) {
|
|
478
|
+
if (seen.has(k))
|
|
479
|
+
continue;
|
|
480
|
+
seen.add(k);
|
|
481
|
+
keys.push(k);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return keys;
|
|
485
|
+
}
|
|
486
|
+
function setConflict(s, path, theirs) {
|
|
487
|
+
s.conflicts.set(pathKey(path), { path: [...path], theirs: clone(theirs) });
|
|
488
|
+
}
|
|
489
|
+
function clearConflict(s, path) {
|
|
490
|
+
s.conflicts.delete(pathKey(path));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// dist/store.js
|
|
494
|
+
var LiveResourceStore = class {
|
|
495
|
+
#policy;
|
|
496
|
+
#resources = /* @__PURE__ */ new Map();
|
|
497
|
+
#subscribers = /* @__PURE__ */ new Set();
|
|
498
|
+
/** Non-null exactly while a snapshot cycle is open: the uids seen since `beginSnapshot()`.
|
|
499
|
+
* Pruning reads it in `endSnapshot()` and NOWHERE else — a cycle that never completes must prune
|
|
500
|
+
* nothing, or a network hiccup makes the user watch half their resources evaporate (spec §5). */
|
|
501
|
+
#seen = null;
|
|
502
|
+
constructor(policy = defaultPolicy) {
|
|
503
|
+
this.#policy = policy;
|
|
504
|
+
}
|
|
505
|
+
// ------------------------------------------------------------------ the stream in --
|
|
506
|
+
/** `added` and `modified` — the only two upsert spellings, and they are treated identically
|
|
507
|
+
* (spec §4). Both mean "here is this object's complete current state". */
|
|
508
|
+
applyServerEvent(object, opts = {}) {
|
|
509
|
+
const id = object.metadata.uid;
|
|
510
|
+
const incoming = clone(object);
|
|
511
|
+
const redacted = (opts.redacted ?? []).map((entry) => ({
|
|
512
|
+
path: typeof entry.path === "string" ? parsePointer(entry.path) : [...entry.path],
|
|
513
|
+
rev: entry.rev
|
|
514
|
+
}));
|
|
515
|
+
this.#seen?.add(id);
|
|
516
|
+
const existing = this.#resources.get(id);
|
|
517
|
+
if (!existing) {
|
|
518
|
+
this.#resources.set(id, { server: incoming, draft: clone(incoming), redacted, conflicts: /* @__PURE__ */ new Map() });
|
|
519
|
+
this.#notify();
|
|
520
|
+
return { added: true, structural: true, flashed: [], conflicts: [] };
|
|
521
|
+
}
|
|
522
|
+
const state = {
|
|
523
|
+
regions: this.#regionsFor(incoming, redacted.map((r) => r.path)),
|
|
524
|
+
conflicts: existing.conflicts,
|
|
525
|
+
flashed: []
|
|
526
|
+
};
|
|
527
|
+
const merged = reconcile(state, existing.server, existing.draft, incoming);
|
|
528
|
+
const structural = !sameShape(existing.draft, merged);
|
|
529
|
+
existing.server = incoming;
|
|
530
|
+
existing.draft = merged;
|
|
531
|
+
existing.redacted = redacted;
|
|
532
|
+
this.#notify();
|
|
533
|
+
return {
|
|
534
|
+
added: false,
|
|
535
|
+
structural,
|
|
536
|
+
flashed: state.flashed,
|
|
537
|
+
conflicts: [...existing.conflicts.values()].map((c) => c.path)
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
/** `deleted`. The object is gone, and so is any draft of it — the user was editing something that
|
|
541
|
+
* no longer exists. (A recreate under the same name is a DIFFERENT uid and starts clean; that is
|
|
542
|
+
* the whole reason identity is the uid.) */
|
|
543
|
+
removeResource(id) {
|
|
544
|
+
if (this.#resources.delete(id))
|
|
545
|
+
this.#notify();
|
|
546
|
+
}
|
|
547
|
+
/** `reset`. Mark every known uid unseen — and prune NOTHING yet. */
|
|
548
|
+
beginSnapshot() {
|
|
549
|
+
this.#seen = /* @__PURE__ */ new Set();
|
|
550
|
+
}
|
|
551
|
+
/** `synced`. The snapshot is complete, so what it did not mention is genuinely gone. This is the
|
|
552
|
+
* only place anything is pruned, and it is what removes an object deleted while the consumer was
|
|
553
|
+
* disconnected — the one event the consumer never saw. */
|
|
554
|
+
endSnapshot() {
|
|
555
|
+
const seen = this.#seen;
|
|
556
|
+
if (!seen)
|
|
557
|
+
return;
|
|
558
|
+
this.#seen = null;
|
|
559
|
+
let pruned = false;
|
|
560
|
+
for (const id of [...this.#resources.keys()]) {
|
|
561
|
+
if (!seen.has(id)) {
|
|
562
|
+
this.#resources.delete(id);
|
|
563
|
+
pruned = true;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
if (pruned)
|
|
567
|
+
this.#notify();
|
|
568
|
+
}
|
|
569
|
+
/** The save succeeded and this is the object it produced. The watch will echo it too — and that
|
|
570
|
+
* echo is a harmless no-op (I-IDEMPOTENT) — but a UI should not have to wait for it to stop
|
|
571
|
+
* showing the field as dirty. */
|
|
572
|
+
/** Adopt the object a save returned.
|
|
573
|
+
*
|
|
574
|
+
* `object` MUST be projected — the same projection the stream uses. An object straight from a
|
|
575
|
+
* Kubernetes client carries managedFields, status and the Secret values the projection withholds,
|
|
576
|
+
* and handing it here puts all of them in the browser through the one endpoint the stream does not
|
|
577
|
+
* guard. Project it on the server with `gateway.Project` first.
|
|
578
|
+
*
|
|
579
|
+
* You probably do not need this. The recommended save is 204: the write reaches the API server, the
|
|
580
|
+
* watch echoes it back down the stream already projected, and the store converges. Dirty state is
|
|
581
|
+
* derived from draft-versus-server, so there is nothing to adopt. Use this only when a host cannot
|
|
582
|
+
* wait for the echo. See docs/saving.md. */
|
|
583
|
+
adoptSaved(object) {
|
|
584
|
+
const existing = this.#resources.get(object.metadata.uid);
|
|
585
|
+
if (!existing) {
|
|
586
|
+
this.applyServerEvent(object);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
this.applyServerEvent(object, { redacted: existing.redacted });
|
|
590
|
+
}
|
|
591
|
+
// ------------------------------------------------------------------------- edits --
|
|
592
|
+
setValue(id, path, value) {
|
|
593
|
+
const res = this.#editable(id, path);
|
|
594
|
+
setAt(res.draft, path, clone(value));
|
|
595
|
+
this.#settle(res, path);
|
|
596
|
+
}
|
|
597
|
+
/** For a map entry or an object key the user deleted. It stays deleted across watch events (the
|
|
598
|
+
* merge sees ours=undefined, base=theirs and keeps the deletion) and becomes a `null` in the patch. */
|
|
599
|
+
removeKey(id, path) {
|
|
600
|
+
const res = this.#editable(id, path);
|
|
601
|
+
removeAt(res.draft, path);
|
|
602
|
+
this.#settle(res, path);
|
|
603
|
+
}
|
|
604
|
+
/** `path` addresses the MAP; `key` is the new entry. A new row in a UI starts empty. */
|
|
605
|
+
addKey(id, path, key, value = "") {
|
|
606
|
+
const res = this.#editable(id, [...path, key]);
|
|
607
|
+
setAt(res.draft, [...path, key], clone(value));
|
|
608
|
+
this.#settle(res, [...path, key]);
|
|
609
|
+
}
|
|
610
|
+
/** `path` addresses the MAP. Order is preserved — renaming a label must not make its row jump to
|
|
611
|
+
* the bottom of the list while the user is typing in it. */
|
|
612
|
+
renameKey(id, path, oldKey, newKey) {
|
|
613
|
+
const res = this.#editable(id, [...path, oldKey]);
|
|
614
|
+
this.#editable(id, [...path, newKey]);
|
|
615
|
+
const map = get(res.draft, path);
|
|
616
|
+
if (!isPlainObject(map))
|
|
617
|
+
throw new Error(`krm-stream: ${pathKey(path)} is not a map`);
|
|
618
|
+
if (!Object.hasOwn(map, oldKey))
|
|
619
|
+
throw new Error(`krm-stream: key ${JSON.stringify(oldKey)} does not exist`);
|
|
620
|
+
if (oldKey !== newKey && Object.hasOwn(map, newKey)) {
|
|
621
|
+
throw new Error(`krm-stream: key ${JSON.stringify(newKey)} already exists`);
|
|
622
|
+
}
|
|
623
|
+
const renamed = {};
|
|
624
|
+
for (const [k, v] of Object.entries(map))
|
|
625
|
+
renamed[k === oldKey ? newKey : k] = v;
|
|
626
|
+
setAt(res.draft, path, renamed);
|
|
627
|
+
this.#settle(res, [...path, oldKey]);
|
|
628
|
+
this.#settle(res, [...path, newKey]);
|
|
629
|
+
}
|
|
630
|
+
/** Throw the local edit away and take the server's value — which is also how a conflict is
|
|
631
|
+
* resolved in the server's favour. */
|
|
632
|
+
revert(id, path) {
|
|
633
|
+
const res = this.#editable(id, path);
|
|
634
|
+
if (has(res.server, path))
|
|
635
|
+
setAt(res.draft, path, clone(get(res.server, path)));
|
|
636
|
+
else
|
|
637
|
+
removeAt(res.draft, path);
|
|
638
|
+
this.#settle(res, path);
|
|
639
|
+
}
|
|
640
|
+
/** Resolve a conflict by keeping the server's value. */
|
|
641
|
+
takeTheirs(id, path) {
|
|
642
|
+
this.revert(id, path);
|
|
643
|
+
}
|
|
644
|
+
// ----------------------------------------------------------------------- queries --
|
|
645
|
+
ids() {
|
|
646
|
+
return [...this.#resources.keys()];
|
|
647
|
+
}
|
|
648
|
+
/** The authoritative server object, including live `status`. */
|
|
649
|
+
server(id) {
|
|
650
|
+
return clone(this.#must(id).server);
|
|
651
|
+
}
|
|
652
|
+
/** The server object with the editable regions merged over it. What a UI renders and edits. */
|
|
653
|
+
draft(id) {
|
|
654
|
+
return clone(this.#must(id).draft);
|
|
655
|
+
}
|
|
656
|
+
/** Read-only convenience for the watch UI. A kind with no status simply has none. */
|
|
657
|
+
status(id) {
|
|
658
|
+
return clone(this.#must(id).server.status);
|
|
659
|
+
}
|
|
660
|
+
/** Every pending edit, derived fresh by comparing the draft to the server object. Arrays appear
|
|
661
|
+
* whole (§4.1), which is also what RFC 7386 wants. */
|
|
662
|
+
changes(id) {
|
|
663
|
+
const res = this.#must(id);
|
|
664
|
+
const out = [];
|
|
665
|
+
this.#diff(this.#regionsFor(res.server, res.redacted.map((r) => r.path)), [], res.server, res.draft, out);
|
|
666
|
+
return out;
|
|
667
|
+
}
|
|
668
|
+
/** R-DERIVED: never a stored flag. A read-only path is never dirty by construction. */
|
|
669
|
+
isDirty(id, path) {
|
|
670
|
+
const res = this.#must(id);
|
|
671
|
+
if (this.isEditable(id, path))
|
|
672
|
+
return !deepEqual(get(res.server, path), get(res.draft, path));
|
|
673
|
+
if (!this.#policy.containsEditable(res.server, path))
|
|
674
|
+
return false;
|
|
675
|
+
return this.changes(id).some((c) => isPrefix(path, c.path));
|
|
676
|
+
}
|
|
677
|
+
conflicts(id) {
|
|
678
|
+
return [...this.#must(id).conflicts.values()].map((c) => ({ path: [...c.path], theirs: clone(c.theirs) }));
|
|
679
|
+
}
|
|
680
|
+
/** The policy, minus this object's redacted paths. A redacted value is read-only for exactly the
|
|
681
|
+
* same reason `status` is: it is not the user's to change — and here, they never even saw it. */
|
|
682
|
+
isEditable(id, path) {
|
|
683
|
+
const res = this.#must(id);
|
|
684
|
+
return this.#regionsFor(res.server, res.redacted.map((r) => r.path)).editable(path);
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* The paths whose values the gateway withheld. **This is the only place a consumer learns that a
|
|
688
|
+
* redacted field exists**, and rendering it is the whole of keys-only disclosure.
|
|
689
|
+
*
|
|
690
|
+
* The value itself is not in the object — there is no mask, no placeholder, nothing (spec §3). So a
|
|
691
|
+
* UI showing `token ••••••` reads it from HERE, not from the object:
|
|
692
|
+
*
|
|
693
|
+
* ```ts
|
|
694
|
+
* for (const { path } of store.redactions(uid)) row(path, "••••••", { readOnly: true });
|
|
695
|
+
* ```
|
|
696
|
+
*
|
|
697
|
+
* That is deliberate. A placeholder sitting in the object is a value a browser can save back — and
|
|
698
|
+
* a merge patch carrying it writes the placeholder over the real Secret.
|
|
699
|
+
*/
|
|
700
|
+
redactions(id) {
|
|
701
|
+
return this.#must(id).redacted.map((r) => ({ path: [...r.path], rev: r.rev }));
|
|
702
|
+
}
|
|
703
|
+
/** An RFC 7386 merge patch of the editable changes, or null when there is nothing to save.
|
|
704
|
+
*
|
|
705
|
+
* Built from the user's EDITS — never from a diff of the whole object against the server. The
|
|
706
|
+
* object on the wire is a projection: a path the projection removed is simply not there, and a
|
|
707
|
+
* whole-object diff would read that absence as a deletion and try to patch it away (spec §3). */
|
|
708
|
+
patch(id) {
|
|
709
|
+
const changes = this.changes(id);
|
|
710
|
+
if (changes.length === 0)
|
|
711
|
+
return null;
|
|
712
|
+
const out = {};
|
|
713
|
+
for (const c of changes)
|
|
714
|
+
setAt(out, c.path, c.new === void 0 ? null : clone(c.new));
|
|
715
|
+
return out;
|
|
716
|
+
}
|
|
717
|
+
/** A coarse "something changed" signal — the host re-renders and re-queries. Returns an
|
|
718
|
+
* unsubscribe. */
|
|
719
|
+
subscribe(cb) {
|
|
720
|
+
this.#subscribers.add(cb);
|
|
721
|
+
return () => this.#subscribers.delete(cb);
|
|
722
|
+
}
|
|
723
|
+
// ---------------------------------------------------------------------- internals --
|
|
724
|
+
#must(id) {
|
|
725
|
+
const res = this.#resources.get(id);
|
|
726
|
+
if (!res)
|
|
727
|
+
throw new Error(`krm-stream: no resource ${JSON.stringify(id)}`);
|
|
728
|
+
return res;
|
|
729
|
+
}
|
|
730
|
+
/** Every edit goes through here: a write to `status`, to `metadata.name`, or to a redacted path is
|
|
731
|
+
* refused. The engine is not the security boundary — the gateway rejects such a patch too — but a
|
|
732
|
+
* UI that cannot even form the edit is safer than one that relies on client cooperation. */
|
|
733
|
+
#editable(id, path) {
|
|
734
|
+
const res = this.#must(id);
|
|
735
|
+
if (!this.isEditable(id, path)) {
|
|
736
|
+
throw new Error(`krm-stream: ${pathKey(path)} is read-only`);
|
|
737
|
+
}
|
|
738
|
+
return res;
|
|
739
|
+
}
|
|
740
|
+
#regionsFor(object, redacted) {
|
|
741
|
+
const insideRedacted = (path) => redacted.some((r) => isPrefix(r, path));
|
|
742
|
+
const containsRedacted = (path) => redacted.some((r) => isPrefix(path, r));
|
|
743
|
+
return {
|
|
744
|
+
editable: (path) => !insideRedacted(path) && !containsRedacted(path) && this.#policy.isEditable(object, path),
|
|
745
|
+
container: (path) => !insideRedacted(path) && this.#policy.containsEditable(object, path),
|
|
746
|
+
listMapKeys: (path) => this.#policy.listMapKeys?.(object, path)
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
/** After an edit: a conflict whose path the draft has now brought back to the server's value is no
|
|
750
|
+
* longer a conflict. (Typing the server's value by hand resolves it, and so does `revert`.) */
|
|
751
|
+
#settle(res, path) {
|
|
752
|
+
for (const [k, c] of res.conflicts) {
|
|
753
|
+
if (!isPrefix(c.path, path) && !isPrefix(path, c.path))
|
|
754
|
+
continue;
|
|
755
|
+
if (deepEqual(get(res.server, c.path), get(res.draft, c.path)))
|
|
756
|
+
res.conflicts.delete(k);
|
|
757
|
+
}
|
|
758
|
+
this.#notify();
|
|
759
|
+
}
|
|
760
|
+
#diff(regions, path, srv, drf, out) {
|
|
761
|
+
if (regions.editable(path)) {
|
|
762
|
+
const defined = [srv, drf].filter((v) => v !== void 0);
|
|
763
|
+
if (defined.length > 0 && defined.every(isPlainObject)) {
|
|
764
|
+
for (const k of unionKeys2(srv, drf)) {
|
|
765
|
+
this.#diff(regions, [...path, k], srv?.[k], drf?.[k], out);
|
|
766
|
+
}
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
if (!deepEqual(srv, drf)) {
|
|
770
|
+
const kind = srv === void 0 ? "add" : drf === void 0 ? "delete" : "update";
|
|
771
|
+
out.push({ path: [...path], kind, old: clone(srv), new: clone(drf) });
|
|
772
|
+
}
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
if (regions.container(path)) {
|
|
776
|
+
for (const k of unionKeys2(srv, drf)) {
|
|
777
|
+
this.#diff(regions, [...path, k], srv?.[k], drf?.[k], out);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
#notify() {
|
|
782
|
+
for (const cb of this.#subscribers)
|
|
783
|
+
cb();
|
|
784
|
+
}
|
|
785
|
+
};
|
|
786
|
+
function unionKeys2(...values) {
|
|
787
|
+
const keys = [];
|
|
788
|
+
const seen = /* @__PURE__ */ new Set();
|
|
789
|
+
for (const v of values) {
|
|
790
|
+
if (!isPlainObject(v))
|
|
791
|
+
continue;
|
|
792
|
+
for (const k of Object.keys(v)) {
|
|
793
|
+
if (!seen.has(k)) {
|
|
794
|
+
seen.add(k);
|
|
795
|
+
keys.push(k);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
return keys;
|
|
800
|
+
}
|
|
801
|
+
function sameShape(a, b) {
|
|
802
|
+
if (isPlainObject(a) && isPlainObject(b)) {
|
|
803
|
+
const ka = Object.keys(a);
|
|
804
|
+
const kb = Object.keys(b);
|
|
805
|
+
if (ka.length !== kb.length)
|
|
806
|
+
return false;
|
|
807
|
+
return ka.every((k) => Object.hasOwn(b, k) && sameShape(a[k], b[k]));
|
|
808
|
+
}
|
|
809
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
810
|
+
return a.length === b.length && a.every((x, i) => sameShape(x, b[i]));
|
|
811
|
+
}
|
|
812
|
+
return isPlainObject(a) === isPlainObject(b) && Array.isArray(a) === Array.isArray(b);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// dist/url.js
|
|
816
|
+
function resourceStreamURL(base, scope) {
|
|
817
|
+
if (!scope.resource)
|
|
818
|
+
throw new Error("krm-stream: scope needs a `resource` (the plural, lowercase API name)");
|
|
819
|
+
if (!scope.version)
|
|
820
|
+
throw new Error("krm-stream: scope needs a `version` (there is no default: v1 and v1beta1 differ)");
|
|
821
|
+
const q = new URLSearchParams();
|
|
822
|
+
const set = (k, v) => {
|
|
823
|
+
if (v)
|
|
824
|
+
q.append(k, v);
|
|
825
|
+
};
|
|
826
|
+
set("target", scope.target);
|
|
827
|
+
set("group", scope.group);
|
|
828
|
+
set("version", scope.version);
|
|
829
|
+
set("resource", scope.resource);
|
|
830
|
+
set("namespace", scope.namespace);
|
|
831
|
+
set("name", scope.name);
|
|
832
|
+
set("labelSelector", scope.labelSelector);
|
|
833
|
+
set("projection", scope.projection);
|
|
834
|
+
return `${base}${base.includes("?") ? "&" : "?"}${q.toString()}`;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// dist/version.js
|
|
838
|
+
var VERSION = "0.2.0";
|
|
839
|
+
var PROTOCOL_VERSION = 1;
|
|
840
|
+
export {
|
|
841
|
+
DEFAULT_EDITABLE_REGIONS,
|
|
842
|
+
LiveResourceStore,
|
|
843
|
+
PROTOCOL_VERSION,
|
|
844
|
+
SSEDecoder,
|
|
845
|
+
StreamSequence,
|
|
846
|
+
VERSION,
|
|
847
|
+
applyStreamEvent,
|
|
848
|
+
clone,
|
|
849
|
+
connectResourceStream,
|
|
850
|
+
connectWithEventSource,
|
|
851
|
+
deepEqual,
|
|
852
|
+
defaultPolicy,
|
|
853
|
+
get,
|
|
854
|
+
has,
|
|
855
|
+
isPrefix,
|
|
856
|
+
parsePointer,
|
|
857
|
+
pathKey,
|
|
858
|
+
readOnlyPolicy,
|
|
859
|
+
regionPolicy,
|
|
860
|
+
resourceStreamURL,
|
|
861
|
+
withOpenAPIKeyedLists
|
|
862
|
+
};
|
package/dist/sse.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { LiveResourceStore } from "./store.ts";
|
|
2
|
-
import type { ErrorCode, Path, StreamEvent } from "./types.ts";
|
|
2
|
+
import type { ErrorCode, EventType, Path, StreamEvent } from "./types.ts";
|
|
3
3
|
/** Incremental SSE parser. Bytes arrive in whatever chunks the network feels like — a frame can be
|
|
4
4
|
* split down the middle, and it WILL be, under exactly the load where you least want to debug it —
|
|
5
5
|
* so this buffers and only yields complete frames. */
|
|
@@ -21,16 +21,38 @@ export declare class StreamSequence {
|
|
|
21
21
|
* it is exported because it IS the protocol — a host feeding a store from its own transport should
|
|
22
22
|
* not have to reimplement the switch and get `synced` subtly wrong.
|
|
23
23
|
*
|
|
24
|
-
* Returns the
|
|
25
|
-
export declare function applyStreamEvent(store: LiveResourceStore, ev: StreamEvent):
|
|
24
|
+
* Returns the complete StreamChange — which resource, and what happened to it. */
|
|
25
|
+
export declare function applyStreamEvent(store: LiveResourceStore, ev: StreamEvent): StreamChange;
|
|
26
|
+
/** What one stream event did to the store.
|
|
27
|
+
*
|
|
28
|
+
* This is the whole ApplyResult and the uid it belongs to, because anything less makes a host
|
|
29
|
+
* reimplement the stream loop to get the rest back. A UI rendering more than ONE resource per stream
|
|
30
|
+
* — which is most of them — cannot use a bare list of paths: it knows what moved and not what moved.
|
|
31
|
+
*
|
|
32
|
+
* Each field answers a question a renderer actually has:
|
|
33
|
+
*
|
|
34
|
+
* uid which resource. Absent only on reset/synced, which are about the stream, not an object.
|
|
35
|
+
* added an arrival, not a change. Animate it in; do not flash it as if a value moved.
|
|
36
|
+
* structural keys or rows appeared or disappeared. REBUILD the list; re-reading values is not enough.
|
|
37
|
+
* flashed the paths the server moved. Highlight these.
|
|
38
|
+
* conflicts the paths now conflicted, complete — not just the new ones.
|
|
39
|
+
*/
|
|
40
|
+
export interface StreamChange {
|
|
41
|
+
type: EventType;
|
|
42
|
+
uid?: string;
|
|
43
|
+
added: boolean;
|
|
44
|
+
structural: boolean;
|
|
45
|
+
flashed: Path[];
|
|
46
|
+
conflicts: Path[];
|
|
47
|
+
}
|
|
26
48
|
export interface StreamOptions {
|
|
27
49
|
/** Called for every `error` event. A terminal one has already closed the connection by the time
|
|
28
50
|
* this returns — there is nothing to retry, and retrying is the bug. */
|
|
29
51
|
onError?: (code: ErrorCode, message: string, terminal: boolean) => void;
|
|
30
52
|
/** Called at the end of every snapshot cycle. The store is now consistent: a good moment to paint. */
|
|
31
53
|
onSynced?: () => void;
|
|
32
|
-
/** Called after any change, with the
|
|
33
|
-
onChange?: (
|
|
54
|
+
/** Called after any change, with what the event did and which resource it did it to. */
|
|
55
|
+
onChange?: (change: StreamChange) => void;
|
|
34
56
|
/** A missing or duplicated event was observed. The connection is closed; reconnect for a snapshot. */
|
|
35
57
|
onGap?: (expected: number, received: number) => void;
|
|
36
58
|
/** Abort the stream from outside. */
|
package/dist/sse.js
CHANGED
|
@@ -87,28 +87,34 @@ function parseFrame(frame) {
|
|
|
87
87
|
* it is exported because it IS the protocol — a host feeding a store from its own transport should
|
|
88
88
|
* not have to reimplement the switch and get `synced` subtly wrong.
|
|
89
89
|
*
|
|
90
|
-
* Returns the
|
|
90
|
+
* Returns the complete StreamChange — which resource, and what happened to it. */
|
|
91
91
|
export function applyStreamEvent(store, ev) {
|
|
92
92
|
switch (ev.type) {
|
|
93
93
|
case "reset":
|
|
94
94
|
store.beginSnapshot();
|
|
95
|
-
return [];
|
|
95
|
+
return { type: ev.type, added: false, structural: false, flashed: [], conflicts: [] };
|
|
96
96
|
case "added":
|
|
97
|
-
case "modified":
|
|
97
|
+
case "modified": {
|
|
98
98
|
if (!ev.object)
|
|
99
|
-
return [];
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
99
|
+
return { type: ev.type, added: false, structural: false, flashed: [], conflicts: [] };
|
|
100
|
+
const result = store.applyServerEvent(ev.object, { redacted: ev.redacted });
|
|
101
|
+
return { type: ev.type, uid: ev.object.metadata.uid, ...result };
|
|
102
|
+
}
|
|
103
|
+
case "deleted": {
|
|
104
|
+
const uid = ev.identity?.uid;
|
|
105
|
+
if (uid)
|
|
106
|
+
store.removeResource(uid);
|
|
107
|
+
// A removal IS structural: a row left the collection, and a UI that only re-reads values would
|
|
108
|
+
// keep rendering it.
|
|
109
|
+
return { type: ev.type, uid, added: false, structural: true, flashed: [], conflicts: [] };
|
|
110
|
+
}
|
|
105
111
|
case "synced":
|
|
106
112
|
store.endSnapshot();
|
|
107
|
-
return [];
|
|
113
|
+
return { type: ev.type, added: false, structural: false, flashed: [], conflicts: [] };
|
|
108
114
|
default:
|
|
109
115
|
// An unknown event type MUST be ignored, not treated as an error (spec §0). That is what lets
|
|
110
116
|
// the gateway add an optional event type later without breaking a browser nobody can update.
|
|
111
|
-
return [];
|
|
117
|
+
return { type: ev.type, added: false, structural: false, flashed: [], conflicts: [] };
|
|
112
118
|
}
|
|
113
119
|
}
|
|
114
120
|
/** Consume a resource stream over fetch, feeding a store. Use this when the gateway authenticates
|
|
@@ -207,9 +213,9 @@ function feed(store, sequence, ev, opts) {
|
|
|
207
213
|
opts.onError?.(ev.code ?? "INTERNAL", ev.message ?? "", ev.terminal ?? false);
|
|
208
214
|
return ev.terminal === true;
|
|
209
215
|
}
|
|
210
|
-
const
|
|
216
|
+
const change = applyStreamEvent(store, ev);
|
|
211
217
|
if (ev.type === "synced")
|
|
212
218
|
opts.onSynced?.();
|
|
213
|
-
opts.onChange?.(
|
|
219
|
+
opts.onChange?.(change);
|
|
214
220
|
return false;
|
|
215
221
|
}
|
package/dist/store.d.ts
CHANGED
|
@@ -37,6 +37,17 @@ export declare class LiveResourceStore {
|
|
|
37
37
|
/** The save succeeded and this is the object it produced. The watch will echo it too — and that
|
|
38
38
|
* echo is a harmless no-op (I-IDEMPOTENT) — but a UI should not have to wait for it to stop
|
|
39
39
|
* showing the field as dirty. */
|
|
40
|
+
/** Adopt the object a save returned.
|
|
41
|
+
*
|
|
42
|
+
* `object` MUST be projected — the same projection the stream uses. An object straight from a
|
|
43
|
+
* Kubernetes client carries managedFields, status and the Secret values the projection withholds,
|
|
44
|
+
* and handing it here puts all of them in the browser through the one endpoint the stream does not
|
|
45
|
+
* guard. Project it on the server with `gateway.Project` first.
|
|
46
|
+
*
|
|
47
|
+
* You probably do not need this. The recommended save is 204: the write reaches the API server, the
|
|
48
|
+
* watch echoes it back down the stream already projected, and the store converges. Dirty state is
|
|
49
|
+
* derived from draft-versus-server, so there is nothing to adopt. Use this only when a host cannot
|
|
50
|
+
* wait for the echo. See docs/saving.md. */
|
|
40
51
|
adoptSaved(object: KRMObject): void;
|
|
41
52
|
setValue(id: string, path: Path, value: unknown): void;
|
|
42
53
|
/** For a map entry or an object key the user deleted. It stays deleted across watch events (the
|
package/dist/store.js
CHANGED
|
@@ -98,6 +98,17 @@ export class LiveResourceStore {
|
|
|
98
98
|
/** The save succeeded and this is the object it produced. The watch will echo it too — and that
|
|
99
99
|
* echo is a harmless no-op (I-IDEMPOTENT) — but a UI should not have to wait for it to stop
|
|
100
100
|
* showing the field as dirty. */
|
|
101
|
+
/** Adopt the object a save returned.
|
|
102
|
+
*
|
|
103
|
+
* `object` MUST be projected — the same projection the stream uses. An object straight from a
|
|
104
|
+
* Kubernetes client carries managedFields, status and the Secret values the projection withholds,
|
|
105
|
+
* and handing it here puts all of them in the browser through the one endpoint the stream does not
|
|
106
|
+
* guard. Project it on the server with `gateway.Project` first.
|
|
107
|
+
*
|
|
108
|
+
* You probably do not need this. The recommended save is 204: the write reaches the API server, the
|
|
109
|
+
* watch echoes it back down the stream already projected, and the store converges. Dirty state is
|
|
110
|
+
* derived from draft-versus-server, so there is nothing to adopt. Use this only when a host cannot
|
|
111
|
+
* wait for the echo. See docs/saving.md. */
|
|
101
112
|
adoptSaved(object) {
|
|
102
113
|
const existing = this.#resources.get(object.metadata.uid);
|
|
103
114
|
if (!existing) {
|
|
@@ -250,8 +261,7 @@ export class LiveResourceStore {
|
|
|
250
261
|
}
|
|
251
262
|
/** Every edit goes through here: a write to `status`, to `metadata.name`, or to a redacted path is
|
|
252
263
|
* refused. The engine is not the security boundary — the gateway rejects such a patch too — but a
|
|
253
|
-
* UI that cannot even form the edit is
|
|
254
|
-
* honest". */
|
|
264
|
+
* UI that cannot even form the edit is safer than one that relies on client cooperation. */
|
|
255
265
|
#editable(id, path) {
|
|
256
266
|
const res = this.#must(id);
|
|
257
267
|
if (!this.isEditable(id, path)) {
|
package/dist/version.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
/** The npm package version of this build. Assert it against the copy you vendored. */
|
|
2
|
-
export declare const VERSION = "0.
|
|
2
|
+
export declare const VERSION = "0.2.0";
|
|
3
3
|
/** The wire protocol this build speaks (spec/v1.md). The gateway sends it as `X-KRM-Stream-Protocol`. */
|
|
4
4
|
export declare const PROTOCOL_VERSION = 1;
|
package/dist/version.js
CHANGED
|
@@ -21,6 +21,6 @@
|
|
|
21
21
|
// release-please-config.json). Do not edit it by hand: the release PR bumps package.json and this
|
|
22
22
|
// line together, and version.test.ts fails if they ever disagree.
|
|
23
23
|
/** The npm package version of this build. Assert it against the copy you vendored. */
|
|
24
|
-
export const VERSION = "0.
|
|
24
|
+
export const VERSION = "0.2.0"; // x-release-please-version
|
|
25
25
|
/** The wire protocol this build speaks (spec/v1.md). The gateway sends it as `X-KRM-Stream-Protocol`. */
|
|
26
26
|
export const PROTOCOL_VERSION = 1;
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@configbutler/krm-stream",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Receive live Kubernetes resource updates in browser apps and three-way merge them with form edits.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"kubernetes",
|
|
7
7
|
"krm",
|
|
8
|
+
"config-as-data",
|
|
8
9
|
"sse",
|
|
9
10
|
"watch",
|
|
10
11
|
"live",
|
|
@@ -24,6 +25,10 @@
|
|
|
24
25
|
".": {
|
|
25
26
|
"types": "./dist/index.d.ts",
|
|
26
27
|
"default": "./dist/index.js"
|
|
28
|
+
},
|
|
29
|
+
"./bundle": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"default": "./dist/krm-stream.js"
|
|
27
32
|
}
|
|
28
33
|
},
|
|
29
34
|
"files": [
|
|
@@ -34,16 +39,19 @@
|
|
|
34
39
|
"node": ">=22"
|
|
35
40
|
},
|
|
36
41
|
"scripts": {
|
|
37
|
-
"build": "tsc",
|
|
42
|
+
"build": "tsc && npm run build:bundle",
|
|
43
|
+
"//build:bundle": "Flatten the emitted module graph into ONE file for hosts with no bundler and no node_modules — the ones that vendor the library by copying it and serving it. Bundling dist/index.js (not src/) keeps a single source of truth: the file a bundler-based consumer imports is the file this flattens. --target=es2022 pins it to the same output tsconfig emits, so a bundle can never quietly acquire a polyfill the per-module build does not have. No sourcemap: a host serving this publicly should not have to also serve a .map, or field a console warning for not doing so.",
|
|
44
|
+
"build:bundle": "esbuild dist/index.js --bundle --format=esm --target=es2022 --legal-comments=none --outfile=dist/krm-stream.js",
|
|
38
45
|
"test": "node --test",
|
|
39
46
|
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json",
|
|
40
47
|
"lint": "biome check",
|
|
41
48
|
"format": "biome check --write"
|
|
42
49
|
},
|
|
43
|
-
"//": "ZERO dependencies, and that one is absolute: the engine emits plain ESM a browser imports with no bundler, and gitops-api has no bundler and is not getting one. devDependencies ship NOTHING and are kept to the
|
|
50
|
+
"//": "ZERO dependencies, and that one is absolute: the engine emits plain ESM a browser imports with no bundler, and gitops-api has no bundler and is not getting one. devDependencies ship NOTHING and are kept to the four that pay for themselves: typescript (the compiler), @types/node (without it `tsc` cannot see test/ at all, because the tests import node:test — and `node --test` STRIPS types rather than checking them, so the suite that IS the contract check would be the one unverified file in the repo), biome (lint + format in one binary, the TypeScript half of what gofmt/go vet/golangci-lint do for the gateway), and esbuild (flattens dist/ into the single-file ./bundle — it runs ONCE per release here, so that every no-bundler consumer does not each reinvent it, and it is the reason a host can vendor ONE file instead of eleven; it emits no polyfill and no runtime dependency, and if it ever needs to, the bundle is wrong).",
|
|
44
51
|
"devDependencies": {
|
|
45
52
|
"@biomejs/biome": "^2.5.3",
|
|
46
53
|
"@types/node": "^26.1.1",
|
|
47
|
-
"
|
|
54
|
+
"esbuild": "^0.28.1",
|
|
55
|
+
"typescript": "^7.0.2"
|
|
48
56
|
}
|
|
49
57
|
}
|