@abide/abide 0.123.1 → 0.123.3

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.
Files changed (98) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/bin/abide.ts +36 -65
  3. package/docs/REGISTRY.md +2 -2
  4. package/docs/RULEBOOK.md +2 -2
  5. package/docs/abide.md +2 -2
  6. package/package.json +1 -1
  7. package/src/compiler/Diagnostics.ts +198 -0
  8. package/src/compiler/KeptChecker.ts +97 -0
  9. package/src/compiler/Lowering.ts +1 -1
  10. package/src/compiler/PositionedRefusal.ts +1 -1
  11. package/src/compiler/TypeSession.ts +62 -85
  12. package/src/compiler/appLayout.ts +28 -20
  13. package/src/compiler/argsDefaultsIn.ts +1 -1
  14. package/src/compiler/checkApp.ts +64 -68
  15. package/src/compiler/checkedShadow.ts +1 -1
  16. package/src/compiler/compileApp.ts +101 -70
  17. package/src/compiler/declaredExports.ts +2 -4
  18. package/src/compiler/emitClient.ts +1 -1
  19. package/src/compiler/formatAbide.ts +2 -2
  20. package/src/compiler/handlersUnder.ts +17 -29
  21. package/src/compiler/lineOf.ts +0 -19
  22. package/src/compiler/matchersOf.ts +3 -3
  23. package/src/compiler/mountsOf.ts +3 -3
  24. package/src/compiler/readSources.ts +16 -18
  25. package/src/compiler/readsIn.ts +1 -1
  26. package/src/compiler/serveLanguage.ts +23 -57
  27. package/src/compiler/sharedModuleWrites.ts +1 -1
  28. package/src/compiler/templateWarnings.ts +1 -2
  29. package/src/compiler/trackingWarnings.ts +1 -1
  30. package/src/server/CLIENT_BUILD.ts +4 -32
  31. package/src/server/RESUME_MILLISECONDS.ts +2 -0
  32. package/src/server/answerRequest.ts +11 -300
  33. package/src/server/answerRpc.ts +12 -54
  34. package/src/server/answerSocket.ts +8 -35
  35. package/src/server/appBuild.ts +377 -0
  36. package/src/server/appPlugins.ts +15 -17
  37. package/src/server/appTree.ts +6 -5
  38. package/src/server/argsContract.ts +209 -0
  39. package/src/server/bridgeRelay.ts +153 -65
  40. package/src/server/claudeCode.ts +30 -127
  41. package/src/server/config.ts +6 -7
  42. package/src/server/createApp.ts +30 -64
  43. package/src/server/devWorker.ts +11 -15
  44. package/src/server/handlerFileMount.ts +40 -0
  45. package/src/server/index.ts +2 -2
  46. package/src/server/packageDirectory.ts +14 -0
  47. package/src/server/preload.ts +3 -2
  48. package/src/server/principal.ts +4 -3
  49. package/src/server/remote.ts +1 -1
  50. package/src/server/runCompiled.ts +1 -1
  51. package/src/server/runDesktop.ts +1 -1
  52. package/src/server/seedBuffer.ts +1 -1
  53. package/src/server/serveDev.ts +24 -58
  54. package/src/server/servedApp.ts +390 -0
  55. package/src/server/signInChanged.ts +20 -0
  56. package/src/server/surfaceOf.ts +2 -1
  57. package/src/server/versionOf.ts +6 -15
  58. package/src/shared/applyPatch.ts +1 -1
  59. package/src/shared/muxFrames.ts +1 -1
  60. package/src/shared/reactive/Keyed.ts +1 -1
  61. package/src/shared/reactive/Reactive.ts +21 -8
  62. package/src/shared/reactive/argsContract.ts +131 -4
  63. package/src/shared/reactive/index.ts +1 -0
  64. package/src/shared/reactive/patchLog.ts +79 -0
  65. package/src/shared/reactive/patchMarks.ts +34 -0
  66. package/src/shared/reactive/recordWrites.ts +11 -14
  67. package/src/shared/reactive/share.ts +4 -3
  68. package/src/shared/rpc.ts +12 -19
  69. package/src/shared/validateJson.ts +14 -3
  70. package/src/ui/ArmRegion.ts +10 -4
  71. package/src/ui/Level.ts +11 -20
  72. package/src/ui/LevelChain.ts +272 -0
  73. package/src/ui/Router.ts +54 -245
  74. package/src/ui/Scope.ts +17 -3
  75. package/src/ui/TryRegion.ts +22 -23
  76. package/src/ui/bindSpread.ts +2 -4
  77. package/src/ui/listenForRoutes.ts +3 -10
  78. package/src/ui/outboxQueue.ts +21 -3
  79. package/src/ui/reconcile.ts +4 -6
  80. package/src/ui/routing.ts +2 -1
  81. package/src/ui/rows.ts +2 -2
  82. package/src/ui/setAttribute.ts +12 -3
  83. package/src/ui/socketWire.ts +26 -11
  84. package/src/compiler/Finding.ts +0 -2
  85. package/src/compiler/Refusals.ts +0 -65
  86. package/src/compiler/formatDiagnosis.ts +0 -20
  87. package/src/server/appDirectories.ts +0 -9
  88. package/src/server/argsFor.ts +0 -39
  89. package/src/server/buildDirectory.ts +0 -10
  90. package/src/server/coerceArgs.ts +0 -109
  91. package/src/server/derivedArgs.ts +0 -95
  92. package/src/server/runBuild.ts +0 -24
  93. package/src/server/serveBuild.ts +0 -144
  94. package/src/server/testBuild.ts +0 -220
  95. package/src/shared/reactive/FOLLOWED.ts +0 -8
  96. package/src/shared/reactive/PATCHES.ts +0 -11
  97. package/src/shared/reactive/PATCH_SINKS.ts +0 -26
  98. package/src/shared/reactive/canonicalKey.ts +0 -95
@@ -0,0 +1,209 @@
1
+ // WHAT A CALL'S ARGS ARE BEFORE ITS HANDLER SEES THEM, on a server: the schema a handler's args are
2
+ // read by, out of the sources it has, and the args a call off the wire is answered with — over http
3
+ // (16.2) and a `GET` followed over the socket mux (18.3). Each lane reads the args off its own
4
+ // transport and answers a refusal in its own form; what every way of calling a handler shares, in
5
+ // process too, is `acceptedArgs` (`#shared/reactive/argsContract.ts`), which this ends in.
6
+ //
7
+ // 19.7 — A URL CARRIES TEXT, so `?id=42` is `'42'` until the schema says the member is a number.
8
+ // Coercion is the pipeline's and not the schema's (19.6): the schema is handed what this made of the
9
+ // text, and refuses what would not coerce. A schema with no document has declared no types, and its
10
+ // args stay the text they arrived as.
11
+
12
+ import { DERIVED_ARGS } from '#shared/DERIVED_ARGS.ts'
13
+ import { isRecord } from '#shared/isRecord.ts'
14
+ import type { JsonValue } from '#shared/JsonValue.ts'
15
+ import { numberFromText } from '#shared/numberFromText.ts'
16
+ import { bareName } from '#shared/queryText.ts'
17
+ import {
18
+ acceptedArgs,
19
+ type Gated,
20
+ gateOf,
21
+ type HeldToAnnotation,
22
+ } from '#shared/reactive/argsContract.ts'
23
+ import { refuse } from '#shared/refuse.ts'
24
+ import { validateJson } from '#shared/validateJson.ts'
25
+
26
+ import type { Mounted } from './answerRpc.ts'
27
+
28
+ export type Document = {
29
+ type?: string | string[]
30
+ properties?: Record<string, Document>
31
+ items?: Document
32
+ }
33
+
34
+ // Each handler's document, read once: 19.7, 20.11 — a third-party schema converts itself on every
35
+ // probe. 19.9 — the one its annotation derives is filed here by `derivedArgs`.
36
+ const DOCUMENTS = new WeakMap<object, Document | undefined>()
37
+
38
+ const NO_PARAMETERS: Record<string, string | string[]> = {}
39
+
40
+ /**
41
+ * The document a handler's args are read by: the one its `args` schema declares, or else the one the
42
+ * build derived off its annotation (`derivedArgs`), or `undefined`.
43
+ */
44
+ export function argsDocumentOf(handler: Gated<unknown>): Document | undefined {
45
+ if (DOCUMENTS.has(handler)) return DOCUMENTS.get(handler)
46
+ const document = declaredDocument(handler)
47
+ DOCUMENTS.set(handler, document)
48
+ return document
49
+ }
50
+
51
+ function declaredDocument(handler: Gated<unknown>): Document | undefined {
52
+ const gate = gateOf(handler) as
53
+ { '~standard'?: { jsonSchema?: { input?: () => unknown } } } | undefined
54
+ const document = gate?.['~standard']?.jsonSchema?.input?.()
55
+ return document !== null && typeof document === 'object'
56
+ ? (document as Document)
57
+ : undefined
58
+ }
59
+
60
+ /**
61
+ * Holds `handler`'s args to `schema`, the one the build derived off its annotation, for every call
62
+ * of it from now on: in process and over the wire (15.8, 19.9).
63
+ *
64
+ * What the module a handler is loaded through calls once its handler is built — a handler file's
65
+ * face and a `<script server>`'s module — so the schema is there wherever the handler can be
66
+ * called, whether or not an app mounted it. Not where its `args` gate publishes a document of its
67
+ * own, which holds the args itself (19.7). Args that did not come are held as an empty record: a
68
+ * schema is only derived where the handler's args are a required record, so a required member
69
+ * missing is refused, not read off nothing.
70
+ */
71
+ export function derivedArgs(
72
+ handler: HeldToAnnotation & Gated<unknown>,
73
+ schema: JsonValue,
74
+ ): void {
75
+ // Held already, by the module it loaded through, to the schema its row names as well.
76
+ if (handler[DERIVED_ARGS] !== undefined) return
77
+ const declared = declaredDocument(handler)
78
+ DOCUMENTS.set(handler, declared ?? (schema as Document))
79
+ if (declared === undefined)
80
+ handler[DERIVED_ARGS] = (args) => validateJson(schema, args ?? {})
81
+ }
82
+
83
+ /**
84
+ * Name and value pairs as args: a name given more than once is a list, one given once its text.
85
+ *
86
+ * 16.18 — a query's and a form's members, and 38.61 — `abide call`'s `name=value` words, alike.
87
+ */
88
+ export function collapsed(
89
+ entries: URLSearchParams | FormData,
90
+ ): Record<string, string | string[]> {
91
+ const args: Record<string, string | string[]> = {}
92
+ for (const name of new Set(entries.keys())) {
93
+ const values = entries.getAll(name) as string[]
94
+ args[name] = values.length === 1 ? (values[0] as string) : values
95
+ }
96
+ return args
97
+ }
98
+
99
+ /**
100
+ * The args a call over the wire is answered with, out of the args it sent: the parameters its route
101
+ * matched laid in, the text a URL or a form carried coerced where `query` says the args are text,
102
+ * then `acceptedArgs`. Throws the refusal where they are not accepted.
103
+ *
104
+ * `query` is the raw `?…` the args were read from — `''` for a form, `undefined` for JSON.
105
+ * 16.65 — the path's parameters, whatever the method. 16.66 — one the query or the body names as well
106
+ * is refused rather than one of the two chosen. A JSON body's members are typed already, so the
107
+ * path's text is coerced to the declared types before it joins them (19.7); text args are coerced
108
+ * with the rest of the text.
109
+ */
110
+ export function argsFor(
111
+ handler: Mounted['handler'],
112
+ sent: unknown,
113
+ query: string | undefined,
114
+ parameters: Record<string, string | string[]> = NO_PARAMETERS,
115
+ ): unknown {
116
+ const document = argsDocumentOf(handler)
117
+ let args = sent
118
+ let none = true
119
+ for (const name in parameters) {
120
+ none = false
121
+ if (isRecord(sent) && name in sent)
122
+ throw refuse(
123
+ 400,
124
+ `The parameter '${name}' is in the path and in the request as well.`,
125
+ )
126
+ }
127
+ if (!none) {
128
+ const fromPath =
129
+ query === undefined
130
+ ? (coerceArgs(parameters, document, '') as Record<
131
+ string,
132
+ unknown
133
+ >)
134
+ : parameters
135
+ args = isRecord(sent) ? { ...sent, ...fromPath } : { ...fromPath }
136
+ }
137
+ if (query !== undefined)
138
+ args = coerceArgs(
139
+ args as Record<string, string | string[]> | undefined,
140
+ document,
141
+ query,
142
+ )
143
+ return acceptedArgs(handler, args)
144
+ }
145
+
146
+ /**
147
+ * The text args, each member coerced to the type `document` declares.
148
+ *
149
+ * `URLSearchParams` reads `rpcRequest`'s empty list, a bare name, as `''` — the same as `tags=`, the
150
+ * list `['']` — so a declared array tells the two apart by `query`. A text member keeps the `''`.
151
+ */
152
+ function coerceArgs(
153
+ args: Record<string, string | string[]> | undefined,
154
+ document: Document | undefined,
155
+ query: string,
156
+ ): unknown {
157
+ if (args === undefined || document?.properties === undefined) return args
158
+ const coerced: Record<string, unknown> = {}
159
+ for (const [name, text] of Object.entries(args)) {
160
+ const member = document.properties[name]
161
+ if (member === undefined) coerced[name] = text
162
+ else if (
163
+ text === '' &&
164
+ typesOf(member).includes('array') &&
165
+ `&${query.slice(1)}&`.includes(`&${bareName(name)}&`)
166
+ )
167
+ coerced[name] = []
168
+ else coerced[name] = coerceMember(text, member)
169
+ }
170
+ return coerced
171
+ }
172
+
173
+ function typesOf(member: Document): string[] {
174
+ if (member.type === undefined) return []
175
+ return Array.isArray(member.type) ? member.type : [member.type]
176
+ }
177
+
178
+ function coerceMember(text: string | string[], member: Document): unknown {
179
+ const types = typesOf(member)
180
+ // A list is its member repeated, and one given once still answers a declared array.
181
+ if (types.includes('array')) {
182
+ const items = Array.isArray(text) ? text : [text]
183
+ const item = member.items
184
+ return item === undefined
185
+ ? items
186
+ : items.map((each) => coerceScalar(each, item))
187
+ }
188
+ if (Array.isArray(text)) return text
189
+ return coerceScalar(text, member)
190
+ }
191
+
192
+ /**
193
+ * The text as the first declared type it reads as, in the order the schema lists them.
194
+ *
195
+ * Text that reads as none stays text, for the schema to refuse.
196
+ */
197
+ function coerceScalar(text: string, member: Document): unknown {
198
+ for (const type of typesOf(member)) {
199
+ if (type === 'string') return text
200
+ if (type === 'number' || type === 'integer') {
201
+ const number = numberFromText(text)
202
+ if (number !== undefined) return number
203
+ }
204
+ if (type === 'boolean' && (text === 'true' || text === 'false'))
205
+ return text === 'true'
206
+ if (type === 'null' && text === 'null') return null
207
+ }
208
+ return text
209
+ }
@@ -12,6 +12,9 @@
12
12
  // in there when its code was minted (44.33), for as long as the bridge's stream is open, or
13
13
  // resumable. HELD IN THIS PROCESS: an app served by several needs a visitor's requests to reach the
14
14
  // one its bridge dialed.
15
+ //
16
+ // Every frame on that stream is written here; what the rest of the server sees is a turn run, a
17
+ // pairing's status as it changes, and a pairing code.
15
18
 
16
19
  import { isFailed } from '#shared/Failed.ts'
17
20
  import { refuse } from '#shared/refuse.ts'
@@ -19,14 +22,17 @@ import { refuse } from '#shared/refuse.ts'
19
22
  import { answered } from './answerFailure.ts'
20
23
  import { bodyWithin } from './answerRpc.ts'
21
24
  import { framed } from './answerValue.ts'
25
+ import type { AssistantEvent } from './assistant.ts'
22
26
  import { Inbox } from './Inbox.ts'
23
27
  import { randomToken } from './randomToken.ts'
28
+ import { RESUME_MILLISECONDS } from './RESUME_MILLISECONDS.ts'
29
+ import { onSignInChanged } from './signInChanged.ts'
24
30
 
25
31
  /**
26
32
  * What a turn hears from the bridge, in the order the bridge posted it: an event, an MCP request, the
27
33
  * end, or a failure. ONE SHAPE for all four, every field set, since one is built per streamed chunk.
28
34
  */
29
- export type TurnItem = {
35
+ type TurnItem = {
30
36
  kind: 'event' | 'mcp' | 'end' | 'fail'
31
37
  event: unknown
32
38
  id: number
@@ -36,7 +42,7 @@ export type TurnItem = {
36
42
  }
37
43
 
38
44
  /** One paired bridge: what it is sent, and the turns it is answering. */
39
- export type Bridge = {
45
+ type Bridge = {
40
46
  /** The browser and the sign-in it is paired to (`pairingKey`). 44.33. */
41
47
  key: string
42
48
  token: string
@@ -53,8 +59,6 @@ export type Bridge = {
53
59
  const CODE_LIFETIME = 10 * 60_000
54
60
  // How long a bridge outlives the last page watching it, so a reload does not end it.
55
61
  const IDLE_GRACE = 30_000
56
- /** How long a pairing whose stream dropped waits to be resumed. 44.26. */
57
- export const RESUME_MILLISECONDS = 60_000
58
62
 
59
63
  const CODES = new Map<string, { key: string; expires: number }>()
60
64
  const BY_KEY = new Map<string, Bridge>()
@@ -65,6 +69,13 @@ const FOLLOWERS = new Map<
65
69
  Set<(caller: string, signedIn: string) => void>
66
70
  >()
67
71
 
72
+ // 44.34 — A STATUS STREAM OPENED BEFORE A SIGN-IN carries the cookies it was opened with, so it hears
73
+ // of the new one, or of the caller a sign-out rotated it to (26.9), here rather than off its own
74
+ // request.
75
+ onSignInChanged((caller, now, signedIn) => {
76
+ for (const follow of FOLLOWERS.get(caller) ?? []) follow(now, signedIn)
77
+ })
78
+
68
79
  /**
69
80
  * What a pairing is filed under: the browser, and who was signed in there (`signedInAs`).
70
81
  *
@@ -75,7 +86,7 @@ function pairingKey(caller: string, signedIn: string): string {
75
86
  return `${caller}\n${signedIn}`
76
87
  }
77
88
 
78
- // 44.19 — the relay answers once the app has handed out a pairing command, and never before.
89
+ // 44.19 — the relay answers once the app has handed out a pairing code, and never before.
79
90
  let minted = false
80
91
 
81
92
  /** A single-use pairing code for `caller` signed in as `signedIn`, and when it stops standing. 44.14. */
@@ -93,24 +104,135 @@ export function mintCode(
93
104
  return { code, expires }
94
105
  }
95
106
 
96
- /** The bridge paired to `caller` signed in as `signedIn`, if its stream is open. 44.33. */
97
- export function pairedBridge(
98
- caller: string,
99
- signedIn: string,
100
- ): Bridge | undefined {
101
- return BY_KEY.get(pairingKey(caller, signedIn))
107
+ /**
108
+ * One turn on the bridge paired to `caller` signed in as `signedIn`: sent `prompt` under `config`,
109
+ * yielding the events its Claude Code reports, each MCP request it makes handed to `answer` and the
110
+ * reply sent back. 44.13, 44.17, 44.21.
111
+ */
112
+ export async function* runTurn({
113
+ caller,
114
+ signedIn,
115
+ prompt,
116
+ config,
117
+ maxTurns,
118
+ answer,
119
+ signal,
120
+ }: {
121
+ caller: string
122
+ signedIn: string
123
+ prompt: unknown[]
124
+ config: unknown
125
+ maxTurns: number
126
+ /** Never rejects: its failure is a reply too. */
127
+ answer: (method: string, params: unknown) => Promise<unknown>
128
+ signal: AbortSignal
129
+ }): AsyncGenerator<AssistantEvent> {
130
+ const bridge = BY_KEY.get(pairingKey(caller, signedIn))
131
+ // 44.13, 44.33 — nothing paired by this sign-in is the page's cue to show the command.
132
+ if (bridge === undefined)
133
+ throw refuse(
134
+ 409,
135
+ 'No Claude Code is paired with this browser as the one signed in. claudeCode.bridgeCommand() is the command that pairs one.',
136
+ )
137
+ const turn = randomToken(9, 'base64url')
138
+ const inbox = new Inbox<TurnItem>()
139
+ bridge.turns.set(turn, inbox)
140
+ // `bridge.frames` is read at each push: a resumed stream (44.26) replaces it.
141
+ bridge.frames.push({ type: 'turn', id: turn, prompt, config, maxTurns })
142
+ // 44.18 — a caller hanging up ends the run on the bridge.
143
+ const cancel = (): void => {
144
+ bridge.frames.push({ type: 'cancel', turn })
145
+ }
146
+ signal.addEventListener('abort', cancel)
147
+ try {
148
+ for await (const item of inbox) {
149
+ if (item.kind === 'event') yield item.event as AssistantEvent
150
+ else if (item.kind === 'mcp')
151
+ void answer(item.method, item.params).then((reply) => {
152
+ bridge.frames.push({
153
+ type: 'reply',
154
+ turn,
155
+ id: item.id,
156
+ reply,
157
+ })
158
+ })
159
+ else if (item.kind === 'end') return
160
+ else throw refuse(502, `Claude Code failed: ${item.message}`)
161
+ }
162
+ } finally {
163
+ signal.removeEventListener('abort', cancel)
164
+ bridge.turns.delete(turn)
165
+ if (!WATCHERS.has(bridge.key)) armIdle(bridge.key)
166
+ }
102
167
  }
103
168
 
104
169
  /**
105
- * Registers `wake` to run whenever the pairing of `caller` signed in as `signedIn` changes; the
106
- * returned function undoes it.
170
+ * The pairing of `caller` signed in as `signedIn` as it changes, until `signal` aborts: the code that
171
+ * pairs it while unpaired, then `undefined` the moment a bridge connects, and a code again if it goes. A
172
+ * code that expires while nothing is paired is replaced with a fresh one. 44.20.
173
+ *
174
+ * 44.34 — IT FOLLOWS WHOEVER SIGNS IN AT THE BROWSER: a sign-in or a sign-out made since is the
175
+ * pairing it watches from then on, and the code it offers is that sign-in's.
107
176
  */
108
- export function watchPairing(
177
+ export async function* pairingStatus(
109
178
  caller: string,
110
179
  signedIn: string,
111
- wake: () => void,
112
- ): () => void {
113
- const key = pairingKey(caller, signedIn)
180
+ signal: AbortSignal,
181
+ ): AsyncGenerator<string | undefined> {
182
+ // A change landing while the loop was not waiting — paused at its `yield` — stays queued, so it
183
+ // looks again; a look that finds nothing new yields nothing.
184
+ const changes = new Inbox<undefined>()
185
+ const waiting = changes[Symbol.asyncIterator]()
186
+ const rouse = (): void => changes.push(undefined)
187
+ const follow = (now: string, next: string): void => {
188
+ caller = now
189
+ signedIn = next
190
+ rouse()
191
+ }
192
+ let watchingKey = ''
193
+ let unwatch = (): void => {}
194
+ let unfollow = (): void => {}
195
+ let pairing: { code: string; expires: number } | undefined
196
+ // `null` until the first is said; `undefined` says paired.
197
+ let said: string | undefined | null = null
198
+ signal.addEventListener('abort', rouse)
199
+ try {
200
+ while (!signal.aborted) {
201
+ const key = pairingKey(caller, signedIn)
202
+ if (key !== watchingKey) {
203
+ unwatch()
204
+ unfollow()
205
+ unwatch = watchPairing(key, rouse)
206
+ unfollow = followSignIn(caller, follow)
207
+ watchingKey = key
208
+ pairing = undefined
209
+ }
210
+ const paired = BY_KEY.has(key)
211
+ if (
212
+ !paired &&
213
+ (pairing === undefined || pairing.expires <= Date.now())
214
+ )
215
+ pairing = mintCode(caller, signedIn)
216
+ const code = paired ? undefined : pairing?.code
217
+ if (code !== said) {
218
+ said = code
219
+ yield code
220
+ }
221
+ const renew = paired
222
+ ? undefined
223
+ : setTimeout(rouse, (pairing?.expires ?? 0) - Date.now())
224
+ await waiting.next()
225
+ clearTimeout(renew)
226
+ }
227
+ } finally {
228
+ signal.removeEventListener('abort', rouse)
229
+ unwatch()
230
+ unfollow()
231
+ }
232
+ }
233
+
234
+ /** Registers `wake` to run whenever the pairing filed under `key` changes; the returned function undoes it. */
235
+ function watchPairing(key: string, wake: () => void): () => void {
114
236
  let watchers = WATCHERS.get(key)
115
237
  if (watchers === undefined) {
116
238
  watchers = new Set()
@@ -131,22 +253,20 @@ export function watchPairing(
131
253
  }
132
254
  }
133
255
 
134
- /** A new turn on `bridge`, the inbox its events arrive in, and the undo that closes it. */
135
- export function openTurn(bridge: Bridge): {
136
- id: string
137
- inbox: Inbox<TurnItem>
138
- close: () => void
139
- } {
140
- const id = randomToken(9, 'base64url')
141
- const inbox = new Inbox<TurnItem>()
142
- bridge.turns.set(id, inbox)
143
- return {
144
- id,
145
- inbox,
146
- close: () => {
147
- bridge.turns.delete(id)
148
- if (!WATCHERS.has(bridge.key)) armIdle(bridge.key)
149
- },
256
+ /** Registers `follow` to hear who signs in at `caller` from here on; the returned function undoes it. */
257
+ function followSignIn(
258
+ caller: string,
259
+ follow: (caller: string, signedIn: string) => void,
260
+ ): () => void {
261
+ let followers = FOLLOWERS.get(caller)
262
+ if (followers === undefined) {
263
+ followers = new Set()
264
+ FOLLOWERS.set(caller, followers)
265
+ }
266
+ followers.add(follow)
267
+ return () => {
268
+ followers.delete(follow)
269
+ if (followers.size === 0) FOLLOWERS.delete(caller)
150
270
  }
151
271
  }
152
272
 
@@ -171,38 +291,6 @@ function wakeWatchers(key: string): void {
171
291
  for (const wake of WATCHERS.get(key) ?? []) wake()
172
292
  }
173
293
 
174
- /**
175
- * Registers `follow` to hear who signs in at `caller` from here on, and the caller a sign-out rotates
176
- * it to (26.9); the returned function undoes it.
177
- *
178
- * 44.34 — A STATUS STREAM OPENED BEFORE A SIGN-IN carries the cookies it was opened with, so it hears
179
- * of the new one here rather than off its own request.
180
- */
181
- export function followSignIn(
182
- caller: string,
183
- follow: (caller: string, signedIn: string) => void,
184
- ): () => void {
185
- let followers = FOLLOWERS.get(caller)
186
- if (followers === undefined) {
187
- followers = new Set()
188
- FOLLOWERS.set(caller, followers)
189
- }
190
- followers.add(follow)
191
- return () => {
192
- followers.delete(follow)
193
- if (followers.size === 0) FOLLOWERS.delete(caller)
194
- }
195
- }
196
-
197
- /** Tells every status stream at `caller` that the browser is `now`, signed in as `signedIn`. 44.34. */
198
- export function signedInAt(
199
- caller: string,
200
- now: string,
201
- signedIn: string,
202
- ): void {
203
- for (const follow of FOLLOWERS.get(caller) ?? []) follow(now, signedIn)
204
- }
205
-
206
294
  function item(
207
295
  kind: TurnItem['kind'],
208
296
  fields: {