Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -191,15 +191,20 @@ tasks:

# ------------------------------------------------------------------- build --
build-client:
desc: "Emit the dependency-free ESM bundle a browser can <script type=module> import."
desc: "Emit the dependency-free ESM a browser can <script type=module> import — per-module, and flattened."
dir: "{{.CLIENT_DIR}}"
sources: ["src/**/*.ts", "tsconfig.json", "package.json"]
generates: ["dist/**/*.js"]
cmds:
- npm install --no-audit --no-fund
# --no-install: bare `npx tsc` with no node_modules silently downloads a REGISTRY package named
# `tsc` (which is not TypeScript) and runs it. Never let a build reach the network by accident.
- npx --no-install tsc
# Two artifacts, both published: dist/index.js keeps its relative imports (tree-shakeable, for
# bundler-based consumers), and dist/krm-stream.js is the same public API flattened into one
# file, for a host that vendors the library by copying it and serving it with no bundler.
#
# `npm run build` and not `npx tsc`: it resolves from node_modules/.bin, so it cannot reach the
# network. Bare `npx tsc` with no node_modules silently downloads a REGISTRY package named `tsc`
# (which is not TypeScript) and runs it.
- npm run --silent build

# ---------------------------------------------------------------- cluster --
# The one rung a fake watch cannot reach. Everything the gateway BELIEVES about Kubernetes — that a
Expand Down
19 changes: 16 additions & 3 deletions examples/vanilla-browser/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
it is really tested: the browser fetches index.js, which imports ./store.js, which imports
./merge.js, and so on. If any of it needed a bundler, this page would simply be blank.

?entry=bundle swaps that for the single-file build — /krm-stream/krm-stream.js, the same public API
with the module graph already flattened, which is what a host vendors when it copies the library in
rather than installing it. Playwright runs this page BOTH ways (see playwright.config.ts), because
"the bundle exports the same things" is a claim about a real browser, not about a build log.

It uses NATIVE EventSource — not fetch — because that is the same-origin session-cookie path a v1
gateway must support (spec §7), and because EventSource's automatic reconnect is a behaviour no
in-process test can exercise.
Expand Down Expand Up @@ -70,10 +75,18 @@ <h2>patch — RFC 7386, editable changes only</h2>
</section>

<script type="module">
// THE import under test. Unbundled, multi-file, straight from the server.
import { LiveResourceStore, connectWithEventSource } from "/krm-stream/index.js";

// THE import under test — and there are two of them, because the library ships two entry points
// and a host with no bundler uses the second one.
//
// index.js the per-module build: the browser then fetches ./store.js, ./merge.js, …
// krm-stream.js the same public API, one file, no relative imports left to resolve
//
// Dynamic, so one page proves both. A static import cannot be chosen at runtime, and having two
// demo pages would mean the second one drifts.
const params = new URLSearchParams(location.search);
const entry = params.get("entry") === "bundle" ? "/krm-stream/krm-stream.js" : "/krm-stream/index.js";
const { LiveResourceStore, connectWithEventSource } = await import(entry);

const fixture = params.get("fixture") ?? "status-follow-live";
const pace = params.get("pace") ?? "600ms";

Expand Down
26 changes: 18 additions & 8 deletions examples/vanilla-browser/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { defineConfig } from "@playwright/test";
import type { Entry } from "./tests/fixtures.ts";

// The browser rung of the e2e ladder — and it needs no cluster.
//
Expand All @@ -8,7 +9,7 @@ import { defineConfig } from "@playwright/test";
// Authorization header, so the v1 baseline is same-origin session cookies). A demo served from a
// second port would need CORS, and would then be proving something we do not ship.

export default defineConfig({
export default defineConfig<{ entry: Entry }>({
testDir: "./tests",
fullyParallel: true,
forbidOnly: !!process.env["CI"],
Expand All @@ -18,20 +19,29 @@ export default defineConfig({
baseURL: "http://127.0.0.1:8100",
trace: "retain-on-failure",
},
projects: [{ name: "chromium", use: { browserName: "chromium" } }],
// The same suite, twice, against the two published entry points. `./bundle` is not a convenience
// build that can be allowed to rot: it is what a host with no node_modules actually vendors, so it
// gets the same real browser, the same real EventSource, and the same assertions as index.js. If a
// flattening step ever dropped an export or a polyfill crept in, the bundle project goes red on its
// own while the module project stays green — which is exactly the signal you want.
projects: [
{ name: "chromium", use: { browserName: "chromium", entry: "index" } },
{ name: "chromium-bundle", use: { browserName: "chromium", entry: "bundle" } },
],

// Build the library first — `dist/` is what the browser imports, unbundled, and a stale dist is a
// test of last week's library.
//
// `npx --no-install`, and it is not a style preference. Bare `npx tsc` in a directory with no
// node_modules does not fail — it goes to the REGISTRY, downloads whatever is published under the
// `npm run build` rather than a hand-copied `tsc` line: the build is now two steps (tsc, then the
// esbuild flatten into dist/krm-stream.js), and a browser test that builds them differently from a
// release is testing a file nobody ships. It resolves binaries from node_modules/.bin, so it never
// reaches the network — which is the property that matters here. Bare `npx tsc` in a directory with
// no node_modules does NOT fail: it goes to the REGISTRY, downloads whatever is published under the
// name `tsc` (a squatter package, not TypeScript), and runs it. That is what this job did on its
// first-ever CI run, and it is a supply-chain hole that happened to announce itself by failing.
// --no-install makes a missing dependency loud instead of resolving it from the internet. Run
// `task e2e-browser`, which installs the library's devDependencies first.
// first-ever CI run. Run `task e2e-browser`, which installs the library's devDependencies first.
webServer: {
command:
"cd ../../packages/krm-stream && npx --no-install tsc && " +
"cd ../../packages/krm-stream && npm run --silent build && " +
"cd ../../gateway && go run ./cmd/replay " +
"--addr 127.0.0.1:8100 --corpus ../conformance " +
"--static ../examples/vanilla-browser --dist ../packages/krm-stream/dist",
Expand Down
30 changes: 30 additions & 0 deletions examples/vanilla-browser/tests/fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// One suite, two entry points.
//
// The library publishes the per-module build (dist/index.js, which imports ./store.js, which imports
// ./merge.js…) AND a flattened single file (dist/krm-stream.js) for hosts that vendor the library by
// copying it and have no bundler to resolve that graph. Both are public API. Only one of them was
// ever loaded in a real browser.
//
// So `entry` is a project option rather than a parameter of any one test: playwright.config.ts runs
// this entire spec twice, once per entry point, and `visit` puts the choice in the URL the demo page
// reads. A test that forgets to thread it through does not silently test index.js twice — it cannot
// navigate at all.

import { test as base } from "@playwright/test";

export type Entry = "index" | "bundle";

export const test = base.extend<{
entry: Entry;
/** Open the demo page for a fixture, on whichever entry point this project is exercising. */
visit: (query: string) => Promise<void>;
}>({
entry: ["index", { option: true }],
visit: async ({ page, entry }, use) => {
await use(async (query) => {
await page.goto(`/?entry=${entry}&${query}`);
});
},
});

export { expect } from "@playwright/test";
26 changes: 13 additions & 13 deletions examples/vanilla-browser/tests/live-krm.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,27 +14,27 @@
// - a user can type into a field while the server changes the object underneath them, and keep
// what they typed.

import { expect, test } from "@playwright/test";
import { expect, test } from "./fixtures.ts";

const path = (...segments: (string | number)[]) => JSON.stringify(segments);

test("the built ESM imports in a browser with no bundler at all", async ({ page }) => {
test("the built ESM imports in a browser with no bundler at all", async ({ page, visit }) => {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
page.on("console", (m) => m.type() === "error" && errors.push(m.text()));

await page.goto("/?fixture=snapshot-then-deltas&pace=0ms");
await visit("fixture=snapshot-then-deltas&pace=0ms");
await expect(page.locator("#status-line")).toHaveText(/synced/);

// If a single bare specifier or a missing extension had crept into the emitted JS, the module graph
// would have failed to load and this page would be blank. That is the whole constraint, checked.
expect(errors, `the browser could not load the library:\n${errors.join("\n")}`).toEqual([]);
});

test("status follows the server live and FLASHES what moved — and never becomes an edit", async ({ page }) => {
test("status follows the server live and FLASHES what moved — and never becomes an edit", async ({ page, visit }) => {
// deploy-web.v1 -> v2 is a status-only change: readyReplicas 1 -> 2, Available False -> True.
// The spec is byte-identical. This is the dominant traffic on a real stream, and the demo.
await page.goto("/?fixture=status-follow-live&pace=250ms");
await visit("fixture=status-follow-live&pace=250ms");

const ready = page.getByTestId(`value:${path("status", "readyReplicas")}`);
await expect(ready).toHaveText(/^1/); // the snapshot
Expand All @@ -56,11 +56,11 @@ test("status follows the server live and FLASHES what moved — and never become
await expect(page.getByTestId("patch")).toHaveText(JSON.stringify({ spec: { replicas: 4 } }, null, 2));
});

test("a conflict is raised, the typing is never overwritten, and it clears on convergence", async ({ page }) => {
test("a conflict is raised, the typing is never overwritten, and it clears on convergence", async ({ page, visit }) => {
// The server moves data.log-level to `warn` while the user is typing `debug` (a real conflict), and
// then a colleague makes the same change the user did — so the server ARRIVES at `debug` and the
// conflict must clear itself. A cached dirty flag gets the second half wrong and stays red forever.
await page.goto("/?fixture=conflict-and-converge&pace=700ms");
await visit("fixture=conflict-and-converge&pace=700ms");

const logLevel = page.getByTestId(`input:${path("data", "log-level")}`);
await expect(logLevel).toHaveValue("info");
Expand All @@ -79,10 +79,10 @@ test("a conflict is raised, the typing is never overwritten, and it clears on co
await expect(page.getByTestId("patch")).toHaveText("null"); // nothing left to save
});

test("an unrelated server change never disturbs the field you are editing", async ({ page }) => {
test("an unrelated server change never disturbs the field you are editing", async ({ page, visit }) => {
// R-THREEWAY. The server bumps `replicas`; nobody touched `log-level`. Compare the incoming object
// to the DRAFT instead of to the previous SERVER object and this false-conflicts on every heartbeat.
await page.goto("/?fixture=edit-vs-unrelated-change&pace=700ms");
await visit("fixture=edit-vs-unrelated-change&pace=700ms");

const logLevel = page.getByTestId(`input:${path("data", "log-level")}`);
await expect(logLevel).toHaveValue("info");
Expand All @@ -96,11 +96,11 @@ test("an unrelated server change never disturbs the field you are editing", asyn
await expect(page.getByTestId("patch")).toHaveText(JSON.stringify({ data: { "log-level": "debug" } }, null, 2));
});

test("a redacted Secret value is not in the page at all — the mask is drawn, not received", async ({ page }) => {
test("a redacted Secret value is not in the page at all — the mask is drawn, not received", async ({ page, visit }) => {
// The rule that makes a Secret safe to display: a value you never RECEIVED cannot round-trip over
// the real one. The mask is something this page DRAWS from `redacted` — it is not a value the
// wire carried, and there is therefore nothing to save back (proposal 0003).
await page.goto("/?fixture=secret-redaction&pace=0ms");
await visit("fixture=secret-redaction&pace=0ms");
await expect(page.locator("#status-line")).toHaveText(/synced/);

// Keys-only disclosure: you can see THAT `token` exists…
Expand All @@ -118,10 +118,10 @@ test("a redacted Secret value is not in the page at all — the mask is drawn, n
await expect(page.getByTestId("patch")).toHaveText("null");
});

test("a named object that does not exist renders as empty, not as a ghost and not as an error", async ({ page }) => {
test("a named object that does not exist renders as empty, not as a ghost and not as an error", async ({ page, visit }) => {
// reset, synced — and nothing else. The fixture that kills the "named scopes may skip the snapshot"
// optimization: skip it, and a delete-while-disconnected leaves the object on screen forever.
await page.goto("/?fixture=named-object-absent&pace=0ms");
await visit("fixture=named-object-absent&pace=0ms");
await expect(page.locator("#status-line")).toHaveText(/synced/);
await expect(page.getByTestId("editable-body")).toBeEmpty();
await expect(page.getByTestId("patch")).toHaveText("null");
Expand Down
28 changes: 26 additions & 2 deletions packages/krm-stream/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,30 @@ connectWithEventSource(
);
```

The unscoped `krm-stream` package is a compatibility forwarder. This project is pre-1.0 and has not
been published to npm yet. See the repository [README](../../README.md),
## Vendoring it without a bundler

The default entry point is the per-module build: `dist/index.js` imports `./store.js`, which imports
`./merge.js`, and so on. A bundler resolves that graph and tree-shakes it, and a browser resolves it
at runtime, so every file has to be there.

If you have no bundler and no `node_modules` — you copy the library in and serve it yourself — import
the flattened build instead. Same public API, one file, no relative imports left to resolve:

```ts
import { LiveResourceStore } from "@configbutler/krm-stream/bundle";
```

Vendored, that is one file to copy (`dist/krm-stream.js`) and one path to serve:

```js
import { LiveResourceStore, connectWithEventSource } from "/krm-stream/krm-stream.js";
```

Both entry points are built from the same source and are exercised by the same browser test suite
against a real `EventSource`. Neither has a runtime dependency.

## Status

The unscoped `krm-stream` package is a compatibility forwarder. This project is pre-1.0: the protocol
and the API may still change before 1.0. See the repository [README](../../README.md),
[client state model](../../docs/client-state-model.md), and [release guide](../../docs/releasing.md).
Loading