diff --git a/Taskfile.yml b/Taskfile.yml
index 3f95fd8..ab55a51 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -7,8 +7,8 @@ vars:
IMAGE: '{{.IMAGE | default "ghcr.io/configbutler/krm-foyer"}}'
# The krm-stream browser bundle the hello example vendors, and npm's integrity for
# its package. Bump both together, then `task vendor-krm-stream`.
- KRM_STREAM_VERSION: 0.7.0
- KRM_STREAM_INTEGRITY: sha512-LFXyJIv4UmlBBEJEdx68wzwaqUIVai5HbDe70ACTWIpFZexlJbd/wyhzryoW5uBg55y/f+dZ4bapajybPJ7c0w==
+ KRM_STREAM_VERSION: 0.10.0
+ KRM_STREAM_INTEGRITY: sha512-Wjl0sq6he959ximL+0wHU1on+mfVfT+MyrJucp+sfd7idepOdBFjwWSv43L34oVRsY/1+QFZcheaoiubPlmEFQ==
VERSION:
sh: git describe --tags --always --dirty 2>/dev/null || echo dev
diff --git a/docs/investigations/adopting-krm-stream-native-watches.md b/docs/investigations/adopting-krm-stream-native-watches.md
new file mode 100644
index 0000000..d160da9
--- /dev/null
+++ b/docs/investigations/adopting-krm-stream-native-watches.md
@@ -0,0 +1,322 @@
+# Native watches from krm-stream: what changed, how it works, and adopting it
+
+**Written 2026-10-06** against krm-stream `main` at `14d4ff4`, with gateway and npm
+0.9.0 published and the 0.10.0 release pending in
+[krm-stream#71](https://github.com/ConfigButler/krm-stream/pull/71). krm-foyer pins
+**0.7.0** (Go gateway in `go.mod`, browser bundle in `Taskfile.yml`). Nothing here is
+implemented in krm-foyer yet.
+
+**Update 2026-10-06:** krm-stream 0.10.0 is released, and krm-foyer pins it: step 1
+below is done, still on the gateway's SSE stream. Steps 2–5 remain.
+
+## In short
+
+On 2026-10-05 we asked krm-stream for a browser connector that reads a native
+Kubernetes collection through a host proxy and feeds krm-stream's store and editor
+([the request](krm-stream-native-connector-request.md)). krm-stream built it, in four
+pieces:
+
+1. **One connection lifecycle** shared by every connector (0.8.0, #53).
+2. **`connectNativeWatch`**: LIST, then WATCH, through a proxy such as `/k8s`
+ (0.8.0, #60).
+3. **Native editing**: conditional merge PATCHes through the same proxy (0.8.0, #61).
+4. **Resumed watches**: a dropped native watch picks up where it left off, with no
+ new LIST and no new snapshot (0.10.0, #66, not yet released).
+
+For krm-foyer this needs **no new route and no server change**: `/k8s` already serves
+LIST and WATCH with the user's own token, inside the bounds. The work is a dependency
+bump with renamed APIs (mechanical), an example page that uses the native connector,
+browser specs that try to get past the session and RBAC boundaries, and docs that stop
+calling the connector "requested".
+
+**Recommendation:** adopt 0.10.0, not 0.9.0. Resuming
+is what makes native watches cheaper than the gateway on reconnect, and the main reason
+to offer them.
+
+## What changed in krm-stream since 0.7.0
+
+| Release | What | Effect on krm-foyer |
+| --- | --- | --- |
+| 0.8.0 | One browser connector, `connectResourceStream(url, callback)`; the old `connectManagedResourceStream(url, store)`, `onSynced`, `onStateChange` and friends are removed | The hello example must be rewritten to the new names |
+| 0.8.0 | Gateway settings move into one embedded `gateway.StreamConfig`; `NewSharedBackendWithOptions` becomes `NewSharedBackend(upstream, options)` | `internal/stream/stream.go` and `shared.go` change, mechanically |
+| 0.8.0 | `connectNativeWatch` with `nativeCollectionURL` | New: a native page needs no list/watch code of its own |
+| 0.8.0 | Native editing with `nativeObjectURL`; `gateway.ValidateNativeMergePatch` for a host that wants to check patches | New; see [the patch check](#should-k8s-check-native-patches) |
+| 0.8.0 | The store refuses edits to `metadata.managedFields` and the last-applied annotation under every policy; `annotations` is edited key by key | The hello example edits `spec` only, so unaffected |
+| 0.8.0 | An exception in a callback, `subscribe` or `onError` stops the stream and rejects `closed` | Attach `connection.closed.catch(...)` |
+| 0.9.0 | An example page for native editing | Nothing |
+| 0.10.0 | Native watches resume from a checkpoint | The point of the exercise |
+
+krm-stream's [upgrade guide](https://github.com/ConfigButler/krm-stream/blob/main/docs/migrating.md)
+lists every rename.
+
+## How it works now
+
+### Two sources, one lifecycle, one store
+
+```mermaid
+flowchart LR
+ subgraph Browser
+ N[connectNativeWatch] --> L[shared lifecycle
states, retries, close]
+ G[connectResourceStream] --> L
+ L -->|reset / added / modified / deleted / synced| S[LiveResourceStore
server state, draft, conflicts]
+ end
+ N -->|GET /k8s/...: LIST, then WATCH| F[krm-foyer]
+ G -->|GET /stream/v1?...: SSE| F
+ F -->|user's token| K[API server]
+ F -->|user's token, or shared identity + SubjectAccessReview| K
+```
+
+Both connectors use `fetch`, so the session cookie goes along and no token reaches the
+browser. Both hand the page the same five events and the same connection states
+(`connecting`, `syncing`, `live`, `retrying`, `closed`, `terminal`, `exhausted`). The
+page code that renders a list or an editor does not care which source fed the store.
+What differs is what arrives:
+
+| | Native (`/k8s`) | Gateway (`/stream/v1`) |
+| --- | --- | --- |
+| Objects | Exactly what the API server returns: `status`, `managedFields`, Secret values | A named view: `krm-full/v1`, `krm-spec/v1` or `krm-raw/v1`, machinery always removed |
+| Updates | Every change | Changes to the view only (`krm-spec/v1` drops status-only updates) |
+| Watches at the API server for N tabs on one scope | N | N, or 1 when the resource is shared |
+| After a dropped connection | Resumes the watch: only what changed arrives | A fresh snapshot, every time |
+| Who decides what the user sees | The API server, on the user's own request | The same, or a SubjectAccessReview when shared |
+
+### The native connection, step by step
+
+1. **LIST** the collection, e.g. `GET /k8s/apis/hello.krm-foyer.example/v1/namespaces/demo/notes`.
+ The connector delivers `reset`, then `added` for every item. The state is `syncing`.
+2. **WATCH** from the list's `resourceVersion`, with `allowWatchBookmarks=true` and the
+ same selectors. Only once the API server accepts the WATCH does the connector deliver
+ `synced` and become `live`. A page enables Save only while `live`.
+3. **Live**: watch events become `added`, `modified`, `deleted`. Bookmarks change no
+ object. After every event the store has applied, and every bookmark, the connector
+ remembers that `resourceVersion` as its **checkpoint**.
+4. **The watch ends** (krm-foyer's 30-minute response limit, the API server's own
+ timeout, a network blip, a 5xx). The connector waits its backoff and opens
+ `WATCH ?resourceVersion=`. No LIST, no `reset`, no `synced`. The state
+ goes `connecting` → `live`; the store keeps every object, draft and conflict, and
+ what changed meanwhile arrives as ordinary events, deletions included.
+5. **History expired** (HTTP or in-stream 410): the checkpoint is thrown away, and the
+ connector goes back to step 1. The old objects stay visible until the new LIST is
+ complete, and only then are the missing ones pruned.
+6. **Refused** (401, 403, any other 4xx): terminal. No retry, no fallback to another
+ source. The page shows why, and a new connection is the page's decision.
+
+The checkpoint is private to the handle: never exposed, never accepted from the page,
+never shared. So it cannot be replayed under another user's session or URL.
+
+Two limits in this release: no **pagination** (a LIST that comes back with a
+`continue` token is refused, terminally, before anything is applied) and no
+**streaming lists** (`sendInitialEvents`). Both are planned upstream. krm-foyer does
+not paginate unasked, so a collection is one page unless the page's code sets `limit`,
+which the connector forbids anyway.
+
+### Why a LIST first, and not a streaming list
+
+A WATCH with `sendInitialEvents=true` would replace LIST + WATCH with one request.
+krm-stream evaluated it and built LIST-then-WATCH first, because only that works
+everywhere: an aggregated API server refused the streaming list on a real cluster
+(`sendInitialEvents is forbidden for watch unless the WatchList feature gate is
+enabled`, [observation F6](https://github.com/ConfigButler/krm-stream/blob/main/docs/facts/observed-v1.36.2+k3s1.md)),
+so a streaming-list connector needs LIST-then-WATCH as its fallback anyway. With
+resuming, a LIST is left only on the first connection and after a 410, which is all a
+streaming list would make cheaper. It is planned
+([proposal 0006, item 3](https://github.com/ConfigButler/krm-stream/blob/main/docs/proposals/0006-stream-and-save-implementation-plan.md)),
+with its own rule: the snapshot is complete only at the `k8s.io/initial-events-end`
+bookmark. Until then the connector refuses `sendInitialEvents` in a caller's URL.
+Either way `/k8s` passes the request through unchanged.
+
+### Where the gateway fits
+
+The native path does not use the Go gateway: it is the browser, `/k8s` and the API
+server. What the two paths share is browser code, the lifecycle and the store. The
+gateway has no native handlers that change what a native watch does; its one native
+function is `ValidateNativeMergePatch`, an optional check for a proxy
+([not in `/k8s`](#should-k8s-check-native-patches)). In krm-foyer the gateway keeps
+serving `/stream/v1`, for projected views and shared watches, unchanged.
+
+### Native editing
+
+The store and its four steps are the same as for a gateway page; what differs is who
+writes the preconditions:
+
+```js
+const intent = store.captureSave(uid); // before any await
+if (intent) {
+ const metadata = { ...intent.patch.metadata, uid: intent.uid, resourceVersion: intent.resourceVersion };
+ // In krm-foyer: through foyer.js, which adds the CSRF proof a change needs.
+ await k8s(nativeObjectURL('', { ...scope, namespace, name }), {
+ method: 'PATCH', contentType: 'application/merge-patch+json',
+ body: { ...intent.patch, metadata },
+ });
+}
+```
+
+The `uid` and `resourceVersion` inside the patch make the API server refuse it with
+409 if anyone wrote in between, or if the object was replaced under the same name.
+Because a native source suppresses nothing, the store always holds the latest version,
+so the gateway's "quiet stream" 409 (status moved on unseen under `krm-spec/v1`)
+does not happen here. A 409 is a real concurrent write.
+
+## What improved, in numbers
+
+From krm-stream's
+[comparison run](https://github.com/ConfigButler/krm-stream/blob/main/docs/facts/comparison-2026-10-05.md),
+real API server, 11 objects per subscriber, two forced reconnects per workload.
+One machine, small collections: a witness, not a capacity figure.
+
+**Resuming (0.10.0) against re-listing (0.9.0), native, 10 subscribers:**
+
+| Workload | LISTs | Snapshots | KiB to the browser | Time back to `live` (median) |
+| --- | --- | --- | --- | --- |
+| quiet (only the reconnects) | 40 → 0 | 40 → 0 | 190 → 0 | 20 → 7.5 ms |
+| churn (214 writes) | 40 → 0 | 40 → 0 | 2302 → 2113 | 13 → 6 ms |
+| forced disconnect ×6 | 120 → 0 | 120 → 0 | 1295 → 723 | 12 → 5 ms |
+
+What a reconnect saves is one LIST of the collection, so it grows with collection size
+and reconnect frequency, not with the write rate. Every write still arrives once.
+
+**Native against the gateway views, same workload, 10 subscribers:**
+
+| Workload | Native | `krm-full/v1` | `krm-spec/v1` |
+| --- | --- | --- | --- |
+| quiet: KiB to the browser | 0 | 103 | 93 |
+| churn: KiB to the browser | 2112 | 1100 | 152 |
+| churn: object updates delivered | 2140 | 2138 | 150 |
+
+So neither wins outright. Native is cheapest where little changes and connections
+drop; the gateway is cheapest where a controller churns status, because it removes
+`managedFields` and (with `krm-spec/v1`) status updates altogether. Shared gateway
+watches cut API-server bytes tenfold in that run (2311 → 231 KiB); native never
+shares.
+
+## What it takes in krm-foyer
+
+### 1. Bump krm-stream to 0.10.0 (mechanical, done)
+
+- **Go**, `internal/stream/stream.go`: move `Authorizer`, `Clients`, `Projections`,
+ `Diagnostics`, `WriteTimeout` and the reauthorization fields into
+ `StreamConfig: gateway.StreamConfig{...}`. Assignments such as
+ `opts.Authorizer = sh.authorize` keep working through field promotion.
+ `internal/stream/shared.go`: `NewSharedBackendWithOptions` → `NewSharedBackend`.
+ `ServeStream`/`Stream` signature changes do not touch krm-foyer, which uses
+ `gateway.Handler`.
+- **Browser**, `examples/hello/web/app.js`: `connectManagedResourceStream(url, store,
+ {onSynced, onStateChange, onError})` becomes `connectResourceStream(url, event =>
+ applyStreamEvent(store, event), {onError})`, with `connection.subscribe(showConnection)`
+ for state and a `synced` event for the first snapshot. Add `closed.catch`.
+- **Vendoring**: `KRM_STREAM_VERSION` and `KRM_STREAM_INTEGRITY` in `Taskfile.yml`,
+ then `task vendor-krm-stream`.
+- **Tests**: existing unit, e2e and browser specs should pass unchanged; that is the
+ check that nothing behaves differently. No server behaviour changes.
+
+Size: small. Commit as `fix(deps):` or `feat:`; krm-foyer's own interface does not
+change.
+
+### 2. Nothing new on the server
+
+`/k8s` already does what the connector needs, checked against the code:
+
+- **LIST and WATCH** pass through as the user, `FlushInterval: -1`, so each watch
+ event reaches the browser as it arrives.
+- **Bounds** already count a native watch as one concurrent request
+ (64 per session, 2000 per replica by default), hold it to the 30-minute response
+ duration, and abort it when the session ends. The response duration now works
+ *with* the connector: the watch is cut, the connector resumes from its checkpoint,
+ and the user sees nothing. The same goes for the 32 MiB response-bytes bound, which
+ a long watch under churn can reach: cut, resumed, unnoticed.
+- **Logout**: the session check aborts the watch; the connector tries to resume;
+ krm-foyer answers 401; the connector stops, terminally. One request, no loop.
+- **Request rate**: each resume is one request against the session's 20/s (burst 100),
+ inside the connector's backoff and bounded retry budget.
+
+#### Should `/k8s` check native patches?
+
+krm-stream's guide says the host proxy should run `gateway.ValidateNativeMergePatch`
+(both preconditions present, no machinery fields). **Recommendation: not in `/k8s`.**
+`/k8s` is the Kubernetes API with the user's token and nothing more; refusing an
+unconditional PATCH that RBAC allows is the second set of rules the
+[design](../design.md#access) avoids, and it would break `kubectl`-style clients. The
+check is not a security boundary either: the user can PATCH the same object without
+the store. The store already refuses machinery edits, and the API server enforces the
+preconditions when they are sent. Record this as a deliberate difference in
+`docs/design.md` and say why.
+
+### 3. A page that uses it
+
+The hello example's notes are a small custom resource, which suits native access. Two
+options:
+
+- **A source switch on hello** (`?source=native`, default stays the gateway). One page,
+ the same rendering and editing code, both sources under the same session, logout and
+ RBAC specs. Shows the point "the page does not care which source" directly.
+- **A second example.** Cleaner to read, but duplicates the page and its specs.
+
+Recommendation: the switch. Writes on the native path go through `foyer.js`'s `k8s()`
+with `nativeObjectURL('', ...)`, so the CSRF proof is added and 409/403 keep their
+existing outcomes. A 409 recovers with a guarded native GET
+(`store.captureReconciliation`), which `/k8s` serves as is; the gateway path keeps its
+current "reopen and save again" recovery.
+
+### 4. Specs that try to get past the boundaries
+
+Browser specs (chromedp, label `browser`), each checked against a deliberately broken
+example as the existing ones are:
+
+- A change made elsewhere appears without a reload (native).
+- **Resume**: the connection is cut (a short `-max-response-duration` in a dedicated
+ spec, or the session-check abort path); the store keeps the draft, no new LIST
+ reaches krm-foyer, and a change made during the gap arrives.
+- **Logout** ends the native watch, and the resume attempt is refused once, terminally:
+ no retry loop. The audit log witnesses no further watch for that user.
+- **RBAC**: a user who may not list notes gets a terminal `FORBIDDEN` on the native
+ source, and the page does **not** fall back to the gateway (and the gateway page does
+ not fall back to native).
+- **Conflict**: two users edit; the second native save gets 409 and keeps its draft.
+
+The rehearsal (200 identities) stays gateway-only unless we decide to measure native
+watches; [watches](../watches.md) says why they were not measured.
+
+### 5. Docs that change in the same PR
+
+- [watches.md](../watches.md): the native column becomes "krm-stream's native
+ connector"; "requested, not yet shipped" goes; the choosing guide gains the
+ trade-off from [the numbers](#what-improved-in-numbers).
+- [bounds.md](../bounds.md#native-watches): the client library now exists; the
+ response duration and logout behaviour above.
+- [design.md](../design.md): the streams section, and the patch-check decision.
+- [roadmap.md](../roadmap.md): a checked item under Streams.
+- [The request](krm-stream-native-connector-request.md): marked delivered, with what
+ was and was not (pagination, streaming lists).
+
+### Worth checking while doing it
+
+- **HTTP/2 in the e2e front door.** [ingress.md](../ingress.md) asks for HTTP/2 to the
+ browser, because HTTP/1.1 allows six connections per origin and each watch holds
+ one. `test/e2e/cluster/nginx-door.conf` says `listen 8443 ssl;` without `http2 on;`,
+ so the fixture's browser may be on HTTP/1.1. krm-stream's comparison page hit exactly
+ this: six streams, and the next save never left the browser. A page with a
+ native and a gateway connection is fine; one with several collections is not.
+- **Large collections.** Until krm-stream paginates, a native source is for
+ collections that fit one LIST response under krm-foyer's 32 MiB response bound.
+
+## What native watches do not give
+
+Say these plainly in `watches.md`, so nobody picks native for the wrong reason:
+
+- **No redaction.** A user who can read a Secret gets its values. That is RBAC's
+ answer, not a leak, but a page that only needs to know a Secret *changed* should use
+ `krm-full/v1`.
+- **No suppression.** Every status update and every `managedFields` change reaches
+ the browser and the store.
+- **No sharing.** N tabs are N watches at the API server.
+- **RBAC revocation** behaves as for a per-user stream: an open watch carries on until
+ it ends. With krm-foyer's response bound that is at most 30 minutes; the resume is a
+ new request, so the API server decides again at that point.
+
+## Proposed order
+
+1. **PR 1** (done): the bump (step 1). Existing specs
+ prove nothing changed.
+2. **PR 2**: the hello source switch, its specs and the doc updates (steps 3–5).
+
+Both are small; PR 2 carries the risk, and its security specs are most of its size.
diff --git a/docs/investigations/krm-stream-native-connector-request.md b/docs/investigations/krm-stream-native-connector-request.md
index 046e739..ffdd850 100644
--- a/docs/investigations/krm-stream-native-connector-request.md
+++ b/docs/investigations/krm-stream-native-connector-request.md
@@ -2,6 +2,10 @@
**Draft for the krm-stream team, 2026-10-05. Not submitted upstream.**
+**Update 2026-10-06:** krm-stream built Deliverables 1 and 2 (0.8.0, and native resume
+in 0.10.0), without pagination or streaming lists. What it takes to adopt
+them here: [adopting native watches](adopting-krm-stream-native-watches.md).
+
Suggested issue title: **feat(client): add a native Kubernetes watch connector with
the shared frontend lifecycle**
diff --git a/docs/investigations/stream-investigate.md b/docs/investigations/stream-investigate.md
new file mode 100644
index 0000000..98958bb
--- /dev/null
+++ b/docs/investigations/stream-investigate.md
@@ -0,0 +1,250 @@
+# Could `/k8s` share watches without anyone noticing?
+
+**Investigation, 2026-10-06, revised the same day.** Kubernetes facts are from the
+Kubernetes repository at `35fc3af1380` (`v1.38.0-alpha.1-235`, checked out in
+`external/kubernetes`) and from the `k8s.io/apiserver` and `k8s.io/apimachinery`
+v0.36.0 modules krm-foyer already builds against. Paths are relative to
+`staging/src/k8s.io/` unless they start with `pkg/`. Nothing here is implemented.
+
+The first version of this document said a transparent shared `/k8s` was not
+workable, because a proxy cannot compare resource versions. That was wrong for
+watches: Kubernetes now ships a comparison, and Kubernetes' own client-go uses it.
+This version replaces that analysis.
+
+## The question
+
+A room of 200 people scans a QR code and opens the same page. Each browser LISTs the
+notes and then WATCHes them through `/k8s`, so the API server holds 200 watches of one
+collection. krm-stream's gateway avoids that by serving everyone from one upstream
+watch, but it does so with its own SSE protocol, which is one more way to do things.
+
+Could `/k8s` notice that many people make the same request, open **one** watch, and
+send each of them the same data, without any client being able to tell? Is that not
+the same game as the gateway's shared watches?
+
+## Short answer
+
+- **For WATCH, yes, soundly**, on resources whose resource versions are integers
+ (everything stored in etcd: built-in resources and CRDs). One upstream watch per
+ `(resource, namespace, selectors, encoding)` can serve every request that watches
+ from a resource version at or after the point where the shared watch started. Each
+ client gets exactly the events after its own version, the same events the API server
+ would have sent it.
+- **For LIST, it is not worth it.** The API server already serves consistent LISTs
+ from its cache, and people in a room arrive over tens of seconds, not milliseconds.
+ LISTs stay pass-through.
+- **Visible to an operator, not to a client.** The API server's audit log shows the
+ shared watch and a SelfSubjectAccessReview per user, instead of a watch per user.
+ krm-foyer must also start telling watches apart, using the API server's own parser.
+- **It is the same game as the gateway's sharing, minus the protocol.** The gateway
+ can admit a subscriber at any moment because its protocol defines what a subscriber
+ gets: a snapshot from the gateway's cache. The native protocol defines freshness
+ relative to the client's own request, so the proxy may only serve what the API
+ server would have served. Comparable resource versions are what make that check
+ possible.
+- **It reduces the number of ways.** Pages use one browser connector, krm-stream's
+ native one, against `/k8s`. Whether a resource is shared becomes a deployment
+ setting, the same `-shared-watch-resources` that exists today, not a choice each page
+ makes. The gateway remains for pages that need a projected view (redaction,
+ suppression).
+
+**Recommendation:** measure the room first (an experiment is
+[below](#measure-first)). If the watches hurt, build this in `/k8s` rather than asking
+pages to switch protocol.
+
+## Why a watch, not a LIST, is the thing to share
+
+### What Kubernetes already shares
+
+| Mechanism | What it does | Where |
+| --- | --- | --- |
+| **Watch cache (`Cacher`)** | "Responsible for serving WATCH and LIST requests for a given resource from its internal cache." One storage watch per resource, however many clients watch it | `apiserver/pkg/storage/cacher/cacher.go` |
+| **`cachingObject`** | "Able to cache its serializations so that each of those is computed exactly once": a thousand JSON watchers of one event cost one JSON encoding | `apiserver/pkg/storage/cacher/caching_object.go` |
+| **Consistent LIST from cache** | A LIST without a `resourceVersion` is served from the watch cache, which first proves it is fresh with an etcd progress notification (`RequestWatchProgress`). GA and locked on since 1.34 | `apiserver/pkg/features/kube_features.go` (`ConsistentListFromCache`) |
+| **Watch history** | At least `DefaultEventFreshDuration` (bookmark frequency, one minute, plus 15 s), from 100 up to 102 400 events | `apiserver/pkg/storage/cacher/watch_cache.go` |
+
+A LIST is therefore a read from memory, charged by APF (API Priority and Fairness, the
+API server's request throttling) by object count, and then it is over. Two hundred
+LISTs of a small collection spread over a minute are not a storm.
+
+### What a watch keeps costing
+
+Per open watch, the API server keeps a watcher with its own goroutine and buffer
+(`cache_watcher.go`). It filters every event of the resource for that watcher and writes
+it to that connection. A watcher that cannot keep up is terminated: "Since we don't want
+to block on it infinitely, we simply terminate it."
+
+APF makes the cost land on writers: `apiserver/pkg/util/flowcontrol/request/mutating_work_estimator.go`
+charges every write `ceil(watchers / 10)` extra seats for 5 ms once its resource has
+10 or more watchers (`watchesPerSeat = 10`). Its comment: "the actual work associated
+with processing watch events is happening in multiple goroutines (proportional to the
+number of watchers)".
+
+So 1800 watches on one resource (the size of krm-foyer's
+[rehearsal](../bounds.md#measured-the-rehearsal)) make every write to it cost about
+180 seats for 5 ms. That counts against the writer's priority level, which can be a
+controller's. One shared watch makes it one watcher. **That is the load worth removing**,
+and it comes from watches, not LISTs.
+
+## How a shared native watch stays exact
+
+### The missing piece: comparable resource versions
+
+The API documentation tells clients to treat `resourceVersion` as opaque. The API server
+keeps its cache fresh by comparing versions internally. A proxy that cannot compare
+cannot know whether a client's version falls inside the history it holds.
+
+Kubernetes has since added `apimachinery/pkg/util/resourceversion.CompareResourceVersion`
+(commit `2cef54c1450`, 2025-09-29, "Add helper function for client-go to compare
+resource version"). It is present in v0.36.0, which krm-foyer already depends on:
+
+> CompareResourceVersion runs a comparison between two ResourceVersions. This only has
+> semantic meaning when the comparison is done on two objects of the same resource.
+> ... The function will return an error if the resource version is not a properly
+> formatted positive integer.
+
+Kubernetes uses it the same way a proxy would. `client-go/util/consistency` tracks
+"written resource versions and ensuring that local informer caches have observed
+resource versions at least as new as those written". The storage version migrator also
+relies on it and fails a migration when versions are not comparable
+(`pkg/controller/storageversionmigrator`). Where a version is not a well-formed integer,
+as an aggregated API may serve, the helper returns an error. The proxy then simply
+passes the request through.
+
+### The rule
+
+The proxy holds one upstream watch per key: resource, namespace, label selector, field
+selector, `Accept` encoding. It opened that watch from version **S** and keeps a history
+of every event since, in order.
+
+A user's `WATCH ?resourceVersion=X` for that key can join if `X ≥ S`. The proxy then
+sends that user exactly the events with version **> X**: first from history, then live.
+This is what the API server would have sent. A watch from X means "everything after X",
+and the shared watch has received everything after S, in order, for the same key.
+Events at or before X are already in the user's LIST. An event that arrives later with a
+version ≤ X is skipped, for the same reason.
+
+There is no freshness wait. If the user's X is ahead of what the shared watch has
+received so far, the missing events are still on their way, in order, and arrive like
+any live event.
+
+Everything else passes through to the API server, as today: a LIST; a WATCH with
+`X < S`, an unset or `"0"` version, `sendInitialEvents`, or a non-integer version; a
+resource that is not configured for sharing.
+
+For the room: the first person's WATCH opens the shared watch from their LIST's
+version. Everyone who arrives later has a LIST at least as new, so `X ≥ S`, and joins.
+krm-stream's native connector resumes from the last version it applied, which on a
+shared stream is a version the shared watch delivered, so that joins too. Two hundred
+browsers, one watch at the API server.
+
+### What else must match
+
+| Native behaviour | Shared `/k8s` | Source |
+| --- | --- | --- |
+| Recognizing a WATCH (`watch=1`, `watch=yes`, a bare `watch`, `/watch/` paths) | Use the API server's own `RequestInfoFactory`, not a second parser ([why krm-foyer avoided this so far](../bounds.md#native-watches)) | `apiserver/pkg/endpoints/request/requestinfo.go` |
+| Authorization of the user's WATCH | A SelfSubjectAccessReview **with the user's own token**, built from the same attributes the API server's authorization filter builds: verb, group, version, resource, namespace, name (from a `metadata.name` field selector), field and label selector requirements. Kubernetes keeps the SAR fields and the authorizer attributes in step with a test (`pkg/registry/authorization/util/helpers_test.go`). No `create subjectaccessreviews` grant is needed for this | `apiserver/pkg/endpoints/filters/authorization.go` (`GetAuthorizerAttributes`) |
+| Authentication | The same SelfSubjectAccessReview only succeeds with a token the API server accepts | |
+| A response that depends on who asks | None for list and watch in kube-apiserver's registries: their uses of the requesting user are on create and update (CSR requestor, PodCertificateRequest status, RBAC escalation, admission policy bindings, the self-reviews). Aggregated APIs may filter per user, so pass them through | `pkg/registry/**` (`UserFrom`) |
+| Event bytes | Same key, same `Accept`: the frames can be relayed as received, parsed only for type and version | |
+| Bookmarks | Relay those with a version > X when the client asked for bookmarks | |
+| Object leaving a label selector | Keyed on the exact selector, so the API server already turns it into `DELETED` | |
+| History expired (410) on the shared watch | Every subscriber receives the 410 and re-lists, as each would have | |
+| Shared watch ends (API server timeout) | Reopen from the last version, unseen; the history continues | |
+| Per-client `timeoutSeconds`, response bounds, logout | Applied per client, exactly as today; ending one client's response leaves the others | `internal/gate` |
+| Slow client | Drop that client alone (abort). The native connector resumes and joins again from its version, as the API server's own watch cache does with a slow watcher | `cache_watcher.go` |
+| Revocation | A native watch is authorized once and never rechecked. Rechecking like shared streams do is optional and stricter | |
+| Audit | The API server records the shared identity's watch and each user's SelfSubjectAccessReview, not each user's watch | **Visible to operators** |
+
+The identity that holds the shared watch is the one shared watches already use:
+`-shared-watch-token-file`, granted `list` and `watch` on the configured resources and
+nothing else. With per-user SelfSubjectAccessReviews it no longer needs
+`create subjectaccessreviews`. That part of the existing gateway path could be
+simplified the same way.
+
+### What it costs krm-foyer
+
+- **Telling watches apart.** `bounds.md` records the decision not to parse requests.
+ This would reverse it for configured resources only, using the API server's own code.
+ That adds a dependency on `k8s.io/apiserver`, a large module, and krm-foyer must keep
+ it in step with the clusters it fronts.
+- **A history per shared watch,** bounded in events and age like the API server's. A
+ client older than the history passes through, so the only effect is less sharing.
+- **Two code paths in `/k8s`:** pass-through and shared. Every bound, the logout cut-off
+ and the response limits must hold on both, tested against the real API server.
+- **Security tests that try to get through:** a user denied by RBAC joins nothing; a
+ shared watch never delivers to a session after logout; one user's slow or aborted
+ stream never stalls another's; a client with `X < S` never receives a partial history;
+ a non-integer version, an aggregated API and an unconfigured resource always pass
+ through; the shared identity is never used for anything but the watch.
+
+## Is it the same game as the gateway?
+
+| | Gateway shared stream | Shared native watch in `/k8s` |
+| --- | --- | --- |
+| Upstream | One watch per scope | One watch per key |
+| A subscriber can join | Any time: the gateway sends a snapshot from its cache | When its version ≥ S: the proxy sends what the API server would have |
+| Freshness defined by | krm-stream's protocol | The Kubernetes API |
+| Browser code | `connectResourceStream` (SSE) | `connectNativeWatch`, unchanged |
+| Per-user authorization | SubjectAccessReview by the shared identity, rechecked | SelfSubjectAccessReview with the user's token, at join |
+| Projection, redaction, suppression | Yes | No: native objects |
+| Reconnect | Fresh snapshot | Resume from the client's version |
+
+The fan-out is the same. What differs is who defines what a joining client gets. The
+gateway can take any subscriber because it owns its protocol. The proxy must stay inside
+the native one, so it may only take a request whose answer it can reproduce exactly.
+
+## Prior art
+
+| Project | Shares watches across users? | API it serves |
+| --- | --- | --- |
+| kube-apiserver `Cacher` | Yes: one storage watch per resource | Native. It owns consistency with etcd |
+| client-go `SharedInformerFactory` | Within one process and one identity | Go objects in memory |
+| client-go `util/consistency` | No, but it is the same "has my cache caught up" check, by comparing resource versions | |
+| `kubectl proxy` | No | Native, passed through |
+| [Rancher steve](https://github.com/rancher/steve) | Yes: its own informers, with per-user access computed from RBAC bindings | Its own WebSocket watch API, `/v1/subscribe`; its Kubernetes proxy passes through with `Impersonate-*` |
+| krm-stream gateway | Yes, `SharedBackend` | Its own SSE protocol |
+
+I found no proxy that shares behind the native API. The ones that share define their own
+protocol. Comparable resource versions are recent (2025), which may be part of why.
+This is not a survey of every dashboard.
+
+## Fewer ways, not more
+
+Today a page chooses between three: a per-user stream, a shared stream (both via the
+gateway's SSE) and a native watch. With shared native watches the choice becomes:
+
+1. **Does the page need a projected view** (Secret values withheld, status-only updates
+ suppressed)? Then the gateway.
+2. **Otherwise, native.** One connector, `/k8s`, and whether the resource is shared is
+ the operator's setting, not the page's.
+
+Before choosing, also ask whether the gateway's per-user streams and shared streams are
+still needed once native sharing exists. The gateway would keep projected views; its
+sharing would serve only pages that use them.
+
+## Measure first
+
+Add a native variant to the rehearsal (`test/e2e`), next to its per-user and shared
+gateway runs:
+
+- 200 identities, each with krm-stream's native connector on one collection through
+ `/k8s`, and again on nine collections;
+- a steady write rate to the watched resource from a controller-like client in its own
+ APF priority level;
+- recorded: `apiserver_registered_watchers`, `apiserver_flowcontrol_work_estimated_seats`
+ and wait time for those writes, write latency, API server CPU and memory.
+
+If 1800 native watches do not visibly delay writes or grow the API server, nothing
+needs sharing. If they do, the same run is the baseline for the shared `/k8s`, which
+should show one watcher and unchanged write cost.
+
+## Side finding: selectors in shared-watch reviews
+
+krm-stream's `kube.SubjectAccessReviewAuthorizer` leaves label and field selectors out
+of its reviews (`gateway/kube/authz.go`), and krm-foyer's decision cache keys on the
+scope without the label selector ([watches](../watches.md#what-happens-for-each-stream)).
+Since `AuthorizeWithSelectors` went GA in 1.34, an authorizer (a webhook, not RBAC) may
+allow a list or watch only with a certain selector. Asking without the selector asks the
+broader question, so such a grant is refused. That is the safe direction: it can
+over-refuse, never over-grant. A shared `/k8s` would send the selectors, as above.
diff --git a/docs/watches.md b/docs/watches.md
index 10a2eae..3cf6406 100644
--- a/docs/watches.md
+++ b/docs/watches.md
@@ -66,7 +66,7 @@ either way later: only krm-foyer's flags change, never the page.
## Choosing a projection
-These views are implemented in the pinned krm-stream **0.7.0** gateway, and krm-foyer
+These views are implemented in the pinned krm-stream **0.10.0** gateway, and krm-foyer
accepts all three. Pass `projection` on `/stream/v1`; omitting it selects `krm-full/v1`.
| Projection | Status | Secret values | Use when |
diff --git a/examples/hello/README.md b/examples/hello/README.md
index 98af337..860e051 100644
--- a/examples/hello/README.md
+++ b/examples/hello/README.md
@@ -41,15 +41,16 @@ change either way.
```js
import { session, login, k8s } from '/_foyer/foyer.js';
-import { LiveResourceStore, connectManagedResourceStream, resourceStreamURL } from './krm-stream.js';
+import { LiveResourceStore, applyStreamEvent, connectResourceStream, resourceStreamURL } from './krm-stream.js';
const s = await session(); // {authenticated, email, ...}; never a token
if (!s.authenticated) login(); // to the issuer, and back to this page
// The notes as they are, and as they change, for what RBAC lets this user see.
const store = new LiveResourceStore();
-connectManagedResourceStream(resourceStreamURL('/stream/v1',
- { group: 'hello.krm-foyer.example', version: 'v1', resource: 'notes', namespace: 'hello' }), store);
+connectResourceStream(resourceStreamURL('/stream/v1',
+ { group: 'hello.krm-foyer.example', version: 'v1', resource: 'notes', namespace: 'hello' }),
+ (event) => applyStreamEvent(store, event));
// A save is the user's edit alone, on the version they last saw.
const { uid, resourceVersion, patch } = store.captureSave(id);
diff --git a/examples/hello/web/app.js b/examples/hello/web/app.js
index 7fcbd8b..f992575 100644
--- a/examples/hello/web/app.js
+++ b/examples/hello/web/app.js
@@ -11,7 +11,7 @@
import { session, login, logout, k8s } from '/_foyer/foyer.js';
// krm-stream's browser library, one file vendored from its npm package (task
// vendor-krm-stream). It keeps what the server sent apart from what the user typed.
-import { LiveResourceStore, connectManagedResourceStream, resourceStreamURL } from './krm-stream.js';
+import { LiveResourceStore, applyStreamEvent, connectResourceStream, resourceStreamURL } from './krm-stream.js';
const namespace = new URLSearchParams(location.search).get('namespace') || 'hello';
const group = 'hello.krm-foyer.example';
@@ -94,12 +94,15 @@ function showConnection(state) {
function connect() {
connection?.close();
return new Promise((synced) => {
- connection = connectManagedResourceStream(
+ connection = connectResourceStream(
resourceStreamURL('/stream/v1', { group, version: 'v1', resource: 'notes', namespace }),
- store,
+ (event) => {
+ applyStreamEvent(store, event);
+ if (event.type === 'synced') {
+ synced();
+ }
+ },
{
- onSynced: synced,
- onStateChange: showConnection,
onError: (code, message, terminal) => {
if (!terminal) {
return; // the connection recovers on its own
@@ -113,6 +116,10 @@ function connect() {
},
},
);
+ showConnection(connection.state);
+ connection.subscribe(showConnection);
+ // A rendering bug stops the stream; say so rather than leave it unhandled.
+ connection.closed.catch((err) => say(`The live view stopped: ${err.message}`, 'error'));
});
}
diff --git a/examples/hello/web/krm-stream.js b/examples/hello/web/krm-stream.js
index 6088230..d495fe9 100644
--- a/examples/hello/web/krm-stream.js
+++ b/examples/hello/web/krm-stream.js
@@ -1,3 +1,220 @@
+// dist/lifecycle.js
+function runConnection(transport, consume, opts) {
+ const maxRetries = opts.maxRetries ?? 8;
+ const healthyResetMs = opts.healthyResetMs ?? 3e4;
+ const delay = opts.retryDelayMs ?? 500;
+ const cap = opts.maxRetryDelayMs ?? 3e4;
+ if (!Number.isFinite(healthyResetMs) || healthyResetMs <= 0 || healthyResetMs > 2147483647 || !Number.isSafeInteger(maxRetries) || maxRetries < 0 || !Number.isFinite(delay) || delay < 0 || !Number.isFinite(cap) || cap < 0 || cap > 2147483647) {
+ throw new RangeError("krm-stream: invalid retry budget or delay");
+ }
+ const controller = new AbortController();
+ const subscribers = /* @__PURE__ */ new Set();
+ let state = Object.freeze({ status: "connecting", retries: 0 });
+ let terminal = false;
+ let hintMs;
+ let healthTimer;
+ const clearHealthTimer = () => {
+ clearTimeout(healthTimer);
+ healthTimer = void 0;
+ };
+ let failure;
+ const fail = (error) => {
+ failure ??= { error };
+ clearHealthTimer();
+ controller.abort();
+ };
+ const call = (callback) => {
+ try {
+ callback();
+ } catch (error) {
+ fail(error);
+ }
+ };
+ const publish = (status, detail = {}) => {
+ state = Object.freeze({ status, retries: state.retries, ...detail });
+ for (const callback of subscribers)
+ call(() => callback(state));
+ };
+ const close = () => {
+ clearHealthTimer();
+ controller.abort();
+ };
+ opts.signal?.addEventListener("abort", close, { once: true });
+ if (opts.signal?.aborted)
+ close();
+ const run = async () => {
+ while (!controller.signal.aborted) {
+ publish("connecting");
+ if (controller.signal.aborted)
+ break;
+ hintMs = void 0;
+ let gap;
+ await transport({
+ consume: (event) => call(() => consume(event)),
+ opened: () => publish("syncing"),
+ reset: () => {
+ clearHealthTimer();
+ publish("syncing");
+ },
+ synced: () => {
+ hintMs = void 0;
+ if (state.status !== "live") {
+ healthTimer = setTimeout(() => {
+ healthTimer = void 0;
+ if (!controller.signal.aborted && state.status === "live") {
+ state = { ...state, retries: 0 };
+ publish("live");
+ }
+ }, healthyResetMs);
+ }
+ publish("live");
+ },
+ gap: (expected, received) => {
+ clearHealthTimer();
+ gap = Object.freeze({ expected, received });
+ },
+ error: (code, message, isTerminal, retryAfterMs) => {
+ if (isTerminal)
+ clearHealthTimer();
+ terminal ||= isTerminal;
+ if (!isTerminal && retryAfterMs !== void 0)
+ hintMs = retryAfterMs;
+ call(() => opts.onError?.(code, message, isTerminal, retryAfterMs));
+ }
+ }, controller.signal);
+ clearHealthTimer();
+ if (controller.signal.aborted)
+ break;
+ if (terminal)
+ return "terminal";
+ if (state.retries >= maxRetries)
+ return "exhausted";
+ const ceiling = Math.min(cap, delay * 2 ** Math.min(state.retries, 30));
+ const jittered = Math.floor(ceiling * (0.5 + Math.random() * 0.5));
+ const wait = Math.min(cap, Math.max(jittered, hintMs ?? 0));
+ state = { ...state, retries: state.retries + 1 };
+ publish("retrying", { retryInMs: wait, ...gap && { gap } });
+ await new Promise((resolve) => {
+ const done = () => {
+ clearTimeout(timer);
+ controller.signal.removeEventListener("abort", done);
+ resolve();
+ };
+ const timer = setTimeout(done, wait);
+ controller.signal.addEventListener("abort", done, { once: true });
+ if (controller.signal.aborted)
+ done();
+ });
+ }
+ return "closed";
+ };
+ const closed = Promise.resolve().then(async () => {
+ try {
+ const ended = await run();
+ publish(failure ? "closed" : ended);
+ } finally {
+ clearHealthTimer();
+ opts.signal?.removeEventListener("abort", close);
+ subscribers.clear();
+ }
+ if (failure)
+ throw failure.error;
+ });
+ return {
+ close,
+ closed,
+ get state() {
+ return state;
+ },
+ subscribe(callback) {
+ subscribers.add(callback);
+ return () => {
+ subscribers.delete(callback);
+ };
+ }
+ };
+}
+
+// dist/http.js
+function request(url, accept, signal, opts) {
+ return (opts.fetch ?? globalThis.fetch)(url, {
+ signal,
+ headers: { Accept: accept, ...opts.headers },
+ cache: "no-store",
+ credentials: opts.credentials ?? "same-origin"
+ });
+}
+function refusal(status) {
+ const code = status === 401 ? "UNAUTHENTICATED" : status === 403 ? "FORBIDDEN" : status === 429 || status === 502 || status === 503 || status === 504 ? "UPSTREAM_UNAVAILABLE" : "INTERNAL";
+ return { code, terminal: status >= 400 && status < 500 && status !== 408 && status !== 429 };
+}
+var maxStatusBytes = 16 * 1024;
+var statusBudgetMs = 2e3;
+async function statusMessage(res, signal, budgetMs = statusBudgetMs) {
+ if (!res.body)
+ return void 0;
+ if (signal.aborted || !/^application\/json\b/i.test(res.headers.get("Content-Type") ?? "")) {
+ await res.body.cancel().catch(() => {
+ });
+ return void 0;
+ }
+ const reader = res.body.getReader();
+ let gaveUp = false;
+ const giveUp = () => {
+ gaveUp = true;
+ void reader.cancel().catch(() => {
+ });
+ };
+ const timer = setTimeout(giveUp, budgetMs);
+ signal.addEventListener("abort", giveUp, { once: true });
+ const chunks = [];
+ let size = 0;
+ try {
+ for (; ; ) {
+ const { done, value } = await reader.read();
+ if (gaveUp)
+ return void 0;
+ if (done)
+ break;
+ size += value.byteLength;
+ if (size > maxStatusBytes)
+ return void 0;
+ chunks.push(value);
+ }
+ const bytes = new Uint8Array(size);
+ let offset = 0;
+ for (const chunk of chunks) {
+ bytes.set(chunk, offset);
+ offset += chunk.byteLength;
+ }
+ const status = JSON.parse(new TextDecoder().decode(bytes));
+ if (typeof status !== "object" || status === null)
+ return void 0;
+ const { kind, message } = status;
+ return kind === "Status" && typeof message === "string" && message !== "" ? message : void 0;
+ } catch {
+ return void 0;
+ } finally {
+ clearTimeout(timer);
+ signal.removeEventListener("abort", giveUp);
+ await reader.cancel().catch(() => {
+ });
+ }
+}
+function retryAfter(header, now = Date.now()) {
+ if (header === null)
+ return void 0;
+ const value = header.trim();
+ if (/^\d+$/.test(value))
+ return Number(value) * 1e3;
+ const at2 = Date.parse(value);
+ return Number.isNaN(at2) ? void 0 : Math.max(0, at2 - now);
+}
+
+// dist/version.js
+var VERSION = "0.10.0";
+var PROTOCOL_VERSION = 1;
+
// dist/sse.js
var SSEDecoder = class {
#buffer = "";
@@ -52,61 +269,93 @@ function parseFrame(frame) {
return null;
}
}
-function applyStreamEvent(store, ev) {
- switch (ev.type) {
+function toStateEvent(wire) {
+ switch (wire.type) {
case "reset":
- store.beginSnapshot();
- return { type: ev.type, added: false, structural: false, flashed: [], conflicts: [] };
+ return {
+ type: "reset",
+ ...wire.target === void 0 ? {} : { target: wire.target },
+ ...wire.scope === void 0 ? {} : { scope: wire.scope },
+ ...wire.projection === void 0 ? {} : { projection: wire.projection }
+ };
case "added":
- case "modified": {
- if (!ev.object)
- return { type: ev.type, added: false, structural: false, flashed: [], conflicts: [] };
- const result = store.applyServerEvent(ev.object, { redacted: ev.redacted });
- return { type: ev.type, uid: ev.object.metadata.uid, ...result };
- }
- case "deleted": {
- const uid = ev.identity?.uid;
- if (uid)
- store.removeResource(uid);
- return { type: ev.type, uid, added: false, structural: true, flashed: [], conflicts: [] };
- }
+ case "modified":
+ if (!wire.object)
+ return null;
+ return {
+ type: wire.type,
+ object: wire.object,
+ ...wire.redacted === void 0 ? {} : { redacted: wire.redacted }
+ };
+ case "deleted":
+ if (!wire.identity?.uid)
+ return null;
+ return { type: "deleted", identity: wire.identity };
case "synced":
- store.endSnapshot();
- return { type: ev.type, added: false, structural: false, flashed: [], conflicts: [] };
+ return { type: "synced" };
default:
- return { type: ev.type, added: false, structural: false, flashed: [], conflicts: [] };
+ return null;
}
}
-function connectResourceStream(url, store, opts = {}) {
- if (opts.signal?.aborted)
- return { close: () => {
- }, closed: Promise.resolve() };
+async function streamOnce(url, hooks, signal, opts = {}) {
const controller = new AbortController();
- const fetchImpl = opts.fetch ?? globalThis.fetch;
const abort = () => controller.abort();
- opts.signal?.addEventListener("abort", abort, { once: true });
- const closed = (async () => {
- const res = await fetchImpl(url, {
- signal: controller.signal,
- headers: { Accept: "text/event-stream", ...opts.headers },
- // The stream IS the response body; a cached one is a stream that never moves.
- cache: "no-store",
- credentials: opts.credentials ?? "same-origin"
- });
+ signal.addEventListener("abort", abort, { once: true });
+ if (signal.aborted)
+ abort();
+ const sequence = new StreamSequence();
+ const { consume } = hooks;
+ const deliver2 = (wire) => {
+ const gap = sequence.observe(wire);
+ if (gap) {
+ hooks.gap(gap.expected, gap.received);
+ return false;
+ }
+ if (wire.type === "error") {
+ const hint = typeof wire.retryAfterMs === "number" && wire.retryAfterMs >= 0 ? wire.retryAfterMs : void 0;
+ hooks.error(wire.code ?? "INTERNAL", wire.message ?? "", wire.terminal ?? false, hint);
+ return wire.terminal !== true;
+ }
+ const event = toStateEvent(wire);
+ if (!event)
+ return true;
+ if (event.type === "reset") {
+ hooks.reset();
+ if (controller.signal.aborted)
+ return false;
+ }
+ consume(event);
+ if (event.type === "synced" && !controller.signal.aborted)
+ hooks.synced();
+ return true;
+ };
+ try {
+ if (controller.signal.aborted)
+ return;
+ const res = await request(url, "text/event-stream", controller.signal, opts);
if (!res.ok || !res.body) {
- const code = res.status === 401 ? "UNAUTHENTICATED" : res.status === 403 ? "FORBIDDEN" : res.status === 429 || res.status === 502 || res.status === 503 || res.status === 504 ? "UPSTREAM_UNAVAILABLE" : "INTERNAL";
- const terminal = res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429;
+ const { code, terminal } = refusal(res.status);
const message = await statusMessage(res, controller.signal) ?? `stream: HTTP ${res.status}`;
if (controller.signal.aborted)
return;
- opts.onError?.(code, message, terminal, retryAfter(res.headers.get("Retry-After")));
+ hooks.error(code, message, terminal, retryAfter(res.headers.get("Retry-After")));
return;
}
if (controller.signal.aborted) {
- await res.body.cancel();
+ await res.body.cancel().catch(() => {
+ });
+ return;
+ }
+ const protocol = res.headers.get("X-KRM-Stream-Protocol");
+ if (protocol !== null && protocol.trim() !== String(PROTOCOL_VERSION)) {
+ await res.body.cancel().catch(() => {
+ });
+ if (controller.signal.aborted)
+ return;
+ hooks.error("INTERNAL", `stream: protocol mismatch: the gateway speaks X-KRM-Stream-Protocol ${JSON.stringify(protocol)}, this client speaks ${PROTOCOL_VERSION}`, true);
return;
}
- opts.onOpen?.();
+ hooks.opened();
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
const cancelReader = () => {
void reader.cancel().catch(() => {
@@ -116,7 +365,6 @@ function connectResourceStream(url, store, opts = {}) {
if (controller.signal.aborted)
cancelReader();
const decoder = new SSEDecoder();
- const sequence = new StreamSequence();
try {
for (; ; ) {
const { done, value } = await reader.read();
@@ -125,278 +373,26 @@ function connectResourceStream(url, store, opts = {}) {
for (const ev of decoder.push(value)) {
if (controller.signal.aborted)
return;
- if (feed(store, sequence, ev, opts)) {
+ if (!deliver2(ev)) {
controller.abort();
return;
}
}
}
- } catch (err) {
- if (!controller.signal.aborted)
- throw err;
} finally {
controller.signal.removeEventListener("abort", cancelReader);
await reader.cancel().catch(() => {
});
}
- })();
- return {
- close: () => controller.abort(),
- closed: closed.catch(() => {
- }).finally(() => opts.signal?.removeEventListener("abort", abort))
- };
-}
-var maxStatusBytes = 16 * 1024;
-var statusBudgetMs = 2e3;
-async function statusMessage(res, signal, budgetMs = statusBudgetMs) {
- if (!res.body)
- return void 0;
- if (signal.aborted || !/^application\/json\b/i.test(res.headers.get("Content-Type") ?? "")) {
- await res.body.cancel().catch(() => {
- });
- return void 0;
- }
- const reader = res.body.getReader();
- let gaveUp = false;
- const giveUp = () => {
- gaveUp = true;
- void reader.cancel().catch(() => {
- });
- };
- const timer = setTimeout(giveUp, budgetMs);
- signal.addEventListener("abort", giveUp, { once: true });
- const chunks = [];
- let size = 0;
- try {
- for (; ; ) {
- const { done, value } = await reader.read();
- if (gaveUp)
- return void 0;
- if (done)
- break;
- size += value.byteLength;
- if (size > maxStatusBytes)
- return void 0;
- chunks.push(value);
- }
- const bytes = new Uint8Array(size);
- let offset = 0;
- for (const chunk of chunks) {
- bytes.set(chunk, offset);
- offset += chunk.byteLength;
- }
- const status = JSON.parse(new TextDecoder().decode(bytes));
- if (typeof status !== "object" || status === null)
- return void 0;
- const { kind, message } = status;
- return kind === "Status" && typeof message === "string" && message !== "" ? message : void 0;
} catch {
- return void 0;
} finally {
- clearTimeout(timer);
- signal.removeEventListener("abort", giveUp);
- await reader.cancel().catch(() => {
- });
- }
-}
-function retryAfter(header, now = Date.now()) {
- if (header === null)
- return void 0;
- const value = header.trim();
- if (/^\d+$/.test(value))
- return Number(value) * 1e3;
- const at2 = Date.parse(value);
- return Number.isNaN(at2) ? void 0 : Math.max(0, at2 - now);
-}
-function connectWithEventSource(url, store, opts = {}) {
- if (opts.signal?.aborted)
- return { close: () => {
- }, closed: Promise.resolve() };
- const es = new EventSource(url, { withCredentials: true });
- let sequence = new StreamSequence();
- let stopped = false;
- let resolve;
- const closed = new Promise((r) => {
- resolve = r;
- });
- const shut = () => {
- stopped = true;
- es.close();
- opts.signal?.removeEventListener("abort", shut);
- resolve();
- };
- es.onopen = () => {
- if (stopped)
- return;
- sequence = new StreamSequence();
- opts.onOpen?.();
- };
- es.onmessage = (e) => {
- if (stopped)
- return;
- let ev;
- try {
- ev = JSON.parse(e.data);
- } catch {
- return;
- }
- if (feed(store, sequence, ev, opts))
- shut();
- };
- es.onerror = () => {
- if (es.readyState === EventSource.CLOSED)
- shut();
- };
- opts.signal?.addEventListener("abort", shut, { once: true });
- return { close: shut, closed };
-}
-function feed(store, sequence, ev, opts) {
- const gap = sequence.observe(ev);
- if (gap) {
- opts.onGap?.(gap.expected, gap.received);
- return true;
+ signal.removeEventListener("abort", abort);
}
- if (ev.type === "error") {
- const hint = typeof ev.retryAfterMs === "number" && ev.retryAfterMs >= 0 ? ev.retryAfterMs : void 0;
- opts.onError?.(ev.code ?? "INTERNAL", ev.message ?? "", ev.terminal ?? false, hint);
- return ev.terminal === true;
- }
- const change = applyStreamEvent(store, ev);
- if (ev.type === "synced")
- opts.onSynced?.();
- opts.onChange?.(change);
- return false;
}
// dist/connection.js
-function connectManagedResourceStream(url, store, opts = {}) {
- const maxRetries = opts.maxRetries ?? 8;
- const healthyResetMs = opts.healthyResetMs ?? 3e4;
- const delay = opts.retryDelayMs ?? 500;
- const cap = opts.maxRetryDelayMs ?? 3e4;
- if (!Number.isFinite(healthyResetMs) || healthyResetMs <= 0 || healthyResetMs > 2147483647 || !Number.isSafeInteger(maxRetries) || maxRetries < 0 || !Number.isFinite(delay) || delay < 0 || !Number.isFinite(cap) || cap < 0 || cap > 2147483647) {
- throw new RangeError("krm-stream: invalid retry budget or delay");
- }
- const controller = new AbortController();
- const subscribers = /* @__PURE__ */ new Set();
- let state = Object.freeze({ status: "connecting", retries: 0 });
- let terminal = false;
- let hintMs;
- let healthTimer;
- const clearHealthTimer = () => {
- clearTimeout(healthTimer);
- healthTimer = void 0;
- };
- const publish = (status, retryInMs) => {
- state = Object.freeze({ status, retries: state.retries, ...retryInMs === void 0 ? {} : { retryInMs } });
- opts.onStateChange?.(state);
- for (const callback of subscribers)
- callback(state);
- };
- const close = () => {
- clearHealthTimer();
- controller.abort();
- };
- opts.signal?.addEventListener("abort", close, { once: true });
- if (opts.signal?.aborted)
- close();
- const closed = Promise.resolve().then(async () => {
- try {
- while (!controller.signal.aborted) {
- publish("connecting");
- if (controller.signal.aborted)
- break;
- hintMs = void 0;
- const stream = connectResourceStream(url, store, {
- ...opts,
- signal: controller.signal,
- onGap: (expected, received) => {
- clearHealthTimer();
- opts.onGap?.(expected, received);
- },
- onOpen: () => {
- publish("syncing");
- opts.onOpen?.();
- },
- onChange: (change) => {
- if (change.type === "reset") {
- clearHealthTimer();
- publish("syncing");
- }
- opts.onChange?.(change);
- },
- onSynced: () => {
- hintMs = void 0;
- if (state.status !== "live") {
- healthTimer = setTimeout(() => {
- healthTimer = void 0;
- if (!controller.signal.aborted && state.status === "live") {
- state = { ...state, retries: 0 };
- publish("live");
- }
- }, healthyResetMs);
- }
- publish("live");
- opts.onSynced?.();
- },
- onError: (code, message, isTerminal, retryAfterMs) => {
- if (isTerminal)
- clearHealthTimer();
- terminal ||= isTerminal;
- if (!isTerminal && retryAfterMs !== void 0)
- hintMs = retryAfterMs;
- opts.onError?.(code, message, isTerminal, retryAfterMs);
- }
- });
- await stream.closed;
- clearHealthTimer();
- if (controller.signal.aborted)
- break;
- if (terminal) {
- publish("terminal");
- return;
- }
- if (state.retries >= maxRetries) {
- publish("exhausted");
- return;
- }
- const ceiling = Math.min(cap, delay * 2 ** Math.min(state.retries, 30));
- const jittered = Math.floor(ceiling * (0.5 + Math.random() * 0.5));
- const wait = Math.min(cap, Math.max(jittered, hintMs ?? 0));
- state = { ...state, retries: state.retries + 1 };
- publish("retrying", wait);
- await new Promise((resolve) => {
- const done = () => {
- clearTimeout(timer);
- controller.signal.removeEventListener("abort", done);
- resolve();
- };
- const timer = setTimeout(done, wait);
- controller.signal.addEventListener("abort", done, { once: true });
- if (controller.signal.aborted)
- done();
- });
- }
- publish("closed");
- } finally {
- clearHealthTimer();
- opts.signal?.removeEventListener("abort", close);
- subscribers.clear();
- }
- });
- return {
- close,
- closed,
- get state() {
- return state;
- },
- subscribe(callback) {
- subscribers.add(callback);
- return () => {
- subscribers.delete(callback);
- };
- }
- };
+function connectResourceStream(url, consume, opts = {}) {
+ return runConnection((hooks, signal) => streamOnce(url, hooks, signal, opts), consume, opts);
}
// dist/deep.js
@@ -432,6 +428,369 @@ function clone(v) {
return v;
}
+// dist/native.js
+var reserved = [
+ "watch",
+ "resourceVersion",
+ "resourceVersionMatch",
+ "limit",
+ "continue",
+ "sendInitialEvents",
+ "allowWatchBookmarks"
+];
+function connectNativeWatch(collectionURL, consume, opts = {}) {
+ const url = collectionURL.split("#")[0];
+ const query = new URLSearchParams(url.includes("?") ? url.slice(url.indexOf("?") + 1) : "");
+ for (const name of reserved) {
+ if (query.has(name))
+ throw new Error(`krm-stream: the collection URL must not set ${name}; the connector does`);
+ }
+ const position = { checkpoint: void 0, type: void 0 };
+ return runConnection((hooks, signal) => watchOnce(url, hooks, signal, opts, position), consume, opts);
+}
+var Malformed = class extends Error {
+};
+var Truncated = class extends Error {
+};
+var resumable = (status) => status === 408 || status === 429 || status >= 500;
+async function watchOnce(url, hooks, signal, opts, position) {
+ const controller = new AbortController();
+ const abort = () => controller.abort();
+ signal.addEventListener("abort", abort, { once: true });
+ if (signal.aborted)
+ abort();
+ const aborted = () => controller.signal.aborted;
+ const { consume } = hooks;
+ const refused = async (phase, res) => {
+ if (!resumable(res.status))
+ position.checkpoint = void 0;
+ const { code, terminal } = classify(res.status);
+ const message = await statusMessage(res, controller.signal) ?? `native ${phase}: HTTP ${res.status}`;
+ if (aborted())
+ return;
+ hooks.error(code, message, terminal, retryAfter(res.headers.get("Retry-After")));
+ };
+ const malformed = (phase, error) => {
+ position.checkpoint = void 0;
+ if (!aborted())
+ hooks.error("INTERNAL", `native ${phase}: ${error.message}`, false);
+ };
+ const advance = (resourceVersion) => {
+ if (aborted())
+ return;
+ position.checkpoint = typeof resourceVersion === "string" && resourceVersion !== "" ? resourceVersion : void 0;
+ };
+ try {
+ if (aborted())
+ return;
+ const resumeFrom = position.checkpoint;
+ let from;
+ if (resumeFrom === void 0) {
+ const listed = await request(url, "application/json", controller.signal, opts);
+ if (!listed.ok || !listed.body)
+ return await refused("list", listed);
+ if (aborted())
+ return void await listed.body.cancel().catch(() => {
+ });
+ hooks.opened();
+ let collection;
+ try {
+ const text = await readText(listed.body, controller.signal);
+ if (text === void 0)
+ return;
+ collection = readCollection(text);
+ } catch (error) {
+ if (error instanceof Unpaginated) {
+ if (!aborted())
+ hooks.error("INTERNAL", error.message, true);
+ return;
+ }
+ if (error instanceof Malformed)
+ return malformed("list", error);
+ throw error;
+ }
+ position.type = collection.type;
+ from = collection.resourceVersion;
+ hooks.reset();
+ if (aborted())
+ return;
+ consume({ type: "reset" });
+ for (const object of collection.items) {
+ if (aborted())
+ return;
+ consume({ type: "added", object });
+ }
+ if (aborted())
+ return;
+ } else {
+ from = resumeFrom;
+ }
+ const watched = await request(`${url}${url.includes("?") ? "&" : "?"}watch=1&allowWatchBookmarks=true&resourceVersion=${encodeURIComponent(from)}`, "application/json", controller.signal, opts);
+ if (!watched.ok || !watched.body)
+ return await refused("watch", watched);
+ if (aborted())
+ return void await watched.body.cancel().catch(() => {
+ });
+ if (resumeFrom === void 0) {
+ consume({ type: "synced" });
+ if (aborted())
+ return void await watched.body.cancel().catch(() => {
+ });
+ position.checkpoint = from;
+ }
+ hooks.synced();
+ const reader = watched.body.getReader();
+ const cancelReader = () => {
+ void reader.cancel().catch(() => {
+ });
+ };
+ controller.signal.addEventListener("abort", cancelReader, { once: true });
+ if (aborted())
+ cancelReader();
+ const decoder = new WatchDecoder();
+ try {
+ for (; ; ) {
+ const { done, value } = await reader.read();
+ if (aborted())
+ return;
+ let lines;
+ try {
+ if (done)
+ return decoder.end();
+ lines = decoder.push(value);
+ } catch (error) {
+ if (error instanceof Truncated) {
+ return void hooks.error("INTERNAL", `native watch: ${error.message}`, false);
+ }
+ if (!(error instanceof Malformed))
+ throw error;
+ return malformed("watch", error);
+ }
+ for (const line of lines) {
+ if (aborted())
+ return;
+ let more;
+ try {
+ more = deliver(line, position.type, hooks, advance, position);
+ } catch (error) {
+ if (!(error instanceof Malformed))
+ throw error;
+ return malformed("watch", error);
+ }
+ if (!more)
+ return;
+ }
+ }
+ } finally {
+ controller.signal.removeEventListener("abort", cancelReader);
+ await reader.cancel().catch(() => {
+ });
+ }
+ } catch {
+ } finally {
+ controller.abort();
+ signal.removeEventListener("abort", abort);
+ }
+}
+function deliver(line, type, hooks, advance, position) {
+ let frame;
+ try {
+ frame = JSON.parse(line);
+ } catch {
+ throw new Malformed("a watch frame is not JSON");
+ }
+ if (!isRecord(frame) || typeof frame.type !== "string" || !isRecord(frame.object)) {
+ throw new Malformed("a watch frame has no type or object");
+ }
+ const object = frame.object;
+ switch (frame.type) {
+ case "ADDED":
+ case "MODIFIED": {
+ const applied = resource(object, type, "watch");
+ hooks.consume({ type: frame.type === "ADDED" ? "added" : "modified", object: applied });
+ advance(applied.metadata.resourceVersion);
+ return true;
+ }
+ case "DELETED": {
+ const removed = resource(object, type, "watch");
+ hooks.consume({ type: "deleted", identity: identity(removed) });
+ advance(removed.metadata.resourceVersion);
+ return true;
+ }
+ case "BOOKMARK":
+ advance(isRecord(object.metadata) ? object.metadata.resourceVersion : void 0);
+ return true;
+ case "ERROR": {
+ const { code, terminal, message, retryAfterMs, keepsCheckpoint } = statusError(object);
+ if (!keepsCheckpoint)
+ position.checkpoint = void 0;
+ hooks.error(code, message, terminal, retryAfterMs);
+ return false;
+ }
+ default:
+ throw new Malformed(`unknown watch event type ${JSON.stringify(frame.type)}`);
+ }
+}
+function classify(status) {
+ return status === 410 ? { code: "RESYNC_REQUIRED", terminal: false } : refusal(status);
+}
+function statusError(status) {
+ const reason = typeof status.reason === "string" ? status.reason : void 0;
+ const expired = reason === "Expired" || reason === "Gone";
+ const code = typeof status.code === "number" ? status.code : expired ? 410 : void 0;
+ const { code: errorCode, terminal } = code === void 0 ? { code: "INTERNAL", terminal: false } : classify(code);
+ const text = typeof status.message === "string" ? status.message : "";
+ const details = status.details;
+ const seconds = isRecord(details) ? details.retryAfterSeconds : void 0;
+ return {
+ code: errorCode,
+ terminal,
+ message: text !== "" ? text : `native watch: error ${code ?? reason ?? "without a status"}`,
+ retryAfterMs: typeof seconds === "number" && seconds >= 0 ? seconds * 1e3 : void 0,
+ keepsCheckpoint: code !== void 0 && !expired && resumable(code)
+ };
+}
+var Unpaginated = class extends Error {
+};
+function readCollection(text) {
+ let body;
+ try {
+ body = JSON.parse(text);
+ } catch {
+ throw new Malformed("the collection is not JSON");
+ }
+ if (!isRecord(body))
+ throw new Malformed("the collection is not an object");
+ const metadata = isRecord(body.metadata) ? body.metadata : {};
+ if (typeof metadata.continue === "string" && metadata.continue !== "") {
+ throw new Unpaginated("native list: the response is one page of a paginated collection, which this connector does not support");
+ }
+ const resourceVersion = metadata.resourceVersion;
+ if (typeof resourceVersion !== "string" || resourceVersion === "") {
+ throw new Malformed("the collection has no metadata.resourceVersion");
+ }
+ const raw = body.items;
+ if (raw !== null && !Array.isArray(raw))
+ throw new Malformed("the collection has no items array");
+ const type = itemType(body);
+ const uids = /* @__PURE__ */ new Set();
+ const items = (raw ?? []).map((item) => {
+ const object = resource(item, type, "list");
+ if (uids.has(object.metadata.uid))
+ throw new Malformed(`the collection lists UID ${object.metadata.uid} twice`);
+ uids.add(object.metadata.uid);
+ return object;
+ });
+ return { items, resourceVersion, type };
+}
+function itemType(collection) {
+ const { apiVersion, kind } = collection;
+ if (typeof apiVersion !== "string" || apiVersion === "" || apiVersion.startsWith("meta.k8s.io/"))
+ return void 0;
+ if (typeof kind !== "string" || !/^[A-Za-z0-9]+List$/.test(kind))
+ return void 0;
+ return { apiVersion, kind: kind.slice(0, -"List".length) };
+}
+function resource(value, type, phase) {
+ const where = phase === "list" ? "a collection item" : "a watch object";
+ if (!isRecord(value) || !isRecord(value.metadata))
+ throw new Malformed(`${where} has no metadata`);
+ const { uid, name, namespace } = value.metadata;
+ if (typeof uid !== "string" || uid === "")
+ throw new Malformed(`${where} has no metadata.uid`);
+ if (typeof name !== "string" || name === "")
+ throw new Malformed(`${where} ${uid} has no metadata.name`);
+ if (namespace !== void 0 && typeof namespace !== "string") {
+ throw new Malformed(`${where} ${uid} has an invalid metadata.namespace`);
+ }
+ const present = (field) => typeof field === "string" && field !== "";
+ if (present(value.apiVersion) && present(value.kind))
+ return value;
+ const apiVersion = present(value.apiVersion) ? value.apiVersion : type?.apiVersion;
+ const kind = present(value.kind) ? value.kind : type?.kind;
+ if (apiVersion === void 0 || kind === void 0) {
+ throw new Malformed(`${where} ${uid} has no apiVersion or kind, and the collection does not name its item type`);
+ }
+ return { ...value, apiVersion, kind };
+}
+function identity(object) {
+ const { uid, name, namespace } = object.metadata;
+ return {
+ uid,
+ apiVersion: object.apiVersion,
+ kind: object.kind,
+ ...namespace === void 0 ? {} : { namespace },
+ name
+ };
+}
+var WatchDecoder = class {
+ #utf8 = new TextDecoder("utf-8", { fatal: true });
+ #buffer = "";
+ /** Feed a chunk; get back the complete, nonblank lines it finished. */
+ push(chunk) {
+ this.#buffer += decode(this.#utf8, chunk);
+ const lines = [];
+ for (; ; ) {
+ const end = this.#buffer.indexOf("\n");
+ if (end === -1)
+ break;
+ const line = this.#buffer.slice(0, end).trim();
+ this.#buffer = this.#buffer.slice(end + 1);
+ if (line !== "")
+ lines.push(line);
+ }
+ return lines;
+ }
+ /** The stream ended. Anything still buffered — a character cut off included — is a truncated frame:
+ * every complete line has already been returned, so the cut can only be inside the last one. */
+ end() {
+ let rest;
+ try {
+ rest = this.#utf8.decode();
+ } catch {
+ throw new Truncated("the watch ended inside a UTF-8 character");
+ }
+ if ((this.#buffer + rest).trim() !== "")
+ throw new Truncated("the watch ended inside a frame");
+ }
+};
+async function readText(body, signal) {
+ const reader = body.getReader();
+ const cancel = () => {
+ void reader.cancel().catch(() => {
+ });
+ };
+ signal.addEventListener("abort", cancel, { once: true });
+ if (signal.aborted)
+ cancel();
+ const utf8 = new TextDecoder("utf-8", { fatal: true });
+ let text = "";
+ try {
+ for (; ; ) {
+ const { done, value } = await reader.read();
+ if (signal.aborted)
+ return void 0;
+ if (done)
+ return text + decode(utf8);
+ text += decode(utf8, value);
+ }
+ } finally {
+ signal.removeEventListener("abort", cancel);
+ await reader.cancel().catch(() => {
+ });
+ }
+}
+function decode(utf8, chunk) {
+ try {
+ return chunk === void 0 ? utf8.decode() : utf8.decode(chunk, { stream: true });
+ } catch {
+ throw new Malformed("the response is not valid UTF-8");
+ }
+}
+function isRecord(value) {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
// dist/path.js
function pathKey(path) {
return JSON.stringify(path);
@@ -539,8 +898,8 @@ function withOpenAPIKeyedLists(policy, schema) {
}
function mapKeysAt(root, path) {
let schema = root;
- for (const segment of path) {
- schema = typeof segment === "number" ? schema?.items : schema?.properties?.[segment];
+ for (const segment2 of path) {
+ schema = typeof segment2 === "number" ? schema?.items : schema?.properties?.[segment2];
if (!schema)
return void 0;
}
@@ -628,12 +987,12 @@ function remapListConflicts(s, path, previousOrder, outputKeys) {
const outputIndex = new Map(outputKeys.map((key, index) => [key, index]));
const moved = [];
for (const [encoded, conflict] of s.conflicts) {
- const segment = conflict.path[path.length];
- if (!isPrefix(path, conflict.path) || typeof segment !== "number")
+ const segment2 = conflict.path[path.length];
+ if (!isPrefix(path, conflict.path) || typeof segment2 !== "number")
continue;
- const key = previousOrder[segment];
+ const key = previousOrder[segment2];
const nextIndex = key === void 0 ? void 0 : outputIndex.get(key);
- if (nextIndex === void 0 || nextIndex === segment)
+ if (nextIndex === void 0 || nextIndex === segment2)
continue;
s.conflicts.delete(encoded);
moved.push({ ...conflict, path: [...path, nextIndex, ...conflict.path.slice(path.length + 1)] });
@@ -670,10 +1029,10 @@ function indexAssociativeList(values, keys) {
for (const value of values) {
if (!isPlainObject(value))
return void 0;
- const identity = keys.map((key) => value[key]);
- if (identity.some((part) => part === void 0 || part === null || typeof part === "object"))
+ const identity2 = keys.map((key) => value[key]);
+ if (identity2.some((part) => part === void 0 || part === null || typeof part === "object"))
return void 0;
- const encoded = JSON.stringify(identity);
+ const encoded = JSON.stringify(identity2);
if (indexed.values.has(encoded))
return void 0;
indexed.order.push(encoded);
@@ -718,6 +1077,30 @@ function clearConflict(s, path) {
}
// dist/store.js
+function applyStreamEvent(store, event) {
+ switch (event.type) {
+ case "reset":
+ store.beginSnapshot();
+ return { type: event.type, added: false, structural: false, flashed: [], conflicts: [] };
+ case "added":
+ case "modified": {
+ const result = store.applyServerEvent(event.object, { redacted: event.redacted });
+ return { type: event.type, uid: event.object.metadata.uid, ...result };
+ }
+ case "deleted": {
+ const uid = event.identity.uid;
+ store.removeResource(uid);
+ return { type: event.type, uid, added: false, structural: true, flashed: [], conflicts: [] };
+ }
+ case "synced":
+ store.endSnapshot();
+ return { type: event.type, added: false, structural: false, flashed: [], conflicts: [] };
+ }
+}
+var MACHINERY = [
+ ["metadata", "managedFields"],
+ ["metadata", "annotations", "kubectl.kubernetes.io/last-applied-configuration"]
+];
var LiveResourceStore = class {
#policy;
#revision = 0;
@@ -733,15 +1116,22 @@ var LiveResourceStore = class {
}
// ------------------------------------------------------------------ the stream in --
/** `added` and `modified` — the only two upsert spellings, and they are treated identically
- * (spec §4). Both mean "here is this object's complete current state". */
+ * (spec §4). Both mean "here is this object's complete current state", and during a snapshot cycle
+ * that the object is still in scope. */
applyServerEvent(object, opts = {}) {
+ this.#seen?.add(object.metadata.uid);
+ return this.#upsert(object, opts);
+ }
+ /** Replace the server object and reconcile the draft. Membership is the caller's business: only the
+ * stream's own upserts count towards a snapshot, so a save or read response that lands mid-cycle
+ * can never keep alive an object the snapshot no longer contains. */
+ #upsert(object, opts) {
const id = object.metadata.uid;
const incoming = clone(object);
const redacted = (opts.redacted ?? []).map((entry) => ({
path: typeof entry.path === "string" ? parsePointer(entry.path) : [...entry.path],
rev: entry.rev
}));
- this.#seen?.add(id);
const existing = this.#resources.get(id);
if (!existing) {
this.#resources.set(id, {
@@ -818,10 +1208,10 @@ var LiveResourceStore = class {
adoptSaved(object) {
const existing = this.#resources.get(object.metadata.uid);
if (!existing) {
- this.applyServerEvent(object);
+ this.#upsert(object, {});
return;
}
- this.applyServerEvent(object, { redacted: existing.redacted });
+ this.#upsert(object, { redacted: existing.redacted });
}
// ------------------------------------------------------------------------- edits --
setValue(id, path, value) {
@@ -905,7 +1295,8 @@ var LiveResourceStore = class {
const res = this.#must(id);
if (this.isEditable(id, path))
return !deepEqual(get(res.server, path), get(res.draft, path));
- if (!this.#policy.containsEditable(res.server, path))
+ const regions = this.#regionsFor(res.server, res.redacted.map((r) => r.path));
+ if (!regions.container(path))
return false;
return this.changes(id).some((c) => isPrefix(path, c.path));
}
@@ -952,14 +1343,14 @@ var LiveResourceStore = class {
/** Capture the patch, UID and merge-base version synchronously, before any await. A stale version
* is safe: Kubernetes rejects it with 409. Never replace it with a newer GET's version. */
captureSave(id) {
- const resource = this.#must(id);
+ const resource2 = this.#must(id);
const patch = this.patch(id);
if (!patch)
return null;
- const resourceVersion = resource.server.metadata.resourceVersion;
+ const resourceVersion = resource2.server.metadata.resourceVersion;
if (!resourceVersion)
throw new Error("krm-stream: conditional save requires resourceVersion");
- return { uid: resource.server.metadata.uid, resourceVersion, patch };
+ return { uid: resource2.server.metadata.uid, resourceVersion, patch };
}
/** Capture before starting a host GET. The returned function applies its projected response only
* if no server event or earlier response has advanced this resource since capture. Local edits
@@ -983,7 +1374,7 @@ var LiveResourceStore = class {
return false;
redacted = paths.map((path) => known.get(pathKey(path)));
}
- this.applyServerEvent(object, { redacted });
+ this.#upsert(object, { redacted });
return true;
};
}
@@ -1010,12 +1401,17 @@ var LiveResourceStore = class {
}
return res;
}
+ /** The policy, minus the protected paths: this object's redactions and the machinery. A path
+ * inside one is read-only. A path holding one cannot be replaced or removed whole, which would
+ * rewrite what it holds, so where the policy makes it editable it is merged key by key instead: a
+ * new Secret key beside withheld values, or one annotation beside the last-applied one. */
#regionsFor(object, redacted) {
- const insideRedacted = (path) => redacted.some((r) => isPrefix(r, path));
- const containsRedacted = (path) => redacted.some((r) => isPrefix(path, r));
+ const protectedPaths = [...MACHINERY, ...redacted];
+ const inside = (path) => protectedPaths.some((p) => isPrefix(p, path));
+ const holds = (path) => protectedPaths.some((p) => isPrefix(path, p));
return {
- editable: (path) => !insideRedacted(path) && !containsRedacted(path) && this.#policy.isEditable(object, path),
- container: (path) => !insideRedacted(path) && this.#policy.containsEditable(object, path),
+ editable: (path) => !inside(path) && !holds(path) && this.#policy.isEditable(object, path),
+ container: (path) => !inside(path) && (this.#policy.containsEditable(object, path) || holds(path) && this.#policy.isEditable(object, path)),
listMapKeys: (path) => this.#policy.listMapKeys?.(object, path)
};
}
@@ -1106,10 +1502,42 @@ function resourceStreamURL(base, scope) {
set("projection", scope.projection);
return `${base}${base.includes("?") ? "&" : "?"}${q.toString()}`;
}
-
-// dist/version.js
-var VERSION = "0.7.0";
-var PROTOCOL_VERSION = 1;
+function nativeCollectionURL(proxyBase, scope) {
+ const q = new URLSearchParams();
+ if (scope.labelSelector)
+ q.append("labelSelector", scope.labelSelector);
+ if (scope.name)
+ q.append("fieldSelector", `metadata.name=${scope.name}`);
+ const query = q.toString();
+ return collectionPath(proxyBase, scope) + (query ? `?${query}` : "");
+}
+function nativeObjectURL(proxyBase, scope) {
+ return `${collectionPath(proxyBase, scope)}/${segment("name", scope.name)}`;
+}
+function collectionPath(proxyBase, scope) {
+ const path = [withoutTrailingSlashes(proxyBase)];
+ if (scope.group)
+ path.push("apis", segment("group", scope.group));
+ else
+ path.push("api");
+ path.push(segment("version", scope.version));
+ if (scope.namespace)
+ path.push("namespaces", segment("namespace", scope.namespace));
+ path.push(segment("resource", scope.resource));
+ return path.join("/");
+}
+function segment(what, value) {
+ if (!value || value === "." || value === ".." || value.includes("/")) {
+ throw new Error(`krm-stream: invalid ${what} ${JSON.stringify(value ?? "")}`);
+ }
+ return encodeURIComponent(value);
+}
+function withoutTrailingSlashes(base) {
+ let end = base.length;
+ while (end > 0 && base[end - 1] === "/")
+ end--;
+ return base.slice(0, end);
+}
export {
DEFAULT_EDITABLE_REGIONS,
LiveResourceStore,
@@ -1119,14 +1547,15 @@ export {
VERSION,
applyStreamEvent,
clone,
- connectManagedResourceStream,
+ connectNativeWatch,
connectResourceStream,
- connectWithEventSource,
deepEqual,
defaultPolicy,
get,
has,
isPrefix,
+ nativeCollectionURL,
+ nativeObjectURL,
parsePointer,
pathKey,
readOnlyPolicy,
diff --git a/go.mod b/go.mod
index 05ede11..55ad081 100644
--- a/go.mod
+++ b/go.mod
@@ -3,8 +3,8 @@ module github.com/ConfigButler/krm-foyer
go 1.27.1
require (
- github.com/ConfigButler/krm-stream/gateway v0.7.0
- github.com/ConfigButler/krm-stream/gateway/kube v0.7.0
+ github.com/ConfigButler/krm-stream/gateway v0.10.0
+ github.com/ConfigButler/krm-stream/gateway/kube v0.10.0
github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f
github.com/chromedp/chromedp v0.16.0
github.com/coreos/go-oidc/v3 v3.21.0
diff --git a/go.sum b/go.sum
index 12eabf0..6890e96 100644
--- a/go.sum
+++ b/go.sum
@@ -1,7 +1,7 @@
-github.com/ConfigButler/krm-stream/gateway v0.7.0 h1:Pb1fWshxt44D+LMcD7sIrzo3lJQyKtYivIfRuXpBLxI=
-github.com/ConfigButler/krm-stream/gateway v0.7.0/go.mod h1:sVRhasWxXotQQHsyY4mKvvT0npkupDjrvg/i3Elq5+E=
-github.com/ConfigButler/krm-stream/gateway/kube v0.7.0 h1:8Zm8+il5xgFlRWLWehm1P7e4qSentcMzkRa5AcbqyS4=
-github.com/ConfigButler/krm-stream/gateway/kube v0.7.0/go.mod h1:4NPwh1gb4ewfGunozln3G8W4y0//VPirwx+AdeNsmuo=
+github.com/ConfigButler/krm-stream/gateway v0.10.0 h1:bL3ZsY3B9hR4qiMVAz+rv1fdHoJ2tZAXd66utnCtYgQ=
+github.com/ConfigButler/krm-stream/gateway v0.10.0/go.mod h1:sVRhasWxXotQQHsyY4mKvvT0npkupDjrvg/i3Elq5+E=
+github.com/ConfigButler/krm-stream/gateway/kube v0.10.0 h1:Kv7q1AOaQLJQaOQc+TFs1EufKu9YABU1jW/DXVh9Xu0=
+github.com/ConfigButler/krm-stream/gateway/kube v0.10.0/go.mod h1:s65yd6idUaNknxk5oepnj0n6n8IIvk9Olr7Y/G7D5Q8=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
diff --git a/internal/stream/shared.go b/internal/stream/shared.go
index 0b0d7b5..4a72cd6 100644
--- a/internal/stream/shared.go
+++ b/internal/stream/shared.go
@@ -167,7 +167,7 @@ func newShared(cfg SharedConfig, s *Streams) (*shared, error) {
m := s.gate.Metrics()
upstream := &sharedUpstream{kube: kube.NewBackend(data), metrics: m}
return &shared{
- backend: gateway.NewSharedBackendWithOptions(upstream, gateway.SharedOptions{
+ backend: gateway.NewSharedBackend(upstream, gateway.SharedOptions{
Observer: observer{metrics: m},
}),
resources: slices.Clone(cfg.Resources),
diff --git a/internal/stream/stream.go b/internal/stream/stream.go
index 427908f..920ae88 100644
--- a/internal/stream/stream.go
+++ b/internal/stream/stream.go
@@ -129,22 +129,24 @@ func (s *Streams) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// The gate has found the user; the gateway only carries the credential back
// to Clients.
Principal: func(*http.Request) (gateway.Principal, error) { return a.Credential, nil },
- // Kubernetes decides: every watch is opened with the user's own token, and
- // the API server refuses what RBAC does not allow.
- Authorizer: gateway.AllowAll{},
- Clients: u.backend,
// krm-foyer keeps no list of resources (docs/design.md, "Access"): what the
// user may watch is what the API server lets them, since every watch is
// theirs. The gateway still parses the scope itself and refuses one it will
// not serve, an API-server address or a credential among them; only the
// unnamed target, the one cluster, exists.
- Scopes: gateway.ScopePolicy{Targets: []string{""}, AnyResource: true, AllowLabelSelector: true},
- Projections: gateway.ProjectionPolicyFunc(project),
- Diagnostics: func(d gateway.Diagnostic) { s.diagnose(a, d) },
- // Every write to the browser is bounded, so one that stopped reading ends its
- // stream rather than holding it, and a shared stream's rechecks, which wait
- // for the write in progress.
- WriteTimeout: s.writes,
+ Scopes: gateway.ScopePolicy{Targets: []string{""}, AnyResource: true, AllowLabelSelector: true},
+ StreamConfig: gateway.StreamConfig{
+ // Kubernetes decides: every watch is opened with the user's own token, and
+ // the API server refuses what RBAC does not allow.
+ Authorizer: gateway.AllowAll{},
+ Clients: u.backend,
+ Projections: gateway.ProjectionPolicyFunc(project),
+ Diagnostics: func(d gateway.Diagnostic) { s.diagnose(a, d) },
+ // Every write to the browser is bounded, so one that stopped reading ends its
+ // stream rather than holding it, and a shared stream's rechecks, which wait
+ // for the write in progress.
+ WriteTimeout: s.writes,
+ },
}
// A scope the gateway refuses goes the user's way, and is refused there.
if scope, err := gateway.ScopeFromQuery(r.URL.Query()); err == nil && s.shared.serves(scope) {