feat!: move to the alpha track — DSH 0.1.2-alpha.x only (v0.18.0-alpha.0)

DSH itself is still on its 0.1.2-alpha line (npm `alpha` dist-tag), so the
plugin mirrors it: v0.18.0-alpha.0 publishes under the npm `alpha`
dist-tag and supports DSH 0.1.2-alpha.x only. Stable-DSH (0.1.0-rc.8 ~
0.1.1-rc.2) users stay on v0.17.1 (npm `latest`). Verified end-to-end
against a real 0.1.2-alpha.2 host: full vitest suite, typecheck,
build/pack, consumer type surface, and the headless mount smoke 14/14
(the prerelease version also passes `dsh plugin add file:`).

- peer floor raised `^0.1.0-rc.8` -> `^0.1.2-alpha.2` (the mount install
  no longer warns: the old floor could not semver-match a prerelease
  host); dsh.plugin.json engines.dsh `>=0.1.2-alpha.2`.
- Dropped the pre-alpha compatibility layer:
  - e2e host RPC collapsed from the dot/slash dual dialect to slash-only
    (rpcAttempt; the 404-probe negotiation and its cache are gone), and
    parseLaunchUrl now requires the ?token= launch URL;
  - MarkdownText labels collapsed to the nested single shape (the cast
    became unnecessary against the alpha.2 declarations);
  - the defunct `@deepseek-ai/dsh-client-runtime` row is gone from the
    chunk externals allowlist (chunk-loader / tsdown / two specs) and
    from dsh.client.inject.
- market-manifest guard now allows an exact prerelease version (alpha
  cuts are not the market's verified `latest` target); release.yml picks
  the npm dist-tag from the version (prerelease -> `alpha`, else
  `latest`) so an alpha can never hijack `latest`.
- Docs retargeted: AGENTS.md §2/§3, README/README_EN badges + v0.18.0
  -alpha.0 section + install note (`dsh-better-sidebar@alpha`), guide.
This commit is contained in:
Menghuan1918
2026-08-30 23:42:16 +08:00
parent f9e00bb0d0
commit 07eebbbad0
17 changed files with 222 additions and 312 deletions
-1
View File
@@ -417,7 +417,6 @@ describe('externals contract', () => {
'cordis',
'@deepseek-ai/dsh-client-ui-slots',
'@deepseek-ai/dsh-client-ui-primitives',
'@deepseek-ai/dsh-client-runtime/client',
])
})
})
+35 -83
View File
@@ -1,23 +1,22 @@
/**
* Unit tests for the e2e lanes' host-transport contracts (tests/e2e/
* host-protocol.ts + host.ts), locking in the two DSH web dialects the lanes
* must speak:
* host-protocol.ts + host.ts), locking in the DSH 0.1.2-alpha web dialect
* the lanes speak:
*
* - 0.1.1-rc.x: bare-origin launch URL, no browser auth, ApiProxy dot
* endpoints (`POST /api/workspace.create`, payload = the bare args).
* - 0.1.2-alpha.1+ (Remote gateway + one-time-token browser auth):
* `/?token=<43 chars>` launch URL (303 → signed cookie), slash endpoints
* (`POST /api/workspace/create`, payload must be exactly `{ args }`, the
* envelope method equal to the path endpoint; a dot path is no longer
* claimed → 404).
* - one-time-token browser auth: `/?token=<43 chars>` launch URL
* (303 → signed cookie; a token-less URL is a pre-alpha host and must
* fail loudly),
* - Remote-gateway slash endpoints (`POST /api/workspace/create`, payload
* must be exactly `{ args }` keyed by the controller's parameter name,
* the envelope method equal to the path endpoint).
*
* The shapes were transcribed from the deepseek-harness sources at tags
* dsh-v0.1.1-rc.2 and dsh-v0.1.2-alpha.1 (packages/client/connection,
* packages/api/gateway, packages/bundle/web-app).
* The shapes were transcribed from the deepseek-harness sources at tag
* dsh-v0.1.2-alpha.1 and re-verified on the npm-published 0.1.2-alpha.2
* (packages/client/connection, packages/api/gateway, packages/bundle/web-app).
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeAll, describe, expect, it, vi } from 'vitest'
import type { APIRequestContext } from '@playwright/test'
import { parseLaunchUrl, pageUrlWith, rpcAttempts } from './e2e/host-protocol'
import { parseLaunchUrl, pageUrlWith, rpcAttempt } from './e2e/host-protocol'
const BARE_URL = 'http://127.0.0.1:4199'
const TOKEN_URL = 'http://127.0.0.1:4199/?token=AbCdEf0123456789_-AbCdEf0123456789_-AbCd'
@@ -27,32 +26,24 @@ const TOKEN_URL = 'http://127.0.0.1:4199/?token=AbCdEf0123456789_-AbCdEf01234567
let host: typeof import('./e2e/host')
beforeAll(async () => {
vi.stubEnv('DSH_E2E_URL', BARE_URL)
vi.stubEnv('DSH_E2E_URL', TOKEN_URL)
host = await import('./e2e/host')
})
describe('host-protocol: launch URL parsing', () => {
it('splits a bare-origin launch URL (0.1.1-rc.x hosts)', () => {
const launch = parseLaunchUrl(BARE_URL)
expect(launch.origin).toBe(BARE_URL)
expect(launch.pageUrl).toBe(BARE_URL)
expect(launch.token).toBeUndefined()
})
it('extracts the one-time token of an authenticated launch URL (0.1.2-alpha.1+)', () => {
it('extracts the one-time token of an authenticated launch URL', () => {
const launch = parseLaunchUrl(TOKEN_URL)
expect(launch.origin).toBe(BARE_URL)
expect(launch.pageUrl).toBe(TOKEN_URL)
expect(launch.token).toBe('AbCdEf0123456789_-AbCdEf0123456789_-AbCd')
})
it('rejects a token-less launch URL (a pre-0.1.2-alpha host the plugin no longer supports)', () => {
expect(() => parseLaunchUrl(BARE_URL)).toThrow(/no \?token=/)
})
})
describe('host-protocol: page URL query merge', () => {
it('appends stamps to a bare-origin URL', () => {
expect(pageUrlWith(BARE_URL, { 'dsh-desktop-mode': 'advanced' }))
.toBe(`${BARE_URL}/?dsh-desktop-mode=advanced`)
})
it('merges stamps into a token URL without breaking the token (never append ?…?…)', () => {
const merged = pageUrlWith(TOKEN_URL, { 'dsh-desktop-mode': 'advanced', 'dsh-desktop-platform': 'win32' })
const parsed = new URL(merged)
@@ -63,45 +54,33 @@ describe('host-protocol: page URL query merge', () => {
})
})
describe('host-protocol: dual-dialect RPC attempts', () => {
it('orders the 0.1.1-rc.x dot dialect first (published hosts never pay a probe)', () => {
describe('host-protocol: slash RPC attempts', () => {
it('targets the slash endpoint with args wrapped by the parameter name', () => {
const args = { path: '/tmp/w' }
const [dot, slash] = rpcAttempts('workspace.create', args)
expect(dot).toEqual({
protocol: 'dot',
path: '/api/workspace.create',
method: 'workspace.create',
payload: args,
})
// The dot payload is the BARE args object (same reference — no wrapper).
expect(dot!.payload).toBe(args)
expect(slash).toEqual({
protocol: 'slash',
expect(rpcAttempt('workspace.create', args)).toEqual({
path: '/api/workspace/create',
method: 'workspace/create',
payload: { args: { request: args } },
})
})
it('keys slash args by the parameter name — session/list is literally `_request` (verified on a live 0.1.2-alpha.1 host)', () => {
it('keys args by the parameter name — session/list is literally `_request` (verified on a live 0.1.2-alpha host)', () => {
// typert gateway: session/list declares `_request` and rejects `{}` /
// `{request:{}}` — the wrapper table must reproduce the exact shape.
expect(rpcAttempts('session.list', {})[1]).toEqual({
protocol: 'slash',
expect(rpcAttempt('session.list', {})).toEqual({
path: '/api/session/list',
method: 'session/list',
payload: { args: { _request: {} } },
})
expect(rpcAttempts('session.create', { workspaceId: 'w' })[1]).toEqual({
protocol: 'slash',
expect(rpcAttempt('session.create', { workspaceId: 'w' })).toEqual({
path: '/api/session/create',
method: 'session/create',
payload: { args: { request: { workspaceId: 'w' } } },
})
})
it('fails loudly for methods without a verified slash args key', () => {
expect(() => rpcAttempts('settings.describe', {})).toThrow(/no slash-dialect args key/)
it('fails loudly for methods without a verified args key', () => {
expect(() => rpcAttempt('settings.describe', {})).toThrow(/no args key/)
})
})
@@ -136,43 +115,16 @@ function stubApi(responses: StubResponse[]): APIRequestContext & { paths: string
return api as unknown as APIRequestContext & { paths: string[]; bodies: unknown[] }
}
describe('host glue: hostRpc dialect negotiation', () => {
beforeEach(() => {
host.resetHostRpcForTests()
})
it('uses the dot dialect directly when the host answers it (0.1.1-rc.x)', async () => {
const api = stubApi([{ status: 200, body: { type: 'server-response', rpcId: 'x', result: { ok: true, value: { items: [] } } } }])
const first = await host.hostRpc<{ items: unknown[] }>(api, 'session.list', {})
expect(first.value.items).toEqual([])
// Second call must NOT probe again: the resolved dialect is cached.
await host.hostRpc(api, 'session.list', {})
expect(api.paths).toEqual(['/api/session.list', '/api/session.list'])
})
it('falls back to the slash dialect on 404 and caches it (0.1.2-alpha.1+)', async () => {
const api = stubApi([
{ status: 404, body: { error: 'not found' } },
{ status: 200, body: { type: 'server-response', rpcId: 'x', result: { ok: true, value: { sessionId: 'session-1' } } } },
])
describe('host glue: hostRpc', () => {
it('posts the Remote-gateway envelope to the slash endpoint and unwraps the result', async () => {
const api = stubApi([{ status: 200, body: { type: 'server-response', rpcId: 'x', result: { ok: true, value: { sessionId: 'session-1' } } } }])
const seeded = await host.hostRpc<{ sessionId: string }>(api, 'session.create', { workspaceId: 'w' })
expect(seeded.value.sessionId).toBe('session-1')
expect(api.paths).toEqual(['/api/session.create', '/api/session/create'])
// The slash attempt carries the Remote-gateway payload shape: args keyed
// by the controller's parameter name (`request` for session/create),
// method equal to the path endpoint.
const slashEnvelope = api.bodies[1] as { type: string; method: string; payload: Record<string, unknown> }
expect(slashEnvelope.type).toBe('client-request')
expect(slashEnvelope.method).toBe('session/create')
expect(slashEnvelope.payload).toEqual({ args: { request: { workspaceId: 'w' } } })
// Cached: the next call goes straight to the slash endpoint.
await host.hostRpc(api, 'session.list', {})
expect(api.paths[2]).toBe('/api/session/list')
})
it('fails loudly when neither dialect is claimed', async () => {
const api = stubApi([{ status: 404, body: {} }])
await expect(host.hostRpc(api, 'workspace.create', { path: '/tmp/w' })).rejects.toThrow(/no dialect answered/)
expect(api.paths).toEqual(['/api/session/create'])
const envelope = api.bodies[0] as { type: string; method: string; payload: Record<string, unknown> }
expect(envelope.type).toBe('client-request')
expect(envelope.method).toBe('session/create')
expect(envelope.payload).toEqual({ args: { request: { workspaceId: 'w' } } })
})
it('surfaces an envelope error instead of returning it', async () => {
+49 -57
View File
@@ -1,28 +1,28 @@
/**
* Pure host-transport contracts for the e2e lanes, spanning the DSH
* 0.1.1-rc.x → 0.1.2-alpha.1 breaking split (Remote gateway + one-time-token
* browser auth). No Playwright imports here — these shapes are unit-testable
* Pure host-transport contracts for the e2e lanes against the DSH
* 0.1.2-alpha.x web host (Remote gateway + one-time-token browser auth). No
* Playwright imports here — these shapes are unit-testable
* (tests/e2e-host-protocol.spec.ts); ./host.ts wires them to a request
* context.
*
* What changed in 0.1.2-alpha.1 (deepseek-harness tag dsh-v0.1.2-alpha.1):
* Host contract (deepseek-harness tags dsh-v0.1.2-alpha.1/2, verified on
* live hosts; the pre-alpha dual-dialect layer was dropped in the plugin's
* v0.18.0-alpha.0 alpha track):
*
* - `dsh web` prints an AUTHENTICATED launch URL — `dsh web:
* http://127.0.0.1:<port>/?token=<43 base64url chars>`. Navigating it
* exchanges the token for a signed cookie (HttpOnly, SameSite=Strict) that
* every `/api` request must then carry; the clean URL answers 401. Older
* hosts print a bare origin and have no browser auth.
* - The legacy ApiProxy dot-method endpoints (`POST /api/workspace.create`
* with `payload: <args>`) were replaced by the Remote gateway's slash
* endpoints — `POST /api/workspace/create` with `payload: { args }` whose
* args object is keyed by the controller's TypeScript PARAMETER name
* (`workspace/create` → `{ request: {...} }`; `session/list`'s unused
* parameter is literally `_request` and is not omissible); a dot path is
* no longer claimed (404). The `{type:'client-request', rpcId, method,
* payload}` / `{type:'server-response', rpcId, result}` envelopes are
* unchanged, and the plugin's own `/sidebar/*` routes stay public (the
* webserver carrier owns no authentication — only `/api`, the index HTML,
* and the remote mux upgrade sit behind the browser auth).
* every `/api` request must then carry; the clean URL answers 401.
* - The Remote gateway serves SLASH endpoints — `POST /api/workspace/create`
* with `payload: { args }` whose args object is keyed by the controller's
* TypeScript PARAMETER name (`workspace/create` → `{ request: {...} }`;
* `session/list`'s unused parameter is literally `_request` and is NOT
* omissible); the legacy dot path is no longer claimed (404). The
* `{type:'client-request', rpcId, method, payload}` /
* `{type:'server-response', rpcId, result}` envelopes are unchanged, and
* the plugin's own `/sidebar/*` routes stay public (the webserver carrier
* owns no authentication — only `/api`, the index HTML, and the remote mux
* upgrade sit behind the browser auth).
*/
/** The pieces of a `dsh web` launch URL the lanes need. */
@@ -30,22 +30,23 @@ export interface LaunchUrl {
/** Scheme + host + port — the base for URL construction (a token URL would
* corrupt path concatenation). */
origin: string
/** The URL as printed (token query included on 0.1.2-alpha.1+); the correct
* page.goto target — navigating it performs the token→cookie exchange. */
/** The URL as printed (token query included) — the correct page.goto
* target; navigating it performs the token→cookie exchange. */
pageUrl: string
/** The one-time launch token, when the host printed one. */
token: string | undefined
/** The one-time launch token. */
token: string
}
/** Split a `dsh web` launch URL (bare origin on 0.1.1-rc.x, `/?token=` on
* 0.1.2-alpha.1+) into the pieces the lanes address. */
/** Split a `dsh web` launch URL (`/?token=` — every supported host prints
* one) into the pieces the lanes address. Throws without a token: a bare
* origin is a pre-0.1.2-alpha host this plugin no longer supports. */
export function parseLaunchUrl(raw: string): LaunchUrl {
const url = new URL(raw)
return {
origin: url.origin,
pageUrl: raw,
token: url.searchParams.get('token') ?? undefined,
const token = url.searchParams.get('token')
if (token === null) {
throw new Error(`launch URL carries no ?token= — not a DSH 0.1.2-alpha+ authenticated launch URL: ${raw}`)
}
return { origin: url.origin, pageUrl: raw, token }
}
/** Page navigation URL with extra query stamps (e.g. desktop-shell URL
@@ -57,48 +58,39 @@ export function pageUrlWith(raw: string, extra: Record<string, string>): string
return url.toString()
}
/** One host-RPC protocol attempt. */
/** One host-RPC request. */
export interface RpcAttempt {
/** Which host dialect this attempt speaks. */
protocol: 'dot' | 'slash'
/** Request path, relative to the origin, starting with `/api/`. */
path: string
/** Envelope `method` string — must equal the endpoint the path selects. */
method: string
/** Envelope `payload` — the bare args object on dot hosts, `{ args }` on
* slash hosts (the gateway rejects any other payload shape). */
/** Envelope `payload` — `{ args }` keyed by the controller parameter name
* (the gateway rejects any other payload shape). */
payload: Record<string, unknown>
}
/** The slash-dialect args key per host method: the 0.1.2-alpha.1 Remote
* controllers key the args object by the TypeScript PARAMETER name, not by
* the endpoint's own naming — workspace/create and session/create declare a
* single `request` parameter, while session/list's unused parameter is
* literally named `_request` (and is NOT omissible: the typert gateway
* rejects `{}` and `{request:{}}` alike). Verified against a live
* dsh-v0.1.2-alpha.1 host: every other shape fails with `args fields do not
* match the descriptor`. */
const SLASH_ARGS_KEY: Record<string, string> = {
/** The args key per host method: the 0.1.2-alpha Remote controllers key the
* args object by the TypeScript PARAMETER name, not by the endpoint's own
* naming — workspace/create and session/create declare a single `request`
* parameter, while session/list's unused parameter is literally named
* `_request` (and is NOT omissible: the typert gateway rejects `{}` and
* `{request:{}}` alike). Verified against live dsh-v0.1.2-alpha hosts:
* every other shape fails with `args fields do not match the descriptor`. */
const RPC_ARGS_KEY: Record<string, string> = {
'workspace.create': 'request',
'session.create': 'request',
'session.list': '_request',
}
/** Build the two dialects of one host RPC (method in dot form, e.g.
* `'workspace.create'`): the 0.1.1-rc.x dot endpoint first — the dialect
* every currently-published host speaks, so the deployed lanes never pay a
* probe round-trip — then the 0.1.2-alpha.1+ slash endpoint as the 404
* fallback. Throws for methods without a known slash args key: the wrapper
* is per-parameter, so an unverified shape must fail loudly at build time
* instead of as a confusing gateway error mid-lane. */
export function rpcAttempts(method: string, args: Record<string, unknown>): RpcAttempt[] {
const slashKey = SLASH_ARGS_KEY[method]
if (slashKey === undefined) {
throw new Error(`host-protocol: no slash-dialect args key for ${JSON.stringify(method)} — verify the parameter name on the host and extend SLASH_ARGS_KEY`)
/** Build one host RPC request (method in dot form, e.g.
* `'workspace.create'`). Throws for methods without a known args key: the
* wrapper is per-parameter, so an unverified shape must fail loudly at
* build time instead of as a confusing gateway error mid-lane. */
export function rpcAttempt(method: string, args: Record<string, unknown>): RpcAttempt {
const key = RPC_ARGS_KEY[method]
if (key === undefined) {
throw new Error(`host-protocol: no args key for ${JSON.stringify(method)} — verify the parameter name on the host and extend RPC_ARGS_KEY`)
}
const slash = method.split('.').join('/')
return [
{ protocol: 'dot', path: `/api/${method}`, method, payload: args },
{ protocol: 'slash', path: `/api/${slash}`, method: slash, payload: { args: { [slashKey]: args } } },
]
const endpoint = method.split('.').join('/')
return { path: `/api/${endpoint}`, method: endpoint, payload: { args: { [key]: args } } }
}
+42 -62
View File
@@ -1,21 +1,18 @@
/**
* Host glue for the e2e lanes: page/asset URL construction, the authenticated
* request context, and the dual-protocol host RPC. The cross-version shapes
* (bare origin vs one-time-token URL, dot vs slash `/api` endpoints) live in
* ./host-protocol.ts; this module only wires them to Playwright.
* request context, and the host RPC. The transport contracts (one-time-token
* launch URL, slash `/api` endpoints) live in ./host-protocol.ts; this
* module only wires them to Playwright.
*
* Every lane consumes the same contract regardless of host version:
* Every lane consumes the same contract:
* - `PAGE_URL` / `pageUrl({...})` for page.goto (the token URL performs the
* browser's token→cookie exchange on 0.1.2-alpha.1+; a bare origin is a
* no-op on older hosts),
* browser's token→cookie exchange),
* - `sidebarApi(path)` for the plugin's own public routes,
* - `createHostApi()` for an APIRequestContext that carries the auth cookie
* when the host printed a token,
* - `hostRpc(api, 'workspace.create', {...})` for the host's unary RPC —
* dot endpoint first, slash endpoint on 404 fallback (see host-protocol).
* - `createHostApi()` for an APIRequestContext carrying the auth cookie,
* - `hostRpc(api, 'workspace.create', {...})` for the host's unary RPC.
*/
import { request, type APIRequestContext, type Page } from '@playwright/test'
import { parseLaunchUrl, pageUrlWith, rpcAttempts } from './host-protocol'
import { parseLaunchUrl, pageUrlWith, rpcAttempt } from './host-protocol'
const envUrl = process.env.DSH_E2E_URL
if (!envUrl) {
@@ -30,8 +27,8 @@ const LAUNCH = parseLaunchUrl(RAW_URL)
/** Origin for URL construction (a token URL would corrupt path joins). */
export const ORIGIN = LAUNCH.origin
/** page.goto target: the launch URL as printed (token included on
* 0.1.2-alpha.1+ — navigating it exchanges the token for the cookie). */
/** page.goto target: the launch URL as printed (token included —
* navigating it exchanges the token for the cookie). */
export const PAGE_URL = LAUNCH.pageUrl
/** page.goto target with extra query stamps merged (token preserved). */
@@ -64,14 +61,14 @@ export async function exchangeLaunchCookie(
let cookieHeader: string | undefined
/** page.goto through the host auth. A plain navigation goes to PAGE_URL (the
* token URL performs the browser's exchange on 0.1.2-alpha.1+), but a
* STAMPED navigation cannot: the exchange answers `303 → /` and silently
* DROPS every sibling query param (the mount lane caught the desktop-shell
* stamps vanishing this way), so pre-seed the browser context with the
* exchanged cookie and navigate the stamped URL directly. Playwright gives
* every test a fresh context, so the seeding is per-call and idempotent. */
* token URL performs the browser's exchange), but a STAMPED navigation
* cannot: the exchange answers `303 → /` and silently DROPS every sibling
* query param (the mount lane caught the desktop-shell stamps vanishing
* this way), so pre-seed the browser context with the exchanged cookie and
* navigate the stamped URL directly. Playwright gives every test a fresh
* context, so the seeding is per-call and idempotent. */
export async function gotoPage(page: Page, extra: Record<string, string> = {}): Promise<void> {
if (LAUNCH.token !== undefined && Object.keys(extra).length > 0) {
if (Object.keys(extra).length > 0) {
if (cookieHeader === undefined) cookieHeader = await exchangeLaunchCookie(RAW_URL)
const eq = cookieHeader.indexOf('=')
await page.context().addCookies([{
@@ -85,15 +82,14 @@ export async function gotoPage(page: Page, extra: Record<string, string> = {}):
await page.goto(pageUrl(extra), { waitUntil: 'domcontentloaded' })
}
/** Authenticated request context for host RPC seeding (cookie attached when
* the launch URL carried a one-time token; plain on older hosts). */
/** Authenticated request context for host RPC seeding (cookie attached). */
export async function createHostApi(): Promise<APIRequestContext> {
if (cookieHeader === undefined && LAUNCH.token !== undefined) {
if (cookieHeader === undefined) {
cookieHeader = await exchangeLaunchCookie(RAW_URL)
}
return request.newContext({
baseURL: ORIGIN,
extraHTTPHeaders: cookieHeader === undefined ? {} : { cookie: cookieHeader },
extraHTTPHeaders: { cookie: cookieHeader },
})
}
@@ -103,52 +99,36 @@ export interface HostRpcOk<T> {
value: T
}
/** Resolved dialect for hostRpc — cached after the first call so only the
* first RPC ever pays the 404 probe (dot-first: published hosts answer it
* directly; 0.1.2-alpha.1+ 404s the dot path and answers the slash one). */
let protocol: 'dot' | 'slash' | undefined
/** Test hook: forget the resolved dialect between unit-test cases. */
export function resetHostRpcForTests(): void {
protocol = undefined
}
let rpcCounter = 0
/** Call a host unary RPC (`'workspace.create'`, `'session.create'`, …) with
* the args object both dialects accept. Throws with the response body when
* the HTTP call or the envelope result fails — the lanes' seeding treats any
* failure as fatal, so the error text carries everything a report needs. */
/** Call a host unary RPC (`'workspace.create'`, `'session.create'`, …).
* Throws with the response body when the HTTP call or the envelope result
* fails — the lanes' seeding treats any failure as fatal, so the error text
* carries everything a report needs. */
export async function hostRpc<T = unknown>(
api: APIRequestContext,
method: string,
args: Record<string, unknown> = {},
): Promise<HostRpcOk<T>> {
const attempts = rpcAttempts(method, args)
const ordered = protocol === 'slash' ? [attempts[1]!, attempts[0]!] : attempts
const attempt = rpcAttempt(method, args)
rpcCounter += 1
const rpcId = `e2e-${method}-${rpcCounter}`
for (const attempt of ordered) {
const res = await api.post(attempt.path, {
data: { type: 'client-request', rpcId, method: attempt.method, payload: attempt.payload },
})
const bodyText = await res.text()
if (res.status() === 404 && protocol === undefined) continue // endpoint not claimed on this host → other dialect
if (!res.ok()) {
throw new Error(`hostRpc ${method} [${attempt.protocol}] HTTP ${res.status()}: ${bodyText.slice(0, 400)}`)
}
let envelope: { type?: string; result?: { ok: true; value: T } | { ok: false; error: unknown } }
try {
envelope = JSON.parse(bodyText) as typeof envelope
} catch {
throw new Error(`hostRpc ${method} [${attempt.protocol}]: non-JSON response: ${bodyText.slice(0, 400)}`)
}
const result = envelope.result
if (result === undefined || result.ok !== true) {
throw new Error(`hostRpc ${method} [${attempt.protocol}] envelope error: ${bodyText.slice(0, 400)}`)
}
protocol = attempt.protocol
return result
const res = await api.post(attempt.path, {
data: { type: 'client-request', rpcId, method: attempt.method, payload: attempt.payload },
})
const bodyText = await res.text()
if (!res.ok()) {
throw new Error(`hostRpc ${method} [${attempt.path}] HTTP ${res.status()}: ${bodyText.slice(0, 400)}`)
}
throw new Error(`hostRpc ${method}: no dialect answered (dot and slash both 404) — is this a DSH web host?`)
let envelope: { type?: string; result?: { ok: true; value: T } | { ok: false; error: unknown } }
try {
envelope = JSON.parse(bodyText) as typeof envelope
} catch {
throw new Error(`hostRpc ${method} [${attempt.path}]: non-JSON response: ${bodyText.slice(0, 400)}`)
}
const result = envelope.result
if (result === undefined || result.ok !== true) {
throw new Error(`hostRpc ${method} [${attempt.path}] envelope error: ${bodyText.slice(0, 400)}`)
}
return result
}
-1
View File
@@ -51,7 +51,6 @@ const CLIENT_REQUIRE_ALLOWED = new Set([
'cordis',
'@deepseek-ai/dsh-client-ui-slots',
'@deepseek-ai/dsh-client-ui-primitives',
'@deepseek-ai/dsh-client-runtime/client',
])
/** The registered `__ModuleLoader__.load({ id })` value of a built client bundle. */
+8 -5
View File
@@ -14,7 +14,9 @@
* - a safe, bund-visible `dsh.bundle.patch` path (`./cordis.patch.yml`),
* - a repository identity that normalizes to a credential-free HTTPS GitHub
* owner/repo URL (the catalog ↔ npm backlink comparison),
* - an exact stable SemVer version (no ranges, no tags, no prerelease).
* - an exact SemVer version (no ranges, no tags; a prerelease suffix is
* allowed — prerelease releases ride the npm `alpha` dist-tag and are not
* the market's verified `latest` install target until a stable cut).
*
* The packed-manifest checks run `pnpm pack` into a temp dir (the `prepare`
* script rebuilds the gitignored lib/, like the release flow does).
@@ -43,8 +45,9 @@ const pkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf8')) as {
/** The market's lifecycle-script reject list (install-and-uninstall.zh.md). */
const LIFECYCLE_SCRIPTS = ['preinstall', 'install', 'postinstall', 'prepare'] as const
/** Stable exact SemVer: exactly three numeric segments (no tag / prerelease). */
const STABLE_SEMVER = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/u
/** Exact SemVer: exactly three numeric segments, optional prerelease suffix
* (no range / tag — prerelease cuts publish under the npm `alpha` dist-tag). */
const EXACT_SEMVER = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u
/** Read `package.json` from a fresh `pnpm pack` tarball (the publish surface). */
function packedManifest(): Record<string, unknown> {
@@ -105,7 +108,7 @@ describe('DSH community-market manifest compatibility', () => {
expect(url.pathname.split('/').filter(Boolean)).toHaveLength(2)
})
it('declares an exact stable SemVer version (no range, tag, or prerelease)', () => {
expect(pkg.version).toMatch(STABLE_SEMVER)
it('declares an exact SemVer version (no range or tag; prerelease allowed for the alpha track)', () => {
expect(pkg.version).toMatch(EXACT_SEMVER)
})
})