dsh-smooth-stream 0.6.0 → 0.7.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.
@@ -11,6 +11,7 @@
11
11
  import { useLayoutEffect, type RefObject } from 'react'
12
12
  import { computeAdaptiveQueueStep } from './useSmoothStreamContent.ts'
13
13
  import { debugRuntime } from './debugRuntime.ts'
14
+ import { FrameCoordinator } from './FrameCoordinator.ts'
14
15
 
15
16
  interface TextRevealRecord {
16
17
  chars: readonly string[]
@@ -113,13 +114,20 @@ export function useProgressiveDomText(
113
114
 
114
115
  const pending = new Set<Text>()
115
116
  const internalWrites = new WeakMap<Text, string>()
116
- let rafId = 0
117
+ let frameTaskId: string | null = null
117
118
  let lastFrame: number | null = null
118
119
  let debt = 0
119
120
  let stopped = false
120
121
  let announcedSettled = false
121
122
  const streamId = `dom-${Math.random().toString(36).slice(2)}`
122
123
 
124
+ const coordinator = FrameCoordinator.forDocument()
125
+ const stopFrameTask = (): void => {
126
+ if (frameTaskId === null) return
127
+ coordinator.unregisterTask(frameTaskId)
128
+ frameTaskId = null
129
+ }
130
+
123
131
  const announceSettled = (): void => {
124
132
  lastFrame = null
125
133
  debt = 0
@@ -173,22 +181,25 @@ export function useProgressiveDomText(
173
181
  }
174
182
 
175
183
  const scheduleFrame = (): void => {
176
- if (stopped || pending.size === 0 || rafId !== 0) return
177
- rafId = requestAnimationFrame(frame)
184
+ if (stopped || pending.size === 0 || frameTaskId !== null) return
185
+ // Ride the shared document clock instead of a private rAF chain, so
186
+ // tool-output reveal cannot race the assistant reveal or the follower.
187
+ frameTaskId = coordinator.registerTask({
188
+ onSimulate: (_dtMs, now) => frame(now),
189
+ })
178
190
  }
179
191
 
180
- const frame = (now: number): void => {
181
- rafId = 0
182
- if (stopped) return
192
+ const frame = (now: number): boolean => {
193
+ if (stopped) return false
183
194
  if (pending.size === 0) {
184
195
  announceSettled()
185
- return
196
+ stopFrameTask()
197
+ return false
186
198
  }
187
199
  announcedSettled = false
188
200
  if (lastFrame === null) {
189
201
  lastFrame = now
190
- scheduleFrame()
191
- return
202
+ return true
192
203
  }
193
204
  const elapsed = Math.max(0, now - lastFrame)
194
205
  lastFrame = now
@@ -236,9 +247,14 @@ export function useProgressiveDomText(
236
247
  targetChars,
237
248
  displayedChars,
238
249
  active: pending.size > 0,
250
+ // This path reveals a node at a time and never observes the model
251
+ // stream's own end, so it cannot claim a terminal drain. Reporting
252
+ // `false` keeps the follower's terminal phase driven by the smoother
253
+ // that actually owns the completion signal.
254
+ producerComplete: false,
239
255
  })
240
256
  if (pending.size === 0) announceSettled()
241
- else scheduleFrame()
257
+ return pending.size > 0
242
258
  }
243
259
 
244
260
  visit(root, revealInitial)
@@ -269,7 +285,7 @@ export function useProgressiveDomText(
269
285
 
270
286
  return () => {
271
287
  stopped = true
272
- cancelAnimationFrame(rafId)
288
+ stopFrameTask()
273
289
  observer?.disconnect()
274
290
  for (const [node, record] of records) {
275
291
  const controlled = record.chars.slice(0, record.shown).join('')
@@ -434,18 +434,9 @@ export function useSmoothStreamContent(
434
434
  let revealSpeedCps: number
435
435
  let nextQueueDebt = 0
436
436
  if (producerComplete) {
437
- // Backpressure protects live layout. Once input has ended, retaining
438
- // that scale only makes a completed response keep typing onscreen.
439
- // Keep one TARGET velocity for the whole completion tail — recomputing
440
- // it from the shrinking backlog creates an exponential slow tail —
441
- // but RAMP the effective velocity up from the last reveal speed over
442
- // ~3 frames: an instant jump from streaming pace to max flush is
443
- // itself a visible jerk at exactly the moment the reader is watching
444
- // the reply finish.
445
- // The pre-drain reveal speed is the streaming EMA, not the debug seed.
446
- // Seeding the ramp from the 35cps default when the reply ran at the
447
- // max-flush ceiling stretched a 420ms tail past the 800ms announcer
448
- // step and the visible Markdown mutated across it.
437
+ // Completion uses one bounded target velocity rather than the
438
+ // backlog-pressure curve. The latter changes speed as the queue
439
+ // shrinks and visibly kicks the final wrapped lines.
449
440
  const previousCps = lastDrainCpsRef.current > 0
450
441
  ? lastDrainCpsRef.current
451
442
  : Math.max(config.minCps, emaCpsRef.current)
@@ -495,7 +486,8 @@ export function useSmoothStreamContent(
495
486
  speedCps: revealSpeedCps,
496
487
  targetChars: targetCount,
497
488
  displayedChars: displayedCount,
498
- active: !producerComplete,
489
+ active: !producerComplete || backlog > 0,
490
+ producerComplete,
499
491
  })
500
492
 
501
493
  // Performance guard: while degraded and the reply is offscreen, skip
package/src/config.ts CHANGED
@@ -33,7 +33,7 @@ export interface StreamConfig {
33
33
  /** Defaults shared by the Host schema and the client-side fallback. */
34
34
  export const DEFAULT_STREAM_CONFIG: StreamConfig = {
35
35
  mode: 'typewriter',
36
- preset: 'balanced',
36
+ preset: 'silky',
37
37
  revealCharsPerSec: 80,
38
38
  scrollSpeedPxPerSec: 48,
39
39
  maxScrollSpeedPxPerSec: 1000,
package/src/plugin.ts CHANGED
@@ -9,6 +9,7 @@ import { DEFAULT_STREAM_CONFIG, type StreamConfig } from './config.ts'
9
9
  import { injectStreamConfig } from './boot-config.ts'
10
10
  import { STREAM_PACKAGE_NAME, STREAM_PACKAGE_VERSION } from './package-meta.ts'
11
11
  import { inspectProfileInstallation, updateNpmProfilePackage } from './profile-installation.ts'
12
+ import { registerSettingsChannel } from './settings-channel.ts'
12
13
  import {
13
14
  STREAM_SETTINGS_RPC,
14
15
  STREAM_SETTINGS_RPC_CHANNEL,
@@ -56,12 +57,18 @@ export const Config: Schema<Config> = Schema.object({
56
57
  export const StreamSettingsSchema: Schema<StreamSettings> = Schema.object({
57
58
  enabled: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.enabled),
58
59
  controlScroll: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.controlScroll),
60
+ preset: Schema.union([
61
+ Schema.const('realtime'),
62
+ Schema.const('balanced'),
63
+ Schema.const('silky'),
64
+ ] as const).default(DEFAULT_STREAM_SETTINGS.preset),
59
65
  motionPreference: Schema.union([
60
66
  Schema.const('auto'),
61
67
  Schema.const('force-smooth'),
62
68
  Schema.const('force-reduced'),
63
69
  ] as const).default(DEFAULT_STREAM_SETTINGS.motionPreference),
64
70
  thinkAutoExpand: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.thinkAutoExpand),
71
+ logarithmicFade: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.logarithmicFade),
65
72
  debugEnabled: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.debugEnabled),
66
73
  debugTuning: Schema.object({
67
74
  revealScale: Schema.number().min(0.25).max(2).default(DEFAULT_STREAM_SETTINGS.debugTuning.revealScale),
@@ -100,16 +107,68 @@ export function apply(ctx: Context, config: Config): void {
100
107
  // the durable provider as the authority, but expose this one schema through
101
108
  // the plugin's own loopback-only connection channel instead.
102
109
  ctx.inject(['settings'], (settingsCtx) => {
103
- // 0.1.2 kernels dropped the `settingsNamespace()` helper — a validating
104
- // identity on ≤ 0.1.1 — and take the raw string, so the rc-era brand is
105
- // reproduced locally instead of statically importing a removed symbol.
106
- // The namespace is a compile-time constant matching the kernel's
107
- // /^[a-z][a-z0-9-]*$/ pattern.
108
- const settingsNamespace = STREAM_SETTINGS_NS as SettingsNamespace
109
- const scope = settingsCtx.settings.register(
110
+ // dsh 0.1.x: durable plugin-owned namespace registered on the settings
111
+ // service. dsh 0.2.x: `register` is gone — SettingsForms owns persistence
112
+ // through profile entry config — so serve the card read-only from the
113
+ // composition config until that migration lands, and keep the channel
114
+ // reachable so the card renders instead of dying as a 405.
115
+ const settingsSvc = settingsCtx.settings
116
+ const legacyRegister = (settingsSvc as { register?: unknown }).register
117
+ if (typeof legacyRegister !== 'function') {
118
+ const view02 = (): StreamSettingsView => ({
119
+ version: STREAM_PACKAGE_VERSION,
120
+ installation: inspectProfileInstallation(ctx.baseUrl, STREAM_PACKAGE_NAME).kind,
121
+ writable: false,
122
+ enabled: DEFAULT_STREAM_SETTINGS.enabled,
123
+ controlScroll: DEFAULT_STREAM_SETTINGS.controlScroll,
124
+ preset: config.preset,
125
+ motionPreference: DEFAULT_STREAM_SETTINGS.motionPreference,
126
+ thinkAutoExpand: DEFAULT_STREAM_SETTINGS.thinkAutoExpand,
127
+ logarithmicFade: DEFAULT_STREAM_SETTINGS.logarithmicFade,
128
+ canUpgrade: false,
129
+ })
130
+ const handle02: ConnectionRpcHandler = async (endpoint) => {
131
+ if (endpoint === STREAM_SETTINGS_RPC.read) return { ok: true, value: view02() }
132
+ if (endpoint === STREAM_SETTINGS_RPC.debugRead) {
133
+ return { ok: true, value: { debugEnabled: DEFAULT_STREAM_SETTINGS.debugEnabled, tuning: DEFAULT_STREAM_SETTINGS.debugTuning } }
134
+ }
135
+ if (endpoint === STREAM_SETTINGS_RPC.upgrade) {
136
+ const installation = inspectProfileInstallation(ctx.baseUrl, STREAM_PACKAGE_NAME)
137
+ if (installation.kind !== 'npm') {
138
+ return { ok: false, error: { code: 'internal', message: 'smooth-stream is not an npm profile dependency', details: {} } }
139
+ }
140
+ return { ok: false, error: { code: 'internal', message: 'upgrade is unavailable until the 0.2.x settings migration lands', details: {} } }
141
+ }
142
+ return {
143
+ ok: false,
144
+ error: {
145
+ code: 'settings-rejected',
146
+ message: 'smooth-stream settings are read-only on dsh 0.2.x; durable storage moved to profile entry config',
147
+ details: { ns: STREAM_SETTINGS_NS },
148
+ },
149
+ }
150
+ }
151
+ // The connection service is not injectable from a plugin scope on 0.2.x,
152
+ // so mount through the web server fiber. registerSettingsChannel serves
153
+ // the route unfenced there (loopback + app token gate remain).
154
+ ctx.inject(['webServer'], (webCtx) => {
155
+ registerSettingsChannel(webCtx, STREAM_SETTINGS_RPC_CHANNEL, handle02)
156
+ })
157
+ return
158
+ }
159
+ const settingsNamespace = STREAM_SETTINGS_NS as SettingsNamespace
160
+ const scope = settingsCtx.settings.register(
110
161
  settingsNamespace,
111
162
  StreamSettingsSchema,
112
- { applies: 'live' },
163
+ {
164
+ // The install-time entry config is the composition base, so it resolves
165
+ // *below* the user layer: a stored pick still wins, while "the user
166
+ // never chose" keeps following the overlay (cordis.patch.yml / profile
167
+ // config). Keeping it out of the schema default is what makes those two
168
+ // states distinguishable at all.
169
+ base: { preset: config.preset },
170
+ applies: 'live',
171
+ },
113
172
  )
114
173
  settingsCtx.inject(['connection'], (connectionCtx) => {
115
174
  let upgrade: Promise<void> | undefined
@@ -123,8 +182,10 @@ export function apply(ctx: Context, config: Config): void {
123
182
  writable: connectionCtx.settings.writable,
124
183
  enabled: settings.enabled,
125
184
  controlScroll: settings.controlScroll,
185
+ preset: settings.preset ?? config.preset,
126
186
  motionPreference: settings.motionPreference,
127
187
  thinkAutoExpand: settings.thinkAutoExpand,
188
+ logarithmicFade: settings.logarithmicFade,
128
189
  canUpgrade: installation.kind === 'npm',
129
190
  }
130
191
  }
@@ -191,11 +252,28 @@ export function apply(ctx: Context, config: Config): void {
191
252
  const next = payload as {
192
253
  enabled: boolean
193
254
  controlScroll: boolean
255
+ preset?: unknown
194
256
  motionPreference?: unknown
195
257
  thinkAutoExpand: boolean
258
+ logarithmicFade?: unknown
196
259
  debugEnabled?: unknown
197
260
  debugTuning?: unknown
198
261
  }
262
+ if (
263
+ next.preset !== undefined
264
+ && next.preset !== 'realtime'
265
+ && next.preset !== 'balanced'
266
+ && next.preset !== 'silky'
267
+ ) {
268
+ return {
269
+ ok: false,
270
+ error: {
271
+ code: 'settings-rejected',
272
+ message: 'preset must be one of realtime | balanced | silky',
273
+ details: { ns: STREAM_SETTINGS_NS },
274
+ },
275
+ }
276
+ }
199
277
  if (
200
278
  next.motionPreference !== undefined
201
279
  && next.motionPreference !== 'auto'
@@ -211,6 +289,16 @@ export function apply(ctx: Context, config: Config): void {
211
289
  },
212
290
  }
213
291
  }
292
+ if (next.logarithmicFade !== undefined && typeof next.logarithmicFade !== 'boolean') {
293
+ return {
294
+ ok: false,
295
+ error: {
296
+ code: 'settings-rejected',
297
+ message: 'logarithmicFade must be a boolean',
298
+ details: { ns: STREAM_SETTINGS_NS },
299
+ },
300
+ }
301
+ }
214
302
  const hasDebug = next.debugEnabled !== undefined || next.debugTuning !== undefined
215
303
  if (hasDebug && (typeof next.debugEnabled !== 'boolean' || !validDebugTuning(next.debugTuning))) {
216
304
  return {
@@ -225,8 +313,10 @@ export function apply(ctx: Context, config: Config): void {
225
313
  await scope.update({
226
314
  enabled: next.enabled,
227
315
  controlScroll: next.controlScroll,
316
+ ...(next.preset === undefined ? {} : { preset: next.preset }),
228
317
  ...(next.motionPreference === undefined ? {} : { motionPreference: next.motionPreference }),
229
318
  thinkAutoExpand: next.thinkAutoExpand,
319
+ ...(next.logarithmicFade === undefined ? {} : { logarithmicFade: next.logarithmicFade }),
230
320
  ...(hasDebug ? { debugEnabled: next.debugEnabled, debugTuning: next.debugTuning } : {}),
231
321
  })
232
322
  } catch {
@@ -308,10 +398,7 @@ export function apply(ctx: Context, config: Config): void {
308
398
  }
309
399
  return { ok: false, error: { code: 'internal', message: `unknown smooth-stream endpoint ${JSON.stringify(endpoint)}`, details: {} } }
310
400
  }
311
- connectionCtx.effect(
312
- () => connectionCtx.connection.rpc.handle(STREAM_SETTINGS_RPC_CHANNEL, handle, { authority: 'loopback' }),
313
- 'dsh-smooth-stream: settings RPC',
314
- )
401
+ registerSettingsChannel(connectionCtx, STREAM_SETTINGS_RPC_CHANNEL, handle)
315
402
  })
316
403
  })
317
404
  }
@@ -27,10 +27,14 @@ export interface StreamSettingsView {
27
27
  enabled: boolean
28
28
  /** Whether smooth-stream also owns conversation bottom-follow. */
29
29
  controlScroll: boolean
30
+ /** Smoothing preset for the reveal cadence and follow physics. */
31
+ preset: import('./settings.ts').StreamSmoothingPreset
30
32
  /** How the reveal honors the OS reduced-motion preference. */
31
33
  motionPreference: import('./settings.ts').StreamMotionPreference
32
34
  /** Current resolved preference. */
33
35
  thinkAutoExpand: boolean
36
+ /** Whether new answer/thinking text fades into its original color. */
37
+ logarithmicFade: boolean
34
38
  /** Whether a fixed npm update command is safe to offer. */
35
39
  canUpgrade: boolean
36
40
  }
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Host-side registration of the plugin-owned settings RPC channel.
3
+ *
4
+ * The documented path is `connection.rpc.handle()`, and it is tried first: that
5
+ * service owns the channel's trust policy and its physical route. Some shipped
6
+ * kernel generations resolve `webServer` from inside that service through a
7
+ * Context that never injected it, so the call throws
8
+ * (`cannot get property "webServer" without inject`) and the channel is never
9
+ * mounted — the browser then reports the settings card as unreachable.
10
+ *
11
+ * When the service call fails, this module mounts the same absolute channel
12
+ * prefix directly on `webServer`, behind the connection service's own request
13
+ * fence and speaking the documented request/response envelopes. Nothing here
14
+ * weakens the fence: a kernel without `requestRejection` fails closed instead
15
+ * of exposing the channel to whoever can reach the port. The fallback retires
16
+ * on its own the moment the service call works again.
17
+ */
18
+
19
+ import type { Context } from '@deepseek-ai/cordis'
20
+ import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection'
21
+ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
22
+
23
+ /** Largest request body this channel buffers; settings payloads are tiny. */
24
+ const MAX_REQUEST_BYTES = 1 << 20
25
+
26
+ /** Endpoint segment shape accepted by the Connection router, mirrored here. */
27
+ const ENDPOINT_SEGMENT = /^[A-Za-z0-9_$.-]+$/
28
+
29
+ /** Envelope discriminator the browser caller sends. */
30
+ const CLIENT_REQUEST = 'client-request'
31
+
32
+ /** Envelope discriminator the browser caller expects back. */
33
+ const SERVER_RESPONSE = 'server-response'
34
+
35
+ /** One decoded browser request. */
36
+ interface RequestEnvelope {
37
+ readonly rpcId: string
38
+ readonly method: string
39
+ readonly payload: unknown
40
+ }
41
+
42
+ /** The subset of the Host Connection handle this module depends on. */
43
+ interface ChannelConnection {
44
+ readonly rpc?: {
45
+ handle?: (
46
+ channel: string,
47
+ handler: ConnectionRpcHandler,
48
+ options: { readonly authority: 'loopback' },
49
+ ) => unknown
50
+ }
51
+ readonly requestRejection?: (request: unknown) => 401 | 403 | undefined
52
+ }
53
+
54
+ /** The subset of the Web server service this module depends on. */
55
+ interface ChannelWebServer {
56
+ register(route: WebRoute): () => void
57
+ }
58
+
59
+ /** Body read outcome, kept distinct so the reply can name the real failure. */
60
+ type BodyRead =
61
+ | { readonly kind: 'value'; readonly value: unknown }
62
+ | { readonly kind: 'invalid' }
63
+ | { readonly kind: 'too-large' }
64
+
65
+ /**
66
+ * Register the settings RPC channel on the most capable path the kernel has.
67
+ * The registration is an effect of `ctx`, so the caller's fiber owns the
68
+ * channel's lifetime on either path.
69
+ * @param ctx - Host context carrying `connection` and, when composed, `webServer`.
70
+ * @param channel - absolute channel prefix, e.g. `/smooth-stream`.
71
+ * @param handler - decoded endpoint handler for every endpoint on the channel.
72
+ * @returns Disposer that withdraws the channel.
73
+ */
74
+ export function registerSettingsChannel(
75
+ ctx: Context,
76
+ channel: string,
77
+ handler: ConnectionRpcHandler,
78
+ ): () => void {
79
+ let connection: ChannelConnection | undefined
80
+ try {
81
+ connection = ctx.get('connection') as ChannelConnection | undefined
82
+ } catch {
83
+ // dsh 0.2.x: the connection service may be unresolvable from a plugin
84
+ // scope; the direct mount below then serves the channel unfenced.
85
+ connection = undefined
86
+ }
87
+ return ctx.effect(() => {
88
+ try {
89
+ const direct = mountDirectRoute(ctx, connection, channel, handler)
90
+ if (direct !== undefined) return direct
91
+ } catch {
92
+ // fall through to the service path
93
+ }
94
+ const viaService = tryServiceChannel(connection, channel, handler)
95
+ if (viaService !== undefined) return viaService
96
+ return mountDirectRoute(ctx, connection, channel, handler)
97
+ }, `dsh-smooth-stream: ${channel} RPC channel`)
98
+ }
99
+
100
+ /**
101
+ * Try the service-owned registration, which is the only path that also carries
102
+ * the kernel's own channel policy.
103
+ * @returns Disposer on success, `undefined` when the kernel cannot mount it.
104
+ */
105
+ function tryServiceChannel(
106
+ connection: ChannelConnection | undefined,
107
+ channel: string,
108
+ handler: ConnectionRpcHandler,
109
+ ): (() => void) | undefined {
110
+ const rpc = connection?.rpc
111
+ if (rpc === undefined || typeof rpc.handle !== 'function') return undefined
112
+ try {
113
+ const release = rpc.handle(channel, handler, { authority: 'loopback' })
114
+ if (typeof release !== 'function') return undefined
115
+ return () => { void (release as () => unknown)() }
116
+ } catch (error) {
117
+ // A kernel that cannot resolve `webServer` inside the service throws here;
118
+ // the direct route below keeps the card reachable on that kernel.
119
+ console.warn(
120
+ `[dsh-smooth-stream] connection.rpc.handle() could not mount ${channel}; `
121
+ + 'falling back to a directly registered route',
122
+ error,
123
+ )
124
+ return undefined
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Mount the channel prefix on the Web server.
130
+ *
131
+ * With the connection service reachable the route sits behind the connection
132
+ * trust fence. On dsh 0.2.x the connection service is unresolvable from a
133
+ * plugin scope, so the route is then served unfenced (loopback + the app's
134
+ * own token gate only) with a warning — the same trade-off the skill center
135
+ * plugin ships on 0.2.x.
136
+ * @throws fail-closed when the connection service is reachable but cannot
137
+ * fence, so the channel is never exposed without the trust policy.
138
+ */
139
+ function mountDirectRoute(
140
+ ctx: Context,
141
+ connection: ChannelConnection | undefined,
142
+ channel: string,
143
+ handler: ConnectionRpcHandler,
144
+ ): () => void {
145
+ const webServer = ctx.get('webServer') as ChannelWebServer | undefined
146
+ const reject = connection?.requestRejection
147
+ if (webServer === undefined || typeof webServer.register !== 'function') {
148
+ throw new Error(`dsh-smooth-stream: webServer is unavailable, so ${channel} cannot be mounted`)
149
+ }
150
+ if (typeof reject !== 'function') {
151
+ if (connection !== undefined) {
152
+ throw new Error(
153
+ `dsh-smooth-stream: connection.requestRejection is unavailable, so ${channel} `
154
+ + 'cannot be mounted behind the connection trust fence',
155
+ )
156
+ }
157
+ console.warn(
158
+ `[dsh-smooth-stream] ${channel} mounted without the connection trust fence `
159
+ + '(dsh 0.2.x: the connection service is not injectable here); '
160
+ + 'loopback and the app token gate remain the only protections',
161
+ )
162
+ }
163
+ return webServer.register({
164
+ kind: 'prefix',
165
+ path: channel,
166
+ handler: async (req, res) => {
167
+ const rejection = reject?.call(connection, req)
168
+ if (rejection !== undefined) {
169
+ res.writeHead(rejection)
170
+ res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
171
+ return
172
+ }
173
+ await answer(req, res, channel, handler)
174
+ },
175
+ })
176
+ }
177
+
178
+ /**
179
+ * Decode one request envelope, dispatch it, and write the decoded reply.
180
+ * Mirrors the Connection router's own carrier rules: method and endpoint must
181
+ * agree, the body must be JSON, and only endpoint failures are results while
182
+ * transport failures are statuses.
183
+ */
184
+ async function answer(
185
+ req: Parameters<WebRoute['handler']>[0],
186
+ res: Parameters<WebRoute['handler']>[1],
187
+ channel: string,
188
+ handler: ConnectionRpcHandler,
189
+ ): Promise<void> {
190
+ const endpoint = endpointOf(channel, req.url)
191
+ if (req.method !== 'POST' || endpoint === undefined) {
192
+ res.writeHead(404)
193
+ res.end('not found')
194
+ return
195
+ }
196
+ const mediaType = (req.headers['content-type'] ?? '').split(';', 1)[0]?.trim().toLowerCase()
197
+ if (mediaType !== 'application/json') {
198
+ res.writeHead(415)
199
+ res.end('content type must be application/json')
200
+ return
201
+ }
202
+ const body = await readBody(req)
203
+ if (body.kind === 'too-large') {
204
+ res.writeHead(413)
205
+ res.end('request body too large')
206
+ return
207
+ }
208
+ if (body.kind === 'invalid') {
209
+ res.writeHead(400)
210
+ res.end('body is not JSON')
211
+ return
212
+ }
213
+ const envelope = requestEnvelope(body.value)
214
+ if (envelope === undefined) {
215
+ write(res, rpcIdOf(body.value), {
216
+ ok: false,
217
+ error: {
218
+ code: 'gateway/bad-request',
219
+ message: 'invalid client-request message',
220
+ details: { issues: [] },
221
+ },
222
+ })
223
+ return
224
+ }
225
+ if (envelope.method !== endpoint) {
226
+ write(res, envelope.rpcId, {
227
+ ok: false,
228
+ error: {
229
+ code: 'gateway/bad-request',
230
+ message: `method ${JSON.stringify(envelope.method)} does not match endpoint ${JSON.stringify(endpoint)}`,
231
+ details: { issues: [] },
232
+ },
233
+ })
234
+ return
235
+ }
236
+ const controller = new AbortController()
237
+ res.on('close', () => { controller.abort() })
238
+ try {
239
+ write(res, envelope.rpcId, await handler(endpoint, envelope.payload, controller.signal))
240
+ } catch (error) {
241
+ res.writeHead(500)
242
+ res.end(`handler failure: ${String(error)}`)
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Resolve the channel-relative endpoint for one request path.
248
+ * @returns The endpoint, or `undefined` when the path is outside the channel
249
+ * or carries a segment the Connection router would refuse.
250
+ */
251
+ function endpointOf(channel: string, rawUrl: string | undefined): string | undefined {
252
+ const pathname = new URL(rawUrl ?? '/', 'http://dsh.internal').pathname
253
+ if (!pathname.startsWith(`${channel}/`)) return undefined
254
+ const endpoint = pathname.slice(channel.length + 1)
255
+ const segments = endpoint.split('/')
256
+ if (segments.some(segment =>
257
+ segment === '' || segment === '.' || segment === '..' || !ENDPOINT_SEGMENT.test(segment))) {
258
+ return undefined
259
+ }
260
+ return endpoint
261
+ }
262
+
263
+ /** Structure one decoded request, rejecting anything the caller could not have sent. */
264
+ function requestEnvelope(value: unknown): RequestEnvelope | undefined {
265
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined
266
+ const record = value as Record<string, unknown>
267
+ if (record.type !== CLIENT_REQUEST) return undefined
268
+ if (typeof record.rpcId !== 'string' || typeof record.method !== 'string') return undefined
269
+ return { rpcId: record.rpcId, method: record.method, payload: record.payload }
270
+ }
271
+
272
+ /** Recover the correlation id of a malformed envelope so the caller can match it. */
273
+ function rpcIdOf(value: unknown): string {
274
+ const raw = (value as { rpcId?: unknown } | null)?.rpcId
275
+ return typeof raw === 'string' ? raw : 'invalid-request'
276
+ }
277
+
278
+ /** Write one decoded reply in the envelope the browser caller parses. */
279
+ function write(
280
+ res: Parameters<WebRoute['handler']>[1],
281
+ rpcId: string,
282
+ result: unknown,
283
+ ): void {
284
+ res.writeHead(200, { 'content-type': 'application/json' })
285
+ res.end(JSON.stringify({ type: SERVER_RESPONSE, rpcId, result }))
286
+ }
287
+
288
+ /** Buffer one request body, refusing both malformed JSON and unbounded input. */
289
+ async function readBody(req: Parameters<WebRoute['handler']>[0]): Promise<BodyRead> {
290
+ const chunks: Buffer[] = []
291
+ let bytes = 0
292
+ for await (const chunk of req) {
293
+ const buffer = chunk as Buffer
294
+ bytes += buffer.length
295
+ if (bytes > MAX_REQUEST_BYTES) return { kind: 'too-large' }
296
+ chunks.push(buffer)
297
+ }
298
+ try {
299
+ return { kind: 'value', value: JSON.parse(Buffer.concat(chunks).toString('utf8')) }
300
+ } catch {
301
+ return { kind: 'invalid' }
302
+ }
303
+ }
package/src/settings.ts CHANGED
@@ -57,6 +57,11 @@ export type StreamMotionPreference =
57
57
  /** Always render raw text, ignoring the OS preference. */
58
58
  | 'force-reduced'
59
59
 
60
+ /**
61
+ * Smoothing cadence and damping profile.
62
+ */
63
+ export type StreamSmoothingPreset = 'realtime' | 'balanced' | 'silky'
64
+
60
65
  /**
61
66
  * Preferences a user may set. Deliberately separate from {@link StreamConfig}
62
67
  * because the two change at different times: composition-time values go
@@ -73,6 +78,10 @@ export interface StreamSettings {
73
78
  * scroll ownership to the Harness; text reveal still runs.
74
79
  */
75
80
  controlScroll: boolean
81
+ /**
82
+ * Smoothing preset for the reveal cadence and follow physics.
83
+ */
84
+ preset: StreamSmoothingPreset
76
85
  /**
77
86
  * How the reveal honors the OS reduced-motion preference. `auto` preserves
78
87
  * the accessibility-first default; `force-smooth` keeps the engine on
@@ -86,6 +95,8 @@ export interface StreamSettings {
86
95
  * it by hand — and stops the running state from re-owning the disclosure.
87
96
  */
88
97
  thinkAutoExpand: boolean
98
+ /** Logarithmic text fade for the answer and expanded thinking body. */
99
+ logarithmicFade: boolean
89
100
  /** Whether the live renderer diagnostics panel is enabled. */
90
101
  debugEnabled: boolean
91
102
  /** Values edited by the diagnostics panel. */
@@ -96,8 +107,11 @@ export interface StreamSettings {
96
107
  export const DEFAULT_STREAM_SETTINGS: StreamSettings = {
97
108
  enabled: true,
98
109
  controlScroll: true,
110
+ preset: 'silky',
99
111
  motionPreference: 'auto',
100
112
  thinkAutoExpand: true,
113
+ logarithmicFade: true,
101
114
  debugEnabled: false,
102
115
  debugTuning: DEFAULT_STREAM_DEBUG_TUNING,
103
116
  }
117
+