diff --git a/docs/compatibility.md b/docs/compatibility.md index 67140e6..6ffab0e 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -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`) diff --git a/src/client/apiRoutes.ts b/src/client/apiRoutes.ts new file mode 100644 index 0000000..7951b5e --- /dev/null +++ b/src/client/apiRoutes.ts @@ -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' diff --git a/src/client/attachmentSource.ts b/src/client/attachmentSource.ts index 5bbaa08..56e1993 100644 --- a/src/client/attachmentSource.ts +++ b/src/client/attachmentSource.ts @@ -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 diff --git a/src/client/balance.ts b/src/client/balance.ts index 818710e..8c2094b 100644 --- a/src/client/balance.ts +++ b/src/client/balance.ts @@ -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 { diff --git a/src/client/fleetDetail.ts b/src/client/fleetDetail.ts index ce88f08..9f1b07c 100644 --- a/src/client/fleetDetail.ts +++ b/src/client/fleetDetail.ts @@ -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 } diff --git a/src/client/overview.ts b/src/client/overview.ts index 2deb994..cfc54b8 100644 --- a/src/client/overview.ts +++ b/src/client/overview.ts @@ -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). */ diff --git a/src/client/skills.ts b/src/client/skills.ts index fc0bd30..37a5eca 100644 --- a/src/client/skills.ts +++ b/src/client/skills.ts @@ -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 diff --git a/src/client/timelineSource.ts b/src/client/timelineSource.ts index 563dacd..23e9518 100644 --- a/src/client/timelineSource.ts +++ b/src/client/timelineSource.ts @@ -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 { diff --git a/tests/client/apiRoutes.spec.ts b/tests/client/apiRoutes.spec.ts new file mode 100644 index 0000000..0861b25 --- /dev/null +++ b/tests/client/apiRoutes.spec.ts @@ -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) + } + }) +}) diff --git a/tests/client/attachmentSource.spec.ts b/tests/client/attachmentSource.spec.ts index 9b894c7..aa598e6 100644 --- a/tests/client/attachmentSource.spec.ts +++ b/tests/client/attachmentSource.spec.ts @@ -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' }) }) diff --git a/tests/client/components/agentGraph.spec.ts b/tests/client/components/agentGraph.spec.ts index 302d8dc..9c324d7 100644 --- a/tests/client/components/agentGraph.spec.ts +++ b/tests/client/components/agentGraph.spec.ts @@ -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()) }) }) diff --git a/tests/client/components/overviewPanel.spec.ts b/tests/client/components/overviewPanel.spec.ts index 7f7ae49..36225f4 100644 --- a/tests/client/components/overviewPanel.spec.ts +++ b/tests/client/components/overviewPanel.spec.ts @@ -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 diff --git a/tests/client/overview.sessions.spec.ts b/tests/client/overview.sessions.spec.ts index d1f5da2..838bd96 100644 --- a/tests/client/overview.sessions.spec.ts +++ b/tests/client/overview.sessions.spec.ts @@ -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') }) diff --git a/tests/client/skills.spec.ts b/tests/client/skills.spec.ts index 4122073..def74fe 100644 --- a/tests/client/skills.spec.ts +++ b/tests/client/skills.spec.ts @@ -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' }) diff --git a/tests/client/timelineSource.spec.ts b/tests/client/timelineSource.spec.ts index 3d0eb23..b7aedfa 100644 --- a/tests/client/timelineSource.spec.ts +++ b/tests/client/timelineSource.spec.ts @@ -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' }) }) }) diff --git a/tests/host/apiRoutes.spec.ts b/tests/host/apiRoutes.spec.ts new file mode 100644 index 0000000..08903c9 --- /dev/null +++ b/tests/host/apiRoutes.spec.ts @@ -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`) + } + }) +})