From d5a4237a4097c891c53fb7c22901f13e7eda60e2 Mon Sep 17 00:00:00 2001 From: Elliot Braem Date: Tue, 6 Oct 2026 16:45:16 -0500 Subject: [PATCH 1/5] =?UTF-8?q?docs:=20every=20app=20is=20a=20node=20?= =?UTF-8?q?=E2=80=94=20ADR=200027=20and=20Nodes=20glossary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- GLOSSARY.md | 78 +++++++++++++++++++++++----- docs/adr/0027-every-app-is-a-node.md | 28 ++++++++++ docs/adr/README.md | 3 +- 3 files changed, 94 insertions(+), 15 deletions(-) create mode 100644 docs/adr/0027-every-app-is-a-node.md diff --git a/GLOSSARY.md b/GLOSSARY.md index 72c3c090c..6219e3fda 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -1,12 +1,62 @@ # everything.dev -The open runtime for apps on NEAR — composed from published config and loaded through a shared host, UI, and API runtime. +The open runtime for apps on NEAR — every app is a node, owned by a NEAR account, composed from published config and run by a shared runtime. + +## Nodes + +**Node**: +An app published at `bos:///` — its config is signed by `` and authored with `App(...)`. Every app is a node; "node" names its place in the graph, "app" names what it is. +_Avoid_: runtime (that's the package that runs nodes), tenant, project + +**Root node**: +`bos://dev.everything.near/everything.dev` — the node every other node ultimately extends. +_Avoid_: base runtime, platform, parent app + +**Child node**: +A node addressed under another node's path, `bos:////plugins/` — usually a plugin published from its parent's repo, runnable on its own and composable into any node. +_Avoid_: embedded plugin (a plugin that has not yet published as a node), sub-app + +**Gateway**: +The second segment of a node address; for a top-level node, usually its domain. +_Avoid_: name, slug + +**Owner**: +The NEAR account that signs a node's config — a single-key account or a multisig (Sputnik DAOs are multisigs). Ownership is whatever is true on-chain. +_Avoid_: organization (an organization links to an owner; it never is one), admin + +**Address**: +Where a node is reached: a subdomain of its parent's domain, or its own domain. +_Avoid_: URL, host + +**Hosting**: +What serves a node: the shared host, a sandbox, or a standalone deployment. Independent of Address. +_Avoid_: tier, instance + +**Extends**: +A node inheriting its parent node's config, child-wins merged. +_Avoid_: fork, inherit + +**Composition**: +A node pulling another node's surfaces in as one of its `plugins`; the composed node plays the **plugin** role. References follow the composed node's latest publish unless pinned. +_Avoid_: install, import, dependency + +**Surface**: +One loadable part of a node — host, UI, API, or auth. +_Avoid_: remote (the Module Federation transport word), workspace (the repo directory), slot + +**Contract**: +The API shape a node provides or requires, identified by its published contract types; what makes swapping one node's UI over another node's API checkable. +_Avoid_: interface, schema + +**Node card**: +A node's public self-description — title, extends, surfaces, contracts, skill, MCP endpoint, and source — published with its config. +_Avoid_: manifest (that's the build artifact), metadata ## Language **Runtime**: -A published app configuration (`bos.app.ts` authored, resolved through `extends`) that the host loads as remotes. -_Avoid_: app (ambiguous between the runtime and a tenant), project +The `everything-dev` package and host process that loads a node's surfaces and runs it. `RuntimeConfig` in code is a historical name for a node's resolved config. +_Avoid_: app or node (those are what the runtime runs), base runtime **SlotResolver**: The host's entry-URL seam (`entryUrls` in `everything-dev/ui/slot`) — one pure derivation from a stamped config slot (`EntrySlot`) to the URLs each surface loads: `web` (browser entry — bustered fixed name in development, pin-derived hashed outside it), `ssr` (server container entry; undefined when the slot has no SSR coordinates), and `browserManifest` (the pin-derived hashed mf-manifest, structurally absent for local slots — atomic-deploys 08). Surfaces resolve lazily and throw loudly outside development naming slot + surface; buster arithmetic (integrity for web, ssrIntegrity→containerVersion for the server) lives inside the module, never at call sites. @@ -26,24 +76,24 @@ _Avoid_: sync file, template file (sync no longer carries ui source) ## Organization access +**Organization**: +A group of people who act together; it links to one or more Owners so its members can work on those owners' nodes. +_Avoid_: owner, org account + +**Linked Account**: +An Owner an organization has proven control of — a single-key account the member has signed in with, or a multisig the member belongs to on-chain. An account links to at most one organization. +_Avoid_: DAO link, connected wallet + **Team**: -A named sub-group within an organization that shares access to the organization's feature areas. +A named sub-group within an organization. _Avoid_: team account, team wallet **Active Team**: -The Team currently selected for a user's organization work. +The Team a user is currently acting as. _Avoid_: current team, team account -**Feature Area**: -A product capability that an organization can grant to a Team. -_Avoid_: permission, role - -**Team Workspace**: -The organization view scoped to an Active Team and its granted feature areas. -_Avoid_: organization account - **Gateway Origin**: -The canonical base runtime origin for a network, where every passkey ceremony takes place regardless of which domain the member arrived from. +The root node's origin for a network, where every passkey ceremony takes place regardless of which domain the member arrived from. _Avoid_: base URL, main domain **Device Link**: diff --git a/docs/adr/0027-every-app-is-a-node.md b/docs/adr/0027-every-app-is-a-node.md new file mode 100644 index 000000000..a5a25948d --- /dev/null +++ b/docs/adr/0027-every-app-is-a-node.md @@ -0,0 +1,28 @@ +# ADR 0027: Every app is a node + +Date: 2026-10-06 +Status: Accepted + +Apps already extend each other (`bos:///` → `extends`), but plugins have no identity of their own: `bos plugin publish` rewrites the host app's whole config, and the only way to point at a plugin is a fragment into someone else's config (`bos://acct/gw#plugins.registry`). Most plugins are apps — an API and a UI exposed for composition — so we treat every app as a **node** in one graph, and give plugins a real address nested under the node that publishes them: `bos:////plugins/`, stored at `apps///plugins//bos.config.json`. The authoring surface does not change: a node is still `App(...)`, `gateway` keeps its name, and `RuntimeConfig` stays in code as a historical name; "Runtime" now means the `everything-dev` package and host that run nodes. + +## Decisions + +1. **Address.** A node is `bos:///[/plugins/]…`; the path nests, so a child node can compose its own children. `parseBosUrl` and `getRegistryConfigKey` (`packages/everything-dev/src/fastkv.ts`) already accept the trailing segments. Nested addresses mirror the bundle layout (`bundles////…`, ADR 0015/0020), avoid global plugin names, and let one prefix scan of `apps///` return a node and everything under it. +2. **Ownership is on-chain.** The signing account owns the node (ADR 0001, unchanged) — a single-key account or a multisig; Sputnik DAOs are multisigs and publish through a proposal whose `FunctionCall` executes as the DAO. An organization never owns a node: it **links** to owners through a verified `linkAccount { accountId, kind: "account" | "multisig" }` (the member's signed-in NEAR account, or on-chain membership of the multisig), replacing the unverified `linkDao`. An account links to at most one organization. +3. **Follow by default.** A composed or extended reference follows the target's latest publish — instant updates are the point. `@` pins to the content-addressed version manifest that already exists (`every-plugin/src/version-manifest.ts`). Contract checks gate a follow before the new version is adopted. +4. **Contracts make swaps checkable.** A node declares the contracts it provides (its published contract types, by sha256 and semver) and requires. A UI node requiring `auth@^1` runs over any node providing it — "swap the UI, keep the functionality" becomes a check, not a hope. +5. **Node card.** Each published config carries a node card — title, extends, surfaces, provides/requires, skill URL, MCP endpoint, source (repository, git sha) — also served at `/.well-known/bos.json`, so agents and the registry discover nodes the same way (aligned with NEP-330 source metadata, agent skills, and `.well-known/mcp.json`). +6. **Graduation.** A child node that moves to its own domain publishes at the new top-level address, and its old nested address `extends` the new one, so existing composers keep following. + +## Considered options + +- **Flat `bos:///` for plugins** — host-independent identity, but needs globally unique names per account and redefines `gateway` as a generic name. Rejected; graduation (6) covers the independence case. +- **`modules/{ns}/{name}` + ModuleRecord** (beta-v2 map decision #3) — a second key space and record type beside app configs. Rejected in favour of one config shape at every level of the tree; the record's useful fields land in the node card. +- **Organization-owned nodes** — would make an off-chain auth record the authority over an on-chain write. Rejected; ownership follows the signer. + +## Consequences + +- The registry indexes nested configs and shows "used by" for every node; a plugin that has not published under its own nested address is shown as embedded until it does. +- `bos plugin publish ` must write `apps///plugins//bos.config.json` and the parent references it with `Plugin(id).extends("bos://…/plugins/")`; the CLI and publish track is shared with the citynode "Everything is a node" wayfinder (citynode.app#233–#252). +- Each plugin directory moves from `plugin.dev.ts` to the `bos.app.ts` + `bos.dev.ts` pair, so any node can boot standalone on the universal image (ADR 0021). +- The dev panel can only write a node when the connected wallet is its owner; multisig owners get a proposal flow later. diff --git a/docs/adr/README.md b/docs/adr/README.md index d3305c49b..3b9358764 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -32,6 +32,7 @@ Format: context → decision → consequences, one file per decision. | [0024](./0024-composition-stays-manifest-based.md) | Composition stays manifest-based; route chunks load lazily | Accepted | | [0025](./0025-auth-identity-value-classes.md) | Auth identity values derive from the runtime config | Accepted | | [0026](./0026-pnpm-workspace-node-runtime-v2.md) | pnpm workspace, node runtime — one toolchain for parent and children (v2) | Accepted | +| [0027](./0027-every-app-is-a-node.md) | Every app is a node — nested plugin addresses, on-chain ownership, follow by default | Accepted | Skipped numbers: 0006 exists; there are no gaps otherwise. 0015–0018 were @@ -42,7 +43,7 @@ low-reference file in each pair moved (see git history). ## Number assignment discipline - Take the next free number (`ls docs/adr/ | sort -n | tail -1`, then +1 — - next free is **0026**). Never reuse a number, even for a rejected ADR. + next free is **0028**). Never reuse a number, even for a rejected ADR. - When citing an ADR in code, plans, changesets, or AGENTS.md, cite the number **with the filename** if the context could be ambiguous. - Superseded ADRs stay in place with a `Superseded by` status link to the From 916651b2f2e51348df3261c45c51105b10df485b Mon Sep 17 00:00:00 2001 From: Elliot Braem Date: Tue, 6 Oct 2026 16:45:16 -0500 Subject: [PATCH 2/5] fix(dev): unbreak every-plugin dev HMR entry and surface build errors in bos dev logs --- .changeset/dev-error-visibility.md | 5 +++ .changeset/dev-hmr-data-uri.md | 5 +++ .../build/rspack/fix-mf-data-uri-plugin.ts | 41 +++++++++-------- .../tests/unit/fix-mf-data-uri.test.ts | 36 +++++++++++++++ packages/everything-dev/src/build.ts | 3 +- packages/everything-dev/src/commands/db.ts | 9 ++-- packages/everything-dev/src/commands/init.ts | 3 +- packages/everything-dev/src/commands/keys.ts | 7 +-- packages/everything-dev/src/commands/ops.ts | 7 +-- .../everything-dev/src/commands/upgrade.ts | 9 ++-- .../everything-dev/src/dev-log-pipeline.ts | 20 +++++++-- packages/everything-dev/src/dev-program.ts | 7 +-- packages/everything-dev/src/publish.ts | 3 +- packages/everything-dev/src/utils/error.ts | 29 ++++++++++++ .../tests/unit/describe-error.test.ts | 45 +++++++++++++++++++ .../tests/unit/dev-log-pipeline.test.ts | 45 +++++++++++++++++++ 16 files changed, 233 insertions(+), 41 deletions(-) create mode 100644 .changeset/dev-error-visibility.md create mode 100644 .changeset/dev-hmr-data-uri.md create mode 100644 packages/every-plugin/tests/unit/fix-mf-data-uri.test.ts create mode 100644 packages/everything-dev/src/utils/error.ts create mode 100644 packages/everything-dev/tests/unit/describe-error.test.ts diff --git a/.changeset/dev-error-visibility.md b/.changeset/dev-error-visibility.md new file mode 100644 index 000000000..8d8d28a11 --- /dev/null +++ b/.changeset/dev-error-visibility.md @@ -0,0 +1,5 @@ +--- +"everything-dev": patch +--- + +Build errors are visible again in `bos dev`. Rsbuild prints `error Build error:` on stderr and the actual detail (`File: …`, `× …`) on stdout; the log pipeline now joins those detail lines onto the header as one error event and treats bundler `×` lines as errors on any stream, so the live tail and shutdown summary show the cause instead of a bare header. CLI commands also stop reporting "Unknown error" for Effect tagged errors (e.g. `ArtifactGenError` from `bos typecheck` / `bos types gen`): the tag, phase and cause chain are now shown. diff --git a/.changeset/dev-hmr-data-uri.md b/.changeset/dev-hmr-data-uri.md new file mode 100644 index 000000000..99320172b --- /dev/null +++ b/.changeset/dev-hmr-data-uri.md @@ -0,0 +1,5 @@ +--- +"every-plugin": patch +--- + +`FixMfDataUriPlugin` now only rewrites `@module-federation/*` imports inside data-URI modules. It used to strip the absolute `node_modules` prefix from every data-URI import, which turned Rsbuild 2's dev HMR client entry (`@rsbuild/core/dist/client/hmr.js`) into a bare specifier its exports map rejects, so `every-plugin dev` failed for the UI and plugin UIs with "Package subpath './dist/client/hmr.js' is not defined by exports". diff --git a/packages/every-plugin/src/build/rspack/fix-mf-data-uri-plugin.ts b/packages/every-plugin/src/build/rspack/fix-mf-data-uri-plugin.ts index 34ee7981c..9ba67bc0a 100644 --- a/packages/every-plugin/src/build/rspack/fix-mf-data-uri-plugin.ts +++ b/packages/every-plugin/src/build/rspack/fix-mf-data-uri-plugin.ts @@ -2,6 +2,8 @@ import type { Compiler, RspackPluginInstance } from "@rspack/core"; const MF_DATA_URI_MARKER = "data:text/javascript,"; +const MF_ABSOLUTE_IMPORT_RE = /(["'])\/[^"']*?\/node_modules\/(@module-federation\/[^"']*)/g; + export class FixMfDataUriPlugin implements RspackPluginInstance { name = "FixMfDataUriPlugin"; @@ -9,29 +11,32 @@ export class FixMfDataUriPlugin implements RspackPluginInstance { compiler.hooks.compilation.tap(this.name, (_compilation, { normalModuleFactory }) => { normalModuleFactory.hooks.beforeResolve.tap(this.name, (resolveData) => { if (!resolveData?.request) return; - if (!resolveData.request.includes(MF_DATA_URI_MARKER)) return; - this.reencodeDataUri(resolveData); + resolveData.request = rewriteMfDataUriRequest(resolveData.request); }); }); } +} - private reencodeDataUri(resolveData: { request: string }) { - const { request } = resolveData; - const idx = request.indexOf(MF_DATA_URI_MARKER); - if (idx === -1) return; - - const contentStart = idx + MF_DATA_URI_MARKER.length; - const prefix = request.substring(0, contentStart); - const rawContent = request.substring(contentStart); +/** + * The MF runtime's generated data-URI module imports its runtime pieces by + * absolute node_modules paths (`require.resolve` output), which differ per + * machine. Rewriting them to bare specifiers keeps the module — and therefore + * the module id space — machine-independent; `mfDataUriAliases()` points the + * specifiers back at their on-disk targets for resolution. + * + * Only `@module-federation/*` imports are rewritten: other data-URI entries + * (e.g. Rsbuild's dev HMR client, `@rsbuild/core/dist/client/hmr.js`) import + * subpaths their package's exports map does not expose, so stripping their + * absolute prefix makes them unresolvable. + */ +export function rewriteMfDataUriRequest(request: string): string { + const idx = request.indexOf(MF_DATA_URI_MARKER); + if (idx === -1) return request; - // The MF runtime's generated data-URI module imports its runtime pieces - // by absolute node_modules paths (`require.resolve` output), which differ - // per machine. Rewriting them to bare specifiers keeps the module — and - // therefore the module id space — machine-independent; the composition - // aliases the specifiers to their on-disk targets for resolution. - const decoded = safeDecode(rawContent).replace(/(["'])\/[^"']*?\/node_modules\//g, "$1"); - resolveData.request = prefix + encodeURIComponent(decoded); - } + const contentStart = idx + MF_DATA_URI_MARKER.length; + const prefix = request.substring(0, contentStart); + const decoded = safeDecode(request.substring(contentStart)); + return prefix + encodeURIComponent(decoded.replace(MF_ABSOLUTE_IMPORT_RE, "$1$2")); } function safeDecode(content: string): string { diff --git a/packages/every-plugin/tests/unit/fix-mf-data-uri.test.ts b/packages/every-plugin/tests/unit/fix-mf-data-uri.test.ts new file mode 100644 index 000000000..232c902eb --- /dev/null +++ b/packages/every-plugin/tests/unit/fix-mf-data-uri.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { rewriteMfDataUriRequest } from "../../src/build/rspack/fix-mf-data-uri-plugin"; + +const dataUri = (source: string) => `data:text/javascript,${encodeURIComponent(source)}`; +const decode = (request: string) => + decodeURIComponent(request.slice("data:text/javascript,".length)); + +describe("rewriteMfDataUriRequest", () => { + it("strips the machine-absolute prefix from MF runtime imports", () => { + const request = dataUri( + [ + `import * as runtime from "/Users/a/proj/node_modules/.pnpm/x/node_modules/@module-federation/webpack-bundler-runtime/dist/index.js";`, + `import plugin from '/home/b/node_modules/@module-federation/node/dist/src/runtimePlugin.js';`, + ].join("\n"), + ); + const out = decode(rewriteMfDataUriRequest(request)); + expect(out).toContain(`"@module-federation/webpack-bundler-runtime/dist/index.js"`); + expect(out).toContain(`'@module-federation/node/dist/src/runtimePlugin.js'`); + expect(out).not.toContain("/node_modules/"); + }); + + it("leaves non-MF absolute imports alone so unexported subpaths still resolve", () => { + const hmr = "/Users/a/proj/node_modules/@rsbuild/core/dist/client/hmr.js"; + const overlay = "/Users/a/proj/node_modules/@rsbuild/core/dist/client/overlay.js"; + const request = dataUri( + `import { init } from '${hmr}';\nimport '${overlay}';\ninit('t', {}, "localhost", 3003);`, + ); + const out = decode(rewriteMfDataUriRequest(request)); + expect(out).toContain(`'${hmr}'`); + expect(out).toContain(`'${overlay}'`); + }); + + it("passes through requests that are not data URIs", () => { + expect(rewriteMfDataUriRequest("./src/index.ts")).toBe("./src/index.ts"); + }); +}); diff --git a/packages/everything-dev/src/build.ts b/packages/everything-dev/src/build.ts index 23893afce..10d2bb3d2 100644 --- a/packages/everything-dev/src/build.ts +++ b/packages/everything-dev/src/build.ts @@ -5,6 +5,7 @@ import { resolveLocalDevelopmentPath } from "./config"; import type { WorkspaceDeployResult } from "./contract"; import { syncResolvedSharedDeps } from "./shared-deps"; import type { BosConfig, BosPluginRef, RuntimeConfig } from "./types"; +import { describeError } from "./utils/error"; import { run } from "./utils/run"; import { padRight } from "./utils/string"; import { colors, icons } from "./utils/theme"; @@ -273,7 +274,7 @@ export async function buildWorkspaceTargets(opts: { key: ws.key, kind: ws.kind, success: false, - error: result.reason?.message ?? "Unknown error", + error: describeError(result.reason), }); } } diff --git a/packages/everything-dev/src/commands/db.ts b/packages/everything-dev/src/commands/db.ts index b07f54031..046d168b3 100644 --- a/packages/everything-dev/src/commands/db.ts +++ b/packages/everything-dev/src/commands/db.ts @@ -1,5 +1,6 @@ import { Context, Effect } from "effect"; import { findConfigPath, MISSING_CONFIG_MESSAGE } from "../config"; +import { describeError } from "../utils/error"; import { type BosBuilder, BosDepsTag } from "./shared"; export function registerDb(builder: BosBuilder) { @@ -36,7 +37,7 @@ export function registerDb(builder: BosBuilder) { plugin: input.plugin, source: "remote" as const, section: "", - error: error instanceof Error ? error.message : "Unknown error", + error: describeError(error), }; } }), @@ -82,7 +83,7 @@ export function registerDb(builder: BosBuilder) { appliedHashCount: 0, expectedTables: [], missingTables: [], - error: error instanceof Error ? error.message : "Unknown error", + error: describeError(error), }; } }), @@ -112,9 +113,9 @@ export function registerDb(builder: BosBuilder) { } catch (error) { return { status: "error" as const, - message: error instanceof Error ? error.message : "Unknown error", + message: describeError(error), diagnosis: null, - error: error instanceof Error ? error.message : "Unknown error", + error: describeError(error), }; } }), diff --git a/packages/everything-dev/src/commands/init.ts b/packages/everything-dev/src/commands/init.ts index 34d498400..16de2970c 100644 --- a/packages/everything-dev/src/commands/init.ts +++ b/packages/everything-dev/src/commands/init.ts @@ -31,6 +31,7 @@ import { timePhase } from "../progress"; import { openResolution } from "../resolution/session"; import { syncResolvedSharedDeps } from "../shared-deps"; import type { BosConfig, BosConfigInput, StarterLevel } from "../types"; +import { describeError } from "../utils/error"; import { saveBosConfig } from "../utils/save-config"; import type { BosBuilder } from "./shared"; @@ -351,7 +352,7 @@ export function registerInit(builder: BosBuilder) { overrides: input.overrides, filesCopied: 0, timings: [], - error: error instanceof Error ? error.message : "Unknown error", + error: describeError(error), }; } }), diff --git a/packages/everything-dev/src/commands/keys.ts b/packages/everything-dev/src/commands/keys.ts index d38f135fe..afa5d2d46 100644 --- a/packages/everything-dev/src/commands/keys.ts +++ b/packages/everything-dev/src/commands/keys.ts @@ -23,6 +23,7 @@ import { } from "../near-cli"; import { getNetworkIdForAccount } from "../network"; import type { ResolutionSession } from "../resolution/session"; +import { describeError } from "../utils/error"; import { colors } from "../utils/theme"; import { type BosBuilder, BosDepsTag } from "./shared"; @@ -114,7 +115,7 @@ export function registerKeys(builder: BosBuilder) { contract, allowance: input.allowance, functionNames: PUBLISH_FUNCTION_NAMES, - error: error instanceof Error ? error.message : "Unknown error", + error: describeError(error), }; } }), @@ -200,7 +201,7 @@ export function registerKeys(builder: BosBuilder) { return { status: "error" as const, siteUrl: input.site ?? "", - error: error instanceof Error ? error.message : "Unknown error", + error: describeError(error), }; } }), @@ -244,7 +245,7 @@ export function registerKeys(builder: BosBuilder) { status: "error" as const, revokedApiKey: false, removedPublishKey: false, - error: error instanceof Error ? error.message : "Unknown error", + error: describeError(error), }; } }), diff --git a/packages/everything-dev/src/commands/ops.ts b/packages/everything-dev/src/commands/ops.ts index 832a0147e..ebd4ef481 100644 --- a/packages/everything-dev/src/commands/ops.ts +++ b/packages/everything-dev/src/commands/ops.ts @@ -12,6 +12,7 @@ import { ownerOfPort } from "../infra/port-ownership"; import { killProcessGroupEscalating, reapGroup } from "../process-kill"; import { isPidAlive, pruneDeadEffect, readRegistry, unregisterPid } from "../process-registry"; import { openResolution } from "../resolution/session"; +import { describeError } from "../utils/error"; import { computeDeployedVersionStatus, type DeployedVersionStatus } from "../version-status"; import { type BosBuilder, type BosDeps, BosDepsTag } from "./shared"; @@ -41,7 +42,7 @@ export function registerOps(builder: BosBuilder) { status: "error" as const, packages: [], envFile: "missing" as const, - error: error instanceof Error ? error.message : "Unknown error", + error: describeError(error), }; } }), @@ -87,7 +88,7 @@ export function registerOps(builder: BosBuilder) { return { status: "error" as const, entries: [], - error: error instanceof Error ? error.message : "Unknown error", + error: describeError(error), }; } }), @@ -170,7 +171,7 @@ export function registerOps(builder: BosBuilder) { status: "error" as const, killed: [], skipped: [], - error: error instanceof Error ? error.message : "Unknown error", + error: describeError(error), }; } }), diff --git a/packages/everything-dev/src/commands/upgrade.ts b/packages/everything-dev/src/commands/upgrade.ts index 2e00bceb3..ecdf04ec7 100644 --- a/packages/everything-dev/src/commands/upgrade.ts +++ b/packages/everything-dev/src/commands/upgrade.ts @@ -10,6 +10,7 @@ import { findConfigPath, MISSING_CONFIG_MESSAGE, readAuthoredConfigInput } from import { checkFederationCompat } from "../mf"; import { openResolution } from "../resolution/session"; import type { BosConfig } from "../types"; +import { describeError } from "../utils/error"; import { colors } from "../utils/theme"; import type { BosBuilder } from "./shared"; @@ -45,7 +46,7 @@ export function registerUpgrade(builder: BosBuilder) { updated: [], skipped: [], added: [], - error: error instanceof Error ? error.message : "Unknown error", + error: describeError(error), }; } }), @@ -67,7 +68,7 @@ export function registerUpgrade(builder: BosBuilder) { return { status: "error" as const, packages: [], - error: error instanceof Error ? error.message : "Unknown error", + error: describeError(error), }; } }), @@ -238,7 +239,7 @@ export function registerUpgrade(builder: BosBuilder) { fetched: [], skipped: [], failed: [], - error: error instanceof Error ? error.message : "Unknown error", + error: describeError(error), }; } }), @@ -381,7 +382,7 @@ export function registerUpgrade(builder: BosBuilder) { checked: [], skipped: [], results: [], - error: error instanceof Error ? error.message : "Unknown error", + error: describeError(error), }; } }), diff --git a/packages/everything-dev/src/dev-log-pipeline.ts b/packages/everything-dev/src/dev-log-pipeline.ts index 2a5b433a7..ae5784a0c 100644 --- a/packages/everything-dev/src/dev-log-pipeline.ts +++ b/packages/everything-dev/src/dev-log-pipeline.ts @@ -62,6 +62,13 @@ export function toEffectLogLevel(level: LogLevel): EffectLogLevel { const STACK_FRAME_RE = /^\s+at\s/; +const OPEN_ERROR_HEADER_RE = /^(?:error\b.*|.*\b(?:Build|Compile|Compilation) error)\s*:\s*$/i; + +const ERROR_DETAIL_RE = + /^(?:\s+\S|\s*[×│╭╰├┌└╷╵|]|File:|help:|Caused by|Module not found|Can't resolve|Package subpath)/; + +const MAX_ERROR_BLOCK_LINES = 60; + const countBraces = (line: string): number => { let count = 0; for (const ch of line) { @@ -111,7 +118,13 @@ const feedLine = ( return null; } - const isContinuation = state.openBraces > 0 || STACK_FRAME_RE.test(line) || /^\s*\{/.test(line); + const inErrorBlock = + OPEN_ERROR_HEADER_RE.test(state.pending[0] ?? "") && + state.pending.length < MAX_ERROR_BLOCK_LINES && + ERROR_DETAIL_RE.test(line); + + const isContinuation = + inErrorBlock || state.openBraces > 0 || STACK_FRAME_RE.test(line) || /^\s*\{/.test(line); if (isContinuation) { const closeOnBalance = state.openBraces > 0; @@ -151,6 +164,7 @@ export function normalizeLines(lines: string[]): string[] { } const WARN_RE = /(?:^|\s)WARN\b|\[WARN\]|(?:^|\s)\w*Warning:/i; +const BUNDLER_ERROR_RE = /^(?:error\s{2,}|\s*×\s)/; const DEBUG_RE = /(?:^|\s)DEBUG\b|\[DEBUG\]/i; const EXIT_LINE_RE = @@ -214,7 +228,7 @@ export function classifyEvent(raw: RawLogLine): LogEvent { const category = classifyCategory(text); let level: LogLevel; - let isError = raw.isError === true; + let isError = raw.isError === true || BUNDLER_ERROR_RE.test(text); if (exitMatch) { if (exitMatch[2]) { @@ -223,7 +237,7 @@ export function classifyEvent(raw: RawLogLine): LogEvent { level = exitMatch[1] === "0" ? "info" : "error"; } isError = level === "error"; - } else if (WARN_RE.test(text)) { + } else if (WARN_RE.test(text.split("\n", 1)[0] ?? "")) { level = "warn"; isError = false; } else if (DEBUG_RE.test(text)) { diff --git a/packages/everything-dev/src/dev-program.ts b/packages/everything-dev/src/dev-program.ts index d1ce43ec4..57230c339 100644 --- a/packages/everything-dev/src/dev-program.ts +++ b/packages/everything-dev/src/dev-program.ts @@ -48,6 +48,7 @@ import { syncResolvedSharedDeps } from "./shared-deps"; import { isRegistryStart, resolveStartConfigSource } from "./start-config-source"; import type { BosConfig, SourceMode } from "./types"; import { BosConfigSchema } from "./types"; +import { describeError } from "./utils/error"; import { run } from "./utils/run"; import { ensureFreshDeps, findWorkspaceRoot } from "./workspace"; @@ -440,9 +441,9 @@ export const startBootstrap = ( try: () => resolvePublishedConfig(account, domain, input.registry), catch: (error) => new StartFetchFailed({ - message: `Failed to fetch config for bos://${account}/${domain}: ${ - error instanceof Error ? error.message : "Unknown error" - }\nExpected URL: ${expectedUrl}`, + message: `Failed to fetch config for bos://${account}/${domain}: ${describeError( + error, + )}\nExpected URL: ${expectedUrl}`, }), }); if (remoteConfig) { diff --git a/packages/everything-dev/src/publish.ts b/packages/everything-dev/src/publish.ts index 156705af8..7bca49439 100644 --- a/packages/everything-dev/src/publish.ts +++ b/packages/everything-dev/src/publish.ts @@ -31,6 +31,7 @@ import { openResolution } from "./resolution/session"; import { verifyRollbackSnapshot } from "./rollback"; import { collectDistFiles, uploadBundle, uploadWorkspaceDist } from "./storage-upload"; import type { BosConfig, BosConfigInput, PublishConfig, RuntimeConfig } from "./types"; +import { describeError } from "./utils/error"; import { padRight } from "./utils/string"; import { colors, icons } from "./utils/theme"; import { composeWorkspaceVersionManifest, readBuildReport } from "./version-manifest-deploy"; @@ -268,7 +269,7 @@ export async function preflightPublish(input: PublishToFastKvInput): Promise MAX_CAUSE_DEPTH) return ""; + if (typeof error === "string") return error.trim() || (depth === 0 ? "Unknown error" : ""); + if (error === null || error === undefined || typeof error !== "object") { + return depth === 0 ? (error === undefined ? "Unknown error" : String(error)) : ""; + } + + const record = error as { message?: unknown; _tag?: unknown; phase?: unknown; cause?: unknown }; + const message = typeof record.message === "string" ? record.message.trim() : ""; + const tag = typeof record._tag === "string" ? record._tag : ""; + const phase = typeof record.phase === "string" ? record.phase : ""; + const label = message || (tag ? (phase ? `${tag} (${phase})` : tag) : ""); + const cause = + record.cause !== undefined && record.cause !== error + ? describeError(record.cause, depth + 1) + : ""; + + const parts = label && cause && !label.includes(cause) ? `${label}: ${cause}` : label || cause; + if (parts) return parts; + return depth === 0 ? "Unknown error" : ""; +} diff --git a/packages/everything-dev/tests/unit/describe-error.test.ts b/packages/everything-dev/tests/unit/describe-error.test.ts new file mode 100644 index 000000000..ffc56c77f --- /dev/null +++ b/packages/everything-dev/tests/unit/describe-error.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { ArtifactGenError } from "../../src/code-artifacts"; +import { describeError } from "../../src/utils/error"; + +describe("describeError", () => { + it("surfaces a tagged error's phase and cause instead of 'Unknown error'", () => { + const error = new ArtifactGenError({ + phase: "generate ui manifest (ui)", + cause: new Error( + 'manifest generation failed for ui:\n route "prototype/retro-frames" must live under a mount', + ), + }); + const message = describeError(error); + expect(message).toContain("generate ui manifest (ui)"); + expect(message).toContain('route "prototype/retro-frames" must live under a mount'); + }); + + it("keeps plain Error messages as-is", () => { + expect(describeError(new Error("boom"))).toBe("boom"); + }); + + it("does not repeat a cause already contained in the message", () => { + const inner = new Error("disk full"); + expect(describeError(new Error("write failed: disk full", { cause: inner }))).toBe( + "write failed: disk full", + ); + }); + + it("walks plain-object failures and strings", () => { + expect(describeError({ _tag: "NotFound" })).toBe("NotFound"); + expect(describeError("nope")).toBe("nope"); + }); + + it("falls back to 'Unknown error' only when nothing is describable", () => { + expect(describeError(undefined)).toBe("Unknown error"); + expect(describeError({})).toBe("Unknown error"); + }); + + it("survives self-referencing causes", () => { + const a: { message: string; cause?: unknown } = { message: "" }; + const b = { message: "", cause: a }; + a.cause = b; + expect(describeError(a)).toBe("Unknown error"); + }); +}); diff --git a/packages/everything-dev/tests/unit/dev-log-pipeline.test.ts b/packages/everything-dev/tests/unit/dev-log-pipeline.test.ts index 9f7492956..53285ed6e 100644 --- a/packages/everything-dev/tests/unit/dev-log-pipeline.test.ts +++ b/packages/everything-dev/tests/unit/dev-log-pipeline.test.ts @@ -346,6 +346,51 @@ describe("createLogPipeline broadcast", () => { expect(file[0]).toContain("}"); }); + it("groups an rsbuild build error split across stderr and stdout into one error event", () => { + const { pipeline, display, exported } = makePipeline(); + pipeline.ingest({ source: "ui", line: "start build started..." }); + pipeline.ingest({ source: "ui", line: "error Build error: ", isError: true }); + pipeline.ingest({ + source: "ui", + line: "File: data-uri virtual module (import%20%7B%20init%20%7D%20fr...)", + }); + pipeline.ingest({ + source: "ui", + line: " × Package subpath './dist/client/hmr.js' is not defined by \"exports\" in /x/node_modules/@rsbuild/core/package.json", + }); + pipeline.ingest({ + source: "ui", + line: "Timed out or failed: wait for process readiness: Process failed: ui", + isError: true, + }); + pipeline.ingest({ source: "ui", line: "ready built in 1.4s" }); + pipeline.flush(); + + expect(exported).toHaveLength(2); + expect(exported[0]).toContain("Build error:"); + expect(exported[0]).toContain("File: data-uri virtual module"); + expect(exported[0]).toContain("Package subpath './dist/client/hmr.js'"); + expect(exported[1]).toBe("Timed out or failed: wait for process readiness: Process failed: ui"); + expect(display.every((d) => d.isError)).toBe(true); + }); + + it("surfaces bundler × error lines printed on stdout without a stderr header", () => { + const { pipeline, exported } = makePipeline(); + pipeline.ingest({ source: "auth", line: " × Module not found: Can't resolve './missing'" }); + pipeline.flush(); + + expect(exported).toEqual([" × Module not found: Can't resolve './missing'"]); + }); + + it("does not swallow unrelated lines after an error header that has a message", () => { + const { pipeline, file } = makePipeline(); + pipeline.ingest({ source: "ui", line: "error build failed in 2.04s", isError: true }); + pipeline.ingest({ source: "ui", line: " indented info line" }); + pipeline.flush(); + + expect(file).toEqual(["error build failed in 2.04s", " indented info line"]); + }); + it("separates multi-line blocks per source", () => { const { pipeline, file } = makePipeline(); pipeline.ingest({ source: "api", line: "[INFO] (api) Request context" }); From be3ba651cfc93f541535a697ef3bf896e1623b82 Mon Sep 17 00:00:00 2001 From: Elliot Braem Date: Tue, 6 Oct 2026 16:45:17 -0500 Subject: [PATCH 3/5] chore(prototype): retro-frames variant prototype at /prototype/retro-frames --- ui/src/components/prototype-switcher.tsx | 107 +++++ ui/src/components/ui/retro-prototype.tsx | 319 +++++++++++++++ .../prototype/-retro-frames/gallery.tsx | 223 ++++++++++ .../prototype/-retro-frames/landing.tsx | 46 +++ .../_public/prototype/-retro-frames/login.tsx | 113 ++++++ .../prototype/-retro-frames/my-node.tsx | 384 ++++++++++++++++++ .../-retro-frames/under-construction-gif.tsx | 44 ++ .../prototype/-retro-frames/variants.ts | 54 +++ .../routes/_public/prototype/retro-frames.tsx | 180 ++++++++ 9 files changed, 1470 insertions(+) create mode 100644 ui/src/components/prototype-switcher.tsx create mode 100644 ui/src/components/ui/retro-prototype.tsx create mode 100644 ui/src/routes/_public/prototype/-retro-frames/gallery.tsx create mode 100644 ui/src/routes/_public/prototype/-retro-frames/landing.tsx create mode 100644 ui/src/routes/_public/prototype/-retro-frames/login.tsx create mode 100644 ui/src/routes/_public/prototype/-retro-frames/my-node.tsx create mode 100644 ui/src/routes/_public/prototype/-retro-frames/under-construction-gif.tsx create mode 100644 ui/src/routes/_public/prototype/-retro-frames/variants.ts create mode 100644 ui/src/routes/_public/prototype/retro-frames.tsx diff --git a/ui/src/components/prototype-switcher.tsx b/ui/src/components/prototype-switcher.tsx new file mode 100644 index 000000000..635908441 --- /dev/null +++ b/ui/src/components/prototype-switcher.tsx @@ -0,0 +1,107 @@ +import { CaretLeftIcon, CaretRightIcon } from "@phosphor-icons/react"; +import { type ReactNode, useEffect } from "react"; + +interface PrototypeVariant { + key: string; + name?: string; +} + +interface PrototypeSwitcherProps { + variants: readonly PrototypeVariant[]; + current: string; + onChange: (key: string) => void; + children?: ReactNode; +} + +function isEditable(target: EventTarget | null) { + if (!(target instanceof HTMLElement)) return false; + return ( + target.isContentEditable || + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.tagName === "SELECT" + ); +} + +export function PrototypeSwitcher({ + variants, + current, + onChange, + children, +}: PrototypeSwitcherProps) { + const index = Math.max( + 0, + variants.findIndex((variant) => variant.key === current), + ); + const active = variants[index]; + + const step = (delta: number) => { + const next = variants[(index + delta + variants.length) % variants.length]; + if (next) onChange(next.key); + }; + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.metaKey || event.ctrlKey || event.altKey || isEditable(event.target)) return; + if (event.key === "ArrowLeft") step(-1); + if (event.key === "ArrowRight") step(1); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }); + + if (process.env.NODE_ENV === "production") return null; + + return ( +
+ + + {active?.key} + {active?.name ? ` (${active.name})` : ""} + + + {children} +
+ ); +} + +export function PrototypeSwitcherOption({ + active, + onClick, + children, + label, +}: { + active: boolean; + onClick: () => void; + children: ReactNode; + label: string; +}) { + return ( + + ); +} diff --git a/ui/src/components/ui/retro-prototype.tsx b/ui/src/components/ui/retro-prototype.tsx new file mode 100644 index 000000000..46378f948 --- /dev/null +++ b/ui/src/components/ui/retro-prototype.tsx @@ -0,0 +1,319 @@ +import { Button as ButtonPrimitive } from "@base-ui/react/button"; +import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"; +import { CheckIcon, CopyIcon, MinusIcon, XIcon } from "@phosphor-icons/react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "cn"; +import type * as React from "react"; +import { createContext, type ReactElement, type ReactNode, useContext, useState } from "react"; + +export type RetroIntensity = "soft" | "classic" | "tycoon"; + +const RetroContext = createContext("classic"); + +export function RetroProvider({ + intensity, + children, +}: { + intensity: RetroIntensity; + children: ReactNode; +}) { + return {children}; +} + +export function useRetroIntensity() { + return useContext(RetroContext); +} + +const retroButtonVariants = cva( + "group/button relative inline-flex shrink-0 cursor-pointer items-center justify-center rounded-none font-medium whitespace-nowrap select-none outline-none transition-bevel focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring retro-classic:focus-visible:outline-1 retro-classic:focus-visible:outline-dotted retro-classic:focus-visible:outline-offset-[-6px] retro-classic:focus-visible:outline-current retro-tycoon:font-heading retro-tycoon:font-semibold retro-tycoon:active:not-disabled:press-shift-button retro-tycoon:data-pressed:press-shift-button disabled:cursor-default disabled:bg-face disabled:text-muted-foreground disabled:text-emboss [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground bevel-raised active:not-disabled:bevel-pressed data-pressed:bevel-pressed retro-soft:hover:bg-primary/90", + brand: + "bg-brand text-brand-foreground bevel-raised active:not-disabled:bevel-pressed data-pressed:bevel-pressed retro-soft:hover:bg-brand/90", + outline: + "bg-face text-foreground bevel-raised active:not-disabled:bevel-pressed data-pressed:bevel-pressed retro-soft:hover:bg-accent retro-classic:data-pressed:bg-dither", + secondary: + "bg-card text-card-foreground bevel-raised active:not-disabled:bevel-pressed data-pressed:bevel-pressed retro-soft:hover:bg-muted", + ghost: + "bg-transparent text-foreground bevel-none hover:bevel-raised active:not-disabled:bevel-pressed data-pressed:bevel-pressed retro-soft:hover:bg-muted retro-soft:data-pressed:bg-accent retro-classic:data-pressed:bg-dither retro-tycoon:data-pressed:bg-face retro-tycoon:active:not-disabled:press-shift-label disabled:bg-transparent", + destructive: + "bg-destructive text-destructive-foreground bevel-raised active:not-disabled:bevel-pressed data-pressed:bevel-pressed retro-soft:hover:bg-destructive/90", + }, + size: { + sm: "h-9 gap-1.5 px-3 text-sm", + default: "h-11 gap-2 px-5 text-base", + lg: "h-12 gap-2 px-6 text-base [&_svg:not([class*='size-'])]:size-5", + xl: "h-14 gap-2.5 px-8 text-lg [&_svg:not([class*='size-'])]:size-5", + icon: "size-11", + "icon-sm": "size-9", + titlebar: "size-7 [&_svg:not([class*='size-'])]:size-3.5", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +export type RetroButtonProps = ButtonPrimitive.Props & + VariantProps & { pressed?: boolean }; + +export function RetroButton({ + className, + variant, + size, + pressed, + children, + ...props +}: RetroButtonProps) { + return ( + + + {children} + + + ); +} + +export function RetroWindow({ + title, + icon, + onClose, + chrome = true, + actions, + className, + bodyClassName, + children, + ...props +}: Omit, "title"> & { + title: ReactNode; + icon?: ReactNode; + onClose?: () => void; + chrome?: boolean; + actions?: ReactNode; + bodyClassName?: string; +}) { + const intensity = useRetroIntensity(); + const showChrome = chrome && intensity !== "soft"; + return ( +
+
+ {icon} +
{title}
+ {actions} + {showChrome && ( + + + + )} + {onClose && + (intensity === "soft" ? ( + + + + ) : ( + + + + ))} +
+
+ {children} +
+
+ ); +} + +export function RetroSunken({ + className, + variant = "panel", + ...props +}: React.ComponentProps<"div"> & { variant?: "panel" | "list" }) { + return ( +
+ ); +} + +export function RetroRule({ className }: { className?: string }) { + return ( +
+ ); +} + +export function RetroInput({ className, ...props }: React.ComponentProps<"input">) { + return ( + + ); +} + +export function RetroTextarea({ className, ...props }: React.ComponentProps<"textarea">) { + return ( +