@1agh/maude 0.53.2 → 0.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/studio/api.ts +13 -7
- package/apps/studio/bin/screenshot.sh +43 -3
- package/apps/studio/build.ts +37 -3
- package/apps/studio/client/app.jsx +208 -25
- package/apps/studio/client/export-center.jsx +10 -2
- package/apps/studio/client/panels/SettingsPanel.jsx +6 -1
- package/apps/studio/client/styles/4-components.css +40 -0
- package/apps/studio/cloud-build.ts +208 -0
- package/apps/studio/collab/room.ts +43 -7
- package/apps/studio/context.ts +12 -5
- package/apps/studio/dist/client.bundle.js +1007 -1007
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/git/repo-lock.ts +305 -0
- package/apps/studio/git/service.ts +171 -21
- package/apps/studio/http.ts +145 -12
- package/apps/studio/inspect.ts +66 -6
- package/apps/studio/server.ts +114 -17
- package/apps/studio/session-scope.ts +101 -0
- package/apps/studio/sync/autocommit.ts +84 -46
- package/apps/studio/sync/index.ts +20 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +61 -0
- package/apps/studio/test/canvas-shell-base.test.ts +107 -0
- package/apps/studio/test/cloud-build.test.ts +95 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +31 -2
- package/apps/studio/test/collab-readonly-gate.test.ts +194 -0
- package/apps/studio/test/git-system-engine.test.ts +135 -0
- package/apps/studio/test/read-only-gate.test.ts +8 -0
- package/apps/studio/test/repo-concurrency.test.ts +146 -0
- package/apps/studio/test/repo-lock.test.ts +350 -0
- package/apps/studio/test/server-lifecycle.test.ts +11 -1
- package/apps/studio/test/session-runtime-state.test.ts +226 -0
- package/apps/studio/test/session-scope.test.ts +100 -0
- package/apps/studio/test/shell-importmap.test.ts +8 -1
- package/apps/studio/test/sync-autocommit.test.ts +76 -0
- package/apps/studio/test/sync-hardening.test.ts +75 -0
- package/apps/studio/use-collab.tsx +21 -1
- package/apps/studio/whats-new.json +36 -0
- package/apps/studio/ws.ts +44 -8
- package/cli/commands/kg.mjs +49 -19
- package/cli/lib/gitignore-block.mjs +3 -0
- package/package.json +11 -9
- package/plugins/design/dependencies.json +1 -1
- package/plugins/design/templates/_shell.html +66 -32
- package/plugins/flow/.claude-plugin/config.schema.json +1 -1
- package/plugins/flow/dependencies.json +1 -1
|
@@ -1911,3 +1911,43 @@
|
|
|
1911
1911
|
white-space: nowrap;
|
|
1912
1912
|
max-width: 22ch;
|
|
1913
1913
|
}
|
|
1914
|
+
|
|
1915
|
+
/* …and its counterpart on the right: WHICH ACCOUNT this tab is, and the way to
|
|
1916
|
+
stop being it. The email is quiet (it is context, not a control); the sign-out
|
|
1917
|
+
only resolves on hover, because the common case is reading it and the rare
|
|
1918
|
+
case is needing it. */
|
|
1919
|
+
.st-cloudwho {
|
|
1920
|
+
display: inline-flex;
|
|
1921
|
+
align-items: center;
|
|
1922
|
+
gap: 8px;
|
|
1923
|
+
min-width: 0;
|
|
1924
|
+
}
|
|
1925
|
+
.st-cloudwho-email {
|
|
1926
|
+
font-size: 12px;
|
|
1927
|
+
color: var(--u-fg-2);
|
|
1928
|
+
overflow: hidden;
|
|
1929
|
+
text-overflow: ellipsis;
|
|
1930
|
+
white-space: nowrap;
|
|
1931
|
+
max-width: 24ch;
|
|
1932
|
+
}
|
|
1933
|
+
.st-cloudwho-form {
|
|
1934
|
+
display: inline-flex;
|
|
1935
|
+
margin: 0;
|
|
1936
|
+
}
|
|
1937
|
+
.st-cloudwho-out {
|
|
1938
|
+
font: inherit;
|
|
1939
|
+
font-size: 12px;
|
|
1940
|
+
color: var(--u-fg-3);
|
|
1941
|
+
background: none;
|
|
1942
|
+
border: 0;
|
|
1943
|
+
cursor: pointer;
|
|
1944
|
+
text-decoration: none;
|
|
1945
|
+
white-space: nowrap;
|
|
1946
|
+
border-radius: 5px;
|
|
1947
|
+
padding: 2px 6px;
|
|
1948
|
+
}
|
|
1949
|
+
.st-cloudwho-out:hover,
|
|
1950
|
+
.st-cloudwho-out:focus-visible {
|
|
1951
|
+
color: var(--u-fg-0);
|
|
1952
|
+
background: var(--u-bg-2);
|
|
1953
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// The cloud build — Cloud Phase 27 D1: ELIMINATE, do not un-route.
|
|
2
|
+
//
|
|
3
|
+
// A cell already refuses every one of these surfaces three times over: the
|
|
4
|
+
// route table is pruned at boot (`workspace-mode.ts`), the hub's manifest denies
|
|
5
|
+
// them, and a running cell 404s all of them. That is operational containment,
|
|
6
|
+
// and it is not the same claim as DDR-123's "claude never runs on our
|
|
7
|
+
// infrastructure" — which is a statement about what is IN the image, not about
|
|
8
|
+
// what answers. Code that is present is code that a future route, a future
|
|
9
|
+
// import, or a future mistake can reach.
|
|
10
|
+
//
|
|
11
|
+
// WHAT MADE THIS POSSIBLE. `--define` plus dead-code elimination does not work
|
|
12
|
+
// here: Bun inlines a dynamically-imported module and keeps the branch, so the
|
|
13
|
+
// bytes stay (verified before writing any of this — the sentinel survived a
|
|
14
|
+
// minified `--define`d build). What DOES work is substitution at build time:
|
|
15
|
+
// `Bun.build({ compile })` accepts plugins, and a plugin that replaces a module
|
|
16
|
+
// with an inert one removes the original from the binary entirely. Verified the
|
|
17
|
+
// same way: the sentinel string is absent from the compiled artifact.
|
|
18
|
+
//
|
|
19
|
+
// WHAT IS NOT ELIMINATED, AND WHY. System git stays — Phase 27 D2 makes it the
|
|
20
|
+
// engine a cell RUNS on, because two git engines over one index is not a race a
|
|
21
|
+
// careful caller can avoid. Its presence is deliberate and is not part of the
|
|
22
|
+
// secret-bearing surface: it carries no credential and spawns nothing the tenant
|
|
23
|
+
// can name.
|
|
24
|
+
//
|
|
25
|
+
// The stubs are GENERATED from each module's own export list rather than
|
|
26
|
+
// hand-written. A hand-written stub is a second copy of an interface, and the
|
|
27
|
+
// day it falls behind is the day the cloud build fails to boot for a reason
|
|
28
|
+
// nobody can see. Reading the real file's exports means a module that grows a
|
|
29
|
+
// function still stubs cleanly.
|
|
30
|
+
|
|
31
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
32
|
+
import path from 'node:path';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The modules a cloud build replaces, relative to `apps/studio/`.
|
|
36
|
+
*
|
|
37
|
+
* Each line is one of the surfaces DDR-209's D1 names. Grouped by WHY, because
|
|
38
|
+
* the reason is the thing to check when deciding whether a new module belongs
|
|
39
|
+
* on this list.
|
|
40
|
+
*/
|
|
41
|
+
export const CLOUD_ELIMINATED = Object.freeze([
|
|
42
|
+
// The agent, and everything that starts one. DDR-123: the user's own `claude`
|
|
43
|
+
// subscription, on the user's own machine, never on ours.
|
|
44
|
+
'acp/index.ts',
|
|
45
|
+
'acp/bridge.ts',
|
|
46
|
+
'acp/login-state.ts', // the `bash -c` native installer
|
|
47
|
+
'acp/probe.ts', // resolves + trusts a `claude` binary path
|
|
48
|
+
'acp/plugin-bootstrap.ts',
|
|
49
|
+
// Shell probes — `$SHELL -ilc "command -v …"`. A login shell on a server is
|
|
50
|
+
// an execution surface with a tenant-shaped input near it.
|
|
51
|
+
'readiness.ts',
|
|
52
|
+
'design-setup-readiness.ts',
|
|
53
|
+
// The exporter's subprocess spine.
|
|
54
|
+
'exporters/_runtime.ts',
|
|
55
|
+
// BYOK: provider keys at rest, and the adapters that spend them.
|
|
56
|
+
'generation/keys.ts',
|
|
57
|
+
'generation/adapters/gemini.ts',
|
|
58
|
+
'generation/adapters/elevenlabs.ts',
|
|
59
|
+
'generation/adapters/groq.ts',
|
|
60
|
+
// Endpoints that speak to somebody else's account on the user's behalf.
|
|
61
|
+
'cloud/endpoints.ts',
|
|
62
|
+
'github/endpoints.ts',
|
|
63
|
+
// …and the REST client behind it, which `git/endpoints.ts` also reaches for
|
|
64
|
+
// its "open a pull request" verb. Stubbing only the endpoints left every
|
|
65
|
+
// GitHub API URL in the image through that second importer — found by the
|
|
66
|
+
// gate below, which is the entire reason it greps the artifact instead of
|
|
67
|
+
// trusting the list.
|
|
68
|
+
'github/service.ts',
|
|
69
|
+
'sync/hub-link.ts',
|
|
70
|
+
// The debug bundle scrubs, but it still assembles an environment report.
|
|
71
|
+
'debug-bundle.ts',
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Strings that must not appear in a cloud binary.
|
|
76
|
+
*
|
|
77
|
+
* The gate greps for these because a stub that silently failed to apply is
|
|
78
|
+
* indistinguishable from one that worked, right up until it is not. Chosen to
|
|
79
|
+
* be specific to the eliminated code rather than to any word near it.
|
|
80
|
+
*/
|
|
81
|
+
export const CLOUD_FORBIDDEN_STRINGS = Object.freeze([
|
|
82
|
+
'claude-agent-acp', // the ACP adapter package
|
|
83
|
+
'-ilc', // the login-shell probe
|
|
84
|
+
'application/vnd.github+json', // the REST client that speaks for a user's account
|
|
85
|
+
'generativelanguage.googleapis.com', // BYOK image generation
|
|
86
|
+
'api.elevenlabs.io', // BYOK audio generation
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
// A NOTE ON CHOOSING THESE. They must be strings only OUR code can produce.
|
|
90
|
+
// `api.github.com` looked ideal and is useless: Bun's own runtime carries
|
|
91
|
+
// `GITHUB_API_DOMAIN` and `GITHUB_API_URL`, so it appears in every compiled
|
|
92
|
+
// binary whatever we do. A gate that can never go green teaches people to
|
|
93
|
+
// ignore it. Each string above is verified present in a desktop binary and
|
|
94
|
+
// absent from a cloud one — that pair is the assertion, not the absence alone.
|
|
95
|
+
|
|
96
|
+
/** Runtime-visible exports of a module — what a stub has to provide. Types and
|
|
97
|
+
* interfaces are erased before this matters, so they are deliberately absent. */
|
|
98
|
+
export function exportedNames(source: string): string[] {
|
|
99
|
+
const names = new Set<string>();
|
|
100
|
+
// Leading whitespace is allowed on purpose. The two failure directions are
|
|
101
|
+
// not symmetric: a MISSED export is a boot-time crash in the cloud binary and
|
|
102
|
+
// nowhere else, while an extra one is a duplicate `const` that fails the
|
|
103
|
+
// build loudly, at the desk of whoever caused it. So this errs greedy.
|
|
104
|
+
const patterns = [
|
|
105
|
+
/^\s*export\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/gm,
|
|
106
|
+
/^\s*export\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)/gm,
|
|
107
|
+
/^\s*export\s+class\s+([A-Za-z_$][\w$]*)/gm,
|
|
108
|
+
];
|
|
109
|
+
for (const re of patterns) {
|
|
110
|
+
for (const m of source.matchAll(re)) if (m[1]) names.add(m[1]);
|
|
111
|
+
}
|
|
112
|
+
// `export { a, b as c }` — the re-export form.
|
|
113
|
+
for (const m of source.matchAll(/^\s*export\s*\{([^}]*)\}/gm)) {
|
|
114
|
+
for (const part of (m[1] ?? '').split(',')) {
|
|
115
|
+
const alias = part
|
|
116
|
+
.split(/\s+as\s+/i)
|
|
117
|
+
.pop()
|
|
118
|
+
?.trim();
|
|
119
|
+
if (alias && /^[A-Za-z_$][\w$]*$/.test(alias) && alias !== 'default') names.add(alias);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return [...names];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The inert value every stubbed export becomes.
|
|
127
|
+
*
|
|
128
|
+
* A self-returning proxy rather than `() => {}` or `null`, because the callers
|
|
129
|
+
* are not all the same shape: one boot path calls `createExportJobQueue(...)`
|
|
130
|
+
* and hands the result to the route table, another calls `installLogRing()` for
|
|
131
|
+
* its side effect, a third reads a property off what `createAcp` returned. A
|
|
132
|
+
* value that answers every one of those with another inert value cannot crash a
|
|
133
|
+
* boot on a shape nobody remembered.
|
|
134
|
+
*
|
|
135
|
+
* It is deliberately NOT thenable: `await inert` must yield the proxy, not hang
|
|
136
|
+
* forever waiting for a `then` that never resolves.
|
|
137
|
+
*/
|
|
138
|
+
const INERT_SOURCE = `
|
|
139
|
+
const inert = new Proxy(function cloudEliminated() {}, {
|
|
140
|
+
get(_t, key) {
|
|
141
|
+
if (key === 'then') return undefined;
|
|
142
|
+
if (key === Symbol.toPrimitive || key === 'toString') return () => '';
|
|
143
|
+
if (key === Symbol.iterator) return function* () {};
|
|
144
|
+
return inert;
|
|
145
|
+
},
|
|
146
|
+
apply: () => inert,
|
|
147
|
+
construct: () => inert,
|
|
148
|
+
});
|
|
149
|
+
`;
|
|
150
|
+
|
|
151
|
+
/** Build the replacement source for one eliminated module. */
|
|
152
|
+
export function stubSource(original: string): string {
|
|
153
|
+
const names = exportedNames(original);
|
|
154
|
+
const decls = names.map((n) => `export const ${n} = inert;`).join('\n');
|
|
155
|
+
return `${INERT_SOURCE}\n${decls}\nexport default inert;\n`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The Bun plugin. Pass it to `Bun.build({ plugins: [cloudStubPlugin(root)] })`.
|
|
160
|
+
*
|
|
161
|
+
* `onLoad` rather than `onResolve`: the module still resolves to its real path
|
|
162
|
+
* (so a typo in the list is a build error rather than a silent no-op), and only
|
|
163
|
+
* its CONTENTS are replaced.
|
|
164
|
+
*/
|
|
165
|
+
export function cloudStubPlugin(studioRoot: string): import('bun').BunPlugin {
|
|
166
|
+
const targets = new Set(CLOUD_ELIMINATED.map((rel) => path.join(studioRoot, rel)));
|
|
167
|
+
const applied = new Set<string>();
|
|
168
|
+
|
|
169
|
+
// A LISTED PATH THAT DOES NOT EXIST IS A BUILD FAILURE, NOT A LOG LINE.
|
|
170
|
+
//
|
|
171
|
+
// The plugin matches by exact resolved path, so a rename, a move to `.tsx` or
|
|
172
|
+
// a typo silently stops eliminating that module — and the "never imported"
|
|
173
|
+
// notice below reads as ordinary, because it IS ordinary when a leaf leaves
|
|
174
|
+
// the graph with its stubbed importer. The two cases are indistinguishable at
|
|
175
|
+
// the end of a build, so they are distinguished HERE, where one of them is
|
|
176
|
+
// simply a path that is not on disk.
|
|
177
|
+
const missing = CLOUD_ELIMINATED.filter((rel) => !existsSync(path.join(studioRoot, rel)));
|
|
178
|
+
if (missing.length) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
`cloud build: ${missing.length} module(s) in CLOUD_ELIMINATED do not exist — ` +
|
|
181
|
+
`a rename or a typo means they are NOT being eliminated: ${missing.join(', ')}`
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
name: 'maude-cloud-eliminate',
|
|
186
|
+
setup(build) {
|
|
187
|
+
build.onLoad({ filter: /\.ts$/ }, (args) => {
|
|
188
|
+
if (!targets.has(args.path)) return undefined;
|
|
189
|
+
applied.add(args.path);
|
|
190
|
+
return { contents: stubSource(readFileSync(args.path, 'utf8')), loader: 'ts' };
|
|
191
|
+
});
|
|
192
|
+
build.onEnd?.(() => {
|
|
193
|
+
// A module on the list the graph never reached is USUALLY success, not
|
|
194
|
+
// drift: stubbing `acp/index.ts` takes `acp/bridge.ts` out of the graph
|
|
195
|
+
// with it, so the leaf is eliminated by the same stroke and never gets
|
|
196
|
+
// loaded. Reported at info level for exactly that reason — it is worth
|
|
197
|
+
// seeing (a typo'd path looks identical) and it is not a failure.
|
|
198
|
+
const unused = [...targets].filter((t) => !applied.has(t));
|
|
199
|
+
if (unused.length) {
|
|
200
|
+
console.log(
|
|
201
|
+
`[cloud-build] ${unused.length} listed module(s) left the graph with their importer ` +
|
|
202
|
+
`(or are no longer imported at all): ${unused.map((u) => path.basename(u)).join(', ')}`
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
@@ -41,6 +41,18 @@ export interface RoomConn extends CollabConn {
|
|
|
41
41
|
* default can't silently swallow a future canvas path.
|
|
42
42
|
*/
|
|
43
43
|
realm?: 'main' | 'canvas';
|
|
44
|
+
/**
|
|
45
|
+
* Cloud collab lane (RCA issue-cloud-live-collaboration-dead) — true when
|
|
46
|
+
* this socket was opened under a read-only role (a cell's `viewer`, or a
|
|
47
|
+
* cell socket the proxy did not vouch). A read-only conn still receives every
|
|
48
|
+
* broadcast and publishes awareness — presence for viewers is the point of
|
|
49
|
+
* the channel — but its SYNC writes are gated to the comment lane, mirroring
|
|
50
|
+
* the role matrix (`viewer.comment === true`) the same way the inspector
|
|
51
|
+
* channel's `comments-patch`/`comments-delete` gate does. Absent defaults to
|
|
52
|
+
* writable ON PURPOSE, for the same reason `realm` defaults to trusted:
|
|
53
|
+
* every pre-existing caller is a loopback desktop peer.
|
|
54
|
+
*/
|
|
55
|
+
readOnly?: boolean;
|
|
44
56
|
}
|
|
45
57
|
|
|
46
58
|
export interface RoomCallbacks {
|
|
@@ -194,8 +206,30 @@ export function createRoom(slug: string, callbacks: RoomCallbacks): Room {
|
|
|
194
206
|
}
|
|
195
207
|
|
|
196
208
|
/**
|
|
197
|
-
*
|
|
198
|
-
*
|
|
209
|
+
* Which lanes this conn's sync writes may not touch.
|
|
210
|
+
*
|
|
211
|
+
* Two independent dimensions, both fail-closed on `<unresolved>`:
|
|
212
|
+
* - realm 'canvas' (DDR-122): body lanes are never writable from the
|
|
213
|
+
* untrusted canvas origin, whoever is connected.
|
|
214
|
+
* - readOnly (cloud collab lane): only the comment lane is writable — the
|
|
215
|
+
* WS mirror of the role matrix's viewer capability.
|
|
216
|
+
*/
|
|
217
|
+
function refusedLanes(conn: RoomConn, roots: ReadonlySet<string>): string[] {
|
|
218
|
+
const refused: string[] = [];
|
|
219
|
+
for (const r of roots) {
|
|
220
|
+
if (conn.realm === 'canvas' && (isBodyLane(r) || r === '<unresolved>')) {
|
|
221
|
+
refused.push(r);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (conn.readOnly === true && r !== 'comments') refused.push(r);
|
|
225
|
+
}
|
|
226
|
+
return refused;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Gated frame path — canvas-realm and/or read-only conns. Awareness frames
|
|
231
|
+
* are unchanged (cursors/presence are ephemeral and already
|
|
232
|
+
* server-attributed). Sync frames are gated.
|
|
199
233
|
*/
|
|
200
234
|
function receiveGated(conn: RoomConn, payload: Uint8Array): void {
|
|
201
235
|
if (readMessageType(payload) !== MESSAGE_SYNC) {
|
|
@@ -216,14 +250,16 @@ export function createRoom(slug: string, callbacks: RoomCallbacks): Room {
|
|
|
216
250
|
// Fail closed on anything we could not positively classify — a probe that
|
|
217
251
|
// threw, or a root type we could not resolve to a name.
|
|
218
252
|
const roots = probeRoots ?? new Set<string>();
|
|
219
|
-
const refused =
|
|
253
|
+
const refused = refusedLanes(conn, roots);
|
|
220
254
|
if (probeThrew || refused.length > 0) {
|
|
221
255
|
gateRefusalCount += 1;
|
|
222
256
|
discardMirror();
|
|
223
257
|
console.warn(
|
|
224
|
-
`[collab/${slug}] origin gate REFUSED a canvas-realm
|
|
225
|
-
`(lanes: ${refused.join(', ') || 'unclassifiable'}).
|
|
226
|
-
|
|
258
|
+
`[collab/${slug}] origin gate REFUSED a ${conn.readOnly ? 'read-only' : 'canvas-realm'} ` +
|
|
259
|
+
`sync frame (lanes: ${refused.join(', ') || 'unclassifiable'}). ` +
|
|
260
|
+
(conn.readOnly
|
|
261
|
+
? 'A viewer-role socket may only write comments — role matrix, cloud collab lane.'
|
|
262
|
+
: 'Untrusted canvas script may not write canvas source — see DDR-122 follow-up / collab/origins.ts.')
|
|
227
263
|
);
|
|
228
264
|
// Re-assert server truth to that peer so it converges on the authoritative
|
|
229
265
|
// state instead of silently diverging. Their locally-authored body items
|
|
@@ -315,7 +351,7 @@ export function createRoom(slug: string, callbacks: RoomCallbacks): Room {
|
|
|
315
351
|
}
|
|
316
352
|
|
|
317
353
|
function receive(conn: RoomConn, payload: Uint8Array): void {
|
|
318
|
-
if (conn.realm === 'canvas') {
|
|
354
|
+
if (conn.realm === 'canvas' || conn.readOnly === true) {
|
|
319
355
|
receiveGated(conn, payload);
|
|
320
356
|
return;
|
|
321
357
|
}
|
package/apps/studio/context.ts
CHANGED
|
@@ -115,16 +115,23 @@ export interface Paths {
|
|
|
115
115
|
|
|
116
116
|
// Tiny pub-sub bus. Lazy — modules subscribe with on('selected', fn) and emit
|
|
117
117
|
// the matching event. Avoids cycling imports between inspect.ts <-> ws.ts.
|
|
118
|
+
/** Who an event is about, when that matters — Cloud Phase 27 D3. A `session`
|
|
119
|
+
* scopes a broadcast to one member of a cell; absent means everyone, which is
|
|
120
|
+
* every event this bus carried before and every event on a desktop. */
|
|
121
|
+
export interface BusMeta {
|
|
122
|
+
session?: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
118
125
|
export interface Bus {
|
|
119
126
|
// biome-ignore lint/suspicious/noExplicitAny: heterogeneous pubsub — subscribers annotate their own payload shape.
|
|
120
|
-
on(evt: string, fn: (payload: any) => void): () => void;
|
|
127
|
+
on(evt: string, fn: (payload: any, meta?: BusMeta) => void): () => void;
|
|
121
128
|
// biome-ignore lint/suspicious/noExplicitAny: heterogeneous pubsub — emitters supply their own payload shape.
|
|
122
|
-
emit(evt: string, payload?: any): void;
|
|
129
|
+
emit(evt: string, payload?: any, meta?: BusMeta): void;
|
|
123
130
|
}
|
|
124
131
|
|
|
125
132
|
export function createBus(): Bus {
|
|
126
133
|
// biome-ignore lint/suspicious/noExplicitAny: subscribers are typed at the call site; the bus stores the erased type.
|
|
127
|
-
const subs = new Map<string, Set<(p: any) => void>>();
|
|
134
|
+
const subs = new Map<string, Set<(p: any, meta?: BusMeta) => void>>();
|
|
128
135
|
return {
|
|
129
136
|
on(evt, fn) {
|
|
130
137
|
const set = subs.get(evt) ?? new Set();
|
|
@@ -132,12 +139,12 @@ export function createBus(): Bus {
|
|
|
132
139
|
subs.set(evt, set);
|
|
133
140
|
return () => set.delete(fn);
|
|
134
141
|
},
|
|
135
|
-
emit(evt, payload) {
|
|
142
|
+
emit(evt, payload, meta) {
|
|
136
143
|
const set = subs.get(evt);
|
|
137
144
|
if (!set) return;
|
|
138
145
|
for (const fn of set) {
|
|
139
146
|
try {
|
|
140
|
-
fn(payload);
|
|
147
|
+
fn(payload, meta);
|
|
141
148
|
} catch (err) {
|
|
142
149
|
console.error(`[bus] subscriber for ${evt} threw:`, err);
|
|
143
150
|
}
|