fix(client): ride the fetch routes as browser-relative spellings
The client POSTed every plugin route as a root-absolute /api/dsh-context/* path, which stripped a reverse proxy's /app/dsh prefix at the gateway — every poll 404ed at it, the panel retried on an aggressive backoff, and the deployment fell into the issue #159 reconnect loop. The six route constants now live in one module as their browser-relative spellings: fetch resolves them against the page's document base, the same idiom dsh's own stream/socket clients ride, so a prefixed proxy keeps its prefix and a direct deployment resolves to the exact same URLs before and after. A host/client pairing spec pins each spelling to its route registration, and docs/compatibility.md gains the §5.8 client-seam rule. Fixes #159
This commit is contained in:
1 parent
8331602b0b
commit
f4db11776f
16 files changed
+85
-36
No files matched your search
@@ -76,6 +76,7 @@ Last verified: **2026-10-14** (dsh-context `0.70.0`, dsh `0.2.0-rc.2` npm instal
|
||||
5. **Re-prove everything at the boundary** — collections re-proven, scalars zeroed, whole-value absence stays `null` (the loading screen); structural narrowing over blind casts; skip elements that fail the shape instead of throwing. (`src/client/timelineSource.ts`)
|
||||
6. **Isolate per-item work over hostile objects**; every async read resolves to data or a retryable failure note — never an eternal spinner. (`src/client/timelineSource.ts`)
|
||||
7. **A pushed revision decrease resets the detail ledger** — stale reads never merge into the new head. (`src/client/timelineSource.ts`)
|
||||
8. **Fetch routes ride browser-relative spellings** (issue #159) — the client never issues a root-absolute `/api/...` URL: fetch resolves the relative form against the page's document base, so a deployment behind a path-prefixed reverse proxy (page at `/app/dsh/`) keeps its prefix, where a root-absolute spelling 404s at the gateway. (`src/client/apiRoutes.ts`)
|
||||
|
||||
- **Limitation — complete prompt sections:** a deployment registering a `complete` system-prompt section keeps ONLY that section (registry design); `promptPrefix`/`promptSuffix` are dropped there. (dsh: `packages/core/system-prompt`)
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/** The plugin's fetch routes, in their browser-relative spellings — the host halves register the
|
||||
* absolute `/api/...` paths under the harness's authenticated fence (host/detail.ts and siblings).
|
||||
* Re-declared here because the client bundle inlines every import and must never reach a host module.
|
||||
* Relative on purpose (docs/compatibility.md §5.8, issue #159): a root-absolute spelling would drop
|
||||
* a reverse proxy's path prefix at the gateway. */
|
||||
|
||||
export const DETAIL_ROUTE = 'api/dsh-context/detail'
|
||||
|
||||
export const BALANCE_ROUTE = 'api/dsh-context/balance'
|
||||
|
||||
export const FLEET_ROUTE = 'api/dsh-context/fleet'
|
||||
|
||||
export const SKILLS_ROUTE = 'api/dsh-context/skills'
|
||||
|
||||
export const BACKFILL_ROUTE = 'api/dsh-context/backfill'
|
||||
|
||||
export const ATTACHMENT_ROUTE = 'api/dsh-context/attachment'
|
||||
@@ -8,13 +8,10 @@
|
||||
|
||||
import { previewAddressOf } from './fileActivity'
|
||||
import type { AttachmentRef } from '../shared/attachment'
|
||||
import { ATTACHMENT_ROUTE } from './apiRoutes'
|
||||
import { asRecord, openResourceVia } from './services'
|
||||
import type { AttachmentLocator, AttachmentOpener, ClientCtx } from './services'
|
||||
|
||||
// The route of host/attachment.ts, re-declared here: the client bundle inlines every import and the
|
||||
// host module must never reach it (the detail route's own arrangement in timelineSource.ts).
|
||||
const ATTACHMENT_ROUTE = '/api/dsh-context/attachment'
|
||||
|
||||
/** Cache ceiling: cards are clicked one at a time, so a small FIFO holds a session's working set. */
|
||||
const CACHE_MAX = 32
|
||||
|
||||
|
||||
@@ -4,12 +4,9 @@
|
||||
* spinner or an error where a pill should be. */
|
||||
|
||||
import type { PlatformBalance, PlatformBalanceEntry } from '../shared/types'
|
||||
import { BALANCE_ROUTE } from './apiRoutes'
|
||||
import { asRecord } from './services'
|
||||
|
||||
// The balance route of host/balance.ts, re-declared because the client bundle inlines every import;
|
||||
// same-origin POST under the harness's authenticated `/api` fence.
|
||||
const BALANCE_ROUTE = '/api/dsh-context/balance'
|
||||
|
||||
const STORAGE_KEY = 'dsh-context:platform-balance'
|
||||
|
||||
export interface StorageFace {
|
||||
|
||||
@@ -9,12 +9,9 @@
|
||||
*/
|
||||
|
||||
import type { FleetDetail, FleetDescriptor, FleetMemberInfo, FleetMessage } from '../shared/types'
|
||||
import { FLEET_ROUTE } from './apiRoutes'
|
||||
import { asRecord, numOf } from './services'
|
||||
|
||||
// The route of host/fleet.ts, re-declared here: the client bundle inlines every import and the
|
||||
// host module must never reach it.
|
||||
const FLEET_ROUTE = '/api/dsh-context/fleet'
|
||||
|
||||
function strings(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value !== '' ? value : undefined
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import { workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
import { dayKeyOf, endOfDayKey, startOfDayKey } from '../shared/days'
|
||||
import { BACKFILL_ROUTE } from './apiRoutes'
|
||||
import { billedParts } from './categories'
|
||||
import { cacheHitPercent } from './format'
|
||||
import { estimateSessionCost, mergeCostUsage } from './cost'
|
||||
@@ -713,10 +714,6 @@ export function refreshSessions(ctx: ClientCtx): void {
|
||||
} catch { /* hostile service — no refresh */ }
|
||||
}
|
||||
|
||||
// The plugin's warm-up trigger route (host/backfill.ts), re-declared here: the
|
||||
// client bundle inlines every import and the host module must never reach it.
|
||||
const BACKFILL_ROUTE = '/api/dsh-context/backfill'
|
||||
|
||||
/** Summon the host's projection warm-up (host/backfill.ts): this page is
|
||||
* the rows' only reader, so the host defers the corpus-wide cold reads until
|
||||
* this surface first opens (one pass per host process). */
|
||||
|
||||
@@ -3,12 +3,9 @@
|
||||
* current session; a failed read renders the tallies unenriched. */
|
||||
|
||||
import type { SkillInfo } from '../shared/types'
|
||||
import { SKILLS_ROUTE } from './apiRoutes'
|
||||
import { asRecord } from './services'
|
||||
|
||||
// The skills route of host/skills.ts, re-declared here: the client bundle
|
||||
// inlines every import and the host module must never reach it.
|
||||
const SKILLS_ROUTE = '/api/dsh-context/skills'
|
||||
|
||||
/** Narrow the route's payload: the name is the join key and must prove itself; path and source ride only when proved. */
|
||||
export function skillInfoListOf(value: unknown): SkillInfo[] | null {
|
||||
const list = asRecord(value)?.skills
|
||||
|
||||
@@ -16,13 +16,10 @@
|
||||
|
||||
import { useEffect, useMemo, useSyncExternalStore } from 'react'
|
||||
import type { ContextTimeline, ContextTimelineDetail } from '../shared/types'
|
||||
import { DETAIL_ROUTE } from './apiRoutes'
|
||||
import type { SessionStandardProps } from './services'
|
||||
import { asRecord, collectionsOf, projectionOf, timelineOf } from './services'
|
||||
|
||||
// The detail route of host/detail.ts, re-declared here: the client bundle
|
||||
// inlines every import and the host module must never reach it.
|
||||
const DETAIL_ROUTE = '/api/dsh-context/detail'
|
||||
|
||||
/** Narrow the detail endpoint's payload; a missing or NaN revision rejects the
|
||||
* whole payload so the caller shows its retryable note, never half-merged data. */
|
||||
export function detailOf(value: unknown): ContextTimelineDetail | null {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, test } from 'vitest'
|
||||
import {
|
||||
ATTACHMENT_ROUTE,
|
||||
BACKFILL_ROUTE,
|
||||
BALANCE_ROUTE,
|
||||
DETAIL_ROUTE,
|
||||
FLEET_ROUTE,
|
||||
SKILLS_ROUTE,
|
||||
} from '../../src/client/apiRoutes'
|
||||
|
||||
describe('apiRoutes', () => {
|
||||
test('every fetch route stays browser-relative under the plugin namespace', () => {
|
||||
for (const route of [ATTACHMENT_ROUTE, BACKFILL_ROUTE, BALANCE_ROUTE, DETAIL_ROUTE, FLEET_ROUTE, SKILLS_ROUTE]) {
|
||||
assert.match(route, /^api\/dsh-context\/\w+$/, route)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -64,7 +64,7 @@ describe('makeAttachmentLocator', () => {
|
||||
assert.equal(await locate(FILE), ALIAS)
|
||||
assert.equal(await locate(FILE), ALIAS)
|
||||
assert.equal(calls.length, 1, 'the second read is served from the cache')
|
||||
assert.equal(calls[0].url, '/api/dsh-context/attachment')
|
||||
assert.equal(calls[0].url, 'api/dsh-context/attachment')
|
||||
assert.deepEqual(calls[0].body, { sessionId: 's1', attachmentId: 'sha256:f1' })
|
||||
})
|
||||
|
||||
|
||||
@@ -664,7 +664,7 @@ describe('AgentGraph — cold-relative composition fetch', () => {
|
||||
const m = await mount(h(View, { sessionId: 'root', self: selfStats() }))
|
||||
|
||||
// The cold relative fetched at mount; until the read lands it wears the pressure-only fill.
|
||||
assert.deepEqual(rpc.calls, ['/api/dsh-context/detail:done'])
|
||||
assert.deepEqual(rpc.calls, ['api/dsh-context/detail:done'])
|
||||
assert.equal(query(m.container, '[data-agent="done"]').querySelectorAll('.lc-agent-bar-seg').length, 1)
|
||||
|
||||
// A snapshot tick while the read is in flight re-attaches the SAME
|
||||
@@ -688,14 +688,14 @@ describe('AgentGraph — cold-relative composition fetch', () => {
|
||||
face.setState(family())
|
||||
})
|
||||
await flush()
|
||||
assert.deepEqual(rpc.calls, ['/api/dsh-context/detail:done'])
|
||||
assert.deepEqual(rpc.calls, ['api/dsh-context/detail:done'])
|
||||
|
||||
// A remount (tab switch) resets the instance state but replays the
|
||||
// factory-cached read: the bar recomposes with no new request.
|
||||
await m.unmount()
|
||||
const remounted = await mount(h(View, { sessionId: 'root', self: selfStats() }))
|
||||
await flush()
|
||||
assert.deepEqual(rpc.calls, ['/api/dsh-context/detail:done'], 'the cache never re-fetches')
|
||||
assert.deepEqual(rpc.calls, ['api/dsh-context/detail:done'], 'the cache never re-fetches')
|
||||
const replayed = query(remounted.container, '[data-agent="done"]')
|
||||
assert.equal(replayed.querySelectorAll('.lc-agent-bar-seg').length, 6, 'the cached head replays into the fresh instance')
|
||||
await remounted.unmount()
|
||||
@@ -706,14 +706,14 @@ describe('AgentGraph — cold-relative composition fetch', () => {
|
||||
const { View, face } = makeFetchingView()
|
||||
const m = await mount(h(View, { sessionId: 'root', self: selfStats() }))
|
||||
await flush()
|
||||
assert.deepEqual(rpc.calls, ['/api/dsh-context/detail:done'])
|
||||
assert.deepEqual(rpc.calls, ['api/dsh-context/detail:done'])
|
||||
assert.equal(query(m.container, '[data-agent="done"]').querySelectorAll('.lc-agent-bar-seg').length, 1)
|
||||
// A later snapshot tick (fresh forest, still pressure-only) re-fetches nothing.
|
||||
await act(async () => {
|
||||
face.setState(family())
|
||||
})
|
||||
await flush()
|
||||
assert.deepEqual(rpc.calls, ['/api/dsh-context/detail:done'], 'the sticky failure never re-fetches')
|
||||
assert.deepEqual(rpc.calls, ['api/dsh-context/detail:done'], 'the sticky failure never re-fetches')
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
@@ -723,7 +723,7 @@ describe('AgentGraph — cold-relative composition fetch', () => {
|
||||
const { View } = makeFetchingView()
|
||||
const m = await mount(h(View, { sessionId: 'root', self: selfStats() }))
|
||||
await flush()
|
||||
assert.deepEqual(rpc.calls, ['/api/dsh-context/detail:done'])
|
||||
assert.deepEqual(rpc.calls, ['api/dsh-context/detail:done'])
|
||||
assert.equal(query(m.container, '[data-agent="done"]').querySelectorAll('.lc-agent-bar-seg').length, 1)
|
||||
assert.ok(text(query(m.container, '[data-agent="done"]')).includes('95%'))
|
||||
await m.unmount()
|
||||
@@ -762,7 +762,7 @@ describe('AgentGraph — cold-relative composition fetch', () => {
|
||||
const View = makeAgentGraph(asClientCtx(ctx), kit)
|
||||
const m = await mount(h(View, { sessionId: 'root', self: selfStats() }))
|
||||
assert.equal(query(m.container, '[data-agent="cold"]').querySelectorAll('.lc-agent-bar-seg').length, 0)
|
||||
assert.deepEqual(rpc.calls, ['/api/dsh-context/detail:cold'])
|
||||
assert.deepEqual(rpc.calls, ['api/dsh-context/detail:cold'])
|
||||
|
||||
await act(async () => {
|
||||
rpc.release({ ok: true, status: 200, json: async () => ({ ok: true, value: detailValue(composedHead()) }) })
|
||||
|
||||
@@ -135,7 +135,7 @@ describe('OverviewPanel', () => {
|
||||
const ctx = makeCtx({ refresh: () => { pulls++; return Promise.resolve() } })
|
||||
const { m } = await openPanel(ctx)
|
||||
assert.equal(pulls, 1, 'the baseline re-pull fires on mount')
|
||||
assert.deepEqual(backfillPosts, ['/api/dsh-context/backfill'], 'the warm-up trigger POST fires on mount')
|
||||
assert.deepEqual(backfillPosts, ['api/dsh-context/backfill'], 'the warm-up trigger POST fires on mount')
|
||||
assert.ok(text(m.container).includes('Context Insights'))
|
||||
// The page chrome: the section's one scroll region carries the centered
|
||||
// content column — the page heading, then the 1:1 first row (the KPI
|
||||
|
||||
@@ -240,7 +240,7 @@ describe('requestActivityBackfill', () => {
|
||||
return { ok: true }
|
||||
})
|
||||
requestActivityBackfill()
|
||||
assert.deepEqual(calls, [['/api/dsh-context/backfill', { method: 'POST' }]])
|
||||
assert.deepEqual(calls, [['api/dsh-context/backfill', { method: 'POST' }]])
|
||||
vi.stubGlobal('fetch', async () => Promise.reject(new Error('route absent')))
|
||||
requestActivityBackfill()
|
||||
vi.stubGlobal('fetch', () => { throw new Error('hostile transport') })
|
||||
|
||||
@@ -66,7 +66,7 @@ describe('readSkillCatalog', () => {
|
||||
},
|
||||
})
|
||||
const catalog = await readSkillCatalog('/repo/alpha', 's1')
|
||||
assert.deepEqual(urls, ['/api/dsh-context/skills'])
|
||||
assert.deepEqual(urls, ['api/dsh-context/skills'])
|
||||
assert.deepEqual(bodies, [{ cwd: '/repo/alpha', sessionId: 's1' }])
|
||||
assert.equal(catalog?.size, 2)
|
||||
assert.deepEqual(catalog?.get('tdd'), { name: 'tdd', description: 'Test-driven', path: '/p/tdd/SKILL.md', source: 'user-agents' })
|
||||
|
||||
@@ -156,7 +156,7 @@ describe('makeDetailFetcher', () => {
|
||||
})
|
||||
const d = await makeDetailFetcher('s1')!()
|
||||
assert.equal(d?.rev, 4)
|
||||
assert.equal(seen.url, '/api/dsh-context/detail')
|
||||
assert.equal(seen.url, 'api/dsh-context/detail')
|
||||
assert.deepEqual(seen.body, { sessionId: 's1' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, test } from 'vitest'
|
||||
import { BALANCE_ROUTE as HOST_BALANCE_ROUTE } from '../../src/host/balance'
|
||||
import { DETAIL_ROUTE as HOST_DETAIL_ROUTE } from '../../src/host/detail'
|
||||
import { ATTACHMENT_ROUTE as HOST_ATTACHMENT_ROUTE } from '../../src/host/attachment'
|
||||
import { BACKFILL_ROUTE as HOST_BACKFILL_ROUTE } from '../../src/host/backfill'
|
||||
import { FLEET_ROUTE as HOST_FLEET_ROUTE } from '../../src/host/fleet'
|
||||
import { SKILLS_ROUTE as HOST_SKILLS_ROUTE } from '../../src/host/skills'
|
||||
import {
|
||||
ATTACHMENT_ROUTE,
|
||||
BACKFILL_ROUTE,
|
||||
BALANCE_ROUTE,
|
||||
DETAIL_ROUTE,
|
||||
FLEET_ROUTE,
|
||||
SKILLS_ROUTE,
|
||||
} from '../../src/client/apiRoutes'
|
||||
|
||||
describe('apiRoutePairing', () => {
|
||||
test('each client fetch route is its host registration minus the leading slash', () => {
|
||||
for (const [host, client] of [
|
||||
[HOST_DETAIL_ROUTE, DETAIL_ROUTE],
|
||||
[HOST_BALANCE_ROUTE, BALANCE_ROUTE],
|
||||
[HOST_FLEET_ROUTE, FLEET_ROUTE],
|
||||
[HOST_SKILLS_ROUTE, SKILLS_ROUTE],
|
||||
[HOST_BACKFILL_ROUTE, BACKFILL_ROUTE],
|
||||
[HOST_ATTACHMENT_ROUTE, ATTACHMENT_ROUTE],
|
||||
]) {
|
||||
assert.equal(client, host.slice(1), `${host} and ${client} lost their pairing`)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user