@parity/product-lab 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # @parity/product-lab
2
2
 
3
+ ## 0.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 9e75e10: A new optional setting, `PRODUCT_LAB_FORK_BUNDLE`, says what a start of a saved local copy of a network does about a newer published fork bundle. `fresh`, the default, downloads it in the background as before. `keep` keeps the bundle you have: the lab reads only the small release record, downloads nothing and logs one line with your bundle's age and when the published one was updated. `product-lab lab --refresh` works the same under both. Any other value is refused with a warning naming the fix.
8
+ - e9c8cac: The lab's reading of Vite's update socket is now checked against Vite 7.3.7, the release that fixes five security advisories in Vite's dev server. The AI kit's two complete reference labs now install `vite@7.3.7`. Nothing changes in how the lab reads Vite's updates: the update messages and the page's client are the same in 7.3.6 and 7.3.7.
9
+
3
10
  ## 0.1.0
4
11
 
5
12
  ### Minor Changes
@@ -33,8 +33,8 @@ use to reach an Engine network. Imported from the original lab.
33
33
  | `engine/network-reset.ts` | `resetPersistentNetwork(directory, { stop, sessionFile, commands })` (29 September 2026): stops the saved network, removes its data and network.json, and restores the first boot's template as a new generation (`method: "template"`, with `stopMs` and the copy); a workspace without a template gets `method: "fork-again"` with the reason, and the caller's next `persistentNetwork` forks again (the old path). Notes' `paseo-copy` reset uses it. The persistent network's `stop()` (`persistent-network.ts`) now stops every saved process at once (`stopTogether`; on Notes' fork 3.8 s and 4.2 s against 4.5 s one after another), each with its own stop policy, every failure reported together (`tests/stop-together.test.ts`) |
34
34
  | `engine/runtime-upgrade.ts` | An in-place runtime upgrade of one chain of a running network (relay, People, Asset Hub, Bulletin): `upgradeRuntime(request)` runs Engine's own `ppn upgrade <chain> <wasm> --ws <loopback> --enact-timeout <min>` (with `--allow-same-spec` for the same spec or `withoutChecks`; the caller decides whether Engine's authorization path applies to its network) as a recorded tree of the network's registry through `runEnginePrepare`, writes its code through the injected `FileSystem.writePrivateBytes`, and judges the result on the chain: `observeRuntime(rpc, ws)` before and after (finalized `:code` hash, spec, block, and `System.LastRuntimeUpgrade`, decoded by `decodeLastRuntimeUpgrade`, which says the new runtime's migrations ran). While Engine waits for enactment, the chain is read every 5 s: an authorization dropped with nothing scheduled and the code unchanged (`System.RejectedInvalidAuthorizedUpgrade`: another chain's runtime, a lower spec), which Engine's tool does not notice until its enact timeout, stops the wait at once as a failure. Outcomes: `already-running` (nothing submitted), `upgraded` (the chain runs exactly the code, `migrated` true or false), `failed` (with `unchanged`: the chain still runs its old code). `recordRuntimeUpgrade(files, …)` rewrites a persistent network's saved session (`network.json` and the session file) on purpose, since `checkEngineSession` refuses any runtime change it was not told about, and appends `runtime-upgrades.json` |
35
35
  | `engine/fork-bundle.ts` | A fork bundle is reused only once verified complete. `verifyForkBundle(dir)`: `manifest.json` parses, every snapshot it names is there at the size it records (`snapshotBytes`, written by every bite since Engine v0.8.0; else one snapshot per chain) and decompresses to its end (3.5 s for the 642 MB Paseo bundle). `markForkBundle` writes `.product-lab-complete` (the manifest's sha256, each snapshot's size); `markedComplete` holds while both match. `forkBundleState(bundle)`: complete, absent, or incomplete; an unmarked bundle (fetched before this check, or copied in) is verified once and marked. A start's fetch or bite writes into `<bundle>.incoming` (cleared first; `EngineCommand.installs`), and `installIncomingBundle` verifies it, marks it and renames it into place after the command exits 0, so a fetch or bite stopped part way never leaves a bundle that is reused, and a failed one leaves the bundle in place unchanged. `acquireForkBundleLock` (`<bundle>.lock`, through the injected `LockManager`) is held from the bundle's check to the end of the preparatory commands: a second start from one workspace waits, then finds it complete. A start without isolation, where Engine's own `ppn start` fetches a missing bundle, is not covered |
36
- | `engine/published-bundle.ts` | Engine's published fork bundles (the rolling `PPN_REPO@PPN_BITE_TAG` pre-release of the checkout's `config/versions.env`, `engineReleaseSettings`): `publishedBundle({ files, checkout, network, fetch })` (bounded by `timeoutMs`, default 180 000 ms, with the caller's optional `signal`) reads the asset record from GitHub's API and the bundle's `manifest.json` by streaming at most the first 48 MiB of the compressed tarball (an HTTP range, stopping once `manifest.json` is read), so what is published is known without downloading it; the caller passes `fetch` (the public-endpoint opt-in) and the `FileSystem` system port for the checkout settings and staged bundles. `startPrefetch` downloads it with Engine's own `ppn fork fetch-bundle` into `fork-bundle-<network>.next.partial` at background priority (`backgroundPriority`: macOS `taskpolicy -c utility`, else `nice -n 19`) as a recorded process of the caller's registry; `finishPrefetch` verifies it complete and marks it (`engine/fork-bundle.ts`; an incomplete download is removed), then `completePrefetch` stages it (`.next`, `stagedBundle`, which offers only a verified one); `installStagedBundle` makes it the bundle new forks start from, keeping the old one until `discardPrevious` or `restorePrevious`. `currentBundle`, `bittenAfter`, `prefetchActive`, `bundleLocations` |
37
- | `engine/fork-freshness.ts`, `engine/fork-refresh.ts`, `engine/fork-updates.ts` | Freshness, `--update-runtime` and `--refresh` for any saved Engine fork (fixture sharing, 30 September 2026, backlog 17c). They were lifted from an example, which now wraps them. State: `forkState` reads what a saved fork runs: its saved session, `runtimes.json` and its bite. `liveEndpoints` reads the public RPCs from Engine's network file; a relative one (previewnet's `relay/alice`, `people`) resolves under the file's `bite.source` as `wss://` (`resolveNetworkRpc`, as Engine's bite does). Freshness line: `freshness` is pure: it gives the age, the blocks behind live, and each chain whose spec or code changed, ending with the update command. The live read is injected as `LiveRuntimeReader`; the composition root's is `readLiveRuntimes`. `--update-runtime`: `updateForkRuntimes` applies each changed live runtime in place through `upgradeRuntime`. The code comes from an injected `LiveCodeFetcher`, checked against its hash. It then records the upgrade and waits `settleBlocks`. It refuses without a live read or a sudo key. Other pieces: `recordForkRuntimes`. `prefetchNewerState` fetches a newer published state in the background and never rejects. `prepareRefresh` sets the old fork aside for the newest published state; a lab record is carried across with `carry`. `commit` or `rollback` ends it. For a lab's start: `savedForkUpdates(options)` combines these into three calls. Before the start it runs `--refresh` when asked, and runs the live read and line beside the start. `started(target)` runs `--update-runtime` before anything connects, records the runtimes, commits a refresh and prefetches. `failed()` rolls a refresh back. `liveRpc` (3 October 2026): the lab's own RPC per chain for the live reads, over the network file's. `upgradeToLive(target, signal)` serves `networkSnapshots({ upgrade })` for `fixture refresh`. `tests/fork-freshness.test.ts` |
36
+ | `engine/published-bundle.ts` | Engine's published fork bundles (the rolling `PPN_REPO@PPN_BITE_TAG` pre-release of the checkout's `config/versions.env`, `engineReleaseSettings`): `publishedBundle({ files, checkout, network, fetch })` (bounded by `timeoutMs`, default 180 000 ms, with the caller's optional `signal`) reads the asset record from GitHub's API and the bundle's `manifest.json` by streaming at most the first 48 MiB of the compressed tarball (an HTTP range, stopping once `manifest.json` is read), so what is published is known without downloading it (under `PRODUCT_LAB_FORK_BUNDLE=keep` the background check never calls it: `engine/fork-refresh.ts`); the caller passes `fetch` (the public-endpoint opt-in) and the `FileSystem` system port for the checkout settings and staged bundles. `startPrefetch` downloads it with Engine's own `ppn fork fetch-bundle` into `fork-bundle-<network>.next.partial` at background priority (`backgroundPriority`: macOS `taskpolicy -c utility`, else `nice -n 19`) as a recorded process of the caller's registry; `finishPrefetch` verifies it complete and marks it (`engine/fork-bundle.ts`; an incomplete download is removed), then `completePrefetch` stages it (`.next`, `stagedBundle`, which offers only a verified one); `installStagedBundle` makes it the bundle new forks start from, keeping the old one until `discardPrevious` or `restorePrevious`. `currentBundle`, `bittenAfter`, `prefetchActive`, `bundleLocations` |
37
+ | `engine/fork-freshness.ts`, `engine/fork-refresh.ts`, `engine/fork-updates.ts` | Freshness, `--update-runtime` and `--refresh` for any saved Engine fork (fixture sharing, 30 September 2026, backlog 17c). They were lifted from an example, which now wraps them. State: `forkState` reads what a saved fork runs: its saved session, `runtimes.json` and its bite. `liveEndpoints` reads the public RPCs from Engine's network file; a relative one (previewnet's `relay/alice`, `people`) resolves under the file's `bite.source` as `wss://` (`resolveNetworkRpc`, as Engine's bite does). Freshness line: `freshness` is pure: it gives the age, the blocks behind live, and each chain whose spec or code changed, ending with the update command. The live read is injected as `LiveRuntimeReader`; the composition root's is `readLiveRuntimes`. `--update-runtime`: `updateForkRuntimes` applies each changed live runtime in place through `upgradeRuntime`. The code comes from an injected `LiveCodeFetcher`, checked against its hash. It then records the upgrade and waits `settleBlocks`. It refuses without a live read or a sudo key. Other pieces: `recordForkRuntimes`. `prefetchNewerState` fetches a newer published state in the background and never rejects. `PRODUCT_LAB_FORK_BUNDLE` (29 October 2026, read through the `EnvironmentVariables` port, so `savedForkUpdates` and a lab's own wrapper get it too): `fresh` or unset as described; `keep` reads only the release record from GitHub's API (no asset bytes, no range, nothing staged, no `ppn fork fetch-bundle`) and logs one line with the kept bundle's bite, its age, a newer state already downloaded, and the asset's `updated_at` when more than 6 hours after that bite (an upload within that is the bite's own); a record or local manifest that cannot be read is said, and the lab carries on. Surrounding white space is ignored; any other value logs a warning with the fix and checks nothing. `--refresh` and commands run by hand are the same under both. `tests/fork-bundle-keep.test.ts`. `prepareRefresh` sets the old fork aside for the newest published state; a lab record is carried across with `carry`. `commit` or `rollback` ends it. For a lab's start: `savedForkUpdates(options)` combines these into three calls. Before the start it runs `--refresh` when asked, and runs the live read and line beside the start. `started(target)` runs `--update-runtime` before anything connects, records the runtimes, commits a refresh and prefetches. `failed()` rolls a refresh back. `liveRpc` (3 October 2026): the lab's own RPC per chain for the live reads, over the network file's. `upgradeToLive(target, signal)` serves `networkSnapshots({ upgrade })` for `fixture refresh`. `tests/fork-freshness.test.ts` |
38
38
  | `engine/owned-environment.ts` | `ownedEngineEnvironment`: owned Engine + consumer app sessions as one resettable environment; its local `engineSessionEnvironment` forwards optional app surfaces across reset and closes the current sessions and Engine once |
39
39
  | `engine/network-plan.ts`, `engine/persisted-data.ts`, `engine/eth-rpc-probe.ts` | The persistent network's phases around `persistentNetwork`, split out of `persistent-network.ts`, which still exports their public names: the saved plan (`NetworkPlan`, `resumePlan`, `isolatedPlan`, `completePlan`, `planFacts`, `ENGINE_PORTS_FILE`); the resume's preconditions (`hasChainDatabase`, `missingPersistedData`, `requirePersistedData`, `restateEngineLocalEnv`, `engineCheckout`, `restateEnginePorts`, `portNames`, `requireFreePorts`; and, in `engine/saved-tooling.ts`, `missingSavedTooling` and `requireSavedTooling`: every command, what the lab's launchers in `<network>.isolation` exec, and the Engine checkout are on this machine); eth-rpc's health after a start (`probeEthRpc`, `EthRpcStatus`) |
40
40
  | `engine/checkout-lock.ts` | Exclusive per checkout, enforced: fresh starts and saved resumes require the injected `LockManager` and hold the canonical checkout’s `config/ports.env.lock` from before start through completed stop, including Engine’s `finishGroup`. Another live network is refused immediately, naming the shared-checkout sweep gap and the fix: a separate cache or checkout. The lock records the registry of the holder's Engine and service trees. A holder that is gone (PID plus start time) is recovered and reported only once every member of every registry it recorded is gone too: Engine's trees run detached and can outlive their lab, so a live member refuses the recovery. A member of the workspace's own registry (`<workspace>/processes`) names the workspace, whose next start stops it; a member of a per-start registry (an owned Engine's reports, which no later start reads) names that registry and the live members' commands to stop. A record that names no registry, or an empty list, is refused (an older lab, or a holder that recorded none: a lifetime without a registry records no `registries` field). A saved fork's survivors step (`stopSavedForkSurvivors`) reads its engine.json through `namedCheckout`, so a damaged one names no checkout instead of failing the step, as for `persistentNetwork`. Live and unknown owners are never broken. `stopWorkspaceSurvivors` (and `persistentNetwork`'s `survivors: "stop"`, a workspace's first open in a new lab process) stops what an interrupted owner left in the workspace's own registry only while it holds the lock of every checkout the workspace names; `stop()` stops the saved network's trees under the lock and leaves the registry's other trees (hosts, downloads) to their owners. The historical `acquireCheckoutPorts`, `checkoutPortsLock` and `CheckoutLockTimeout` names remain; `waitMs` no longer enables waiting. |
@@ -3,7 +3,7 @@
3
3
  import { existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
4
4
  import { dirname, join } from "node:path";
5
5
  import { forkState, refreshCommand, shortError } from "./fork-freshness.js";
6
- import { bittenAfter, bundleLocations, finishPrefetch, currentBundle, discardPrevious, installStagedBundle, prefetchActive, publishedBundle, restorePrevious, stagedBundle, startPrefetch, } from "./published-bundle.js";
6
+ import { bittenAfter, bundleLocations, finishPrefetch, currentBundle, discardPrevious, engineReleaseSettings, installStagedBundle, prefetchActive, publishedBundle, restorePrevious, stagedBundle, startPrefetch, } from "./published-bundle.js";
7
7
  export const PUBLISHED_CHECK_MS = 300_000;
8
8
  export function newerStateNotice(snapshot, ready, commands) {
9
9
  const bitten = snapshot.bittenAt.slice(0, 16).replace("T", " ");
@@ -13,6 +13,14 @@ export function newerStateNotice(snapshot, ready, commands) {
13
13
  }
14
14
  export async function prefetchNewerState(forkBite, deps) {
15
15
  try {
16
+ const policy = forkBundlePolicy(deps.environment);
17
+ if (policy !== "fresh") {
18
+ if (policy === "keep")
19
+ await reportKeptBundle(forkBite, deps);
20
+ else
21
+ deps.logger.warn(policy.error);
22
+ return undefined;
23
+ }
16
24
  return await prefetchWhenNewer(forkBite, deps);
17
25
  }
18
26
  catch (error) {
@@ -21,6 +29,69 @@ export async function prefetchNewerState(forkBite, deps) {
21
29
  return undefined;
22
30
  }
23
31
  }
32
+ const FORK_BUNDLE_SETTING = "PRODUCT_LAB_FORK_BUNDLE";
33
+ const RELEASE_RECORD_MS = 30_000;
34
+ const UPLOAD_AFTER_BITE_MS = 6 * 3_600_000;
35
+ function forkBundlePolicy(environment) {
36
+ const raw = environment.get(FORK_BUNDLE_SETTING);
37
+ const value = raw?.trim();
38
+ if (!value || value === "fresh")
39
+ return "fresh";
40
+ if (value === "keep")
41
+ return "keep";
42
+ return { error: `${FORK_BUNDLE_SETTING}=${JSON.stringify(raw)} is not a setting; no newer published state was checked or downloaded. Fix: set ${FORK_BUNDLE_SETTING} to keep (use the fork bundle on this machine) or fresh (download a newer one in the background), or unset it.` };
43
+ }
44
+ function age(from, now) {
45
+ const hours = Math.max(0, Math.floor((now - Date.parse(from)) / 3_600_000));
46
+ return hours < 48 ? `${hours} hour${hours === 1 ? "" : "s"}` : `${Math.floor(hours / 24)} days`;
47
+ }
48
+ function localBundle(read, what, problems) {
49
+ try {
50
+ return read();
51
+ }
52
+ catch (error) {
53
+ problems.push(`the ${what} bundle's manifest could not be read (${shortError(error)})`);
54
+ return undefined;
55
+ }
56
+ }
57
+ async function reportKeptBundle(forkBite, deps) {
58
+ const { engine, network, logger } = deps;
59
+ const problems = [];
60
+ const installed = localBundle(() => currentBundle(deps.files, engine.workspace, network), "installed", problems);
61
+ const staged = localBundle(() => stagedBundle(deps.files, engine.workspace, network), "downloaded", problems);
62
+ const kept = installed ?? forkBite;
63
+ const parts = [kept ? `bitten ${kept.bittenAt.slice(0, 16).replace("T", " ")} UTC, ${age(kept.bittenAt, Date.now())} old` : "no bundle installed yet"];
64
+ if (staged && (!kept || bittenAfter(staged, kept)))
65
+ parts.push(`a newer state (bitten ${staged.bittenAt.slice(0, 16).replace("T", " ")} UTC) is downloaded, so ${refreshCommand(deps.commands)} only boots`);
66
+ parts.push(...problems);
67
+ const line = `Keeping the fork bundle of ${network} on this machine (${FORK_BUNDLE_SETTING}=keep; ${parts.join("; ")})`;
68
+ const asset = `fork-bundle-${network}.tar.gz`;
69
+ let uploaded;
70
+ try {
71
+ const { repository, tag } = engineReleaseSettings(deps.files, engine.checkout, deps.environment.all());
72
+ const token = deps.environment.get("GITHUB_TOKEN");
73
+ const response = await (deps.fetch ?? fetch)(`https://api.github.com/repos/${repository}/releases/tags/${encodeURIComponent(tag)}`, {
74
+ headers: { accept: "application/vnd.github+json", "user-agent": "product-lab", ...(token ? { authorization: `Bearer ${token}` } : {}) },
75
+ signal: AbortSignal.timeout(RELEASE_RECORD_MS),
76
+ });
77
+ if (!response.ok)
78
+ throw new Error(`GitHub answered ${response.status} for ${repository}@${tag}`);
79
+ const release = (await response.json());
80
+ uploaded = release.assets?.find((entry) => entry.name === asset)?.updated_at;
81
+ }
82
+ catch (error) {
83
+ logger.info(`${line}; could not read Engine's release record (${shortError(error)}), carrying on.`);
84
+ return;
85
+ }
86
+ if (!uploaded)
87
+ logger.info(`${line}.`);
88
+ else if (!kept)
89
+ logger.info(`${line}; Engine's published ${asset} was updated ${uploaded}.`);
90
+ else if (Date.parse(uploaded) - Date.parse(kept.bittenAt) > UPLOAD_AFTER_BITE_MS)
91
+ logger.info(`${line}; Engine's published ${asset} was updated ${uploaded}, after that bite; ${refreshCommand(deps.commands)} makes a new fork from the newest published state.`);
92
+ else
93
+ logger.info(`${line}.`);
94
+ }
24
95
  async function prefetchWhenNewer(forkBite, deps) {
25
96
  const { engine, network, logger } = deps;
26
97
  let published;
@@ -10,7 +10,7 @@ Node implementations of the system ports in [`@product-lab/ports/system`](../../
10
10
  | `NodeSourceWatcher` | `SourceWatcher` | Hot reload of app code (backlog item 5): `fs.watch`, recursive on a watched directory (FSEvents on macOS, inotify on Linux), a watched file or a path not created yet through its nearest existing parent directory; reports changed paths relative to the lab with `/` separators (a watch of `.`, the lab's directory, reports every change under it, an editor's safe write by rename included), not debounced (the application's `AppReloads` debounces). `digest`: sha256 over each regular file's relative path and content in path order, an ignored directory skipped whole, symbolic links not followed. `tests/node-source-watcher.test.ts` (real temporary directory) |
11
11
  | `NodeConditionedLinks` | `ConditionedLinks` (backlog 31) | One identity's conditioned link to one loopback endpoint (`node-conditioned-links.ts`): a TCP proxy on a port the caller reserved, carrying every byte as sent through the domain's `ConditionedWire` (one per direction of each connection: in order, a burst at its speed, half the round trip each way while throttled); offline refuses new connections and cuts the open ones; half-closes travel through the wire, so an answer sent before its server closes still arrives. A plain `http:` or `ws:` endpoint is named as the request's `Host` in the first request head of each connection (`request-head.ts`: a local Substrate node answers "Provided Host header is not whitelisted" to any other), and a plain request is made its connection's only one (`Connection: close`); a WebSocket upgrade keeps its connection, and bytes that do not start a request pass as sent. A public endpoint is refused, never proxied. Backpressure is not modelled. `tests/node-conditioned-links.test.ts` (real loopback sockets) |
12
12
  | `NodeChainGates` | `ChainGates` (backlog 92) | Origin gates in front of local chain nodes (`node-chain-gates.ts`): one loopback TCP relay per plain `ws:` loopback chain route that accepts connections (a closed port, TLS and a public node are kept as they are). It holds each connection's first request head, decides it once, then pipes bytes both ways (backpressure, no per-message parsing). A request with neither `Origin` nor `Sec-Fetch-Site` (a native host, Node's fetch and WebSocket) passes; a browser's passes only from an admitted origin (`admit`), otherwise the connection is reset unanswered (a 403 would still resolve a no-cors fetch) and recorded (`refused()`: chain, gate URL, kind, origin, time). The node sees its own address as `Host` and a plain request is its connection's only one (`request-head.ts`), so no later request on a kept-alive connection skips the gate. A half-close travels on for a grace (5 s, `halfCloseGraceMs`), then both sides are cut; either side closing or failing destroys both; `close()` cuts everything. Substrate's own `--rpc-cors` cannot do this: its RPC server adds CORS headers but refuses no WebSocket upgrade. `tests/node-chain-gates.test.ts` (real loopback sockets: a closed upstream, a half-closed client, keep-alive, close) |
13
- | `NodeAppProxies` | `AppProxies` | The lab's proxy in front of an app's dev server (backlog items 64 to 66; `src/app-proxy/`): an HTTP reverse proxy on a loopback port that passes every request and answer as sent, except that an HTML document the browser loads gets the host's bridge script (the domain's `injectScriptTag`; then `no-store`, no validators), bodies are asked for uncompressed and documents unconditionally, a redirect to exactly the dev server's origin names the proxy, and the connection to the dev server is the proxy's own (one per request, since dev servers close idle kept-alive sockets). It reads the document's framing headers and never changes them (`framing()`). WebSockets are tunnelled byte for byte (`web-socket-tunnel.ts`); an update socket it understands (`VITE_UPDATES` in `vite-updates.ts`, Vite's `vite-hmr` subprotocol, checked against Vite 7.3.6) is read message by message: each announced update is put to the policy and forwarded or dropped, the same announcement on several pages' sockets is one update, and the page's side stays open when the dev server's closes (`lost`). A socket or an event stream it does not understand passes untouched and is reported once (`unknown-socket`), never for a declared dev server. `tests/node-app-proxy.test.ts` (real loopback servers, Node's WebSocket client; controls: an asset, a fragment, a conditional request, a dead dev server) |
13
+ | `NodeAppProxies` | `AppProxies` | The lab's proxy in front of an app's dev server (backlog items 64 to 66; `src/app-proxy/`): an HTTP reverse proxy on a loopback port that passes every request and answer as sent, except that an HTML document the browser loads gets the host's bridge script (the domain's `injectScriptTag`; then `no-store`, no validators), bodies are asked for uncompressed and documents unconditionally, a redirect to exactly the dev server's origin names the proxy, and the connection to the dev server is the proxy's own (one per request, since dev servers close idle kept-alive sockets). It reads the document's framing headers and never changes them (`framing()`). WebSockets are tunnelled byte for byte (`web-socket-tunnel.ts`); an update socket it understands (`VITE_UPDATES` in `vite-updates.ts`, Vite's `vite-hmr` subprotocol, checked against Vite 7.3.7) is read message by message: each announced update is put to the policy and forwarded or dropped, the same announcement on several pages' sockets is one update, and the page's side stays open when the dev server's closes (`lost`). A socket or an event stream it does not understand passes untouched and is reported once (`unknown-socket`), never for a declared dev server. `tests/node-app-proxy.test.ts` (real loopback servers, Node's WebSocket client; controls: an asset, a fragment, a conditional request, a dead dev server) |
14
14
  | `RandomIdGenerator` | `IdGenerator` | random UUIDs |
15
15
  | `ConsoleLogger` | `Logger` | level filter; details as JSON; with `redactor` (a `Redactor`'s `text`), every line scrubbed before it is written (backlog 89: the composition root passes the runtime's, so a provider logging a secret of the lab never prints it) |
16
16
  | `ProcessEnvironmentVariables` | `EnvironmentVariables` | wraps `process.env` (or any object), read when asked |
@@ -121,7 +121,7 @@ generic socket paths and are read only when the lab declares them (`AppProxyRequ
121
121
 
122
122
  | Declared | Export | Socket | Checked against |
123
123
  | --- | --- | --- | --- |
124
- | `"vite"` (or nothing) | `VITE_UPDATES` | subprotocol `vite-hmr` | Vite 7.3.6 |
124
+ | `"vite"` (or nothing) | `VITE_UPDATES` | subprotocol `vite-hmr` | Vite 7.3.7 |
125
125
  | `"next-webpack-app-router"` | (registry only) | `/_next/hmr` | stock Next 16.3.1 webpack, App Router |
126
126
  | `"next-turbopack"` | `NEXT_TURBOPACK_UPDATES` | `/_next/hmr` | stock Next 16.3.1 Turbopack, App Router (`tools/verify/tests/next-turbopack-app-proxy.test.ts`) |
127
127
  | `"webpack-dev-server"` | `WEBPACK_DEV_SERVER_UPDATES` | `/ws` | webpack-dev-server 6.0.0, @rspack/dev-server 2.2.1 (`tools/verify/tests/webpack-dev-server-app-proxy.test.ts`) |
@@ -9,7 +9,7 @@ A tiny notes SPA tested end to end: the local dev chain (Omni Node) with People
9
9
  1. Install the dev dependencies the files import before the first MCP call (pin these versions; `@cucumber/cucumber` must be the same version product-lab uses, so step files and the testing kit share one Cucumber). A tool call that fails with a module resolution error right after an install succeeds when repeated: the server reloads the lab.
10
10
 
11
11
  ```sh
12
- npm install --save-dev @cucumber/cucumber@12.9.0 playwright@1.63.0 @noble/hashes@1.8.0 @polkadot-labs/hdkd@0.0.32 @polkadot-labs/hdkd-helpers@0.0.34 @polkadot-api/metadata-builders@0.14.3 @polkadot-api/substrate-bindings@0.20.3 @polkadot-api/ws-provider@0.9.1 @parity/product-sdk-host@0.29.0 polkadot-api@2.2.2 vite@7.3.6
12
+ npm install --save-dev @cucumber/cucumber@12.9.0 playwright@1.63.0 @noble/hashes@1.8.0 @polkadot-labs/hdkd@0.0.32 @polkadot-labs/hdkd-helpers@0.0.34 @polkadot-api/metadata-builders@0.14.3 @polkadot-api/substrate-bindings@0.20.3 @polkadot-api/ws-provider@0.9.1 @parity/product-sdk-host@0.29.0 polkadot-api@2.2.2 vite@7.3.7
13
13
  ```
14
14
 
15
15
  Only for `launch: "deployed-local"` (lab/deployed-local.ts deploys to Bulletin): also `npm install --save-dev bulletin-deploy@0.21.0`. It is an optional peer dependency of `@parity/product-lab` under GPL-3.0-or-later (product-lab itself is Apache-2.0), so it is not installed with product-lab; without it a deployed-local run fails with an error naming this command.
@@ -98,6 +98,7 @@ In CI, cache the shared cache keyed on `lab.lock.json`, run `npx --no-install pr
98
98
  | `PRODUCT_LAB_RECORDINGS`, `PRODUCT_LAB_RECORDINGS_DIR`, `…_KEEP`, `…_MAX_AGE_DAYS`, `…_MAX_MB` | Run recordings and their retention |
99
99
  | `PRODUCT_LAB_STEP_TIMEOUT_MINUTES`, `PRODUCT_LAB_STARTUP_TIMEOUT_MINUTES`, `PRODUCT_LAB_CLEANUP_TIMEOUT_MINUTES` | Feature-run timeouts (10, 30, 5) |
100
100
  | `PRODUCT_LAB_BASE` | Git base a mode's `onlyAffected` compares with (`test --mode <name>`; default `main`) |
101
+ | `PRODUCT_LAB_FORK_BUNDLE` | `keep` or `fresh` (default): what a start of a local copy of a network does about a newer published fork bundle. `fresh` downloads it in the background; `keep` only reads the release record and says how old the kept bundle is. `lab --refresh` works under both |
101
102
 
102
103
  Your lab's own variables (a port base, an app directory) are yours to name. Codex passes only listed variables to the MCP server: list them under `env_vars` in `.codex/config.toml` (`ai init` writes product-lab's).
103
104
 
@@ -9,7 +9,7 @@ One page, one button, one signed remark on People, checked on the finalized chai
9
9
  1. Install the dev dependencies the files import (pin these versions):
10
10
 
11
11
  ```sh
12
- npm install --save-dev @parity/product-lab playwright@1.63.0 @noble/hashes@1.8.0 polkadot-api@3.2.1 @parity/product-sdk-host@0.29.0 vite@7.3.6
12
+ npm install --save-dev @parity/product-lab playwright@1.63.0 @noble/hashes@1.8.0 polkadot-api@3.2.1 @parity/product-sdk-host@0.29.0 vite@7.3.7
13
13
  ```
14
14
 
15
15
  2. Copy `lab.config.ts`, `lab/` and `lab.lock.json` into your repository. Run `npx --no-install product-lab fetch` (it caches the runtime and node binary the lock pins) and `npx --no-install product-lab doctor` (what this machine still lacks, and what fits it). `lab.config.ts` below already runs your people on the released truAPI WASM test host, which `@parity/product-lab` installs (`defaultHost: "truapi-wasm"`, and its modes' hosts): changed from the truAPI CLI host the minimal lab was written for. That host's locked official release takes the lab's local endpoints (https://github.com/paritytech/host-rust-core/pull/1166), so it runs a product's local environment too; `npx --no-install product-lab doctor` says which release is locked and what it provides. No environment variable is required.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parity/product-lab",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "description": "Run and test Polkadot products against real local chains, hosts and browsers: the framework and the product-lab CLI.",
6
6
  "license": "Apache-2.0",