Merge remote-tracking branch 'origin/master' into worktree/web-background-tasks-display-258f7e
# Conflicts: # docs/subsystems/tasks.i18n.yaml # docs/subsystems/tasks.md # docs/subsystems/tasks.zh.md # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/tasks/tasks-local/src/index.ts # packages/tasks/tasks/README.i18n.yaml # packages/tasks/tasks/README.md # packages/tasks/tasks/README.zh.md # packages/tasks/tasks/src/index.ts
This commit is contained in:
@@ -6,10 +6,10 @@
|
||||
// The ./api and ./client subpath exports are the browser-safe channels.
|
||||
|
||||
export type {
|
||||
ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
|
||||
ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, PromptContentPart, HostApi, EventsApi, MuxFrame, HostFrame,
|
||||
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
|
||||
DirectoryEntry, DirectoryListing,
|
||||
WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
ResponseValue, WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels,
|
||||
@@ -38,6 +38,9 @@ export type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
export type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
export type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm/types'
|
||||
|
||||
/** Successful value returned by the connection-generation host handshake. */
|
||||
export type HostDescription = import('@deepseek-ai/dsh-host-apiproxy/api').ResponseValue<'host.describe'>
|
||||
|
||||
import type { RpcResponse, RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
|
||||
/**
|
||||
|
||||
@@ -131,11 +131,15 @@ export class ConnectionController {
|
||||
// subscribed baseline. The timeout guards against a carrier that never fires onOpen
|
||||
// (see ConnectionConfig.streamOpenTimeoutMs).
|
||||
const timeout = new AbortController()
|
||||
await Promise.all([
|
||||
const [description] = await Promise.all([
|
||||
this.api.host.describe({}),
|
||||
Promise.race([streamsOpen, sleep(this.config.streamOpenTimeoutMs, timeout.signal)]),
|
||||
])
|
||||
timeout.abort()
|
||||
const descriptionResult = description.result
|
||||
if (!descriptionResult.ok) {
|
||||
throw new Error(`host.describe failed: ${descriptionResult.error.code}: ${descriptionResult.error.message}`)
|
||||
}
|
||||
if (ac.signal.aborted) throw new Error('generation aborted during readiness handshake')
|
||||
this.attempt = 0
|
||||
this.emitState('connected')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// FixtureApi: standalone UI development without a server. Real contract shape: unary takes
|
||||
// RpcRequest<P> and returns RpcResponse<T> (echoing the rpcId); streams yield RpcRequest<frame>
|
||||
// (the fixture IS the fake server, so it mints frame rpcIds); root respond takes ClientResponse
|
||||
// and returns RpcReceipt. fx-alpha carries a hand-built history script (60 turns, pageable);
|
||||
// and returns RpcReceipt. fx-alpha carries a hand-built history script (74 turns, pageable);
|
||||
// prompt triggers a chunked streaming replay; cancel stops the replay; resident pending
|
||||
// approval/question requests exercise replay and composer takeover with stable rpcIds.
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
ToolResultMessage,
|
||||
UserMessage,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type {
|
||||
SessionEvent,
|
||||
SessionId,
|
||||
@@ -51,10 +52,10 @@ function userMessage(content: ContentBlock[], source: MessageSource = { kind: 'u
|
||||
return createUserMessage({ content, source })
|
||||
}
|
||||
|
||||
function assistantMessage(content: ContentBlock[]): AssistantMessage {
|
||||
function assistantMessage(content: ContentBlock[], model = 'fx-1'): AssistantMessage {
|
||||
return createAssistantMessage({
|
||||
content,
|
||||
source: { provider: 'fixture', model: 'fx-1' },
|
||||
source: { provider: 'fixture', model },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -330,6 +331,16 @@ function sid(id: string): SessionId {
|
||||
return id as SessionId
|
||||
}
|
||||
|
||||
const FIXTURE_IMAGE_DATA = 'iVBORw0KGgoAAAANSUhEUgAAAKAAAABaCAYAAAA/xl1SAAAAvklEQVR42u3SMQ0AAAjAMIyhELM4AAe8PD1qYFlk9cCXEAEDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGxIBCYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIAYEAyIAcGAGBAMiAHBgBgQDIgBwYAYEAyIAcGAGBAMiAHBgBgQDIgB4bYWLb6pnOb1xAAAAABJRU5ErkJggg=='
|
||||
const FIXTURE_IMAGE_REF: ImageAttachmentRef = {
|
||||
attachmentId: 'fixture:image' as AttachmentIdType,
|
||||
mediaType: 'image/png',
|
||||
bytes: 247,
|
||||
width: 160,
|
||||
height: 90,
|
||||
name: 'fixture-image.png',
|
||||
}
|
||||
|
||||
/** Deterministic provider billing attached to fixture assistant messages. */
|
||||
function fixtureUsage(turn: number, step: number): TokenUsage {
|
||||
return {
|
||||
@@ -340,7 +351,7 @@ function fixtureUsage(turn: number, step: number): TokenUsage {
|
||||
}
|
||||
}
|
||||
|
||||
/** fx-alpha history script: 60 turns (~130+ messages -> 3 pages at PAGE_MESSAGES=50),
|
||||
/** fx-alpha history script: 74 turns (~150+ messages -> 4 pages at PAGE_MESSAGES=50),
|
||||
* mixing reasoning blocks / tool call+result / context. */
|
||||
function buildAlphaLog(): SessionEvent[] {
|
||||
const events: Record<string, unknown>[] = []
|
||||
@@ -476,7 +487,7 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
push({ type: 'step/end', data: { turn, step: 0 } })
|
||||
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
|
||||
}
|
||||
// Turn 72: todo_write sample — the TodoRow toolview in the flow plus the
|
||||
// Turn 73: todo_write sample — the TodoRow toolview in the flow plus the
|
||||
// todo/write snapshot event feeding the TodoPanel plan strip. Two items are
|
||||
// in_progress: this fixture chooses the parallel policy, so both surfaces
|
||||
// must render a parallel plan rather than the first active item alone.
|
||||
@@ -535,8 +546,32 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
toolTurn(70, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.')
|
||||
toolTurn(71, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
|
||||
|
||||
// Turn 72: user and assistant images share one durable fixture object.
|
||||
// The todo turn remains last so its standing projection stays visible.
|
||||
push({ type: 'turn/start', data: { turn: 72 } })
|
||||
push({
|
||||
type: 'user/message',
|
||||
surfaceOp: 'append',
|
||||
data: userMessage([{ type: 'image', attachment: FIXTURE_IMAGE_REF }, ...text('历史用户图片')]),
|
||||
})
|
||||
push({ type: 'step/start', data: { turn: 72, step: 0 } })
|
||||
push({
|
||||
type: 'assistant/message',
|
||||
surfaceOp: 'append',
|
||||
data: {
|
||||
turn: 72,
|
||||
step: 0,
|
||||
message: assistantMessage(
|
||||
[...text('结构化模型图片:'), { type: 'image', attachment: FIXTURE_IMAGE_REF }],
|
||||
'fx-vision',
|
||||
),
|
||||
},
|
||||
})
|
||||
push({ type: 'step/end', data: { turn: 72, step: 0 } })
|
||||
push({ type: 'turn/end', data: { turn: 72, reason: { kind: 'completed' } } })
|
||||
|
||||
const todoArgs = JSON.stringify({ todos: fixtureTodos })
|
||||
toolTurn(72, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.')
|
||||
toolTurn(73, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.')
|
||||
// The real tool appends the snapshot mid-execution — between tool/call and
|
||||
// tool/result — so the fixture reproduces that exact ordering (the last
|
||||
// toolTurn events run ... tool/call, tool/result, step/end, turn/end).
|
||||
@@ -855,7 +890,6 @@ function estimateFixtureContent(blocks: readonly ContentBlock[]): number {
|
||||
// ContentBlockMap is merge-extensible: this client graph sees only the
|
||||
// base four members, but fixture turns do carry extended blocks at
|
||||
// runtime, so the structural JSON fallback below is live code.
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition -- the type collapses without the out-of-graph merges (see above).
|
||||
if (block.type === 'tool-result') {
|
||||
return tokens + estimateFixtureContent(block.content) + BLOCK_OVERHEAD
|
||||
}
|
||||
@@ -1057,6 +1091,18 @@ function pageOf(
|
||||
return { events, hasMore: start > 0 }
|
||||
}
|
||||
|
||||
/** Fixture mirror of host session-scoped attachment authorization. */
|
||||
function logReferencesAttachment(log: readonly SessionEvent[], attachmentId: string): boolean {
|
||||
const visit = (value: unknown): boolean => {
|
||||
if (Array.isArray(value)) return value.some(visit)
|
||||
if (typeof value !== 'object' || value === null) return false
|
||||
const record = value as Record<string, unknown>
|
||||
if (record.attachmentId === attachmentId) return true
|
||||
return Object.values(record).some(visit)
|
||||
}
|
||||
return log.some(event => visit(event.data))
|
||||
}
|
||||
|
||||
/** Fixture mirror of first-party message extraction used by session-query. */
|
||||
function searchBlockText(block: ContentBlock): string[] {
|
||||
switch (block.type) {
|
||||
@@ -1351,6 +1397,10 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
session.sessionId,
|
||||
{ provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
]))
|
||||
const attachments = new Map<string, { attachment: ImageAttachmentRef; data: string }>([[
|
||||
String(FIXTURE_IMAGE_REF.attachmentId),
|
||||
{ attachment: FIXTURE_IMAGE_REF, data: FIXTURE_IMAGE_DATA },
|
||||
]])
|
||||
/** Credential store double: set/unset flip the describe badge, values never read back. */
|
||||
const fixtureCredentials = new Map<string, true>([
|
||||
// The assembled fixture represents an already-configured shipped
|
||||
@@ -1368,7 +1418,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
['my-agent', { trust: 'user', content: "- id: tool-read\n name: '@deepseek-ai/dsh-tool-read'\n" }],
|
||||
])
|
||||
let fixtureDefaultPreset = 'standard'
|
||||
const nextTurn = new Map<SessionId, number>([[sid('fx-alpha'), 60]])
|
||||
const nextTurn = new Map<SessionId, number>([[sid('fx-alpha'), 74]])
|
||||
let nextSession = 1
|
||||
let nextRpc = 1
|
||||
let attachedSessions = options.empty ? 0 : 1
|
||||
@@ -2145,9 +2195,26 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
// First accepted prompt appends events: the summary stops being blank.
|
||||
summary.blank = false
|
||||
const userText = content.map(b => (b.type === 'text' ? b.text : '')).join('')
|
||||
const durable: ContentBlock[] = content.map((block) => {
|
||||
if (block.type === 'text') return block
|
||||
const attachment: ImageAttachmentRef = {
|
||||
attachmentId: `fixture:${randomUuid()}` as AttachmentIdType,
|
||||
mediaType: block.mediaType,
|
||||
bytes: Math.max(
|
||||
1,
|
||||
Math.floor(block.data.length * 3 / 4)
|
||||
- (block.data.endsWith('==') ? 2 : block.data.endsWith('=') ? 1 : 0),
|
||||
),
|
||||
width: 160,
|
||||
height: 90,
|
||||
...block.name === undefined ? {} : { name: block.name },
|
||||
}
|
||||
attachments.set(String(attachment.attachmentId), { attachment, data: block.data })
|
||||
return { type: 'image', attachment }
|
||||
})
|
||||
if (mode === 'steer' && replays.has(id)) {
|
||||
// Steering: the durable user/message lands inside the current turn; the replay continues.
|
||||
append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(content) })
|
||||
append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable) })
|
||||
return ok(request, { accepted: true as const })
|
||||
}
|
||||
const turn = nextTurn.get(id) ?? 0
|
||||
@@ -2160,7 +2227,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
if (plan.wanted !== null && plan.wanted !== plan.active) {
|
||||
append(id, { type: 'plan/mode', data: { active: plan.wanted } })
|
||||
}
|
||||
append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(content) })
|
||||
append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable) })
|
||||
// Capacity parallel of the host token-meter's request/context record:
|
||||
// log-only, appended inside the open turn, and deduplicated against the
|
||||
// route already recorded (the fixture never varies contextWindow).
|
||||
@@ -2186,6 +2253,27 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
)
|
||||
return ok(request, { accepted: true as const })
|
||||
},
|
||||
attachment: (request) => {
|
||||
const stored = attachments.get(String(request.payload.attachmentId))
|
||||
if (stored === undefined) {
|
||||
return err(request, {
|
||||
code: 'attachment-error',
|
||||
message: 'fixture attachment missing',
|
||||
details: { reason: 'ATTACHMENT_NOT_FOUND' },
|
||||
})
|
||||
}
|
||||
if (!logReferencesAttachment(
|
||||
logs.get(request.payload.sessionId) ?? [],
|
||||
String(request.payload.attachmentId),
|
||||
)) {
|
||||
return err(request, {
|
||||
code: 'attachment-error',
|
||||
message: 'fixture attachment is not referenced by this session',
|
||||
details: { reason: 'ATTACHMENT_NOT_REFERENCED' },
|
||||
})
|
||||
}
|
||||
return ok(request, stored)
|
||||
},
|
||||
updateQueue: request => err(request, {
|
||||
code: 'queue-item-not-found',
|
||||
message: 'fixture has no pending queue item',
|
||||
@@ -2262,15 +2350,14 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
archivedSessionIds: [...archivedSessionIds],
|
||||
}),
|
||||
create: (request) => {
|
||||
const { path, name } = request.payload
|
||||
const target = path ?? `/tmp/fixture-workspaces/${name ?? ''}`
|
||||
const existing = workspaces.find(w => w.path === target)
|
||||
const { path } = request.payload
|
||||
const existing = workspaces.find(w => w.path === path)
|
||||
if (existing !== undefined) return ok(request, { workspace: { ...existing }, created: false })
|
||||
const now = new Date().toISOString()
|
||||
const created: WorkspaceView = {
|
||||
workspaceId: wid(`fx-ws-${nextWorkspace++}`),
|
||||
path: target,
|
||||
title: name ?? target.split('/').filter(Boolean).at(-1) ?? target,
|
||||
path,
|
||||
title: path.split('/').filter(Boolean).at(-1) ?? path,
|
||||
sessionIds: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -2837,6 +2924,7 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'session.rename': return this.api.sessions.rename(request)
|
||||
case 'session.fork': return this.api.sessions.fork(request)
|
||||
case 'session.prompt': return this.api.sessions.prompt(request)
|
||||
case 'session.attachment': return this.api.sessions.attachment(request)
|
||||
case 'session.updateQueue': return this.api.sessions.updateQueue(request)
|
||||
case 'session.cancel': return this.api.sessions.cancel(request)
|
||||
case 'subagent.list': return this.api.subagents.list(request)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* the shared API client, and lets the runtime object layer start the stream
|
||||
* controller with its sinks.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { IApiClient } from './api.ts'
|
||||
import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts'
|
||||
import { FixtureApiClient } from './fixture.ts'
|
||||
@@ -14,7 +14,7 @@ import type { ClientConnectionRpc } from '../rpc.ts'
|
||||
|
||||
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
|
||||
export type {
|
||||
ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
|
||||
ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, PromptContentPart, HostApi, EventsApi, MuxFrame, HostFrame,
|
||||
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
|
||||
DirectoryEntry, DirectoryListing,
|
||||
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
@@ -25,7 +25,7 @@ export type {
|
||||
TaskView,
|
||||
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
|
||||
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
|
||||
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
|
||||
HostDescription, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
|
||||
GoalsApi, GoalRef,
|
||||
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
|
||||
CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi,
|
||||
|
||||
@@ -21,8 +21,14 @@ export interface FetchHandler {
|
||||
* @param req - incoming node:http request (fully read before dispatch).
|
||||
* @param res - node:http response the bridge writes and owns to completion.
|
||||
* @param apiHandler - fetch-shaped API carrier the request is dispatched to.
|
||||
* @param maxRequestBodyBytes - maximum body bytes buffered before dispatch.
|
||||
*/
|
||||
export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: FetchHandler): Promise<void> {
|
||||
export async function bridge(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
apiHandler: FetchHandler,
|
||||
maxRequestBodyBytes = 32 * 1024 * 1024,
|
||||
): Promise<void> {
|
||||
const abort = new AbortController()
|
||||
// Client-disconnect detection MUST hang off the response, not the request:
|
||||
// since Node 16, IncomingMessage 'close' fires as soon as the request body is
|
||||
@@ -32,8 +38,26 @@ export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandl
|
||||
res.on('close', () => {
|
||||
if (!res.writableEnded) abort.abort()
|
||||
})
|
||||
const declaredLength = req.headers['content-length']
|
||||
if (declaredLength !== undefined && Number(declaredLength) > maxRequestBodyBytes) {
|
||||
res.writeHead(413, { connection: 'close' })
|
||||
res.end()
|
||||
req.destroy()
|
||||
return
|
||||
}
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of req) chunks.push(chunk as Buffer)
|
||||
let received = 0
|
||||
for await (const chunk of req) {
|
||||
const buffer = chunk as Buffer
|
||||
received += buffer.byteLength
|
||||
if (received > maxRequestBodyBytes) {
|
||||
res.writeHead(413, { connection: 'close' })
|
||||
res.end()
|
||||
req.destroy()
|
||||
return
|
||||
}
|
||||
chunks.push(buffer)
|
||||
}
|
||||
/* v8 ignore next 3 -- `??` arms: node:http always sets url/method on server
|
||||
requests; the fields are only optional on the client-side IncomingMessage type */
|
||||
const request = new Request(new URL(req.url ?? '/', 'http://dsh.internal'), {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Host HTTP bridge for browser-client RPC. */
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import type {} from '@deepseek-ai/dsh-attachment'
|
||||
// Activates the httpServer Context merge used below.
|
||||
import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
@@ -25,6 +26,25 @@ export { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts'
|
||||
/** Stable Cordis plugin name. */
|
||||
export const name = 'client-connection'
|
||||
|
||||
/** Headroom for RPC JSON fields around aggregate base64 image payloads. */
|
||||
const REQUEST_ENVELOPE_HEADROOM_BYTES = 1024 * 1024
|
||||
|
||||
function assertImageBodyCapacity(ctx: Context, maxRequestBodyBytes: number): void {
|
||||
const attachments = ctx.get('attachments')
|
||||
if (attachments === undefined) return
|
||||
const requiredImageBodyBytes = Math.ceil(
|
||||
attachments.imageLimits.maxMessageImageBytes * 4 / 3,
|
||||
) + REQUEST_ENVELOPE_HEADROOM_BYTES
|
||||
if (maxRequestBodyBytes < requiredImageBodyBytes) {
|
||||
throw new Error(
|
||||
`client-connection maxRequestBodyBytes (${String(maxRequestBodyBytes)}) must be at least `
|
||||
+ `${String(requiredImageBodyBytes)} for the configured aggregate image limit`,
|
||||
)
|
||||
}
|
||||
}
|
||||
/** Default carrier cap for all HTTP RPC bodies. */
|
||||
const DEFAULT_MAX_REQUEST_BODY_BYTES = 32 * 1024 * 1024
|
||||
|
||||
/** Services required before providing Connection; API Proxy is an optional `/api` fallback. */
|
||||
export const inject = ['httpServer']
|
||||
|
||||
@@ -39,10 +59,13 @@ export interface ConnectionConfig {
|
||||
* that is not a bare, canonical authority fails the plugin load.
|
||||
*/
|
||||
trustedHosts?: string[]
|
||||
/** Maximum buffered JSON body for every `/api` request. */
|
||||
maxRequestBodyBytes?: number
|
||||
}
|
||||
|
||||
export const Config: z<ConnectionConfig> = z.object({
|
||||
trustedHosts: z.array(String).default([]),
|
||||
maxRequestBodyBytes: z.natural().min(1).default(DEFAULT_MAX_REQUEST_BODY_BYTES),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -109,9 +132,11 @@ const PRIVILEGED_METHODS = new Set([
|
||||
export function apply(ctx: Context, config?: ConnectionConfig): void {
|
||||
// The Loader resolves schema defaults; hand-built test contexts may pass none.
|
||||
const trustedHosts = config?.trustedHosts ?? []
|
||||
const maxRequestBodyBytes = config?.maxRequestBodyBytes ?? DEFAULT_MAX_REQUEST_BODY_BYTES
|
||||
// Config boundary: a malformed entry fails the load loudly here rather than
|
||||
// silently authorizing its hostname prefix at request time.
|
||||
for (const entry of trustedHosts) assertTrustedAuthority(entry)
|
||||
if (ctx.get('apiProxy') !== undefined) assertImageBodyCapacity(ctx, maxRequestBodyBytes)
|
||||
const connection = new HostConnectionService(ctx, trustedHosts)
|
||||
const fetchHandler = connection.createSharedFetchHandler(API_PATH, {
|
||||
async fetch(request) {
|
||||
@@ -144,11 +169,12 @@ export function apply(ctx: Context, config?: ConnectionConfig): void {
|
||||
res.end('forbidden')
|
||||
return
|
||||
}
|
||||
await bridge(req, res, fetchHandler)
|
||||
await bridge(req, res, fetchHandler, maxRequestBodyBytes)
|
||||
},
|
||||
}
|
||||
ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route')
|
||||
ctx.inject(['apiProxy'], (apiCtx) => {
|
||||
assertImageBodyCapacity(apiCtx, maxRequestBodyBytes)
|
||||
const downlinks = new WebSocketDownlinks(apiCtx.apiProxy)
|
||||
const registerDownlink = (
|
||||
path: string,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-connection'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Host registry and HTTP adapter for generic Connection RPC channels. */
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import {
|
||||
clientRequestSchema,
|
||||
@@ -32,7 +32,7 @@ interface ConnectionRpcInterceptor {
|
||||
readonly options: ConnectionRpcHandlerOptions
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** Host Connection transport and RPC registrations. */
|
||||
connection: HostConnectionHandle
|
||||
|
||||
Reference in New Issue
Block a user