dsh-smooth-stream 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,277 @@
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
+ const connection = ctx.get('connection') as ChannelConnection | undefined
80
+ return ctx.effect(() => {
81
+ const viaService = tryServiceChannel(connection, channel, handler)
82
+ if (viaService !== undefined) return viaService
83
+ return mountDirectRoute(ctx, connection, channel, handler)
84
+ }, `dsh-smooth-stream: ${channel} RPC channel`)
85
+ }
86
+
87
+ /**
88
+ * Try the service-owned registration, which is the only path that also carries
89
+ * the kernel's own channel policy.
90
+ * @returns Disposer on success, `undefined` when the kernel cannot mount it.
91
+ */
92
+ function tryServiceChannel(
93
+ connection: ChannelConnection | undefined,
94
+ channel: string,
95
+ handler: ConnectionRpcHandler,
96
+ ): (() => void) | undefined {
97
+ const rpc = connection?.rpc
98
+ if (rpc === undefined || typeof rpc.handle !== 'function') return undefined
99
+ try {
100
+ const release = rpc.handle(channel, handler, { authority: 'loopback' })
101
+ if (typeof release !== 'function') return undefined
102
+ return () => { void (release as () => unknown)() }
103
+ } catch (error) {
104
+ // A kernel that cannot resolve `webServer` inside the service throws here;
105
+ // the direct route below keeps the card reachable on that kernel.
106
+ console.warn(
107
+ `[dsh-smooth-stream] connection.rpc.handle() could not mount ${channel}; `
108
+ + 'falling back to a directly registered route',
109
+ error,
110
+ )
111
+ return undefined
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Mount the channel prefix on the Web server with the connection fence intact.
117
+ * @throws when the kernel exposes neither the fence nor a Web server, so the
118
+ * failure is reported instead of silently leaving the card unreachable.
119
+ */
120
+ function mountDirectRoute(
121
+ ctx: Context,
122
+ connection: ChannelConnection | undefined,
123
+ channel: string,
124
+ handler: ConnectionRpcHandler,
125
+ ): () => void {
126
+ const webServer = ctx.get('webServer') as ChannelWebServer | undefined
127
+ const reject = connection?.requestRejection
128
+ if (webServer === undefined || typeof webServer.register !== 'function') {
129
+ throw new Error(`dsh-smooth-stream: webServer is unavailable, so ${channel} cannot be mounted`)
130
+ }
131
+ if (typeof reject !== 'function') {
132
+ throw new Error(
133
+ `dsh-smooth-stream: connection.requestRejection is unavailable, so ${channel} `
134
+ + 'cannot be mounted behind the connection trust fence',
135
+ )
136
+ }
137
+ return webServer.register({
138
+ kind: 'prefix',
139
+ path: channel,
140
+ handler: async (req, res) => {
141
+ const rejection = reject.call(connection, req)
142
+ if (rejection !== undefined) {
143
+ res.writeHead(rejection)
144
+ res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
145
+ return
146
+ }
147
+ await answer(req, res, channel, handler)
148
+ },
149
+ })
150
+ }
151
+
152
+ /**
153
+ * Decode one request envelope, dispatch it, and write the decoded reply.
154
+ * Mirrors the Connection router's own carrier rules: method and endpoint must
155
+ * agree, the body must be JSON, and only endpoint failures are results while
156
+ * transport failures are statuses.
157
+ */
158
+ async function answer(
159
+ req: Parameters<WebRoute['handler']>[0],
160
+ res: Parameters<WebRoute['handler']>[1],
161
+ channel: string,
162
+ handler: ConnectionRpcHandler,
163
+ ): Promise<void> {
164
+ const endpoint = endpointOf(channel, req.url)
165
+ if (req.method !== 'POST' || endpoint === undefined) {
166
+ res.writeHead(404)
167
+ res.end('not found')
168
+ return
169
+ }
170
+ const mediaType = (req.headers['content-type'] ?? '').split(';', 1)[0]?.trim().toLowerCase()
171
+ if (mediaType !== 'application/json') {
172
+ res.writeHead(415)
173
+ res.end('content type must be application/json')
174
+ return
175
+ }
176
+ const body = await readBody(req)
177
+ if (body.kind === 'too-large') {
178
+ res.writeHead(413)
179
+ res.end('request body too large')
180
+ return
181
+ }
182
+ if (body.kind === 'invalid') {
183
+ res.writeHead(400)
184
+ res.end('body is not JSON')
185
+ return
186
+ }
187
+ const envelope = requestEnvelope(body.value)
188
+ if (envelope === undefined) {
189
+ write(res, rpcIdOf(body.value), {
190
+ ok: false,
191
+ error: {
192
+ code: 'gateway/bad-request',
193
+ message: 'invalid client-request message',
194
+ details: { issues: [] },
195
+ },
196
+ })
197
+ return
198
+ }
199
+ if (envelope.method !== endpoint) {
200
+ write(res, envelope.rpcId, {
201
+ ok: false,
202
+ error: {
203
+ code: 'gateway/bad-request',
204
+ message: `method ${JSON.stringify(envelope.method)} does not match endpoint ${JSON.stringify(endpoint)}`,
205
+ details: { issues: [] },
206
+ },
207
+ })
208
+ return
209
+ }
210
+ const controller = new AbortController()
211
+ res.on('close', () => { controller.abort() })
212
+ try {
213
+ write(res, envelope.rpcId, await handler(endpoint, envelope.payload, controller.signal))
214
+ } catch (error) {
215
+ res.writeHead(500)
216
+ res.end(`handler failure: ${String(error)}`)
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Resolve the channel-relative endpoint for one request path.
222
+ * @returns The endpoint, or `undefined` when the path is outside the channel
223
+ * or carries a segment the Connection router would refuse.
224
+ */
225
+ function endpointOf(channel: string, rawUrl: string | undefined): string | undefined {
226
+ const pathname = new URL(rawUrl ?? '/', 'http://dsh.internal').pathname
227
+ if (!pathname.startsWith(`${channel}/`)) return undefined
228
+ const endpoint = pathname.slice(channel.length + 1)
229
+ const segments = endpoint.split('/')
230
+ if (segments.some(segment =>
231
+ segment === '' || segment === '.' || segment === '..' || !ENDPOINT_SEGMENT.test(segment))) {
232
+ return undefined
233
+ }
234
+ return endpoint
235
+ }
236
+
237
+ /** Structure one decoded request, rejecting anything the caller could not have sent. */
238
+ function requestEnvelope(value: unknown): RequestEnvelope | undefined {
239
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined
240
+ const record = value as Record<string, unknown>
241
+ if (record.type !== CLIENT_REQUEST) return undefined
242
+ if (typeof record.rpcId !== 'string' || typeof record.method !== 'string') return undefined
243
+ return { rpcId: record.rpcId, method: record.method, payload: record.payload }
244
+ }
245
+
246
+ /** Recover the correlation id of a malformed envelope so the caller can match it. */
247
+ function rpcIdOf(value: unknown): string {
248
+ const raw = (value as { rpcId?: unknown } | null)?.rpcId
249
+ return typeof raw === 'string' ? raw : 'invalid-request'
250
+ }
251
+
252
+ /** Write one decoded reply in the envelope the browser caller parses. */
253
+ function write(
254
+ res: Parameters<WebRoute['handler']>[1],
255
+ rpcId: string,
256
+ result: unknown,
257
+ ): void {
258
+ res.writeHead(200, { 'content-type': 'application/json' })
259
+ res.end(JSON.stringify({ type: SERVER_RESPONSE, rpcId, result }))
260
+ }
261
+
262
+ /** Buffer one request body, refusing both malformed JSON and unbounded input. */
263
+ async function readBody(req: Parameters<WebRoute['handler']>[0]): Promise<BodyRead> {
264
+ const chunks: Buffer[] = []
265
+ let bytes = 0
266
+ for await (const chunk of req) {
267
+ const buffer = chunk as Buffer
268
+ bytes += buffer.length
269
+ if (bytes > MAX_REQUEST_BYTES) return { kind: 'too-large' }
270
+ chunks.push(buffer)
271
+ }
272
+ try {
273
+ return { kind: 'value', value: JSON.parse(Buffer.concat(chunks).toString('utf8')) }
274
+ } catch {
275
+ return { kind: 'invalid' }
276
+ }
277
+ }
package/src/settings.ts CHANGED
@@ -86,6 +86,8 @@ export interface StreamSettings {
86
86
  * it by hand — and stops the running state from re-owning the disclosure.
87
87
  */
88
88
  thinkAutoExpand: boolean
89
+ /** Logarithmic text fade for the answer and expanded thinking body. */
90
+ logarithmicFade: boolean
89
91
  /** Whether the live renderer diagnostics panel is enabled. */
90
92
  debugEnabled: boolean
91
93
  /** Values edited by the diagnostics panel. */
@@ -98,6 +100,7 @@ export const DEFAULT_STREAM_SETTINGS: StreamSettings = {
98
100
  controlScroll: true,
99
101
  motionPreference: 'auto',
100
102
  thinkAutoExpand: true,
103
+ logarithmicFade: true,
101
104
  debugEnabled: false,
102
105
  debugTuning: DEFAULT_STREAM_DEBUG_TUNING,
103
106
  }