mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
feat: add TypeRT remote gateway infrastructure
This commit is contained in:
@@ -18,6 +18,12 @@ flowchart LR
|
||||
cfg --> plugin_dsh_base_llm
|
||||
plugin_dsh_base_session["session<br/>@deepseek-ai/dsh-session"]
|
||||
cfg --> plugin_dsh_base_session
|
||||
plugin_dsh_base_typert["typert<br/>@deepseek-ai/dsh-typert-registry"]
|
||||
cfg --> plugin_dsh_base_typert
|
||||
plugin_dsh_base_typert_loader["typert-loader<br/>@deepseek-ai/dsh-typert-loader"]
|
||||
cfg --> plugin_dsh_base_typert_loader
|
||||
plugin_dsh_base_typert_gateway["typert-gateway<br/>@deepseek-ai/dsh-host-api-gateway"]
|
||||
cfg --> plugin_dsh_base_typert_gateway
|
||||
plugin_dsh_base_session_title["session-title<br/>@deepseek-ai/dsh-session-title"]
|
||||
cfg --> plugin_dsh_base_session_title
|
||||
plugin_dsh_base_session_title_llm["session-title-llm<br/>@deepseek-ai/dsh-session-title-first-message-llm"]
|
||||
@@ -159,6 +165,9 @@ flowchart LR
|
||||
| `repository-plugins` | `@deepseek-ai/dsh-repository-plugin` |
|
||||
| `llm` | `@deepseek-ai/dsh-llm` |
|
||||
| `session` | `@deepseek-ai/dsh-session` |
|
||||
| `typert` | `@deepseek-ai/dsh-typert-registry` |
|
||||
| `typert-loader` | `@deepseek-ai/dsh-typert-loader` |
|
||||
| `typert-gateway` | `@deepseek-ai/dsh-host-api-gateway` |
|
||||
| `session-title` | `@deepseek-ai/dsh-session-title` |
|
||||
| `session-title-llm` | `@deepseek-ai/dsh-session-title-first-message-llm` |
|
||||
| `user-interaction` | `@deepseek-ai/dsh-user-interaction` |
|
||||
|
||||
@@ -32,6 +32,8 @@ flowchart LR
|
||||
pkg_typert_registry["typert-registry"]
|
||||
svc_typert["ctx.typert<br/>Runtime type registry"]
|
||||
pkg_typert_loader["typert-loader"]
|
||||
pkg_api_gateway["api-gateway"]
|
||||
svc_typertGateway["ctx.typertGateway<br/>TypeRT Host invocation gateway"]
|
||||
svc_sessionPersistence["ctx.sessionPersistence<br/>Durable session persistence seam"]
|
||||
pkg_session_persistence_jsonl["session-persistence-jsonl"]
|
||||
pkg_session_persistence_sqlite["session-persistence-sqlite"]
|
||||
@@ -171,6 +173,7 @@ flowchart LR
|
||||
pkg_acp --> svc_approval
|
||||
pkg_agent --> svc_agents
|
||||
pkg_agent_loop --> svc_agentLoop
|
||||
pkg_api_gateway --> svc_typertGateway
|
||||
pkg_approval --> svc_approval
|
||||
pkg_bash --> svc_bash
|
||||
pkg_bash_env --> svc_bashEnv
|
||||
@@ -347,6 +350,7 @@ flowchart LR
|
||||
svc_tools --> pkg_tool_subagent
|
||||
svc_tools --> pkg_tool_todo
|
||||
svc_tools --> pkg_tool_web
|
||||
svc_typert --> pkg_api_gateway
|
||||
svc_typert --> pkg_typert_loader
|
||||
svc_userInteraction --> pkg_tool_ask_user
|
||||
svc_web --> pkg_tool_web
|
||||
@@ -363,7 +367,8 @@ flowchart LR
|
||||
| `ctx.toolResultPrune` | `core` | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction. |
|
||||
| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. |
|
||||
| `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. |
|
||||
| `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader) | - | Plugins register live zod contributions directly or through dsh-typert-loader; runtime consumers query schemas and reflection metadata at their own edges. |
|
||||
| `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), `api-gateway` | - | Plugins register live zod contributions directly or through dsh-typert-loader; the API gateway consumes invocation descriptors and providers, while other runtime consumers query schemas and reflection metadata at their own edges. |
|
||||
| `ctx.typertGateway` | `core` | `api-gateway` | - | - | - | Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier. |
|
||||
| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. |
|
||||
| `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-local`](../packages/settings/settings-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section; the web gateway serves redacted layered descriptors and writes the user layer. |
|
||||
| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the web gateway exposes value-free views and write-only storage. |
|
||||
|
||||
@@ -291,7 +291,7 @@ Source: [`packages/examples/cli-demo/src/index.ts:26`](../packages/examples/cli-
|
||||
|
||||
## `@deepseek-ai/dsh-client-connection`
|
||||
|
||||
Requires: `httpServer` · `apiProxy`
|
||||
Requires: `httpServer`
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config: the deployment's non-loopback serving authorities. */
|
||||
@@ -308,7 +308,7 @@ export interface ConnectionConfig {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/client/connection/src/index.ts:21`](../packages/client/connection/src/index.ts)
|
||||
Source: [`packages/client/connection/src/index.ts:31`](../packages/client/connection/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-client-hmr`
|
||||
|
||||
@@ -2548,6 +2548,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
|
||||
- `@deepseek-ai/dsh-commands` ([`packages/ui/commands/src/index.ts`](../packages/ui/commands/src/index.ts))
|
||||
- `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts))
|
||||
- `@deepseek-ai/dsh-goal-session` — requires `agents` · `goals` · `sessions` ([`packages/goal/goal-session/src/index.ts`](../packages/goal/goal-session/src/index.ts))
|
||||
- `@deepseek-ai/dsh-host-api-gateway` — requires `typert` ([`packages/host/api-gateway/src/index.ts`](../packages/host/api-gateway/src/index.ts))
|
||||
- `@deepseek-ai/dsh-host-directory-picker-auto` — requires `httpServer` · `loader` ([`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/src/index.ts))
|
||||
- `@deepseek-ai/dsh-host-directory-picker-native` ([`packages/host/directory-picker-native/src/index.ts`](../packages/host/directory-picker-native/src/index.ts))
|
||||
- `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts))
|
||||
@@ -2563,6 +2564,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
|
||||
- `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts))
|
||||
- `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts))
|
||||
- `@deepseek-ai/dsh-tool-subagent-control` — requires `tools` · `subagents` ([`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts))
|
||||
- `@deepseek-ai/dsh-tool-todo` — requires `tools` ([`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/index.ts))
|
||||
- `@deepseek-ai/dsh-typert-registry` ([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts))
|
||||
- `@deepseek-ai/dsh-user-interaction` ([`packages/ui/user-interaction/src/index.ts`](../packages/ui/user-interaction/src/index.ts))
|
||||
- `@deepseek-ai/dsh-workspace` — requires `storageDomain` · `sessionPersistence` ([`packages/workspace/workspace/src/index.ts`](../packages/workspace/workspace/src/index.ts))
|
||||
@@ -2620,4 +2622,6 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
|
||||
- `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts))
|
||||
- `@deepseek-ai/dsh-telemetry` ([`packages/sdk/telemetry/src/index.ts`](../packages/sdk/telemetry/src/index.ts))
|
||||
- `@deepseek-ai/dsh-timeout` ([`packages/util/timeout/src/index.ts`](../packages/util/timeout/src/index.ts))
|
||||
- `@deepseek-ai/dsh-type-meta` ([`packages/typert/type-meta/src/index.ts`](../packages/typert/type-meta/src/index.ts))
|
||||
- `@deepseek-ai/dsh-typert-generator` ([`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts))
|
||||
- `@deepseek-ai/dsh-typert-registry` ([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts))
|
||||
|
||||
@@ -542,7 +542,7 @@ Creation announcement during session publication. A synchronous throw vetoes and
|
||||
|
||||
Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md)
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:73`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:74`](../../packages/core/session/src/index.ts)
|
||||
|
||||
### `session/disposed` — emit
|
||||
|
||||
@@ -563,7 +563,7 @@ Emitted once when an announced session leaves the store, including publication r
|
||||
|
||||
Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md)
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:83`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:84`](../../packages/core/session/src/index.ts)
|
||||
|
||||
### `session/event` — emit
|
||||
|
||||
@@ -586,7 +586,7 @@ Post-commit, fire-and-forget append feed. The listener snapshot resolves before
|
||||
|
||||
Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:95`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:96`](../../packages/core/session/src/index.ts)
|
||||
|
||||
### `session/flush` — parallel
|
||||
|
||||
@@ -606,7 +606,7 @@ Awaited parallel durability checkpoint: every listener runs and the caller await
|
||||
|
||||
Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md)
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:104`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:105`](../../packages/core/session/src/index.ts)
|
||||
|
||||
## `settings/*`
|
||||
|
||||
|
||||
@@ -216,7 +216,7 @@ roots(): Agent[]
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/index.ts:242`](../../packages/core/agent/src/index.ts)
|
||||
Source: [`packages/core/agent/src/index.ts:253`](../../packages/core/agent/src/index.ts)
|
||||
|
||||
## `ctx.approval` — `ApprovalService`
|
||||
|
||||
@@ -1748,7 +1748,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId):
|
||||
|
||||
Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [PrepareSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:800`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:807`](../../packages/core/session/src/index.ts)
|
||||
|
||||
## `ctx.sessionTitle` — `SessionTitleService`
|
||||
|
||||
@@ -2527,16 +2527,17 @@ Source: [`packages/core/tools/src/index.ts:739`](../../packages/core/tools/src/i
|
||||
|
||||
## `ctx.typert` — `TypertRegistry`
|
||||
|
||||
Registry of generated schemas and package reflection.
|
||||
Registry of generated schemas, package reflection, invocations, and Remote dependency providers.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Register one generated contribution atomically for the calling fiber.
|
||||
* Duplicate package-face identities or schema keys reject the whole batch.
|
||||
* @param contribution - generated schemas and package metadata.
|
||||
* Duplicate package-face identities, schemas, invocation ids, or endpoints
|
||||
* reject the whole batch.
|
||||
* @param contribution - generated schemas, reflection, and Host invocations.
|
||||
* @returns the exact effect disposer that removes this contribution.
|
||||
*/
|
||||
register(contribution: TypertContribution): () => void
|
||||
register(contribution: TypertContribution): TypeRTDisposer
|
||||
|
||||
/**
|
||||
* Look up one schema by `<package>#<name>`.
|
||||
@@ -2584,7 +2585,23 @@ listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[]
|
||||
toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema
|
||||
```
|
||||
|
||||
Source: [`packages/typert/registry/src/index.ts:67`](../../packages/typert/registry/src/index.ts)
|
||||
Source: [`packages/typert/registry/src/service.ts:319`](../../packages/typert/registry/src/service.ts)
|
||||
|
||||
## `ctx.typertGateway` — `TypertGatewayService`
|
||||
|
||||
Resolve strict generated definitions or conservative SRC markers against current Cordis Services and TypeRT providers.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Invoke one live Remote method through strict generated reflection or SRC markers.
|
||||
* @param request - decoded endpoint and exact named wire arguments.
|
||||
* @returns the validated business result.
|
||||
* @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity.
|
||||
*/
|
||||
async invoke(request: InvokeRemoteRequest): Promise<unknown>
|
||||
```
|
||||
|
||||
Source: [`packages/host/api-gateway/src/index.ts:94`](../../packages/host/api-gateway/src/index.ts)
|
||||
|
||||
## `ctx.userInteraction` — `UserInteractionService`
|
||||
|
||||
|
||||
@@ -211,6 +211,7 @@ flowchart TD
|
||||
end
|
||||
subgraph group_host["packages/host"]
|
||||
pkg_frontend_static["frontend-static"]
|
||||
pkg_host_api_gateway["host-api-gateway"]
|
||||
pkg_host_apiproxy["host-apiproxy"]
|
||||
pkg_host_directory_picker["host-directory-picker"]
|
||||
pkg_host_directory_picker_auto["host-directory-picker-auto"]
|
||||
@@ -272,6 +273,7 @@ flowchart TD
|
||||
pkg_session_telemetry_otel["session-telemetry-otel"]
|
||||
end
|
||||
subgraph group_typert["packages/typert"]
|
||||
pkg_type_meta["type-meta"]
|
||||
pkg_typert_generator["typert-generator"]
|
||||
pkg_typert_loader["typert-loader"]
|
||||
pkg_typert_registry["typert-registry"]
|
||||
@@ -311,6 +313,7 @@ flowchart TD
|
||||
pkg_host_webserver --> pkg_invariants
|
||||
pkg_storage --> pkg_invariants
|
||||
pkg_subprocess --> pkg_invariants
|
||||
pkg_type_meta --> pkg_invariants
|
||||
pkg_typert_generator --> pkg_invariants
|
||||
pkg_typert_registry --> pkg_invariants
|
||||
pkg_llm --> pkg_brand
|
||||
@@ -374,6 +377,7 @@ flowchart TD
|
||||
pkg_session --> pkg_invariants
|
||||
pkg_session --> pkg_llm
|
||||
pkg_session --> pkg_scope
|
||||
pkg_session --> pkg_type_meta
|
||||
pkg_system_prompt --> pkg_invariants
|
||||
pkg_system_prompt --> pkg_llm
|
||||
pkg_system_prompt --> pkg_scope
|
||||
@@ -420,6 +424,9 @@ flowchart TD
|
||||
pkg_credentials_local --> pkg_credentials
|
||||
pkg_credentials_local --> pkg_invariants
|
||||
pkg_credentials_local --> pkg_paths
|
||||
pkg_host_api_gateway --> pkg_client_connection
|
||||
pkg_host_api_gateway --> pkg_invariants
|
||||
pkg_host_api_gateway --> pkg_typert_registry
|
||||
pkg_lsp --> pkg_brand
|
||||
pkg_lsp --> pkg_invariants
|
||||
pkg_lsp --> pkg_llm
|
||||
@@ -434,6 +441,7 @@ flowchart TD
|
||||
pkg_agent --> pkg_scope
|
||||
pkg_agent --> pkg_session
|
||||
pkg_agent --> pkg_system_prompt
|
||||
pkg_agent --> pkg_type_meta
|
||||
pkg_bash --> pkg_invariants
|
||||
pkg_bash --> pkg_sandbox
|
||||
pkg_bash --> pkg_subprocess
|
||||
@@ -1154,6 +1162,7 @@ flowchart TD
|
||||
| [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) |
|
||||
| [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) |
|
||||
| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`invariants`](../packages/support/invariants) |
|
||||
| [`type-meta`](../packages/typert/type-meta) | `typert` | [`invariants`](../packages/support/invariants) |
|
||||
| [`typert-generator`](../packages/typert/generator) | `typert` | [`invariants`](../packages/support/invariants) |
|
||||
| [`typert-registry`](../packages/typert/registry) | `typert` | [`invariants`](../packages/support/invariants) |
|
||||
| [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) |
|
||||
@@ -1175,7 +1184,7 @@ flowchart TD
|
||||
| [`typert-loader`](../packages/typert/loader) | `typert` | [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) |
|
||||
| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
|
||||
| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
|
||||
| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
|
||||
| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`type-meta`](../packages/typert/type-meta) |
|
||||
| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
|
||||
| [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
|
||||
| [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
|
||||
@@ -1186,10 +1195,11 @@ flowchart TD
|
||||
| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) |
|
||||
| [`host-api-gateway`](../packages/host/api-gateway) | `host` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) |
|
||||
| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
|
||||
| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
|
||||
| [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) |
|
||||
| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`type-meta`](../packages/typert/type-meta) |
|
||||
| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) |
|
||||
| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
|
||||
| [`compact`](../packages/compact/compact) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm run build:lib && npm run build:web",
|
||||
"build:lib": "tsc -b && tsdown",
|
||||
"build:lib": "npm run build:lib:host && npm run build:lib:client",
|
||||
"build:lib:host": "npm run build:lib:contracts && tsc -b tsconfig.host.json",
|
||||
"build:lib:contracts": "tsc -b packages/typert/generator && tsdown --config tsdown.typert-host.config.ts",
|
||||
"build:lib:client": "tsc -b tsconfig.client.json && tsdown",
|
||||
"build:web": "pnpm --filter @deepseek-ai/dsh-frontend run build",
|
||||
"clean": "tsx scripts/clean.ts",
|
||||
"change-scope": "tsx scripts/change-scope.ts",
|
||||
|
||||
@@ -34,6 +34,15 @@
|
||||
- id: session
|
||||
name: '@deepseek-ai/dsh-session'
|
||||
|
||||
- id: typert
|
||||
name: '@deepseek-ai/dsh-typert-registry'
|
||||
|
||||
- id: typert-loader
|
||||
name: '@deepseek-ai/dsh-typert-loader'
|
||||
|
||||
- id: typert-gateway
|
||||
name: '@deepseek-ai/dsh-host-api-gateway'
|
||||
|
||||
- id: session-title
|
||||
name: '@deepseek-ai/dsh-session-title'
|
||||
config:
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"@deepseek-ai/dsh-fs-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-api-gateway": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-pi-ai": "workspace:^",
|
||||
@@ -95,6 +96,8 @@
|
||||
"@deepseek-ai/dsh-tool-web": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||
"@deepseek-ai/dsh-web": "workspace:^",
|
||||
|
||||
@@ -8,7 +8,9 @@ import type { IApiClient } from './api.ts'
|
||||
import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts'
|
||||
import { FixtureApiClient } from './fixture.ts'
|
||||
import { WebApiClient } from './web-api-client.ts'
|
||||
import { createUnavailableConnectionRpc, createWebConnectionRpc } from './rpc.ts'
|
||||
import { isLoopbackHostname } from '../loopback-hostname.ts'
|
||||
import type { ClientConnectionRpc } from '../rpc.ts'
|
||||
|
||||
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
|
||||
export type {
|
||||
@@ -36,6 +38,7 @@ export {
|
||||
// Connection loop types are public through ConnectionHandle.start; the
|
||||
// controller remains package-internal.
|
||||
export type { ConnectionConfig, ConnectionSinks, ConnectionState }
|
||||
export type { ClientConnectionRpc } from '../rpc.ts'
|
||||
|
||||
|
||||
/** Required services (none — this is the wire root). */
|
||||
@@ -51,6 +54,8 @@ export interface ConnectionHandle {
|
||||
readonly api: IApiClient
|
||||
/** Whether the current page authority is loopback; non-browser contexts default to true. */
|
||||
readonly isLoopback: boolean
|
||||
/** Generic logical RPC channels over the same Connection transport. */
|
||||
readonly rpc: ClientConnectionRpc
|
||||
/**
|
||||
* Start the connect/pump/reconnect loop with the consumer's frame sinks.
|
||||
* One consumer owns the streams (the runtime object layer); a second call
|
||||
@@ -70,10 +75,12 @@ export function apply(ctx: Context): void {
|
||||
const pageLocation = typeof location === 'undefined' ? undefined : location
|
||||
const fixture = pageLocation !== undefined && new URLSearchParams(pageLocation.search).has('fixture')
|
||||
const api: IApiClient = fixture ? new FixtureApiClient() : new WebApiClient()
|
||||
const rpc = fixture ? createUnavailableConnectionRpc() : createWebConnectionRpc()
|
||||
let started = false
|
||||
const handle: ConnectionHandle = {
|
||||
api,
|
||||
isLoopback: pageLocation === undefined || isLoopbackHostname(pageLocation.hostname),
|
||||
rpc,
|
||||
start(sinks, config) {
|
||||
if (started) throw new Error('connection: the stream loop is already owned by another consumer')
|
||||
started = true
|
||||
|
||||
75
packages/client/connection/src/client/rpc.ts
Normal file
75
packages/client/connection/src/client/rpc.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/** Browser caller for generic Connection unary RPC channels. */
|
||||
|
||||
import {
|
||||
RpcId,
|
||||
serverResponseSchema,
|
||||
type ClientRequest,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { ClientConnectionRpc } from '../rpc.ts'
|
||||
|
||||
const INTERNAL_BASE = 'http://dsh.internal'
|
||||
const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/
|
||||
const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/
|
||||
|
||||
/**
|
||||
* Create the browser-backed generic RPC caller.
|
||||
* @returns caller that owns request correlation and response-envelope validation.
|
||||
*/
|
||||
export function createWebConnectionRpc(): ClientConnectionRpc {
|
||||
return {
|
||||
async call(channel, endpoint, payload, signal) {
|
||||
assertTarget(channel, endpoint)
|
||||
const rpcId = RpcId(crypto.randomUUID())
|
||||
const message: ClientRequest = {
|
||||
type: 'client-request',
|
||||
rpcId,
|
||||
method: endpoint,
|
||||
payload,
|
||||
}
|
||||
const response = await globalThis.fetch(
|
||||
new URL(`${channel}/${endpoint}`, resolveBase()),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(message),
|
||||
...signal === undefined ? {} : { signal },
|
||||
},
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(`transport failure for ${channel}/${endpoint}: HTTP ${response.status}`)
|
||||
}
|
||||
const full = serverResponseSchema.parse(await response.json())
|
||||
if (full.rpcId !== rpcId) {
|
||||
throw new Error(`rpcId mismatch for ${endpoint}: sent ${rpcId}, got ${full.rpcId}`)
|
||||
}
|
||||
return full.result
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the fixture-mode caller, where no Host Remote registry exists.
|
||||
* @returns caller that rejects every generic Remote invocation.
|
||||
*/
|
||||
export function createUnavailableConnectionRpc(): ClientConnectionRpc {
|
||||
return {
|
||||
call(channel, endpoint) {
|
||||
return Promise.reject(new Error(`connection RPC ${channel}/${endpoint} is unavailable in fixture mode`))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBase(): string {
|
||||
const location = (globalThis as { location?: { origin?: string } }).location
|
||||
return location?.origin !== undefined && location.origin !== 'null' ? location.origin : INTERNAL_BASE
|
||||
}
|
||||
|
||||
function assertTarget(channel: string, endpoint: string): void {
|
||||
const segments = endpoint.split('/')
|
||||
if (!CHANNEL_PATTERN.test(channel)
|
||||
|| segments.length === 0
|
||||
|| segments.some(segment =>
|
||||
segment === '' || segment === '.' || segment === '..' || !ENDPOINT_SEGMENT_PATTERN.test(segment))) {
|
||||
throw new Error(`connection: invalid RPC target ${JSON.stringify(`${channel}/${endpoint}`)}`)
|
||||
}
|
||||
}
|
||||
@@ -7,15 +7,25 @@ import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
import { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts'
|
||||
import { bridge } from './http-bridge.ts'
|
||||
import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts'
|
||||
import { HostConnectionService } from './rpc-host.ts'
|
||||
import { rejectWebSocketUpgrade, WebSocketDownlinks } from './websocket-downlink.ts'
|
||||
|
||||
export type {
|
||||
ConnectionRpcAuthority,
|
||||
ConnectionRpcHandler,
|
||||
ConnectionRpcHandlerOptions,
|
||||
HostConnectionHandle,
|
||||
HostConnectionRpc,
|
||||
} from './rpc.ts'
|
||||
export { HostConnectionService } from './rpc-host.ts'
|
||||
|
||||
export { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts'
|
||||
|
||||
/** Stable Cordis plugin name. */
|
||||
export const name = 'client-connection'
|
||||
|
||||
/** Services required before mounting the route. */
|
||||
export const inject = ['httpServer', 'apiProxy']
|
||||
/** Services required before providing Connection; legacy `/api` attaches when apiProxy is present. */
|
||||
export const inject = ['httpServer']
|
||||
|
||||
/** Plugin config: the deployment's non-loopback serving authorities. */
|
||||
export interface ConnectionConfig {
|
||||
@@ -83,49 +93,52 @@ export function apply(ctx: Context, config?: ConnectionConfig): void {
|
||||
// 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)
|
||||
const apiHandler = toFetchHandler(ctx.apiProxy)
|
||||
const downlinks = new WebSocketDownlinks(ctx.apiProxy)
|
||||
const route: WebRoute = {
|
||||
kind: 'prefix',
|
||||
path: API_PATH,
|
||||
handler: async (req, res) => {
|
||||
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
|
||||
const method = pathname.startsWith(`${API_PATH}/`)
|
||||
? pathname.slice(API_PATH.length + 1)
|
||||
: undefined
|
||||
const allowed = method !== undefined && PRIVILEGED_METHODS.has(method)
|
||||
? isTrustedApiRequest(req, [])
|
||||
: isTrustedApiRequest(req, trustedHosts)
|
||||
if (!allowed) {
|
||||
res.writeHead(403)
|
||||
res.end('forbidden')
|
||||
return
|
||||
}
|
||||
if (req.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) {
|
||||
res.writeHead(426, { connection: 'Upgrade', upgrade: 'websocket' })
|
||||
res.end('upgrade required')
|
||||
return
|
||||
}
|
||||
await bridge(req, res, apiHandler)
|
||||
},
|
||||
}
|
||||
ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route')
|
||||
const registerDownlink = (
|
||||
path: string,
|
||||
handle: WebUpgradeRoute['handler'],
|
||||
): void => {
|
||||
ctx.effect(() => ctx.httpServer.registerUpgrade({
|
||||
path,
|
||||
handler: (req, socket, head) => {
|
||||
if (!isTrustedApiRequest(req, trustedHosts)) {
|
||||
rejectWebSocketUpgrade(socket)
|
||||
new HostConnectionService(ctx, trustedHosts)
|
||||
ctx.inject(['apiProxy'], (apiCtx) => {
|
||||
const apiHandler = toFetchHandler(apiCtx.apiProxy)
|
||||
const downlinks = new WebSocketDownlinks(apiCtx.apiProxy)
|
||||
const route: WebRoute = {
|
||||
kind: 'prefix',
|
||||
path: API_PATH,
|
||||
handler: async (req, res) => {
|
||||
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
|
||||
const method = pathname.startsWith(`${API_PATH}/`)
|
||||
? pathname.slice(API_PATH.length + 1)
|
||||
: undefined
|
||||
const allowed = method !== undefined && PRIVILEGED_METHODS.has(method)
|
||||
? isTrustedApiRequest(req, [])
|
||||
: isTrustedApiRequest(req, trustedHosts)
|
||||
if (!allowed) {
|
||||
res.writeHead(403)
|
||||
res.end('forbidden')
|
||||
return
|
||||
}
|
||||
return handle(req, socket, head)
|
||||
if (req.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) {
|
||||
res.writeHead(426, { connection: 'Upgrade', upgrade: 'websocket' })
|
||||
res.end('upgrade required')
|
||||
return
|
||||
}
|
||||
await bridge(req, res, apiHandler)
|
||||
},
|
||||
}), `client-connection: ${path} WebSocket`)
|
||||
}
|
||||
ctx.effect(() => () => downlinks.close(), 'client-connection: WebSocket downlinks')
|
||||
registerDownlink(MUX_EVENTS_PATH, (req, socket, head) => { downlinks.handleMux(req, socket, head) })
|
||||
registerDownlink(HOST_EVENTS_PATH, (req, socket, head) => { downlinks.handleHost(req, socket, head) })
|
||||
}
|
||||
apiCtx.effect(() => apiCtx.httpServer.register(route), 'client-connection: /api route')
|
||||
const registerDownlink = (
|
||||
path: string,
|
||||
handle: WebUpgradeRoute['handler'],
|
||||
): void => {
|
||||
apiCtx.effect(() => apiCtx.httpServer.registerUpgrade({
|
||||
path,
|
||||
handler: (req, socket, head) => {
|
||||
if (!isTrustedApiRequest(req, trustedHosts)) {
|
||||
rejectWebSocketUpgrade(socket)
|
||||
return
|
||||
}
|
||||
return handle(req, socket, head)
|
||||
},
|
||||
}), `client-connection: ${path} WebSocket`)
|
||||
}
|
||||
apiCtx.effect(() => () => downlinks.close(), 'client-connection: WebSocket downlinks')
|
||||
registerDownlink(MUX_EVENTS_PATH, (req, socket, head) => { downlinks.handleMux(req, socket, head) })
|
||||
registerDownlink(HOST_EVENTS_PATH, (req, socket, head) => { downlinks.handleHost(req, socket, head) })
|
||||
})
|
||||
}
|
||||
|
||||
150
packages/client/connection/src/rpc-host.ts
Normal file
150
packages/client/connection/src/rpc-host.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/** Host registry and HTTP adapter for generic Connection RPC channels. */
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import {
|
||||
clientRequestSchema,
|
||||
RpcId,
|
||||
type ClientRequest,
|
||||
type RpcError,
|
||||
type RpcId as RpcIdType,
|
||||
type ServerResponse as RpcServerResponse,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { bridge } from './http-bridge.ts'
|
||||
import { isTrustedApiRequest } from './api-request-trust.ts'
|
||||
import type {
|
||||
ConnectionRpcHandler,
|
||||
ConnectionRpcHandlerOptions,
|
||||
HostConnectionHandle,
|
||||
HostConnectionRpc,
|
||||
} from './rpc.ts'
|
||||
|
||||
const INVALID_REQUEST_RPC_ID = RpcId('invalid-request')
|
||||
const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/
|
||||
const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/
|
||||
|
||||
/** Host Connection service whose channel registrations belong to the caller fiber. */
|
||||
export class HostConnectionService extends Service implements HostConnectionHandle {
|
||||
/**
|
||||
* Provide the Host half over the active HTTP server.
|
||||
* @param ctx - owning Connection plugin context.
|
||||
* @param trustedHosts - deployment authorities accepted by trusted-host channels.
|
||||
*/
|
||||
constructor(ctx: Context, private readonly trustedHosts: readonly string[]) {
|
||||
super(ctx, 'connection')
|
||||
}
|
||||
|
||||
/** Generic channel registry scoped to the Context reading this service. */
|
||||
get rpc(): HostConnectionRpc {
|
||||
const owner = this.ctx
|
||||
return {
|
||||
handle: (channel, handler, options) => this.register(owner, channel, handler, options),
|
||||
}
|
||||
}
|
||||
|
||||
private register(
|
||||
owner: Context,
|
||||
channel: string,
|
||||
handler: ConnectionRpcHandler,
|
||||
options: ConnectionRpcHandlerOptions,
|
||||
): () => Promise<void> {
|
||||
assertChannel(channel)
|
||||
const trustedHosts = options.authority === 'loopback' ? [] : this.trustedHosts
|
||||
const fetchHandler = rpcFetchHandler(channel, handler)
|
||||
const route: WebRoute = {
|
||||
kind: 'prefix',
|
||||
path: channel,
|
||||
handler: async (req, res) => {
|
||||
if (!isTrustedApiRequest(req, trustedHosts)) {
|
||||
res.writeHead(403)
|
||||
res.end('forbidden')
|
||||
return
|
||||
}
|
||||
await bridge(req, res, fetchHandler)
|
||||
},
|
||||
}
|
||||
return owner.effect(
|
||||
() => owner.httpServer.register(route),
|
||||
`client-connection: ${channel} rpc channel`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function rpcFetchHandler(
|
||||
channel: string,
|
||||
handler: ConnectionRpcHandler,
|
||||
): { fetch: typeof fetch } {
|
||||
return {
|
||||
async fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
const endpoint = endpointFromPath(channel, new URL(request.url).pathname)
|
||||
if (request.method !== 'POST' || endpoint === undefined) {
|
||||
return new Response('not found', { status: 404 })
|
||||
}
|
||||
|
||||
const mediaType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()
|
||||
if (mediaType !== 'application/json') {
|
||||
return new Response('content type must be application/json', { status: 415 })
|
||||
}
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return new Response('body is not JSON', { status: 400 })
|
||||
}
|
||||
|
||||
const envelope = clientRequestSchema.safeParse(body)
|
||||
if (!envelope.success) {
|
||||
const rawId = (body as { rpcId?: unknown } | null)?.rpcId
|
||||
const rpcId = typeof rawId === 'string' ? RpcId(rawId) : INVALID_REQUEST_RPC_ID
|
||||
return errorResponse(rpcId, {
|
||||
code: 'bad-request',
|
||||
message: 'invalid client-request message',
|
||||
details: { issues: envelope.error.issues },
|
||||
})
|
||||
}
|
||||
const message: ClientRequest = envelope.data
|
||||
if (message.method !== endpoint) {
|
||||
return errorResponse(message.rpcId, {
|
||||
code: 'bad-request',
|
||||
message: `method ${JSON.stringify(message.method)} does not match endpoint ${JSON.stringify(endpoint)}`,
|
||||
details: { issues: [] },
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handler(endpoint, message.payload, request.signal)
|
||||
return fullResponse(message.rpcId, result)
|
||||
} catch (error) {
|
||||
return new Response(`handler failure: ${String(error)}`, { status: 500 })
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function endpointFromPath(channel: string, pathname: string): string | undefined {
|
||||
if (!pathname.startsWith(`${channel}/`)) return undefined
|
||||
const endpoint = pathname.slice(channel.length + 1)
|
||||
const segments = endpoint.split('/')
|
||||
if (segments.length === 0 || segments.some(segment =>
|
||||
segment === '' || segment === '.' || segment === '..' || !ENDPOINT_SEGMENT_PATTERN.test(segment))) {
|
||||
return undefined
|
||||
}
|
||||
return endpoint
|
||||
}
|
||||
|
||||
function errorResponse(rpcId: RpcIdType, error: RpcError): Response {
|
||||
return fullResponse(rpcId, { ok: false, error })
|
||||
}
|
||||
|
||||
function fullResponse(rpcId: RpcIdType, result: RpcServerResponse['result']): Response {
|
||||
const body: RpcServerResponse = { type: 'server-response', rpcId, result }
|
||||
return Response.json(body)
|
||||
}
|
||||
|
||||
function assertChannel(channel: string): void {
|
||||
if (!CHANNEL_PATTERN.test(channel) || channel === '/api') {
|
||||
throw new Error(`connection: invalid or reserved RPC channel ${JSON.stringify(channel)}`)
|
||||
}
|
||||
}
|
||||
59
packages/client/connection/src/rpc.ts
Normal file
59
packages/client/connection/src/rpc.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/** Generic unary RPC contracts shared by the Host and Client Connection halves. */
|
||||
|
||||
import type { RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
|
||||
/** Trust fence applied before a Host RPC channel reaches its handler. */
|
||||
export type ConnectionRpcAuthority = 'trusted-host' | 'loopback'
|
||||
|
||||
/** Registration policy for one logical RPC channel. */
|
||||
export interface ConnectionRpcHandlerOptions {
|
||||
/** Browser authority accepted by every endpoint in this channel. */
|
||||
readonly authority: ConnectionRpcAuthority
|
||||
}
|
||||
|
||||
/** Handler invoked after Connection has decoded the transport envelope. */
|
||||
export type ConnectionRpcHandler = (
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
signal: AbortSignal,
|
||||
) => Promise<RpcResult<unknown>>
|
||||
|
||||
/** Host registry for logical RPC channels carried by the current transport. */
|
||||
export interface HostConnectionRpc {
|
||||
/**
|
||||
* Register one absolute channel prefix and its trust policy.
|
||||
* @param channel - absolute logical channel such as `/api2`.
|
||||
* @param handler - decoded endpoint handler returning the existing RPC result shape.
|
||||
* @param options - channel trust policy.
|
||||
* @returns asynchronous disposer removing the channel and its physical route.
|
||||
*/
|
||||
handle(
|
||||
channel: string,
|
||||
handler: ConnectionRpcHandler,
|
||||
options: ConnectionRpcHandlerOptions,
|
||||
): () => Promise<void>
|
||||
}
|
||||
|
||||
/** Host `ctx.connection` shape consumed by transport-independent adapters. */
|
||||
export interface HostConnectionHandle {
|
||||
/** Generic RPC channel registry. */
|
||||
readonly rpc: HostConnectionRpc
|
||||
}
|
||||
|
||||
/** Client caller for logical RPC channels carried by the current transport. */
|
||||
export interface ClientConnectionRpc {
|
||||
/**
|
||||
* Call one endpoint through an already registered logical channel.
|
||||
* @param channel - absolute logical channel such as `/api2`.
|
||||
* @param endpoint - channel-relative endpoint such as `goals/create`.
|
||||
* @param payload - channel-owned request payload.
|
||||
* @param signal - optional caller cancellation.
|
||||
* @returns the existing RPC success/error result; correlation stays inside Connection.
|
||||
*/
|
||||
call(
|
||||
channel: string,
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RpcResult<unknown>>
|
||||
}
|
||||
@@ -203,4 +203,41 @@ describe('connection client apply', () => {
|
||||
expect(sockets).toHaveLength(1)
|
||||
expect(sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED)
|
||||
})
|
||||
|
||||
it('carries generic RPC calls over the isolated channel with rpcId echo validation', async () => {
|
||||
;(globalThis as Win).location = { hostname: 'localhost', search: '' }
|
||||
const handle = await mount()
|
||||
const original = globalThis.fetch
|
||||
const seen: { url: string; body: unknown }[] = []
|
||||
globalThis.fetch = async (input: URL | RequestInfo, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
|
||||
if (typeof init?.body !== 'string') throw new TypeError('expected a JSON string request body')
|
||||
const body = JSON.parse(init.body) as { rpcId: string }
|
||||
seen.push({ url, body })
|
||||
return Response.json({
|
||||
type: 'server-response',
|
||||
rpcId: body.rpcId,
|
||||
result: { ok: true, value: { ref: 'goal-1' } },
|
||||
})
|
||||
}
|
||||
try {
|
||||
await expect(handle.rpc.call('/api2', 'goals/create', { args: { agentId: 'agent-1' } }))
|
||||
.resolves.toEqual({ ok: true, value: { ref: 'goal-1' } })
|
||||
} finally {
|
||||
globalThis.fetch = original
|
||||
}
|
||||
expect(seen).toHaveLength(1)
|
||||
expect(seen[0]?.url).toBe('http://dsh.internal/api2/goals/create')
|
||||
expect(seen[0]?.body).toMatchObject({
|
||||
type: 'client-request',
|
||||
method: 'goals/create',
|
||||
payload: { args: { agentId: 'agent-1' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps generic Remote calls unavailable in the client-only fixture', async () => {
|
||||
;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
|
||||
const handle = await mount()
|
||||
await expect(handle.rpc.call('/api2', 'goals/create', {})).rejects.toThrow(/unavailable in fixture mode/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,8 +7,9 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { RpcId, type ClientRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { HttpServerService, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH } from '../src/index.ts'
|
||||
import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH, type HostConnectionHandle } from '../src/index.ts'
|
||||
|
||||
/** Structural httpServer fake recording both route registries. */
|
||||
function fakeHttpServer(
|
||||
@@ -17,6 +18,9 @@ function fakeHttpServer(
|
||||
): Pick<HttpServerService, 'register' | 'registerUpgrade' | 'tapIndex' | 'port'> {
|
||||
return {
|
||||
register(route) {
|
||||
if (routes.some(candidate => candidate.kind === route.kind && candidate.path === route.path)) {
|
||||
throw new Error(`duplicate route ${route.path}`)
|
||||
}
|
||||
routes.push(route)
|
||||
return () => { routes.splice(routes.indexOf(route), 1) }
|
||||
},
|
||||
@@ -36,15 +40,25 @@ function fakeRequest(headers: Record<string, string>, url = `${API_PATH}/session
|
||||
return request
|
||||
}
|
||||
|
||||
/** JSON POST carrying a complete client-request envelope. */
|
||||
function fakePost(headers: Record<string, string>, url: string, body: unknown): IncomingMessage {
|
||||
const request = Readable.from([Buffer.from(JSON.stringify(body))]) as unknown as IncomingMessage
|
||||
Object.assign(request, { url, method: 'POST', headers: { 'content-type': 'application/json', ...headers } })
|
||||
return request
|
||||
}
|
||||
|
||||
/** Response recorder compatible with both the fence's short-circuit and the bridge. */
|
||||
function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } {
|
||||
const state: { status?: number; body?: unknown } = {}
|
||||
const chunks: Buffer[] = []
|
||||
const response = Object.assign(new EventEmitter(), {
|
||||
writableEnded: false,
|
||||
writeHead(value: number) { state.status = value; return this },
|
||||
write() { return true },
|
||||
write(value: string | Uint8Array) { chunks.push(Buffer.from(value)); return true },
|
||||
end(this: { writableEnded: boolean }, value?: unknown) {
|
||||
if (value !== undefined) state.body = value
|
||||
if (typeof value === 'string' || value instanceof Uint8Array) chunks.push(Buffer.from(value))
|
||||
else if (value !== undefined) throw new TypeError('fake response only accepts string or Uint8Array bodies')
|
||||
if (chunks.length > 0) state.body = Buffer.concat(chunks).toString()
|
||||
this.writableEnded = true
|
||||
return this
|
||||
},
|
||||
@@ -173,6 +187,78 @@ describe('connection node half', () => {
|
||||
expect(declared.state.status).toBe(404)
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('provides a disposable generic RPC channel without requiring apiProxy', async () => {
|
||||
const ctx = new Context()
|
||||
const routes: WebRoute[] = []
|
||||
ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(routes).toHaveLength(0)
|
||||
|
||||
const connection = ctx.get('connection') as HostConnectionHandle
|
||||
const calls: unknown[] = []
|
||||
const remove = connection.rpc.handle('/api2', async (endpoint, payload) => {
|
||||
calls.push({ endpoint, payload })
|
||||
return { ok: true, value: { accepted: true } }
|
||||
}, { authority: 'trusted-host' })
|
||||
const route = routes.find(candidate => candidate.path === '/api2')
|
||||
expect(route).toBeDefined()
|
||||
|
||||
const request: ClientRequest = {
|
||||
type: 'client-request',
|
||||
rpcId: RpcId('rpc-api2'),
|
||||
method: 'goals/create',
|
||||
payload: { args: { agentId: 'agent-1' } },
|
||||
}
|
||||
const result = fakeResponse()
|
||||
await route!.handler(fakePost({ host: '127.0.0.1:3080' }, '/api2/goals/create', request), result.response)
|
||||
expect(result.state.status).toBe(200)
|
||||
expect(JSON.parse(String(result.state.body))).toEqual({
|
||||
type: 'server-response',
|
||||
rpcId: 'rpc-api2',
|
||||
result: { ok: true, value: { accepted: true } },
|
||||
})
|
||||
expect(calls).toEqual([{
|
||||
endpoint: 'goals/create',
|
||||
payload: { args: { agentId: 'agent-1' } },
|
||||
}])
|
||||
|
||||
expect(() => connection.rpc.handle('/api2', async () => ({ ok: true, value: null }), {
|
||||
authority: 'trusted-host',
|
||||
})).toThrow(/duplicate route/)
|
||||
await remove()
|
||||
expect(routes).toHaveLength(0)
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('applies the configured trust fence and JSON envelope checks to generic channels', async () => {
|
||||
const ctx = new Context()
|
||||
const routes: WebRoute[] = []
|
||||
ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] })
|
||||
await fiber.await()
|
||||
const connection = ctx.get('connection') as HostConnectionHandle
|
||||
const remove = connection.rpc.handle('/api2', async () => ({ ok: true, value: null }), {
|
||||
authority: 'trusted-host',
|
||||
})
|
||||
const route = routes[0]!
|
||||
|
||||
const denied = fakeResponse()
|
||||
await route.handler(fakePost({ host: 'other.example' }, '/api2/goals/create', {}), denied.response)
|
||||
expect(denied.state).toMatchObject({ status: 403, body: 'forbidden' })
|
||||
|
||||
const badEnvelope = fakeResponse()
|
||||
await route.handler(fakePost({ host: 'harness.example' }, '/api2/goals/create', {
|
||||
type: 'client-request', rpcId: 'rpc-bad', method: 'other', payload: {},
|
||||
}), badEnvelope.response)
|
||||
expect(JSON.parse(String(badEnvelope.state.body))).toMatchObject({
|
||||
rpcId: 'rpc-bad',
|
||||
result: { ok: false, error: { code: 'bad-request' } },
|
||||
})
|
||||
await remove()
|
||||
await fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('connection node half over a real HTTP server', () => {
|
||||
|
||||
@@ -27,6 +27,9 @@ async function mount(): Promise<Bench> {
|
||||
const handle: ConnectionHandle = {
|
||||
api,
|
||||
isLoopback: true,
|
||||
rpc: {
|
||||
call: () => Promise.reject(new Error('unexpected generic RPC call')),
|
||||
},
|
||||
start: (sinks) => {
|
||||
bench.sinks = sinks
|
||||
return { stop: () => { bench.stopped += 1 } }
|
||||
|
||||
@@ -21,6 +21,9 @@ async function mount(): Promise<Bench> {
|
||||
const handle: ConnectionHandle = {
|
||||
api,
|
||||
isLoopback: true,
|
||||
rpc: {
|
||||
call: () => Promise.reject(new Error('unexpected generic RPC call')),
|
||||
},
|
||||
start: (sinks) => {
|
||||
bench.sinks = sinks
|
||||
return { stop: () => {} }
|
||||
|
||||
@@ -31,6 +31,9 @@ const CSS_VIRTUAL_SUFFIX = '.mjs'
|
||||
*/
|
||||
export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/
|
||||
|
||||
/** Generated descriptor/codec contribution with no shared runtime identity. */
|
||||
const GENERATED_REMOTE = /^@deepseek-ai\/dsh-[a-z0-9]+(?:-[a-z0-9]+)*\/remote$/
|
||||
|
||||
/**
|
||||
* Documented TEMPORARY exemption, not a platform module (hence not in
|
||||
* platform.ts): the snapshot-store engine (createSnapshotStore/defineStore/
|
||||
@@ -126,9 +129,9 @@ export function clientBundle(id: string, libEntry: readonly string[]): [UserConf
|
||||
resolveId(source: string) {
|
||||
if (!source.startsWith('@deepseek-ai/')) return null
|
||||
if (CLIENT_EXTERNALS.includes(source)) return null // platform module: external wins
|
||||
if (INLINE_SAFE.test(source)) return null // wire/type layer: inline is the point
|
||||
if (INLINE_SAFE.test(source) || GENERATED_REMOTE.test(source)) return null // wire contribution: inline is the point
|
||||
throw new Error(
|
||||
`client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS) and not an inline-safe wire layer — `
|
||||
`client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS), an inline-safe wire layer, or a generated /remote contribution — `
|
||||
+ 'cross-plugin value imports are forbidden; collaborate through cordis services (type-only imports are erased and never reach this gate)',
|
||||
)
|
||||
},
|
||||
|
||||
@@ -1118,11 +1118,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
{
|
||||
key: 'typert',
|
||||
summary: 'Registry of generated schemas and package reflection.',
|
||||
summary: 'Registry of generated schemas, package reflection, invocations, and Remote dependency providers.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'register(contribution: TypertContribution): () => void',
|
||||
jsDoc: '/**\n * Register one generated contribution atomically for the calling fiber.\n * Duplicate package-face identities or schema keys reject the whole batch.\n * @param contribution - generated schemas and package metadata.\n * @returns the exact effect disposer that removes this contribution.\n */',
|
||||
signature: 'register(contribution: TypertContribution): TypeRTDisposer',
|
||||
jsDoc: '/**\n * Register one generated contribution atomically for the calling fiber.\n * Duplicate package-face identities, schemas, invocation ids, or endpoints\n * reject the whole batch.\n * @param contribution - generated schemas, reflection, and Host invocations.\n * @returns the exact effect disposer that removes this contribution.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'get(key: string): TypertSchemaRecord | undefined',
|
||||
@@ -1150,6 +1150,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'typertGateway',
|
||||
summary: 'Resolve strict generated definitions or conservative SRC markers against current Cordis Services and TypeRT providers.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'async invoke(request: InvokeRemoteRequest): Promise<unknown>',
|
||||
jsDoc: '/**\n * Invoke one live Remote method through strict generated reflection or SRC markers.\n * @param request - decoded endpoint and exact named wire arguments.\n * @returns the validated business result.\n * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'userInteraction',
|
||||
summary: '`ctx.userInteraction`: one active UI provider plus an `ask()` surface.',
|
||||
@@ -2057,6 +2067,22 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'InvariantInstaller',
|
||||
declaration: 'export interface InvariantInstaller {\n (ctx: Context, fail: InvariantFailure): void | Promise<void>;\n readonly inject?: Inject;\n}',
|
||||
},
|
||||
{
|
||||
name: 'InvocationDescriptor',
|
||||
declaration: 'export interface InvocationDescriptor {\n readonly id: string;\n readonly service: string;\n readonly namespace: string;\n readonly method: string;\n readonly implementation?: string;\n readonly invocation: {\n readonly kind: \'direct\';\n } | {\n readonly kind: \'context\';\n readonly context: string;\n readonly wire: string;\n readonly codec: TypeRTCodec;\n };\n readonly scope?: {\n readonly context: string;\n readonly wire: string;\n };\n readonly parameters: readonly InvocationParameterDescriptor[];\n readonly result: TypeRTCodec;\n readonly sourceLocation?: InvocationSourceLocation;\n}',
|
||||
},
|
||||
{
|
||||
name: 'InvocationParameterDescriptor',
|
||||
declaration: 'export interface InvocationParameterDescriptor {\n readonly name: string;\n readonly wire: string;\n readonly source: \'json\' | \'lookup\';\n readonly lookup?: string;\n readonly codec: TypeRTCodec;\n}',
|
||||
},
|
||||
{
|
||||
name: 'InvocationSourceLocation',
|
||||
declaration: 'export interface InvocationSourceLocation {\n readonly file: string;\n readonly line: number;\n readonly column: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'InvokeRemoteRequest',
|
||||
declaration: 'export interface InvokeRemoteRequest {\n readonly namespace: string;\n readonly method: string;\n readonly args: Readonly<Record<string, unknown>>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'JsonSchemaNode',
|
||||
declaration: 'export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record<string, JsonSchemaNode>;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n}',
|
||||
@@ -3037,9 +3063,17 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'TurnEndReasonMap',
|
||||
declaration: 'export interface TurnEndReasonMap {\n completed: {\n kind: \'completed\';\n };\n aborted: {\n kind: \'aborted\';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: \'blocked\';\n };\n error: {\n kind: \'error\';\n error: LlmFailure;\n };\n \'max-tokens\': {\n kind: \'max-tokens\';\n };\n interrupted: {\n kind: \'interrupted\';\n };\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypeRTCodec',
|
||||
declaration: 'export type TypeRTCodec = {\n readonly mode: \'strict\';\n readonly typeSymbol: string;\n readonly schema: TypeRTSchema;\n} | {\n readonly mode: \'src-json\';\n};',
|
||||
},
|
||||
{
|
||||
name: 'TypertContribution',
|
||||
declaration: 'export interface TypertContribution {\n readonly package: string;\n readonly face: TypertFace;\n readonly schemas: readonly TypertSchema[];\n readonly model: TypertPackageModel;\n}',
|
||||
declaration: 'export interface TypertContribution {\n readonly package: string;\n readonly face: TypertFace;\n readonly schemas: readonly TypertSchema[];\n readonly model: TypertPackageModel;\n readonly invocations?: readonly InvocationDescriptor[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypeRTDisposer',
|
||||
declaration: 'export type TypeRTDisposer = () => Promise<void>;',
|
||||
},
|
||||
{
|
||||
name: 'TypertDocTag',
|
||||
@@ -3077,6 +3111,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'TypertSchema',
|
||||
declaration: 'export interface TypertSchema {\n readonly name: string;\n readonly schema: z.ZodType;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypeRTSchema',
|
||||
declaration: 'export interface TypeRTSchema<Output = unknown> {\n parse(value: unknown): Output;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertSchemaFilter',
|
||||
declaration: 'export interface TypertSchemaFilter {\n readonly package?: string;\n readonly face?: TypertFace;\n}',
|
||||
|
||||
@@ -15,12 +15,17 @@
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./lib/types/types.d.ts",
|
||||
"default": "./lib/types/types.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
@@ -30,6 +35,7 @@
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-type-meta": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -38,6 +44,8 @@
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-type-meta": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { isPromise } from 'node:util/types'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { TypeRTContext, TypeRTLookup } from '@deepseek-ai/dsh-type-meta'
|
||||
import type { Agent, AgentOptions } from './types.ts'
|
||||
|
||||
export * from './types.ts'
|
||||
@@ -20,6 +21,16 @@ export * from './llm-target.ts'
|
||||
export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts'
|
||||
export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-type-meta' {
|
||||
interface TypeRTLookupMap {
|
||||
agent: TypeRTLookup<Agent, SessionId>
|
||||
}
|
||||
|
||||
interface TypeRTContextMap {
|
||||
agent: TypeRTContext<SessionId>
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
agents: AgentRegistry
|
||||
@@ -251,6 +262,20 @@ export class AgentRegistry extends Service {
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'agents')
|
||||
ctx.inject(['typert'], (typeCtx) => {
|
||||
typeCtx.typert.lookups.register('agent', {
|
||||
parameter: 'agent',
|
||||
wire: 'agentId',
|
||||
hostTypeSymbol: '@deepseek-ai/dsh-agent#Agent',
|
||||
wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId',
|
||||
resolve: sessionId => this.get(sessionId),
|
||||
})
|
||||
typeCtx.typert.contexts.registerHost('agent', {
|
||||
wire: 'agentId',
|
||||
wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId',
|
||||
resolve: sessionId => this.get(sessionId)?.ctx,
|
||||
})
|
||||
})
|
||||
// The `ctx.agent` DX accessor: default `undefined` on every context, so a
|
||||
// plain plugin context reads cleanly instead of hitting the Cordis
|
||||
// unknown-property throw. Each Agent.ctx shadows it with an own property
|
||||
|
||||
@@ -6,6 +6,7 @@ import AgentRegistry, {
|
||||
agentEvents,
|
||||
Inbox,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
|
||||
import type {
|
||||
Agent,
|
||||
@@ -142,6 +143,31 @@ describe('Inbox', () => {
|
||||
})
|
||||
|
||||
describe('AgentRegistry', () => {
|
||||
it('contributes Agent lookup and scoped Context providers while TypeRT is live', async () => {
|
||||
const ctx = new Context()
|
||||
const agentFiber = ctx.plugin(AgentRegistry)
|
||||
await agentFiber
|
||||
await ctx.plugin(TypertRegistry)
|
||||
const agent = stubAgent('remote-agent')
|
||||
const disposeAgent = ctx.agents.register(agent)
|
||||
|
||||
const lookup = ctx.typert.lookups.get('agent')
|
||||
expect(lookup).toMatchObject({
|
||||
parameter: 'agent',
|
||||
wire: 'agentId',
|
||||
hostTypeSymbol: '@deepseek-ai/dsh-agent#Agent',
|
||||
wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId',
|
||||
})
|
||||
expect(lookup?.resolve(agent.id)).toBe(agent)
|
||||
expect(ctx.typert.contexts.getHost('agent')?.resolve(agent.id)).toBe(agent.ctx)
|
||||
|
||||
disposeAgent()
|
||||
expect(lookup?.resolve(agent.id)).toBeUndefined()
|
||||
await agentFiber.dispose()
|
||||
expect(ctx.typert.lookups.get('agent')).toBeUndefined()
|
||||
expect(ctx.typert.contexts.getHost('agent')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('registers exact entries, emits lifecycle events, and unregisters on owner disposal', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/type-meta"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"@deepseek-ai/dsh-type-meta": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -45,6 +46,8 @@
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-type-meta": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
|
||||
import type { TypeRTLookup } from '@deepseek-ai/dsh-type-meta'
|
||||
import type { CreateSessionOptions, EpochHeader, PrepareSessionOptions, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
|
||||
import { snapshotJsonValue } from './json.ts'
|
||||
import { deriveEventMessage, SurfaceManager } from './surface.ts'
|
||||
@@ -105,6 +106,12 @@ declare module 'cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-type-meta' {
|
||||
interface TypeRTLookupMap {
|
||||
session: TypeRTLookup<Session, SessionId>
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate and freeze one detached creation header in place. */
|
||||
function validateSessionHeader(id: SessionId, input: unknown): SessionHeader {
|
||||
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
|
||||
@@ -803,6 +810,15 @@ export class SessionStore extends Service {
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'sessions')
|
||||
ctx.inject(['typert'], (typeCtx) => {
|
||||
typeCtx.typert.lookups.register('session', {
|
||||
parameter: 'session',
|
||||
wire: 'sessionId',
|
||||
hostTypeSymbol: '@deepseek-ai/dsh-session#Session',
|
||||
wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId',
|
||||
resolve: sessionId => this.get(sessionId),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
26
packages/core/session/tests/typert.spec.ts
Normal file
26
packages/core/session/tests/typert.spec.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
|
||||
describe('Session TypeRT provider', () => {
|
||||
it('contributes live Session lookup in either service load order', async () => {
|
||||
const ctx = new Context()
|
||||
const sessionFiber = ctx.plugin(SessionStore)
|
||||
await sessionFiber
|
||||
await ctx.plugin(TypertRegistry)
|
||||
const session = ctx.sessions.create(SessionId('remote-session'))
|
||||
|
||||
const lookup = ctx.typert.lookups.get('session')
|
||||
expect(lookup).toMatchObject({
|
||||
parameter: 'session',
|
||||
wire: 'sessionId',
|
||||
hostTypeSymbol: '@deepseek-ai/dsh-session#Session',
|
||||
wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId',
|
||||
})
|
||||
expect(lookup?.resolve(session.id)).toBe(session)
|
||||
|
||||
await sessionFiber.dispose()
|
||||
expect(ctx.typert.lookups.get('session')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -25,6 +25,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/type-meta"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
6
packages/host/api-gateway/README.i18n.yaml
Normal file
6
packages/host/api-gateway/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/api-gateway/README.md
|
||||
README.md: 3ef926ace2ee4d6008b1d6c18b1e070fa39bc176
|
||||
README.zh.md: 77b8b8a87d5f511000aac5cf9f75ebca5fcdfbca
|
||||
36
packages/host/api-gateway/README.md
Normal file
36
packages/host/api-gateway/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# @deepseek-ai/dsh-host-api-gateway
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Two-sided Remote control for Host and Client Cordis environments. The Host entry provides `ctx.typertGateway`, while `@deepseek-ai/dsh-host-api-gateway/client` provides `ctx.api`; both consume the same generated `InvocationDescriptor` contract and leave transport, request correlation, trust, and response envelopes to Connection.
|
||||
|
||||
## Host service: `TypertGatewayService` (ctx key: `typertGateway`)
|
||||
|
||||
`ctx.typertGateway.invoke()` resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, invokes the public business method, and validates its result. Business Services declare participation with `bindTypeRTGateway()` and `@Remote` or `@RemoteContext` from [`dsh-type-meta`](../../typert/type-meta/README.md).
|
||||
|
||||
Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use registered `ctx.typert.lookups` providers, while `@RemoteContext` resolves its receiver through a registered Host Context provider. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation.
|
||||
|
||||
The Host entry registers the trusted-host `/api2` unary RPC channel when Connection is available. Direct `invoke()` calls preserve business errors; `TypertGatewayError` distinguishes failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs.
|
||||
|
||||
## Client service: `ClientApi` (ctx key: `api`)
|
||||
|
||||
`ctx.api.mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Duplicate endpoints, namespace collisions, and descriptors without strict generated codecs fail before methods become callable.
|
||||
|
||||
Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api2', endpoint, ...)`. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject.
|
||||
|
||||
Generated declaration merges provide the TypeScript API. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the package dispatches application calls and registers no prompt, tool, or session event.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct effect; invoked business Services own any model-visible result.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- The Connection adapter currently maps dispatch and business failures to the RPC `internal` code with empty details. Structured `TypertGatewayError` categories remain available only to same-process callers.
|
||||
- SRC mode supports unique identifier parameters without destructuring, defaults, or rest parameters. It validates JSON safety rather than generated business types and never infers optional fields.
|
||||
- Only strict generated contributions can mount on the Client face. SRC markers have no Client codec or type projection.
|
||||
- The package dispatches unary methods only. Incremental Session data uses a separate named-stream protocol over the same Connection.
|
||||
36
packages/host/api-gateway/README.zh.md
Normal file
36
packages/host/api-gateway/README.zh.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# @deepseek-ai/dsh-host-api-gateway
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
为 Host 与 Client 两侧的 Cordis 环境提供 Remote 控制。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-host-api-gateway/client` 则提供 `ctx.api`;两者使用同一份生成的 `InvocationDescriptor` 契约,并将传输、请求关联、信任和响应封装交由 Connection 处理。
|
||||
|
||||
## Host 服务:`TypertGatewayService`(ctx key:`typertGateway`)
|
||||
|
||||
每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,调用公开的业务方法,并校验其结果。业务服务调用 `bindTypeRTGateway()` 并使用 [`dsh-type-meta`](../../typert/type-meta/README.md) 提供的 `@Remote` 或 `@RemoteContext` 装饰器,以显式声明接入。
|
||||
|
||||
严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用已向 `ctx.typert.lookups` 注册的提供方,`@RemoteContext` 则通过已注册的 Host Context 提供方解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。
|
||||
|
||||
Connection 可用时,Host 入口会注册 trusted-host 的 `/api2` 一元 RPC 通道。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。
|
||||
|
||||
## Client 服务:`ClientApi`(ctx key:`api`)
|
||||
|
||||
`ctx.api.mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。重复端点、命名空间冲突,以及缺少生成的严格编解码器的描述符,都会在方法可调用前报错。
|
||||
|
||||
每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api2', endpoint, ...)` 发送。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。
|
||||
|
||||
生成的声明合并提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无,因为该包分发应用调用,不注册任何提示词、工具或会话事件。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无直接影响;被调用的业务服务负责产生任何模型可见结果。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- Connection 适配器目前将分发故障和业务故障映射为 RPC 的 `internal` 代码,且不附带详细信息。结构化的 `TypertGatewayError` 类别仅供同进程调用方使用。
|
||||
- SRC 模式仅支持名称唯一的标识符参数,不支持解构、默认值或剩余参数。它只校验值能否安全表示为 JSON,不校验生成的业务类型,也绝不会推断可选字段。
|
||||
- Client 侧只能挂载严格模式生成的贡献项。SRC 标记不具备 Client 编解码器或类型投影。
|
||||
- 该包只分发一元方法。增量会话数据通过同一个 Connection 上独立的具名流协议传输。
|
||||
68
packages/host/api-gateway/package.json
Normal file
68
packages/host/api-gateway/package.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-host-api-gateway",
|
||||
"description": "Host dispatcher and Client API for TypeRT Remote invocations",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./lib/types/types.d.ts",
|
||||
"default": "./lib/types/types.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-typert-registry",
|
||||
"@deepseek-ai/dsh-client-connection"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-type-meta": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-typert-registry": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
370
packages/host/api-gateway/src/client/index.ts
Normal file
370
packages/host/api-gateway/src/client/index.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* Client projection of generated TypeRT Remote descriptors. Contributions
|
||||
* install concrete namespace methods; no JavaScript Proxy participates in
|
||||
* lookup, invocation, or type exposure.
|
||||
*/
|
||||
|
||||
import { Service } from 'cordis'
|
||||
import type { Context } from 'cordis'
|
||||
import type { ConnectionHandle, RpcError } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
InvocationDescriptor,
|
||||
TypeRTCodec,
|
||||
TypeRTDisposer,
|
||||
TypeRTRemoteContribution,
|
||||
TypeRTRemoteNamespaceMap,
|
||||
} from '@deepseek-ai/dsh-type-meta'
|
||||
|
||||
type RemoteMethod = (...args: unknown[]) => Promise<unknown>
|
||||
|
||||
interface MountToken {
|
||||
active: boolean
|
||||
readonly abort: AbortController
|
||||
}
|
||||
|
||||
interface DirectNamespaceRecord {
|
||||
readonly value: Record<string, RemoteMethod>
|
||||
readonly tokens: Map<string, MountToken>
|
||||
}
|
||||
|
||||
interface ScopedNamespaceRecord {
|
||||
readonly service: ScopedRemoteNamespace
|
||||
readonly tokens: Map<string, MountToken>
|
||||
}
|
||||
|
||||
interface ScopedProjection {
|
||||
readonly context: string
|
||||
readonly wire: string
|
||||
readonly codec: TypeRTCodec
|
||||
readonly parameterIndex?: number
|
||||
}
|
||||
|
||||
/** Typed API service augmented by generated direct Remote namespaces. */
|
||||
export interface ClientApi extends TypeRTRemoteNamespaceMap {
|
||||
/**
|
||||
* Mount one generated Host-for-Client contribution in the caller's fiber.
|
||||
* @param contribution - explicitly selected Remote package artifact.
|
||||
* @returns disposer withdrawing descriptors and concrete methods together.
|
||||
*/
|
||||
mount(contribution: TypeRTRemoteContribution): TypeRTDisposer
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** Generated direct Remote namespaces selected by the Client assembly. */
|
||||
api: ClientApi
|
||||
}
|
||||
}
|
||||
|
||||
/** Required Client services: the TypeRT registry and the existing Connection carrier. */
|
||||
export const inject = ['typert', 'connection']
|
||||
|
||||
/**
|
||||
* Install the typed Client API service.
|
||||
* @param ctx - Client Cordis root.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
new ClientApiService(ctx)
|
||||
}
|
||||
|
||||
class ClientApiService extends Service implements ClientApi {
|
||||
private readonly ownerCtx: Context
|
||||
private readonly direct = new Map<string, DirectNamespaceRecord>()
|
||||
private readonly scoped = new Map<string, ScopedNamespaceRecord>()
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'api')
|
||||
this.ownerCtx = ctx
|
||||
}
|
||||
|
||||
mount(contribution: TypeRTRemoteContribution): TypeRTDisposer {
|
||||
this.validateContribution(contribution)
|
||||
const callerCtx = this.ctx
|
||||
const disposeRemote = callerCtx.typert.remotes.register(contribution)
|
||||
let disposeMethods: () => void | Promise<void>
|
||||
try {
|
||||
disposeMethods = callerCtx.effect(() => {
|
||||
const installed = contribution.descriptors.map(descriptor => this.install(descriptor))
|
||||
return () => {
|
||||
for (const dispose of installed.reverse()) dispose()
|
||||
}
|
||||
}, `api-gateway.client.mount(${JSON.stringify(contribution.package)})`)
|
||||
} catch (error) {
|
||||
disposeRemote().catch(() => {})
|
||||
throw error
|
||||
}
|
||||
return async () => {
|
||||
await Promise.all([disposeMethods(), disposeRemote()])
|
||||
}
|
||||
}
|
||||
|
||||
private validateContribution(contribution: TypeRTRemoteContribution): void {
|
||||
const direct = new Map<string, Set<string>>()
|
||||
const scoped = new Map<string, Set<string>>()
|
||||
const add = (
|
||||
table: Map<string, Set<string>>,
|
||||
descriptor: InvocationDescriptor,
|
||||
kind: 'direct' | 'scoped',
|
||||
): void => {
|
||||
const methods = table.get(descriptor.namespace) ?? new Set<string>()
|
||||
if (methods.has(descriptor.method)) {
|
||||
throw new Error(`client api: contribution repeats ${kind} method ${endpointOf(descriptor)}`)
|
||||
}
|
||||
methods.add(descriptor.method)
|
||||
table.set(descriptor.namespace, methods)
|
||||
const live = kind === 'direct'
|
||||
? this.direct.get(descriptor.namespace)?.tokens
|
||||
: this.scoped.get(descriptor.namespace)?.tokens
|
||||
if (live?.has(descriptor.method) === true) {
|
||||
throw new Error(`client api: ${kind} method ${endpointOf(descriptor)} is already mounted`)
|
||||
}
|
||||
}
|
||||
for (const descriptor of contribution.descriptors) {
|
||||
requireStrictDescriptor(descriptor)
|
||||
if (descriptor.invocation.kind === 'direct') add(direct, descriptor, 'direct')
|
||||
if (scopedProjection(descriptor) !== undefined) add(scoped, descriptor, 'scoped')
|
||||
}
|
||||
for (const namespace of direct.keys()) {
|
||||
if (!this.direct.has(namespace) && namespace in this) {
|
||||
throw new Error(`client api: namespace ${JSON.stringify(namespace)} conflicts with the API service`)
|
||||
}
|
||||
}
|
||||
for (const [namespace, methods] of scoped) {
|
||||
const record = this.scoped.get(namespace)
|
||||
if (record !== undefined) {
|
||||
for (const method of methods) record.service.assertMethodAvailable(method)
|
||||
} else if (this.ownerCtx.reflect.props[namespace] !== undefined) {
|
||||
throw new Error(`client api: scoped namespace ${JSON.stringify(namespace)} conflicts with an existing Context property`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private install(descriptor: InvocationDescriptor): () => void {
|
||||
const token: MountToken = { active: true, abort: new AbortController() }
|
||||
const installed: (() => void)[] = []
|
||||
if (descriptor.invocation.kind === 'direct') {
|
||||
installed.push(this.installDirect(descriptor, token))
|
||||
}
|
||||
const projection = scopedProjection(descriptor)
|
||||
if (projection !== undefined) installed.push(this.installScoped(descriptor, projection, token))
|
||||
return () => {
|
||||
if (!token.active) return
|
||||
token.active = false
|
||||
for (const dispose of installed.reverse()) dispose()
|
||||
token.abort.abort()
|
||||
}
|
||||
}
|
||||
|
||||
private installDirect(descriptor: InvocationDescriptor, token: MountToken): () => void {
|
||||
let namespace = this.direct.get(descriptor.namespace)
|
||||
if (namespace === undefined) {
|
||||
namespace = { value: Object.create(null) as Record<string, RemoteMethod>, tokens: new Map() }
|
||||
this.direct.set(descriptor.namespace, namespace)
|
||||
Object.defineProperty(this, descriptor.namespace, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: namespace.value,
|
||||
})
|
||||
}
|
||||
namespace.tokens.set(descriptor.method, token)
|
||||
Object.defineProperty(namespace.value, descriptor.method, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: (...args: unknown[]) => this.invoke(descriptor, undefined, token, this.ownerCtx, args),
|
||||
})
|
||||
return () => {
|
||||
if (namespace.tokens.get(descriptor.method) !== token) return
|
||||
Reflect.deleteProperty(namespace.value, descriptor.method)
|
||||
namespace.tokens.delete(descriptor.method)
|
||||
if (namespace.tokens.size !== 0) return
|
||||
this.direct.delete(descriptor.namespace)
|
||||
Reflect.deleteProperty(this, descriptor.namespace)
|
||||
}
|
||||
}
|
||||
|
||||
private installScoped(
|
||||
descriptor: InvocationDescriptor,
|
||||
projection: ScopedProjection,
|
||||
token: MountToken,
|
||||
): () => void {
|
||||
let namespace = this.scoped.get(descriptor.namespace)
|
||||
if (namespace === undefined) {
|
||||
namespace = {
|
||||
service: new ScopedRemoteNamespace(
|
||||
this.ownerCtx,
|
||||
descriptor.namespace,
|
||||
(current, currentProjection, currentToken, caller, args) =>
|
||||
this.invoke(current, currentProjection, currentToken, caller, args),
|
||||
),
|
||||
tokens: new Map(),
|
||||
}
|
||||
this.scoped.set(descriptor.namespace, namespace)
|
||||
}
|
||||
namespace.tokens.set(descriptor.method, token)
|
||||
namespace.service.install(descriptor, projection, token)
|
||||
return () => {
|
||||
if (namespace.tokens.get(descriptor.method) !== token) return
|
||||
namespace.service.remove(descriptor.method)
|
||||
namespace.tokens.delete(descriptor.method)
|
||||
}
|
||||
}
|
||||
|
||||
private async invoke(
|
||||
descriptor: InvocationDescriptor,
|
||||
projection: ScopedProjection | undefined,
|
||||
token: MountToken,
|
||||
callerCtx: Context,
|
||||
values: readonly unknown[],
|
||||
): Promise<unknown> {
|
||||
const endpoint = endpointOf(descriptor)
|
||||
if (!token.active) throw new Error(`client api: Remote method ${endpoint} is no longer mounted`)
|
||||
const expected = descriptor.parameters.length - (projection?.parameterIndex === undefined ? 0 : 1)
|
||||
if (values.length !== expected) {
|
||||
throw new Error(
|
||||
`client api: ${endpoint} expected ${String(expected)} argument(s), got ${String(values.length)}`,
|
||||
)
|
||||
}
|
||||
const args: Record<string, unknown> = {}
|
||||
if (projection !== undefined) {
|
||||
const binder = this.ownerCtx.typert.contexts.getClient(projection.context)
|
||||
if (binder === undefined) {
|
||||
throw new Error(`client api: ${endpoint} has no Client Context binder for ${JSON.stringify(projection.context)}`)
|
||||
}
|
||||
const identity = binder.identity(callerCtx)
|
||||
if (identity === undefined) {
|
||||
throw new Error(`client api: ${endpoint} requires a ${JSON.stringify(projection.context)} Context`)
|
||||
}
|
||||
args[projection.wire] = parse(projection.codec, identity, endpoint, projection.wire)
|
||||
}
|
||||
let valueIndex = 0
|
||||
descriptor.parameters.forEach((parameter, parameterIndex) => {
|
||||
if (parameterIndex === projection?.parameterIndex) return
|
||||
args[parameter.wire] = parse(parameter.codec, values[valueIndex], endpoint, parameter.wire)
|
||||
valueIndex += 1
|
||||
})
|
||||
const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined
|
||||
if (connection === undefined) throw new Error(`client api: ${endpoint} has no active Connection`)
|
||||
const result = await connection.rpc.call('/api2', endpoint, { args }, token.abort.signal)
|
||||
if (!mountActive(token)) throw new Error(`client api: Remote method ${endpoint} was withdrawn during invocation`)
|
||||
if (!result.ok) throw remoteFailure(endpoint, result.error)
|
||||
return parse(descriptor.result, result.value, endpoint, 'result')
|
||||
}
|
||||
}
|
||||
|
||||
type InvokeRemote = (
|
||||
descriptor: InvocationDescriptor,
|
||||
projection: ScopedProjection,
|
||||
token: MountToken,
|
||||
callerCtx: Context,
|
||||
args: readonly unknown[],
|
||||
) => Promise<unknown>
|
||||
|
||||
class ScopedRemoteNamespace extends Service {
|
||||
private readonly ownerCtx: Context
|
||||
private readonly methods = new Set<string>()
|
||||
|
||||
constructor(
|
||||
ctx: Context,
|
||||
name: string,
|
||||
private readonly invokeRemote: InvokeRemote,
|
||||
) {
|
||||
super(ctx, name)
|
||||
this.ownerCtx = ctx
|
||||
}
|
||||
|
||||
assertMethodAvailable(method: string): void {
|
||||
if (method in this) {
|
||||
throw new Error(`client api: scoped method ${JSON.stringify(`${this.name}/${method}`)} conflicts with its namespace service`)
|
||||
}
|
||||
}
|
||||
|
||||
install(descriptor: InvocationDescriptor, projection: ScopedProjection, token: MountToken): void {
|
||||
this.assertMethodAvailable(descriptor.method)
|
||||
const method = descriptor.method
|
||||
Object.defineProperty(this, method, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: function (this: ScopedRemoteNamespace, ...args: unknown[]): Promise<unknown> {
|
||||
return this.invokeRemote(descriptor, projection, token, this.ctx, args)
|
||||
},
|
||||
})
|
||||
this.methods.add(method)
|
||||
if (this.methods.size === 1 && this.ownerCtx.get(this.name, false) === undefined) {
|
||||
this.ownerCtx.set(this.name, this)
|
||||
}
|
||||
}
|
||||
|
||||
remove(method: string): void {
|
||||
Reflect.deleteProperty(this, method)
|
||||
this.methods.delete(method)
|
||||
if (this.methods.size === 0) this.ownerCtx.set(this.name, undefined)
|
||||
}
|
||||
}
|
||||
|
||||
function endpointOf(descriptor: Pick<InvocationDescriptor, 'namespace' | 'method'>): string {
|
||||
return `${descriptor.namespace}/${descriptor.method}`
|
||||
}
|
||||
|
||||
function mountActive(token: MountToken): boolean {
|
||||
return token.active
|
||||
}
|
||||
|
||||
function scopedProjection(descriptor: InvocationDescriptor): ScopedProjection | undefined {
|
||||
if (descriptor.invocation.kind === 'context') {
|
||||
return {
|
||||
context: descriptor.invocation.context,
|
||||
wire: descriptor.invocation.wire,
|
||||
codec: descriptor.invocation.codec,
|
||||
}
|
||||
}
|
||||
if (descriptor.scope === undefined) return undefined
|
||||
const lookupParameters = descriptor.parameters
|
||||
.map((parameter, index) => ({ parameter, index }))
|
||||
.filter(candidate => candidate.parameter.source === 'lookup')
|
||||
const selected = lookupParameters.length === 1 ? lookupParameters[0] : undefined
|
||||
if (selected === undefined
|
||||
|| selected.parameter.wire !== descriptor.scope.wire
|
||||
|| selected.parameter.lookup !== descriptor.scope.context) {
|
||||
throw new Error(
|
||||
`client api: generated Remote ${endpointOf(descriptor)} scope must select its only lookup parameter`,
|
||||
)
|
||||
}
|
||||
return {
|
||||
context: descriptor.scope.context,
|
||||
wire: descriptor.scope.wire,
|
||||
codec: selected.parameter.codec,
|
||||
parameterIndex: selected.index,
|
||||
}
|
||||
}
|
||||
|
||||
function requireStrictDescriptor(descriptor: InvocationDescriptor): void {
|
||||
const endpoint = endpointOf(descriptor)
|
||||
requireStrictCodec(descriptor.result, endpoint, 'result')
|
||||
for (const parameter of descriptor.parameters) {
|
||||
requireStrictCodec(parameter.codec, endpoint, parameter.wire)
|
||||
}
|
||||
if (descriptor.invocation.kind === 'context') {
|
||||
requireStrictCodec(descriptor.invocation.codec, endpoint, descriptor.invocation.wire)
|
||||
}
|
||||
}
|
||||
|
||||
function requireStrictCodec(codec: TypeRTCodec, endpoint: string, field: string): void {
|
||||
if (codec.mode !== 'strict') {
|
||||
throw new Error(`client api: generated Remote ${endpoint} field ${JSON.stringify(field)} has no strict codec`)
|
||||
}
|
||||
}
|
||||
|
||||
function parse(codec: TypeRTCodec, value: unknown, endpoint: string, field: string): unknown {
|
||||
if (codec.mode !== 'strict') {
|
||||
throw new Error(`client api: generated Remote ${endpoint} field ${JSON.stringify(field)} has no strict codec`)
|
||||
}
|
||||
try {
|
||||
return codec.schema.parse(value)
|
||||
} catch (cause) {
|
||||
throw new Error(`client api: ${endpoint} rejected ${JSON.stringify(field)}`, { cause })
|
||||
}
|
||||
}
|
||||
|
||||
function remoteFailure(endpoint: string, error: RpcError): Error {
|
||||
return new Error(`client api: ${endpoint} failed: ${error.code}: ${error.message}`, { cause: error })
|
||||
}
|
||||
604
packages/host/api-gateway/src/index.ts
Normal file
604
packages/host/api-gateway/src/index.ts
Normal file
@@ -0,0 +1,604 @@
|
||||
/**
|
||||
* Live TypeRT Remote dispatch over Cordis Services and registered providers.
|
||||
* Transport, request correlation, and response envelopes belong to Connection.
|
||||
* @module @deepseek-ai/dsh-host-api-gateway
|
||||
*/
|
||||
|
||||
import { Context, Service, symbols } from 'cordis'
|
||||
import {
|
||||
remoteMethods,
|
||||
type InvocationDescriptor,
|
||||
type InvocationParameterDescriptor,
|
||||
type TypeRTCodec,
|
||||
type TypeRTGatewayBinding,
|
||||
type TypeRTLookupProvider,
|
||||
} from '@deepseek-ai/dsh-type-meta'
|
||||
import type {
|
||||
InvokeRemoteRequest,
|
||||
TypertGateway,
|
||||
TypertGatewayErrorCode,
|
||||
} from './types.ts'
|
||||
|
||||
export type {
|
||||
InvokeRemoteRequest,
|
||||
TypertGateway,
|
||||
TypertGatewayErrorCode,
|
||||
} from './types.ts'
|
||||
|
||||
interface GatewayErrorOptions {
|
||||
readonly cause?: unknown
|
||||
readonly field?: string
|
||||
}
|
||||
|
||||
interface ResolvedBinding {
|
||||
readonly binding: TypeRTGatewayBinding
|
||||
readonly original: object
|
||||
}
|
||||
|
||||
type ConnectionRpcResult =
|
||||
| { readonly ok: true; readonly value: unknown }
|
||||
| {
|
||||
readonly ok: false
|
||||
readonly error: {
|
||||
readonly code: 'internal'
|
||||
readonly message: string
|
||||
readonly details: Record<never, never>
|
||||
}
|
||||
}
|
||||
|
||||
interface HostConnectionLike {
|
||||
readonly rpc: {
|
||||
handle(
|
||||
channel: string,
|
||||
handler: (endpoint: string, payload: unknown, signal: AbortSignal) => Promise<ConnectionRpcResult>,
|
||||
options: { readonly authority: 'trusted-host' | 'loopback' },
|
||||
): () => Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
/** Dispatch failure produced outside the invoked business method. */
|
||||
export class TypertGatewayError extends Error {
|
||||
/** Machine-readable failure category. */
|
||||
readonly code: TypertGatewayErrorCode
|
||||
/** Canonical `<namespace>/<method>` endpoint. */
|
||||
readonly endpoint: string
|
||||
/** Affected wire field when the failure is field-specific. */
|
||||
readonly field: string | undefined
|
||||
|
||||
/**
|
||||
* Construct a Gateway failure without embedding boundary values in its message.
|
||||
* @param code - stable failure category.
|
||||
* @param endpoint - canonical Remote endpoint.
|
||||
* @param message - correction-oriented diagnostic without sensitive values.
|
||||
* @param options - optional field and contained cause.
|
||||
*/
|
||||
constructor(
|
||||
code: TypertGatewayErrorCode,
|
||||
endpoint: string,
|
||||
message: string,
|
||||
options: GatewayErrorOptions = {},
|
||||
) {
|
||||
super(`typert gateway: ${endpoint}: ${message}`, options.cause === undefined ? undefined : { cause: options.cause })
|
||||
this.name = 'TypertGatewayError'
|
||||
this.code = code
|
||||
this.endpoint = endpoint
|
||||
this.field = options.field
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve strict generated definitions or conservative SRC markers against
|
||||
* current Cordis Services and TypeRT providers.
|
||||
* @typert service typertGateway
|
||||
*/
|
||||
export class TypertGatewayService extends Service implements TypertGateway {
|
||||
static inject = ['typert']
|
||||
|
||||
/**
|
||||
* Register the Gateway against the active TypeRT registry.
|
||||
* @param ctx - owning Host Context with TypeRT registry access.
|
||||
*/
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'typertGateway')
|
||||
ctx.inject(['connection'], (connectionCtx) => {
|
||||
const connection = connectionCtx.get('connection') as unknown as HostConnectionLike
|
||||
connection.rpc.handle(
|
||||
'/api2',
|
||||
(endpoint, payload, signal) => this.dispatchRpc(endpoint, payload, signal),
|
||||
{ authority: 'trusted-host' },
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke one live Remote method through strict generated reflection or SRC markers.
|
||||
* @param request - decoded endpoint and exact named wire arguments.
|
||||
* @returns the validated business result.
|
||||
* @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity.
|
||||
*/
|
||||
async invoke(request: InvokeRemoteRequest): Promise<unknown> {
|
||||
const endpoint = endpointOf(request.namespace, request.method)
|
||||
const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint)
|
||||
assertExactArguments(request.args, descriptor, endpoint)
|
||||
const receiverContext = this.resolveReceiverContext(descriptor, request.args, endpoint)
|
||||
const receiver = receiverContext.get(descriptor.service) as unknown
|
||||
if (!isObject(receiver)) {
|
||||
throw new TypertGatewayError(
|
||||
'service-unavailable',
|
||||
endpoint,
|
||||
`active Service ${JSON.stringify(descriptor.service)} is unavailable`,
|
||||
)
|
||||
}
|
||||
validateBinding(receiver, descriptor.service, descriptor.namespace, endpoint)
|
||||
const args = descriptor.parameters.map(parameter => this.resolveParameter(parameter, request.args, endpoint))
|
||||
const implementation = descriptor.implementation ?? descriptor.method
|
||||
const method = Reflect.get(receiver, implementation) as unknown
|
||||
if (typeof method !== 'function') {
|
||||
throw new TypertGatewayError(
|
||||
'method-unavailable',
|
||||
endpoint,
|
||||
`active Service ${JSON.stringify(descriptor.service)} has no callable method ${JSON.stringify(implementation)}`,
|
||||
)
|
||||
}
|
||||
|
||||
const result = await Reflect.apply(method, receiver, args) as unknown
|
||||
return decode(descriptor.result, result, 'result-invalid', endpoint, 'result')
|
||||
}
|
||||
|
||||
private async dispatchRpc(
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
_signal: AbortSignal,
|
||||
): Promise<ConnectionRpcResult> {
|
||||
return this.invokeRpc(endpoint, payload)
|
||||
}
|
||||
|
||||
private async invokeRpc(endpoint: string, payload: unknown): Promise<ConnectionRpcResult> {
|
||||
try {
|
||||
const segments = endpoint.split('/')
|
||||
const namespace = segments[0]
|
||||
const method = segments[1]
|
||||
if (segments.length !== 2 || namespace === undefined || namespace === '' || method === undefined || method === '') {
|
||||
throw new Error(`invalid Remote endpoint ${JSON.stringify(endpoint)}`)
|
||||
}
|
||||
if (!isObject(payload)
|
||||
|| !isPlainObject(payload)
|
||||
|| Reflect.ownKeys(payload).length !== 1
|
||||
|| !Object.hasOwn(payload, 'args')
|
||||
|| !isObject(payload.args)
|
||||
|| !isPlainObject(payload.args)) {
|
||||
throw new Error('Remote payload must contain exactly one plain-object args field')
|
||||
}
|
||||
const value = await this.invoke({
|
||||
namespace,
|
||||
method,
|
||||
args: payload.args,
|
||||
})
|
||||
return { ok: true, value }
|
||||
} catch (error) {
|
||||
return rpcFailure(error)
|
||||
}
|
||||
}
|
||||
|
||||
private resolveDescriptor(namespace: string, method: string, endpoint: string): InvocationDescriptor {
|
||||
const strict = this.ctx.typert.local.get(endpoint)
|
||||
if (strict !== undefined) return strict
|
||||
if (this.ctx.typert.local.hasSeen(endpoint)) {
|
||||
throw new TypertGatewayError(
|
||||
'definition-unavailable',
|
||||
endpoint,
|
||||
'its strict definition was withdrawn and SRC fallback is forbidden',
|
||||
)
|
||||
}
|
||||
return this.resolveSrcDescriptor(namespace, method, endpoint)
|
||||
}
|
||||
|
||||
private resolveSrcDescriptor(namespace: string, method: string, endpoint: string): InvocationDescriptor {
|
||||
const candidates: InvocationDescriptor[] = []
|
||||
for (const [serviceKey, definition] of Object.entries(this.ctx.reflect.props)) {
|
||||
if (definition.type !== 'service') continue
|
||||
const receiver = this.ctx.get(serviceKey) as unknown
|
||||
if (!isObject(receiver)) continue
|
||||
const original = originalOf(receiver)
|
||||
const value = Reflect.get(original, 'typertGateway') as unknown
|
||||
if (value === undefined) continue
|
||||
const binding = readBinding(value, original, serviceKey, endpoint)
|
||||
if (binding.namespace !== namespace) continue
|
||||
const marker = remoteMethods(original).find(candidate => (candidate.exportName ?? candidate.method) === method)
|
||||
if (marker === undefined) continue
|
||||
candidates.push(this.srcDescriptor(binding, marker, method, endpoint))
|
||||
}
|
||||
if (candidates.length === 0) {
|
||||
throw new TypertGatewayError('invocation-unavailable', endpoint, 'no active Remote method exports this endpoint')
|
||||
}
|
||||
if (candidates.length > 1) {
|
||||
throw new TypertGatewayError(
|
||||
'ambiguous-endpoint',
|
||||
endpoint,
|
||||
`multiple active Services export this endpoint: ${candidates.map(candidate => candidate.service).sort().join(', ')}`,
|
||||
)
|
||||
}
|
||||
return candidates[0] as InvocationDescriptor
|
||||
}
|
||||
|
||||
private srcDescriptor(
|
||||
binding: TypeRTGatewayBinding,
|
||||
marker: ReturnType<typeof remoteMethods>[number],
|
||||
method: string,
|
||||
endpoint: string,
|
||||
): InvocationDescriptor {
|
||||
const names = methodParameterNames(binding.service, marker.method, endpoint)
|
||||
const parameters: InvocationParameterDescriptor[] = []
|
||||
const wires = new Set<string>()
|
||||
for (const name of names) {
|
||||
const matches = this.ctx.typert.lookups.keys()
|
||||
.map(key => ({ key, provider: this.ctx.typert.lookups.get(key) }))
|
||||
.filter((entry): entry is { key: string; provider: TypeRTLookupProvider } =>
|
||||
entry.provider?.parameter === name)
|
||||
if (matches.length > 1) {
|
||||
throw new TypertGatewayError(
|
||||
'signature-invalid',
|
||||
endpoint,
|
||||
`parameter ${JSON.stringify(name)} matches multiple lookup providers`,
|
||||
{ field: name },
|
||||
)
|
||||
}
|
||||
const match = matches[0]
|
||||
const parameter: InvocationParameterDescriptor = match === undefined
|
||||
? { name, wire: name, source: 'json', codec: { mode: 'src-json' } }
|
||||
: {
|
||||
name,
|
||||
wire: match.provider.wire,
|
||||
source: 'lookup',
|
||||
lookup: match.key,
|
||||
codec: { mode: 'src-json' },
|
||||
}
|
||||
if (wires.has(parameter.wire)) {
|
||||
throw new TypertGatewayError(
|
||||
'signature-invalid',
|
||||
endpoint,
|
||||
`multiple parameters use wire field ${JSON.stringify(parameter.wire)}`,
|
||||
{ field: parameter.wire },
|
||||
)
|
||||
}
|
||||
wires.add(parameter.wire)
|
||||
parameters.push(parameter)
|
||||
}
|
||||
|
||||
let receiver: InvocationDescriptor['invocation'] = { kind: 'direct' }
|
||||
if (marker.invocation.kind === 'context') {
|
||||
const provider = this.ctx.typert.contexts.getHost(marker.invocation.context)
|
||||
if (provider === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'context-unavailable',
|
||||
endpoint,
|
||||
`Context provider ${JSON.stringify(marker.invocation.context)} is unavailable`,
|
||||
)
|
||||
}
|
||||
if (wires.has(provider.wire)) {
|
||||
throw new TypertGatewayError(
|
||||
'signature-invalid',
|
||||
endpoint,
|
||||
`Context identity conflicts with wire field ${JSON.stringify(provider.wire)}`,
|
||||
{ field: provider.wire },
|
||||
)
|
||||
}
|
||||
receiver = {
|
||||
kind: 'context',
|
||||
context: marker.invocation.context,
|
||||
wire: provider.wire,
|
||||
codec: { mode: 'src-json' },
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: `src:${binding.serviceKey}#${endpoint}`,
|
||||
service: binding.serviceKey,
|
||||
namespace: binding.namespace,
|
||||
method,
|
||||
...(marker.method === method ? {} : { implementation: marker.method }),
|
||||
invocation: receiver,
|
||||
parameters,
|
||||
result: { mode: 'src-json' },
|
||||
}
|
||||
}
|
||||
|
||||
private resolveReceiverContext(
|
||||
descriptor: InvocationDescriptor,
|
||||
args: Readonly<Record<string, unknown>>,
|
||||
endpoint: string,
|
||||
): Context {
|
||||
if (descriptor.invocation.kind === 'direct') return this.ctx
|
||||
const invocation = descriptor.invocation
|
||||
const provider = this.ctx.typert.contexts.getHost(invocation.context)
|
||||
if (provider === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'context-unavailable',
|
||||
endpoint,
|
||||
`Context provider ${JSON.stringify(invocation.context)} is unavailable`,
|
||||
)
|
||||
}
|
||||
if (provider.wire !== invocation.wire
|
||||
|| (invocation.codec.mode === 'strict' && provider.wireTypeSymbol !== invocation.codec.typeSymbol)) {
|
||||
throw new TypertGatewayError(
|
||||
'provider-mismatch',
|
||||
endpoint,
|
||||
`Context provider ${JSON.stringify(invocation.context)} does not match its strict definition`,
|
||||
{ field: invocation.wire },
|
||||
)
|
||||
}
|
||||
const identity = decode(invocation.codec, args[invocation.wire], 'input-invalid', endpoint, invocation.wire)
|
||||
let context: Context | undefined
|
||||
try {
|
||||
context = provider.resolve(identity)
|
||||
} catch (cause) {
|
||||
throw new TypertGatewayError(
|
||||
'context-failed',
|
||||
endpoint,
|
||||
`Context provider ${JSON.stringify(invocation.context)} failed`,
|
||||
{ cause, field: invocation.wire },
|
||||
)
|
||||
}
|
||||
if (context === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'context-not-found',
|
||||
endpoint,
|
||||
`Context provider ${JSON.stringify(invocation.context)} did not resolve the requested identity`,
|
||||
{ field: invocation.wire },
|
||||
)
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
private resolveParameter(
|
||||
parameter: InvocationParameterDescriptor,
|
||||
args: Readonly<Record<string, unknown>>,
|
||||
endpoint: string,
|
||||
): unknown {
|
||||
const value = decode(parameter.codec, args[parameter.wire], 'input-invalid', endpoint, parameter.wire)
|
||||
if (parameter.source === 'json') return value
|
||||
const key = parameter.lookup
|
||||
if (key === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'lookup-unavailable',
|
||||
endpoint,
|
||||
`lookup parameter ${JSON.stringify(parameter.name)} has no provider key`,
|
||||
{ field: parameter.wire },
|
||||
)
|
||||
}
|
||||
const provider = this.ctx.typert.lookups.get(key)
|
||||
if (provider === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'lookup-unavailable',
|
||||
endpoint,
|
||||
`lookup provider ${JSON.stringify(key)} is unavailable`,
|
||||
{ field: parameter.wire },
|
||||
)
|
||||
}
|
||||
if (provider.wire !== parameter.wire
|
||||
|| (parameter.codec.mode === 'strict' && provider.wireTypeSymbol !== parameter.codec.typeSymbol)) {
|
||||
throw new TypertGatewayError(
|
||||
'provider-mismatch',
|
||||
endpoint,
|
||||
`lookup provider ${JSON.stringify(key)} does not match its strict definition`,
|
||||
{ field: parameter.wire },
|
||||
)
|
||||
}
|
||||
let resolved: unknown
|
||||
try {
|
||||
resolved = provider.resolve(value)
|
||||
} catch (cause) {
|
||||
throw new TypertGatewayError(
|
||||
'lookup-failed',
|
||||
endpoint,
|
||||
`lookup provider ${JSON.stringify(key)} failed`,
|
||||
{ cause, field: parameter.wire },
|
||||
)
|
||||
}
|
||||
if (resolved === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'lookup-not-found',
|
||||
endpoint,
|
||||
`lookup provider ${JSON.stringify(key)} did not resolve the requested identity`,
|
||||
{ field: parameter.wire },
|
||||
)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
|
||||
function rpcFailure(error: unknown): ConnectionRpcResult {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'internal',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
details: {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function endpointOf(namespace: string, method: string): string {
|
||||
return `${namespace}/${method}`
|
||||
}
|
||||
|
||||
function validateBinding(
|
||||
receiver: object,
|
||||
serviceKey: string,
|
||||
namespace: string,
|
||||
endpoint: string,
|
||||
): ResolvedBinding {
|
||||
const original = originalOf(receiver)
|
||||
const value = Reflect.get(original, 'typertGateway') as unknown
|
||||
if (value === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'binding-invalid',
|
||||
endpoint,
|
||||
`Service ${JSON.stringify(serviceKey)} has no visible typertGateway binding`,
|
||||
)
|
||||
}
|
||||
return {
|
||||
binding: readBinding(value, original, serviceKey, endpoint, namespace),
|
||||
original,
|
||||
}
|
||||
}
|
||||
|
||||
function readBinding(
|
||||
value: unknown,
|
||||
original: object,
|
||||
serviceKey: string,
|
||||
endpoint: string,
|
||||
namespace?: string,
|
||||
): TypeRTGatewayBinding {
|
||||
if (!isObject(value)
|
||||
|| Reflect.get(value, 'service') !== original
|
||||
|| Reflect.get(value, 'serviceKey') !== serviceKey
|
||||
|| typeof Reflect.get(value, 'namespace') !== 'string'
|
||||
|| (namespace !== undefined && Reflect.get(value, 'namespace') !== namespace)) {
|
||||
throw new TypertGatewayError(
|
||||
'binding-invalid',
|
||||
endpoint,
|
||||
`Service ${JSON.stringify(serviceKey)} has an inconsistent typertGateway binding`,
|
||||
)
|
||||
}
|
||||
return value as unknown as TypeRTGatewayBinding
|
||||
}
|
||||
|
||||
function originalOf(receiver: object): object {
|
||||
const original = Reflect.get(receiver, symbols.original) as unknown
|
||||
return isObject(original) ? original : receiver
|
||||
}
|
||||
|
||||
function methodParameterNames(service: object, method: string, endpoint: string): readonly string[] {
|
||||
let prototype: object | null = Object.getPrototypeOf(service) as object | null
|
||||
let implementation: ((this: object, ...args: never[]) => unknown) | undefined
|
||||
while (prototype !== null) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(prototype, method)
|
||||
if (descriptor !== undefined) {
|
||||
if ('value' in descriptor && typeof descriptor.value === 'function') {
|
||||
implementation = descriptor.value as (this: object, ...args: never[]) => unknown
|
||||
}
|
||||
break
|
||||
}
|
||||
prototype = Object.getPrototypeOf(prototype) as object | null
|
||||
}
|
||||
if (implementation === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'method-unavailable',
|
||||
endpoint,
|
||||
`Remote marker has no prototype method ${JSON.stringify(method)}`,
|
||||
)
|
||||
}
|
||||
const source = Function.prototype.toString.call(implementation)
|
||||
const open = source.indexOf('(')
|
||||
const close = source.indexOf(')', open + 1)
|
||||
if (open < 0 || close < 0) return invalidSignature(endpoint, method)
|
||||
const body = source.slice(open + 1, close).trim()
|
||||
if (body.length === 0) return []
|
||||
const parts = body.split(',').map(part => part.trim())
|
||||
if (parts.at(-1) === '') parts.pop()
|
||||
const names = new Set<string>()
|
||||
for (const part of parts) {
|
||||
if (!/^[$A-Z_a-z][$\w]*$/u.test(part) || names.has(part)) return invalidSignature(endpoint, method)
|
||||
names.add(part)
|
||||
}
|
||||
return [...names]
|
||||
}
|
||||
|
||||
function invalidSignature(endpoint: string, method: string): never {
|
||||
throw new TypertGatewayError(
|
||||
'signature-invalid',
|
||||
endpoint,
|
||||
`SRC method ${JSON.stringify(method)} must use unique identifier parameters without destructuring, defaults, or rest`,
|
||||
)
|
||||
}
|
||||
|
||||
function assertExactArguments(
|
||||
args: Readonly<Record<string, unknown>>,
|
||||
descriptor: InvocationDescriptor,
|
||||
endpoint: string,
|
||||
): void {
|
||||
if (!isPlainObject(args)) {
|
||||
throw new TypertGatewayError('arguments-invalid', endpoint, 'args must be a plain object')
|
||||
}
|
||||
const expected = new Set(descriptor.parameters.map(parameter => parameter.wire))
|
||||
if (descriptor.invocation.kind === 'context') expected.add(descriptor.invocation.wire)
|
||||
const actual = Reflect.ownKeys(args)
|
||||
const extra = actual.filter(key => typeof key !== 'string' || !expected.has(key))
|
||||
const missing = [...expected].filter(key => !Object.hasOwn(args, key))
|
||||
if (extra.length === 0 && missing.length === 0) return
|
||||
const clauses: string[] = []
|
||||
if (missing.length > 0) clauses.push(`missing ${missing.map(key => JSON.stringify(key)).join(', ')}`)
|
||||
if (extra.length > 0) clauses.push(`unexpected ${extra.map(key => JSON.stringify(String(key))).join(', ')}`)
|
||||
throw new TypertGatewayError('arguments-invalid', endpoint, `args fields do not match the descriptor: ${clauses.join('; ')}`)
|
||||
}
|
||||
|
||||
function decode(
|
||||
codec: TypeRTCodec,
|
||||
value: unknown,
|
||||
code: 'input-invalid' | 'result-invalid',
|
||||
endpoint: string,
|
||||
field: string,
|
||||
): unknown {
|
||||
try {
|
||||
if (codec.mode === 'strict') return codec.schema.parse(value)
|
||||
assertJsonValue(value, new Set())
|
||||
return value
|
||||
} catch (cause) {
|
||||
throw new TypertGatewayError(
|
||||
code,
|
||||
endpoint,
|
||||
code === 'input-invalid'
|
||||
? `wire field ${JSON.stringify(field)} failed boundary validation`
|
||||
: 'business result failed boundary validation',
|
||||
{ cause, field },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function assertJsonValue(value: unknown, ancestors: Set<object>): void {
|
||||
if (value === null || typeof value === 'string' || typeof value === 'boolean') return
|
||||
if (typeof value === 'number') {
|
||||
if (Number.isFinite(value)) return
|
||||
throw new TypeError('non-finite number is not JSON-safe')
|
||||
}
|
||||
if (!isObject(value)) throw new TypeError(`${typeof value} is not JSON-safe`)
|
||||
if (ancestors.has(value)) throw new TypeError('cyclic value is not JSON-safe')
|
||||
ancestors.add(value)
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
if (Object.getOwnPropertySymbols(value).length > 0 || Object.keys(value).length !== value.length) {
|
||||
throw new TypeError('sparse or decorated array is not JSON-safe')
|
||||
}
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
if (!Object.hasOwn(value, index)) throw new TypeError('sparse array is not JSON-safe')
|
||||
assertJsonValue(value[index], ancestors)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!isPlainObject(value)) throw new TypeError('non-plain object is not JSON-safe')
|
||||
if (Object.getOwnPropertySymbols(value).length > 0) throw new TypeError('symbol property is not JSON-safe')
|
||||
for (const key of Reflect.ownKeys(value)) {
|
||||
if (typeof key !== 'string') throw new TypeError('symbol property is not JSON-safe')
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key)
|
||||
if (descriptor === undefined || !descriptor.enumerable || !('value' in descriptor)) {
|
||||
throw new TypeError('non-data property is not JSON-safe')
|
||||
}
|
||||
assertJsonValue(descriptor.value, ancestors)
|
||||
}
|
||||
} finally {
|
||||
ancestors.delete(value)
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: object): value is Record<string, unknown> {
|
||||
if (Array.isArray(value)) return false
|
||||
const prototype = Object.getPrototypeOf(value) as object | null
|
||||
return prototype === null || prototype === Object.prototype
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is object {
|
||||
return (typeof value === 'object' && value !== null) || typeof value === 'function'
|
||||
}
|
||||
|
||||
export default TypertGatewayService
|
||||
30
packages/host/api-gateway/src/invariant.ts
Normal file
30
packages/host/api-gateway/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-host-api-gateway`.
|
||||
* @module @deepseek-ai/dsh-host-api-gateway/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-host-api-gateway'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'host-api-gateway-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: Host calls re-read authoritative Cordis and TypeRT
|
||||
* state, while Client methods and descriptors mutate in one owned effect.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
52
packages/host/api-gateway/src/types.ts
Normal file
52
packages/host/api-gateway/src/types.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Carrier-independent TypeRT Gateway request, service, and error contracts.
|
||||
* @module @deepseek-ai/dsh-host-api-gateway/types
|
||||
*/
|
||||
|
||||
/** One Remote method request after a carrier has decoded its envelope. */
|
||||
export interface InvokeRemoteRequest {
|
||||
/** Remote namespace selected by the generated descriptor. */
|
||||
readonly namespace: string
|
||||
/** Exported Service method name. */
|
||||
readonly method: string
|
||||
/** Named wire values; fields must exactly match the descriptor. */
|
||||
readonly args: Readonly<Record<string, unknown>>
|
||||
}
|
||||
|
||||
/** Stable infrastructure and boundary failures emitted before or after business execution. */
|
||||
export type TypertGatewayErrorCode =
|
||||
| 'ambiguous-endpoint'
|
||||
| 'arguments-invalid'
|
||||
| 'binding-invalid'
|
||||
| 'context-failed'
|
||||
| 'context-not-found'
|
||||
| 'context-unavailable'
|
||||
| 'definition-unavailable'
|
||||
| 'input-invalid'
|
||||
| 'invocation-unavailable'
|
||||
| 'lookup-failed'
|
||||
| 'lookup-not-found'
|
||||
| 'lookup-unavailable'
|
||||
| 'method-unavailable'
|
||||
| 'provider-mismatch'
|
||||
| 'result-invalid'
|
||||
| 'service-unavailable'
|
||||
| 'signature-invalid'
|
||||
|
||||
/** Host dispatcher consumed by Connection adapters. */
|
||||
export interface TypertGateway {
|
||||
/**
|
||||
* Invoke one live Remote method without assuming a carrier or response envelope.
|
||||
* @param request - decoded endpoint and named wire arguments.
|
||||
* @returns the validated business result.
|
||||
* @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity.
|
||||
*/
|
||||
invoke(request: InvokeRemoteRequest): Promise<unknown>
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** Host dispatcher for TypeRT Remote calls. */
|
||||
typertGateway: TypertGateway
|
||||
}
|
||||
}
|
||||
222
packages/host/api-gateway/tests/client.spec.ts
Normal file
222
packages/host/api-gateway/tests/client.spec.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
InvocationDescriptor,
|
||||
TypeRTContext,
|
||||
TypeRTRemoteContextApi,
|
||||
TypeRTRemoteNamespace,
|
||||
} from '@deepseek-ai/dsh-type-meta'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-type-meta' {
|
||||
interface TypeRTContextMap {
|
||||
fixture: TypeRTContext<string>
|
||||
}
|
||||
|
||||
interface TypeRTRemoteMap {
|
||||
'goals/create': (agentId: string, request: { readonly objective: string }) => Promise<{ readonly ref: string }>
|
||||
}
|
||||
|
||||
interface TypeRTRemoteContextMap {
|
||||
'fixture:goals/create': (request: { readonly objective: string }) => Promise<{ readonly ref: string }>
|
||||
'fixture:goals/rename': (request: { readonly objective: string }) => Promise<{ readonly renamed: boolean }>
|
||||
}
|
||||
|
||||
interface TypeRTRemoteNamespaceMap {
|
||||
goals: TypeRTRemoteNamespace<'goals'>
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
type FixtureContext = Context & TypeRTRemoteContextApi<'fixture'>
|
||||
|
||||
const idSchema = z.string().min(1)
|
||||
const requestSchema = z.object({ objective: z.string().min(1) })
|
||||
const createResultSchema = z.object({ ref: z.string().min(1) })
|
||||
const renameResultSchema = z.object({ renamed: z.boolean() })
|
||||
|
||||
function directDescriptor(): InvocationDescriptor {
|
||||
return {
|
||||
id: '@fixture/goals#goals/create',
|
||||
service: 'goals',
|
||||
namespace: 'goals',
|
||||
method: 'create',
|
||||
invocation: { kind: 'direct' },
|
||||
scope: { context: 'fixture', wire: 'agentId' },
|
||||
parameters: [{
|
||||
name: 'agent',
|
||||
wire: 'agentId',
|
||||
source: 'lookup',
|
||||
lookup: 'fixture',
|
||||
codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema },
|
||||
}, {
|
||||
name: 'request',
|
||||
wire: 'request',
|
||||
source: 'json',
|
||||
codec: { mode: 'strict', typeSymbol: '@fixture#CreateRequest', schema: requestSchema },
|
||||
}],
|
||||
result: { mode: 'strict', typeSymbol: '@fixture#CreateResult', schema: createResultSchema },
|
||||
}
|
||||
}
|
||||
|
||||
function contextDescriptor(): InvocationDescriptor {
|
||||
return {
|
||||
id: '@fixture/goals#goals/rename',
|
||||
service: 'goals',
|
||||
namespace: 'goals',
|
||||
method: 'rename',
|
||||
invocation: {
|
||||
kind: 'context',
|
||||
context: 'fixture',
|
||||
wire: 'agentId',
|
||||
codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema },
|
||||
},
|
||||
parameters: [{
|
||||
name: 'request',
|
||||
wire: 'request',
|
||||
source: 'json',
|
||||
codec: { mode: 'strict', typeSymbol: '@fixture#RenameRequest', schema: requestSchema },
|
||||
}],
|
||||
result: { mode: 'strict', typeSymbol: '@fixture#RenameResult', schema: renameResultSchema },
|
||||
}
|
||||
}
|
||||
|
||||
async function bench(call: ConnectionHandle['rpc']['call']): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TypertRegistry)
|
||||
ctx.provide('connection', { rpc: { call } } as unknown as ConnectionHandle)
|
||||
await ctx.plugin({ inject, apply })
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('Client TypeRT API', () => {
|
||||
it('mounts concrete direct methods, validates both boundaries, and withdraws retained handles', async () => {
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
.mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
|
||||
const ctx = await bench(call)
|
||||
let retained: typeof ctx.api.goals.create | undefined
|
||||
const assembly = ctx.plugin(Object.assign(
|
||||
(scope: Context) => {
|
||||
scope.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })
|
||||
retained = scope.api.goals.create
|
||||
},
|
||||
{ inject: ['api'] },
|
||||
))
|
||||
await assembly
|
||||
|
||||
await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ref: 'goal-1' })
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
'/api2',
|
||||
'goals/create',
|
||||
{ args: { agentId: 'agent-1', request: { objective: 'ship' } } },
|
||||
expect.any(AbortSignal),
|
||||
)
|
||||
await expect(ctx.api.goals.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"')
|
||||
|
||||
call.mockResolvedValueOnce({ ok: true, value: { ref: 1 } })
|
||||
await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('rejected "result"')
|
||||
|
||||
await assembly.dispose()
|
||||
expect((ctx.api as unknown as Record<string, unknown>).goals).toBeUndefined()
|
||||
expect(ctx.get('goals')).toBeUndefined()
|
||||
expect(ctx.typert.remotes.list()).toEqual([])
|
||||
await expect(retained?.('agent-1', { objective: 'ship' })).rejects.toThrow('no longer mounted')
|
||||
})
|
||||
|
||||
it('projects one direct lookup descriptor onto an Agent-scoped alias', async () => {
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
.mockResolvedValue({ ok: true, value: { ref: 'goal-2' } })
|
||||
const ctx = await bench(call)
|
||||
const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext
|
||||
ctx.typert.contexts.registerClient('fixture', {
|
||||
identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
|
||||
})
|
||||
const assembly = ctx.plugin(Object.assign(
|
||||
(scope: Context) => {
|
||||
scope.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })
|
||||
},
|
||||
{ inject: ['api'] },
|
||||
))
|
||||
await assembly
|
||||
|
||||
await expect(agentCtx.goals.create({ objective: 'ship scoped' })).resolves.toEqual({ ref: 'goal-2' })
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
'/api2',
|
||||
'goals/create',
|
||||
{ args: { agentId: 'agent-2', request: { objective: 'ship scoped' } } },
|
||||
expect.any(AbortSignal),
|
||||
)
|
||||
await expect((ctx as FixtureContext).goals.create({ objective: 'wrong scope' }))
|
||||
.rejects.toThrow('requires a "fixture" Context')
|
||||
|
||||
await assembly.dispose()
|
||||
expect((ctx.api as unknown as Record<string, unknown>).goals).toBeUndefined()
|
||||
expect(ctx.get('goals')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses the caller Context identity for scoped namespace methods', async () => {
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
.mockResolvedValue({ ok: true, value: { renamed: true } })
|
||||
const ctx = await bench(call)
|
||||
const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext
|
||||
ctx.typert.contexts.registerClient('fixture', {
|
||||
identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
|
||||
})
|
||||
const assembly = ctx.plugin(Object.assign(
|
||||
(scope: Context) => {
|
||||
scope.api.mount({ package: '@fixture/goals', descriptors: [contextDescriptor()] })
|
||||
},
|
||||
{ inject: ['api'] },
|
||||
))
|
||||
await assembly
|
||||
|
||||
await expect(agentCtx.goals.rename({ objective: 'land' })).resolves.toEqual({ renamed: true })
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
'/api2',
|
||||
'goals/rename',
|
||||
{ args: { agentId: 'agent-2', request: { objective: 'land' } } },
|
||||
expect.any(AbortSignal),
|
||||
)
|
||||
await expect((ctx as FixtureContext).goals.rename({ objective: 'land' }))
|
||||
.rejects.toThrow('requires a "fixture" Context')
|
||||
|
||||
await assembly.dispose()
|
||||
expect(ctx.get('goals')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects weak descriptors and namespace collisions before registration', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const weak: InvocationDescriptor = {
|
||||
...directDescriptor(),
|
||||
result: { mode: 'src-json' },
|
||||
}
|
||||
|
||||
expect(() => ctx.api.mount({ package: '@fixture/weak', descriptors: [weak] }))
|
||||
.toThrow('has no strict codec')
|
||||
expect(() => ctx.api.mount({
|
||||
package: '@fixture/conflict',
|
||||
descriptors: [{ ...directDescriptor(), namespace: 'mount' }],
|
||||
})).toThrow('conflicts with the API service')
|
||||
expect(ctx.typert.remotes.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('throws RPC failures with the structured error as its cause', async () => {
|
||||
const rpcError = { code: 'internal' as const, message: 'host failed', details: {} }
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>().mockResolvedValue({ ok: false, error: rpcError }))
|
||||
ctx.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })
|
||||
|
||||
let failure: unknown
|
||||
try {
|
||||
await ctx.api.goals.create('agent-1', { objective: 'ship' })
|
||||
} catch (error) {
|
||||
failure = error
|
||||
}
|
||||
expect(failure).toBeInstanceOf(Error)
|
||||
if (!(failure instanceof Error)) throw new Error('expected Client API invocation to fail')
|
||||
expect(failure.message).toContain('internal: host failed')
|
||||
expect(failure.cause).toBe(rpcError)
|
||||
})
|
||||
})
|
||||
795
packages/host/api-gateway/tests/gateway.spec.ts
Normal file
795
packages/host/api-gateway/tests/gateway.spec.ts
Normal file
@@ -0,0 +1,795 @@
|
||||
import { createServer } from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context, Service, symbols } from 'cordis'
|
||||
import { z } from 'zod'
|
||||
import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection'
|
||||
import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import {
|
||||
bindTypeRTGateway,
|
||||
Remote,
|
||||
RemoteContext,
|
||||
type InvocationDescriptor,
|
||||
type TypeRTContext,
|
||||
type TypeRTLookup,
|
||||
type TypeRTLookupProvider,
|
||||
} from '@deepseek-ai/dsh-type-meta'
|
||||
import TypertRegistry, { type TypertContribution } from '@deepseek-ai/dsh-typert-registry'
|
||||
import TypertGatewayService, { TypertGatewayError } from '@deepseek-ai/dsh-host-api-gateway'
|
||||
|
||||
interface FixtureAgent {
|
||||
readonly id: string
|
||||
}
|
||||
|
||||
interface MarkedContext extends Context {
|
||||
readonly fixtureScope?: string
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-type-meta' {
|
||||
interface TypeRTLookupMap {
|
||||
gatewayFixture: TypeRTLookup<FixtureAgent, string>
|
||||
gatewayFixtureAlias: TypeRTLookup<FixtureAgent, string>
|
||||
}
|
||||
|
||||
interface TypeRTContextMap {
|
||||
gatewayFixture: TypeRTContext<string>
|
||||
}
|
||||
}
|
||||
|
||||
const emptyModel: TypertContribution['model'] = {
|
||||
services: [],
|
||||
events: [],
|
||||
objects: [],
|
||||
}
|
||||
|
||||
class GoalService extends Service {
|
||||
readonly typertGateway = bindTypeRTGateway(this, 'goals')
|
||||
readonly calls: string[] = []
|
||||
nextResult: unknown = undefined
|
||||
businessError: Error | undefined
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'goals')
|
||||
}
|
||||
|
||||
@Remote
|
||||
create(agent: FixtureAgent, request: { readonly title: string }): unknown {
|
||||
this.calls.push('create')
|
||||
return {
|
||||
agentId: agent.id,
|
||||
title: request.title,
|
||||
scope: (this.ctx as MarkedContext).fixtureScope ?? 'root',
|
||||
}
|
||||
}
|
||||
|
||||
@RemoteContext('gatewayFixture')
|
||||
rename(request: { readonly title: string }): unknown {
|
||||
this.calls.push('rename')
|
||||
return { title: request.title, scope: (this.ctx as MarkedContext).fixtureScope ?? 'root' }
|
||||
}
|
||||
|
||||
@Remote
|
||||
passthrough(value: unknown): unknown {
|
||||
this.calls.push('passthrough')
|
||||
return this.nextResult === undefined ? value : this.nextResult
|
||||
}
|
||||
|
||||
@Remote
|
||||
fail(request: unknown): never {
|
||||
void request
|
||||
this.calls.push('fail')
|
||||
throw this.businessError ?? new Error('fixture business failure')
|
||||
}
|
||||
|
||||
strictOnly(request: { readonly title: string }): unknown {
|
||||
this.calls.push('strictOnly')
|
||||
return this.nextResult === undefined ? request : this.nextResult
|
||||
}
|
||||
}
|
||||
|
||||
type FakeRpcResult =
|
||||
| { readonly ok: true; readonly value: unknown }
|
||||
| { readonly ok: false; readonly error: { readonly code: 'internal'; readonly message: string; readonly details: object } }
|
||||
|
||||
type FakeRpcHandler = (endpoint: string, payload: unknown, signal: AbortSignal) => Promise<FakeRpcResult>
|
||||
|
||||
class FakeConnectionService extends Service {
|
||||
channel: string | undefined
|
||||
authority: string | undefined
|
||||
handler: FakeRpcHandler | undefined
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'connection')
|
||||
}
|
||||
|
||||
get rpc() {
|
||||
const owner = this.ctx
|
||||
return {
|
||||
handle: (channel: string, handler: FakeRpcHandler, options: { readonly authority: string }) =>
|
||||
owner.effect(() => {
|
||||
this.channel = channel
|
||||
this.authority = options.authority
|
||||
this.handler = handler
|
||||
return () => {
|
||||
this.channel = undefined
|
||||
this.authority = undefined
|
||||
this.handler = undefined
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fakeHttpServer(routes: WebRoute[]): Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> {
|
||||
return {
|
||||
register(route) {
|
||||
if (routes.some(candidate => candidate.kind === route.kind && candidate.path === route.path)) {
|
||||
throw new Error(`duplicate route ${route.path}`)
|
||||
}
|
||||
routes.push(route)
|
||||
return () => { routes.splice(routes.indexOf(route), 1) }
|
||||
},
|
||||
tapIndex: () => () => {},
|
||||
port: 0,
|
||||
}
|
||||
}
|
||||
|
||||
async function serveRoute(route: WebRoute): Promise<{ readonly origin: string; close(): Promise<void> }> {
|
||||
const server = createServer((request, response) => {
|
||||
void route.handler(request, response)
|
||||
})
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const address = server.address() as AddressInfo
|
||||
return {
|
||||
origin: `http://127.0.0.1:${String(address.port)}`,
|
||||
close: () => new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error === undefined || error === null) resolve()
|
||||
else reject(error)
|
||||
})
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
class FirstSharedService extends Service {
|
||||
readonly typertGateway = bindTypeRTGateway(this, 'firstShared', { namespace: 'shared' })
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'firstShared')
|
||||
}
|
||||
|
||||
@Remote
|
||||
run(value: string): string {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
class SecondSharedService extends Service {
|
||||
readonly typertGateway = bindTypeRTGateway(this, 'secondShared', { namespace: 'shared' })
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'secondShared')
|
||||
}
|
||||
|
||||
@Remote
|
||||
run(value: string): string {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
class DefaultParameterService extends Service {
|
||||
readonly typertGateway = bindTypeRTGateway(this, 'defaultParameter', { namespace: 'invalid-default' })
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'defaultParameter')
|
||||
}
|
||||
|
||||
@Remote
|
||||
run(value = 'fallback'): string {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
class DestructuredParameterService extends Service {
|
||||
readonly typertGateway = bindTypeRTGateway(this, 'destructuredParameter', { namespace: 'invalid-destructure' })
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'destructuredParameter')
|
||||
}
|
||||
|
||||
@Remote
|
||||
run({ value }: { readonly value: string }): string {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
class RestParameterService extends Service {
|
||||
readonly typertGateway = bindTypeRTGateway(this, 'restParameter', { namespace: 'invalid-rest' })
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'restParameter')
|
||||
}
|
||||
|
||||
@Remote
|
||||
run(...values: readonly unknown[]): string {
|
||||
return values.map(String).join(',')
|
||||
}
|
||||
}
|
||||
|
||||
class WrongBindingService extends Service {
|
||||
readonly typertGateway = bindTypeRTGateway(this, 'notWrongBinding', { namespace: 'wrong-binding' })
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'wrongBinding')
|
||||
}
|
||||
|
||||
@Remote
|
||||
run(value: string): string {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
describe('TypertGatewayService', () => {
|
||||
it('invokes a strict direct method with schema decoding and a live lookup', async () => {
|
||||
const { ctx, service } = await setup()
|
||||
const agent = { id: 'agent-1' }
|
||||
registerAgentLookup(ctx, agent)
|
||||
registerStrict(ctx, [createDescriptor()])
|
||||
const caller = ctx.extend({ fixtureScope: 'direct-caller' })
|
||||
|
||||
await expect(caller.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'create',
|
||||
args: { agentId: 'agent-1', request: { title: ' ship ' } },
|
||||
})).resolves.toEqual({ agentId: 'agent-1', title: 'ship', scope: 'direct-caller' })
|
||||
expect(service.calls).toEqual(['create'])
|
||||
})
|
||||
|
||||
it('resolves strict Remote Context identity without adding a business argument', async () => {
|
||||
const { ctx, service } = await setup()
|
||||
const scoped = ctx.extend({ fixtureScope: 'agent-scope' })
|
||||
ctx.typert.contexts.registerHost('gatewayFixture', contextProvider(scoped))
|
||||
registerStrict(ctx, [renameDescriptor()])
|
||||
|
||||
await expect(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'rename',
|
||||
args: { agentId: 'agent-1', request: { title: 'land' } },
|
||||
})).resolves.toEqual({ title: 'land', scope: 'agent-scope' })
|
||||
expect(service.calls).toEqual(['rename'])
|
||||
})
|
||||
|
||||
it('derives SRC direct lookup and JSON parameters from marker and parameter names', async () => {
|
||||
const { ctx } = await setup()
|
||||
const agent = { id: 'agent-1' }
|
||||
registerAgentLookup(ctx, agent)
|
||||
const caller = ctx.extend({ fixtureScope: 'direct-src' })
|
||||
|
||||
await expect(caller.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'create',
|
||||
args: { agentId: 'agent-1', request: { title: 'ship' } },
|
||||
})).resolves.toEqual({ agentId: 'agent-1', title: 'ship', scope: 'direct-src' })
|
||||
})
|
||||
|
||||
it('derives SRC Remote Context identity and preserves the scoped Proxy receiver', async () => {
|
||||
const { ctx } = await setup()
|
||||
const scoped = ctx.extend({ fixtureScope: 'agent-src' })
|
||||
ctx.typert.contexts.registerHost('gatewayFixture', contextProvider(scoped))
|
||||
|
||||
await expect(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'rename',
|
||||
args: { agentId: 'agent-1', request: { title: 'land' } },
|
||||
})).resolves.toEqual({ title: 'land', scope: 'agent-src' })
|
||||
})
|
||||
|
||||
it('re-reads Service and providers on every strict invocation', async () => {
|
||||
const { ctx, serviceFiber } = await setup()
|
||||
const agent = { id: 'agent-1' }
|
||||
const disposeLookup = registerAgentLookup(ctx, agent)
|
||||
registerStrict(ctx, [createDescriptor()])
|
||||
|
||||
await disposeLookup()
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'create',
|
||||
args: { agentId: 'agent-1', request: { title: 'ship' } },
|
||||
}), 'lookup-unavailable')
|
||||
|
||||
registerAgentLookup(ctx, agent)
|
||||
await serviceFiber.dispose()
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'create',
|
||||
args: { agentId: 'agent-1', request: { title: 'ship' } },
|
||||
}), 'service-unavailable')
|
||||
})
|
||||
|
||||
it('re-reads and contains Context providers', async () => {
|
||||
const { ctx } = await setup()
|
||||
const scoped = ctx.extend()
|
||||
const dispose = ctx.typert.contexts.registerHost('gatewayFixture', contextProvider(scoped))
|
||||
registerStrict(ctx, [renameDescriptor()])
|
||||
|
||||
await dispose()
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'rename',
|
||||
args: { agentId: 'agent-1', request: { title: 'land' } },
|
||||
}), 'context-unavailable')
|
||||
|
||||
ctx.typert.contexts.registerHost('gatewayFixture', {
|
||||
...contextProvider(scoped),
|
||||
resolve: () => { throw new Error('provider failed') },
|
||||
})
|
||||
const error = await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'rename',
|
||||
args: { agentId: 'agent-1', request: { title: 'land' } },
|
||||
}), 'context-failed')
|
||||
expect(error.cause).toEqual(new Error('provider failed'))
|
||||
})
|
||||
|
||||
it('never downgrades an observed strict endpoint after definition disposal', async () => {
|
||||
const { ctx } = await setup()
|
||||
const dispose = registerStrict(ctx, [passthroughDescriptor()])
|
||||
await dispose()
|
||||
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'passthrough',
|
||||
args: { value: 'would pass through SRC' },
|
||||
}), 'definition-unavailable')
|
||||
})
|
||||
|
||||
it('seeds the no-downgrade guard from definitions present before Gateway startup', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TypertRegistry)
|
||||
const dispose = registerStrict(ctx, [passthroughDescriptor()])
|
||||
await ctx.plugin(TypertGatewayService)
|
||||
await ctx.plugin(GoalService)
|
||||
await dispose()
|
||||
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'passthrough',
|
||||
args: { value: 'would pass through SRC' },
|
||||
}), 'definition-unavailable')
|
||||
})
|
||||
|
||||
it('retains the no-downgrade guard across Gateway Service reloads', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TypertRegistry)
|
||||
const gatewayFiber = ctx.plugin(TypertGatewayService)
|
||||
await gatewayFiber
|
||||
await ctx.plugin(GoalService)
|
||||
const dispose = registerStrict(ctx, [passthroughDescriptor()])
|
||||
await dispose()
|
||||
|
||||
await gatewayFiber.dispose()
|
||||
await ctx.plugin(TypertGatewayService)
|
||||
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'passthrough',
|
||||
args: { value: 'would pass through SRC' },
|
||||
}), 'definition-unavailable')
|
||||
})
|
||||
|
||||
it('rejects ambiguous SRC endpoints independently of reflection order', async () => {
|
||||
const ctx = await setupGateway()
|
||||
await ctx.plugin(FirstSharedService)
|
||||
await ctx.plugin(SecondSharedService)
|
||||
|
||||
const error = await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'shared',
|
||||
method: 'run',
|
||||
args: { value: 'ship' },
|
||||
}), 'ambiguous-endpoint')
|
||||
expect(error.message).toContain('firstShared, secondShared')
|
||||
})
|
||||
|
||||
it('rejects SRC signatures that cannot map one wire field to each position', async () => {
|
||||
const cases = [
|
||||
{ plugin: DefaultParameterService, namespace: 'invalid-default', args: { value: 'x' } },
|
||||
{ plugin: DestructuredParameterService, namespace: 'invalid-destructure', args: { value: { value: 'x' } } },
|
||||
{ plugin: RestParameterService, namespace: 'invalid-rest', args: { values: ['x'] } },
|
||||
] as const
|
||||
for (const testCase of cases) {
|
||||
const ctx = await setupGateway()
|
||||
await ctx.plugin(testCase.plugin)
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: testCase.namespace,
|
||||
method: 'run',
|
||||
args: testCase.args,
|
||||
}), 'signature-invalid')
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a SRC parameter matching more than one lookup provider', async () => {
|
||||
const { ctx } = await setup()
|
||||
const provider = agentLookup({ id: 'agent-1' })
|
||||
ctx.typert.lookups.register('gatewayFixture', provider)
|
||||
ctx.typert.lookups.register('gatewayFixtureAlias', provider)
|
||||
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'create',
|
||||
args: { agentId: 'agent-1', request: { title: 'ship' } },
|
||||
}), 'signature-invalid')
|
||||
})
|
||||
|
||||
it('requires exact wire fields before invoking business code', async () => {
|
||||
const { ctx, service } = await setup()
|
||||
registerAgentLookup(ctx, { id: 'agent-1' })
|
||||
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'create',
|
||||
args: { request: { title: 'ship' } },
|
||||
}), 'arguments-invalid')
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'create',
|
||||
args: { agentId: 'agent-1', request: { title: 'ship' }, optional: true },
|
||||
}), 'arguments-invalid')
|
||||
expect(service.calls).toEqual([])
|
||||
})
|
||||
|
||||
it('distinguishes strict input and result validation failures', async () => {
|
||||
const { ctx, service } = await setup()
|
||||
registerStrict(ctx, [strictOnlyDescriptor()])
|
||||
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'strictOnly',
|
||||
args: { request: { title: 1 } },
|
||||
}), 'input-invalid')
|
||||
|
||||
service.nextResult = { title: 1 }
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'strictOnly',
|
||||
args: { request: { title: 'ship' } },
|
||||
}), 'result-invalid')
|
||||
})
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
Number.NaN,
|
||||
Number.POSITIVE_INFINITY,
|
||||
1n,
|
||||
Symbol('value'),
|
||||
() => 'value',
|
||||
new Date(0),
|
||||
new Map(),
|
||||
[, 'sparse'],
|
||||
])('rejects non-JSON SRC input %#', async (value) => {
|
||||
const { ctx } = await setup()
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'passthrough',
|
||||
args: { value },
|
||||
}), 'input-invalid')
|
||||
})
|
||||
|
||||
it('rejects cyclic SRC input and non-JSON SRC results', async () => {
|
||||
const { ctx, service } = await setup()
|
||||
const cyclic: { self?: unknown } = {}
|
||||
cyclic.self = cyclic
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'passthrough',
|
||||
args: { value: cyclic },
|
||||
}), 'input-invalid')
|
||||
|
||||
service.nextResult = new Date(0)
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'passthrough',
|
||||
args: { value: null },
|
||||
}), 'result-invalid')
|
||||
})
|
||||
|
||||
it('validates strict provider identity against generated wire metadata', async () => {
|
||||
const { ctx } = await setup()
|
||||
ctx.typert.lookups.register('gatewayFixture', {
|
||||
...agentLookup({ id: 'agent-1' }),
|
||||
wire: 'differentAgentId',
|
||||
})
|
||||
registerStrict(ctx, [createDescriptor()])
|
||||
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'create',
|
||||
args: { agentId: 'agent-1', request: { title: 'ship' } },
|
||||
}), 'provider-mismatch')
|
||||
})
|
||||
|
||||
it('validates binding identity and active method availability', async () => {
|
||||
const ctx = await setupGateway()
|
||||
await ctx.plugin(WrongBindingService)
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'wrong-binding',
|
||||
method: 'run',
|
||||
args: { value: 'ship' },
|
||||
}), 'binding-invalid')
|
||||
|
||||
await ctx.plugin(GoalService)
|
||||
registerStrict(ctx, [{ ...passthroughDescriptor(), method: 'missing' }])
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'missing',
|
||||
args: { value: 'ship' },
|
||||
}), 'method-unavailable')
|
||||
})
|
||||
|
||||
it('preserves business exception identity after invocation begins', async () => {
|
||||
const { ctx, service } = await setup()
|
||||
const failure = new Error('business identity')
|
||||
service.businessError = failure
|
||||
|
||||
await expect(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'fail',
|
||||
args: { request: { reason: 'fixture' } },
|
||||
})).rejects.toBe(failure)
|
||||
})
|
||||
|
||||
it('reports an absent endpoint without retaining receiver state', async () => {
|
||||
const { ctx } = await setup()
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'absent',
|
||||
args: {},
|
||||
}), 'invocation-unavailable')
|
||||
})
|
||||
|
||||
it('mounts /api2 through an optional Connection and returns existing RPC results', async () => {
|
||||
const ctx = new Context().extend({ fixtureScope: 'rpc-caller' })
|
||||
await ctx.plugin(TypertRegistry)
|
||||
await ctx.plugin(FakeConnectionService)
|
||||
const gatewayFiber = ctx.plugin(TypertGatewayService)
|
||||
await gatewayFiber
|
||||
await ctx.plugin(GoalService)
|
||||
const connection = rawConnection(ctx)
|
||||
expect(connection).toMatchObject({ channel: '/api2', authority: 'trusted-host' })
|
||||
|
||||
registerAgentLookup(ctx, { id: 'agent-1' })
|
||||
registerStrict(ctx, [createDescriptor()])
|
||||
const signal = new AbortController().signal
|
||||
const handler = connection.handler
|
||||
if (handler === undefined) throw new Error('fixture Connection did not retain the /api2 handler')
|
||||
await expect(handler('goals/create', {
|
||||
args: { agentId: 'agent-1', request: { title: 'ship' } },
|
||||
}, signal)).resolves.toEqual({
|
||||
ok: true,
|
||||
value: { agentId: 'agent-1', title: 'ship', scope: 'rpc-caller' },
|
||||
})
|
||||
const invalid = await handler('goals/create', { invalid: true }, signal)
|
||||
expect(invalid).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'internal' },
|
||||
})
|
||||
if (invalid.ok) throw new Error('invalid Remote payload unexpectedly succeeded')
|
||||
expect(invalid.error.message).toMatch(/exactly one plain-object args field/)
|
||||
|
||||
await gatewayFiber.dispose()
|
||||
expect(connection.handler).toBeUndefined()
|
||||
})
|
||||
|
||||
it('dispatches a generated invocation through the real /api2 HTTP carrier', async () => {
|
||||
const ctx = new Context().extend({ fixtureScope: 'http-caller' })
|
||||
const routes: WebRoute[] = []
|
||||
ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
|
||||
const connectionFiber = ctx.plugin({ inject: [...connectionInject], apply: applyConnection })
|
||||
await connectionFiber
|
||||
await ctx.plugin(TypertRegistry)
|
||||
const gatewayFiber = ctx.plugin(TypertGatewayService)
|
||||
await gatewayFiber
|
||||
const goalFiber = ctx.plugin(GoalService)
|
||||
await goalFiber
|
||||
const removeLookup = registerAgentLookup(ctx, { id: 'agent-1' })
|
||||
const removeStrict = registerStrict(ctx, [createDescriptor()])
|
||||
expect(routes).toHaveLength(1)
|
||||
const server = await serveRoute(routes[0]!)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${server.origin}/api2/goals/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'client-request',
|
||||
rpcId: 'rpc-http',
|
||||
method: 'goals/create',
|
||||
payload: { args: { agentId: 'agent-1', request: { title: ' ship ' } } },
|
||||
}),
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
type: 'server-response',
|
||||
rpcId: 'rpc-http',
|
||||
result: {
|
||||
ok: true,
|
||||
value: { agentId: 'agent-1', title: 'ship', scope: 'http-caller' },
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
await server.close()
|
||||
await removeStrict()
|
||||
await removeLookup()
|
||||
await goalFiber.dispose()
|
||||
await gatewayFiber.dispose()
|
||||
await connectionFiber.dispose()
|
||||
}
|
||||
expect(routes).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
async function setup(): Promise<{
|
||||
readonly ctx: Context
|
||||
readonly service: GoalService
|
||||
readonly serviceFiber: ReturnType<Context['plugin']>
|
||||
}> {
|
||||
const ctx = await setupGateway()
|
||||
const serviceFiber = ctx.plugin(GoalService)
|
||||
await serviceFiber
|
||||
return { ctx, service: rawGoalService(ctx), serviceFiber }
|
||||
}
|
||||
|
||||
async function setupGateway(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TypertRegistry)
|
||||
await ctx.plugin(TypertGatewayService)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function rawGoalService(ctx: Context): GoalService {
|
||||
const receiver = ctx.get('goals') as unknown as GoalService & { [symbols.original]?: GoalService }
|
||||
return receiver[symbols.original] ?? receiver
|
||||
}
|
||||
|
||||
function rawConnection(ctx: Context): FakeConnectionService {
|
||||
const receiver = ctx.get('connection') as unknown as FakeConnectionService & {
|
||||
[symbols.original]?: FakeConnectionService
|
||||
}
|
||||
return receiver[symbols.original] ?? receiver
|
||||
}
|
||||
|
||||
function registerStrict(ctx: Context, descriptors: readonly InvocationDescriptor[]): () => Promise<void> {
|
||||
return ctx.typert.register({
|
||||
package: '@fixture/gateway',
|
||||
face: 'host',
|
||||
schemas: [],
|
||||
model: emptyModel,
|
||||
invocations: descriptors,
|
||||
})
|
||||
}
|
||||
|
||||
function registerAgentLookup(ctx: Context, agent: FixtureAgent): () => Promise<void> {
|
||||
return ctx.typert.lookups.register('gatewayFixture', agentLookup(agent))
|
||||
}
|
||||
|
||||
function agentLookup(agent: FixtureAgent): TypeRTLookupProvider<FixtureAgent, string> {
|
||||
return {
|
||||
parameter: 'agent',
|
||||
wire: 'agentId',
|
||||
hostTypeSymbol: '@fixture/domain#Agent',
|
||||
wireTypeSymbol: '@fixture/domain#AgentId',
|
||||
resolve: id => id === agent.id ? agent : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function contextProvider(context: Context) {
|
||||
return {
|
||||
wire: 'agentId',
|
||||
wireTypeSymbol: '@fixture/domain#AgentId',
|
||||
resolve: (id: string) => id === 'agent-1' ? context : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function strictCodec(typeSymbol: string, schema: z.ZodType): InvocationDescriptor['result'] {
|
||||
return { mode: 'strict', typeSymbol, schema }
|
||||
}
|
||||
|
||||
function createDescriptor(): InvocationDescriptor {
|
||||
return {
|
||||
id: '@fixture/gateway#goals/create',
|
||||
service: 'goals',
|
||||
namespace: 'goals',
|
||||
method: 'create',
|
||||
invocation: { kind: 'direct' },
|
||||
parameters: [
|
||||
{
|
||||
name: 'agent',
|
||||
wire: 'agentId',
|
||||
source: 'lookup',
|
||||
lookup: 'gatewayFixture',
|
||||
codec: strictCodec('@fixture/domain#AgentId', z.string()),
|
||||
},
|
||||
{
|
||||
name: 'request',
|
||||
wire: 'request',
|
||||
source: 'json',
|
||||
codec: strictCodec('@fixture/gateway#CreateRequest', z.object({
|
||||
title: z.string().transform(value => value.trim()),
|
||||
})),
|
||||
},
|
||||
],
|
||||
result: strictCodec('@fixture/gateway#CreateResult', z.object({
|
||||
agentId: z.string(),
|
||||
title: z.string(),
|
||||
scope: z.string(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function renameDescriptor(): InvocationDescriptor {
|
||||
return {
|
||||
id: '@fixture/gateway#goals/rename',
|
||||
service: 'goals',
|
||||
namespace: 'goals',
|
||||
method: 'rename',
|
||||
invocation: {
|
||||
kind: 'context',
|
||||
context: 'gatewayFixture',
|
||||
wire: 'agentId',
|
||||
codec: strictCodec('@fixture/domain#AgentId', z.string()),
|
||||
},
|
||||
parameters: [{
|
||||
name: 'request',
|
||||
wire: 'request',
|
||||
source: 'json',
|
||||
codec: strictCodec('@fixture/gateway#RenameRequest', z.object({ title: z.string() })),
|
||||
}],
|
||||
result: strictCodec('@fixture/gateway#RenameResult', z.object({
|
||||
title: z.string(),
|
||||
scope: z.string(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function passthroughDescriptor(): InvocationDescriptor {
|
||||
return {
|
||||
id: '@fixture/gateway#goals/passthrough',
|
||||
service: 'goals',
|
||||
namespace: 'goals',
|
||||
method: 'passthrough',
|
||||
invocation: { kind: 'direct' },
|
||||
parameters: [{
|
||||
name: 'value',
|
||||
wire: 'value',
|
||||
source: 'json',
|
||||
codec: { mode: 'src-json' },
|
||||
}],
|
||||
result: { mode: 'src-json' },
|
||||
}
|
||||
}
|
||||
|
||||
function strictOnlyDescriptor(): InvocationDescriptor {
|
||||
const value = strictCodec('@fixture/gateway#StrictValue', z.object({ title: z.string() }))
|
||||
return {
|
||||
id: '@fixture/gateway#goals/strictOnly',
|
||||
service: 'goals',
|
||||
namespace: 'goals',
|
||||
method: 'strictOnly',
|
||||
invocation: { kind: 'direct' },
|
||||
parameters: [{ name: 'request', wire: 'request', source: 'json', codec: value }],
|
||||
result: value,
|
||||
}
|
||||
}
|
||||
|
||||
async function expectCode(
|
||||
promise: Promise<unknown>,
|
||||
code: TypertGatewayError['code'],
|
||||
): Promise<TypertGatewayError> {
|
||||
try {
|
||||
await promise
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(TypertGatewayError)
|
||||
expect(error).toMatchObject({ code })
|
||||
return error as TypertGatewayError
|
||||
}
|
||||
throw new Error(`expected TypertGatewayError ${code}`)
|
||||
}
|
||||
27
packages/host/api-gateway/tsconfig.json
Normal file
27
packages/host/api-gateway/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../client/connection"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/type-meta"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/host/api-gateway/tsdown.config.ts
Normal file
3
packages/host/api-gateway/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../../client/tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-host-api-gateway', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
@@ -72,6 +72,11 @@ export type {
|
||||
// ---- Errors and ids ----
|
||||
export { RpcId, transportError } from './rpc.ts'
|
||||
export type { RpcError, RpcErrorCode, RpcErrorDetailsMap, RpcResult } from './rpc.ts'
|
||||
export {
|
||||
clientRequestSchema,
|
||||
serverRequestSchema,
|
||||
serverResponseSchema,
|
||||
} from './rpc.schema.ts'
|
||||
|
||||
// ---- Fixed session-search product bounds ----
|
||||
export {
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.13",
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -15,6 +15,8 @@ import type {
|
||||
EnumMemberModel,
|
||||
ExportModel,
|
||||
FaceModel,
|
||||
InvocationModel,
|
||||
InvocationParameterModel,
|
||||
JsDocTagModel,
|
||||
KeywordTypeName,
|
||||
MemberBase,
|
||||
@@ -23,6 +25,8 @@ import type {
|
||||
ObjectModel,
|
||||
PackageModel,
|
||||
ParameterModel,
|
||||
RemoteBoundaryModel,
|
||||
RemoteTypeImportModel,
|
||||
SchemaModel,
|
||||
ServiceModel,
|
||||
SignatureModel,
|
||||
@@ -122,6 +126,25 @@ interface ModuleIdentity {
|
||||
readonly subpath: string
|
||||
}
|
||||
|
||||
interface StaticLookupDeclaration {
|
||||
readonly key: string
|
||||
readonly hostSymbol: SymbolId
|
||||
readonly wireType: ts.TypeNode
|
||||
readonly site: ts.Node
|
||||
}
|
||||
|
||||
interface StaticContextDeclaration {
|
||||
readonly key: string
|
||||
readonly wireType: ts.TypeNode
|
||||
readonly site: ts.Node
|
||||
}
|
||||
|
||||
interface GatewayBinding {
|
||||
readonly service: string
|
||||
readonly namespace: string
|
||||
readonly site: ts.PropertyDeclaration
|
||||
}
|
||||
|
||||
type ReferenceSite = ts.TypeReferenceNode | ts.ExpressionWithTypeArguments | ts.ImportTypeNode
|
||||
|
||||
const EMPTY_DOCUMENTATION: DocumentationModel = { tags: [] }
|
||||
@@ -453,15 +476,11 @@ export class WorkspaceAnalyzer {
|
||||
config: this.caches.config(configPath),
|
||||
manifest,
|
||||
}
|
||||
const packagePath = slash(relative(this.options.root, packageRoot))
|
||||
const clientPackage = packagePath === 'packages/client' || packagePath.startsWith('packages/client/')
|
||||
if (clientPackage && isDualFacePackage(manifest)) {
|
||||
if (isDualFacePackage(manifest)) {
|
||||
registrations.push({ ...registration, face: 'host', exportSubpaths: hostExportSubpaths(manifest) })
|
||||
registrations.push({ ...registration, face: 'client', exportSubpaths: clientExportSubpaths(manifest) })
|
||||
} else if (clientPackage) {
|
||||
registrations.push({ ...registration, face: 'client' })
|
||||
} else {
|
||||
registrations.push({ ...registration, face: 'host' })
|
||||
registrations.push(registration)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -480,6 +499,7 @@ export class WorkspaceAnalyzer {
|
||||
&& subpath !== './package.json'
|
||||
&& subpath !== './typert'
|
||||
&& subpath !== './client/typert'
|
||||
&& subpath !== './remote'
|
||||
&& !target.endsWith('.json'))
|
||||
.map(([, target]) => sourcePathForExport(registration.root, target))
|
||||
.filter(existsSync)
|
||||
@@ -578,6 +598,8 @@ class FaceAnalyzer {
|
||||
private readonly nodes = new Map<TypeNodeId, TypeNodeModel>()
|
||||
private readonly exportsByPackage = new Map<string, ExportRecord[]>()
|
||||
private readonly nodeOrdinals = new Map<string, number>()
|
||||
private staticLookups: readonly StaticLookupDeclaration[] | undefined
|
||||
private staticContexts: ReadonlyMap<string, StaticContextDeclaration> | undefined
|
||||
|
||||
constructor(options: FaceAnalyzerOptions) {
|
||||
this.root = options.root
|
||||
@@ -601,6 +623,7 @@ class FaceAnalyzer {
|
||||
const packages = this.registrations
|
||||
.map(registration => this.analyzePackage(registration))
|
||||
.filter(hasPackageSurface)
|
||||
this.validateInvocationIdentity(packages)
|
||||
return {
|
||||
face: this.face,
|
||||
packages,
|
||||
@@ -634,6 +657,7 @@ class FaceAnalyzer {
|
||||
}
|
||||
}
|
||||
}
|
||||
const explicitServices = this.collectExplicitServices(records)
|
||||
|
||||
const objects: ObjectModel[] = []
|
||||
const schemas: SchemaModel[] = []
|
||||
@@ -672,10 +696,14 @@ class FaceAnalyzer {
|
||||
root: slash(relative(this.root, registration.root)),
|
||||
exports: records.map(record => record.model)
|
||||
.sort((left, right) => left.subpath.localeCompare(right.subpath) || left.name.localeCompare(right.name)),
|
||||
services: uniqueBy(services, service => service.key).sort((left, right) => left.key.localeCompare(right.key)),
|
||||
services: uniqueBy([...explicitServices, ...services], service => service.key)
|
||||
.sort((left, right) => left.key.localeCompare(right.key)),
|
||||
events: uniqueBy(events, event => event.name).sort((left, right) => left.name.localeCompare(right.name)),
|
||||
objects: objects.sort((left, right) => left.export.name.localeCompare(right.export.name)),
|
||||
schemas: schemas.sort((left, right) => left.export.name.localeCompare(right.export.name)),
|
||||
invocations: this.face === 'host'
|
||||
? this.collectInvocations(registration, reachable).sort((left, right) => left.id.localeCompare(right.id))
|
||||
: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -686,7 +714,7 @@ class FaceAnalyzer {
|
||||
const records: ExportRecord[] = []
|
||||
for (const [subpath, target] of targets) {
|
||||
if (target.includes('*') || subpath === './package.json'
|
||||
|| subpath === './typert' || subpath === './client/typert'
|
||||
|| subpath === './typert' || subpath === './client/typert' || subpath === './remote'
|
||||
// Data exports (bundle patch lists, JSON manifests) carry no TypeScript API.
|
||||
|| target.endsWith('.json') || target.endsWith('.yml') || target.endsWith('.yaml')) continue
|
||||
const sourcePath = sourcePathForExport(registration.root, target)
|
||||
@@ -849,6 +877,740 @@ class FaceAnalyzer {
|
||||
return result
|
||||
}
|
||||
|
||||
private collectExplicitServices(records: readonly ExportRecord[]): ServiceModel[] {
|
||||
const result: ServiceModel[] = []
|
||||
const seen = new Set<SymbolId>()
|
||||
for (const record of records) {
|
||||
const tag = typertServiceTag(record.declaration)
|
||||
if (tag === undefined) continue
|
||||
const words = (ts.getTextOfJSDocComment(tag.comment) ?? '').trim().split(/\s+/)
|
||||
if (words.length !== 2 || !isRemoteSegment(words[1] ?? '')) {
|
||||
this.fail(tag, '@typert service requires exactly one nonempty Cordis service key without "/"')
|
||||
}
|
||||
if (!ts.isClassDeclaration(record.declaration)) {
|
||||
this.fail(record.declaration, '@typert service requires an exported class')
|
||||
}
|
||||
const symbol = this.resolveSymbol(record.symbol)
|
||||
const symbolId = this.symbolId(symbol)
|
||||
if (seen.has(symbolId)) continue
|
||||
seen.add(symbolId)
|
||||
const model = this.ensureDeclaration(symbol, record.declaration)
|
||||
result.push({
|
||||
...documentationOf(record.declaration),
|
||||
key: words[1] as string,
|
||||
symbol: symbolId,
|
||||
export: record.model,
|
||||
members: model.members.filter(exposableMember).map(member => member.id),
|
||||
location: this.location(record.declaration),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private collectInvocations(
|
||||
registration: PackageRegistration,
|
||||
reachable: readonly ts.SourceFile[],
|
||||
): InvocationModel[] {
|
||||
const result: InvocationModel[] = []
|
||||
for (const sourceFile of reachable) {
|
||||
for (const statement of sourceFile.statements) {
|
||||
if (!ts.isClassDeclaration(statement)) continue
|
||||
const marked = statement.members.flatMap((member) => {
|
||||
const invocation = this.remoteMarker(member)
|
||||
if (invocation === undefined) return []
|
||||
if (!ts.isMethodDeclaration(member)) {
|
||||
this.fail(member, 'Remote decorators require a public instance method')
|
||||
}
|
||||
return [{ method: member, invocation }]
|
||||
})
|
||||
const first = marked[0]
|
||||
if (first === undefined) continue
|
||||
const binding = this.gatewayBinding(statement)
|
||||
if (binding === undefined) {
|
||||
this.fail(first.method, 'Remote methods require readonly typertGateway = bindTypeRTGateway(this, serviceKey)')
|
||||
}
|
||||
for (const { method, invocation } of marked) {
|
||||
result.push(this.invocationModel(registration, binding, method, invocation))
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private invocationModel(
|
||||
registration: PackageRegistration,
|
||||
binding: GatewayBinding,
|
||||
method: ts.MethodDeclaration,
|
||||
invocation:
|
||||
| { readonly kind: 'direct'; readonly exportName?: string }
|
||||
| { readonly kind: 'context'; readonly context: string; readonly exportName?: string },
|
||||
): InvocationModel {
|
||||
if (visibilityOf(method) !== 'public' || hasModifier(method, ts.SyntaxKind.StaticKeyword)) {
|
||||
this.fail(method, 'Remote decorators require a public instance method')
|
||||
}
|
||||
if (hasModifier(method, ts.SyntaxKind.AbstractKeyword) || method.body === undefined) {
|
||||
this.fail(method, 'Remote methods must have a concrete implementation')
|
||||
}
|
||||
if (!ts.isIdentifier(method.name)) {
|
||||
this.fail(method, 'Remote method names must be identifiers')
|
||||
}
|
||||
if ((method.typeParameters?.length ?? 0) > 0) {
|
||||
this.fail(method, 'generic Remote methods are not supported')
|
||||
}
|
||||
const methodName = method.name.text
|
||||
const exportedMethod = invocation.exportName ?? methodName
|
||||
|
||||
const lookups = this.lookupDeclarations()
|
||||
const lookupByHost = new Map(lookups.map(lookup => [lookup.hostSymbol, lookup]))
|
||||
const parameters: InvocationParameterModel[] = []
|
||||
const wires = new Set<string>()
|
||||
for (const parameter of method.parameters) {
|
||||
if (!ts.isIdentifier(parameter.name)) {
|
||||
this.fail(parameter, 'Remote parameters must use identifier bindings')
|
||||
}
|
||||
if (parameter.dotDotDotToken !== undefined) this.fail(parameter, 'Remote parameters cannot be rest parameters')
|
||||
if (parameter.initializer !== undefined) this.fail(parameter, 'Remote parameters cannot have default values')
|
||||
if (parameter.questionToken !== undefined) this.fail(parameter, 'Remote parameters cannot be optional')
|
||||
if (parameter.name.text === 'this') this.fail(parameter, 'Remote methods cannot declare an explicit this parameter')
|
||||
const authoredType = this.requiredType(parameter, parameter.type, 'parameter')
|
||||
const hostSymbol = this.symbolAtType(authoredType)
|
||||
const lookup = hostSymbol === undefined ? undefined : lookupByHost.get(this.symbolId(hostSymbol))
|
||||
let modeled: InvocationParameterModel
|
||||
if (lookup !== undefined) {
|
||||
if (parameter.name.text !== lookup.key) {
|
||||
this.fail(parameter, `lookup parameter for ${lookup.key} must also be named ${lookup.key}`)
|
||||
}
|
||||
const boundary = this.remoteBoundary(
|
||||
lookup.wireType,
|
||||
`${registration.name}#${binding.namespace}/${exportedMethod}:${lookup.key}Id`,
|
||||
true,
|
||||
)
|
||||
modeled = {
|
||||
name: parameter.name.text,
|
||||
wire: `${lookup.key}Id`,
|
||||
source: 'lookup',
|
||||
lookup: lookup.key,
|
||||
boundary,
|
||||
}
|
||||
} else {
|
||||
if (hostSymbol !== undefined && this.isWorkspaceClass(hostSymbol)) {
|
||||
this.fail(parameter, `non-JSON class parameter ${hostSymbol.name} requires a TypeRTLookupMap entry`)
|
||||
}
|
||||
modeled = {
|
||||
name: parameter.name.text,
|
||||
wire: parameter.name.text,
|
||||
source: 'json',
|
||||
boundary: this.remoteBoundary(
|
||||
authoredType,
|
||||
`${registration.name}#${binding.namespace}/${exportedMethod}:${parameter.name.text}`,
|
||||
false,
|
||||
),
|
||||
}
|
||||
}
|
||||
if (wires.has(modeled.wire)) this.fail(parameter, `duplicate Remote wire field ${modeled.wire}`)
|
||||
wires.add(modeled.wire)
|
||||
parameters.push(modeled)
|
||||
}
|
||||
|
||||
let receiver: InvocationModel['invocation'] = { kind: 'direct' }
|
||||
if (invocation.kind === 'context') {
|
||||
const context = this.contextDeclarations().get(invocation.context)
|
||||
if (context === undefined) {
|
||||
this.fail(method, `Remote Context ${invocation.context} has no TypeRTContextMap entry`)
|
||||
}
|
||||
const wire = `${invocation.context}Id`
|
||||
if (wires.has(wire)) this.fail(method, `Remote Context wire field ${wire} conflicts with a method parameter`)
|
||||
receiver = {
|
||||
kind: 'context',
|
||||
context: invocation.context,
|
||||
wire,
|
||||
boundary: this.remoteBoundary(
|
||||
context.wireType,
|
||||
`${registration.name}#${binding.namespace}/${exportedMethod}:${wire}`,
|
||||
true,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
let scope: InvocationModel['scope']
|
||||
if (invocation.kind === 'direct') {
|
||||
const lookupParameters = parameters.filter(parameter => parameter.source === 'lookup')
|
||||
const parameter = lookupParameters.length === 1 ? lookupParameters[0] : undefined
|
||||
const context = parameter?.lookup === undefined
|
||||
? undefined
|
||||
: this.contextDeclarations().get(parameter.lookup)
|
||||
if (parameter !== undefined && context !== undefined) {
|
||||
const contextBoundary = this.remoteBoundary(
|
||||
context.wireType,
|
||||
`${registration.name}#${binding.namespace}/${exportedMethod}:scope:${context.key}`,
|
||||
true,
|
||||
)
|
||||
if (contextBoundary.typeSymbol !== parameter.boundary.typeSymbol) {
|
||||
this.fail(
|
||||
method,
|
||||
`Remote scope ${context.key} wire type ${contextBoundary.typeSymbol} does not match lookup wire type ${parameter.boundary.typeSymbol}`,
|
||||
)
|
||||
}
|
||||
scope = { context: context.key, wire: parameter.wire }
|
||||
}
|
||||
}
|
||||
|
||||
const resultType = this.remoteResultType(method)
|
||||
return {
|
||||
id: `${registration.name}#${binding.namespace}/${exportedMethod}`,
|
||||
service: binding.service,
|
||||
namespace: binding.namespace,
|
||||
method: exportedMethod,
|
||||
...(exportedMethod === methodName ? {} : { implementation: methodName }),
|
||||
invocation: receiver,
|
||||
...(scope === undefined ? {} : { scope }),
|
||||
parameters,
|
||||
result: this.remoteBoundary(
|
||||
resultType,
|
||||
`${registration.name}#${binding.namespace}/${exportedMethod}:result`,
|
||||
false,
|
||||
),
|
||||
location: this.location(method.name),
|
||||
}
|
||||
}
|
||||
|
||||
private gatewayBinding(declaration: ts.ClassDeclaration): GatewayBinding | undefined {
|
||||
const candidates = declaration.members.filter((member): member is ts.PropertyDeclaration =>
|
||||
ts.isPropertyDeclaration(member) && memberName(member.name) === 'typertGateway')
|
||||
const [property, duplicate] = candidates
|
||||
if (property === undefined) return undefined
|
||||
if (duplicate !== undefined) this.fail(duplicate, 'Service has more than one typertGateway field')
|
||||
if (visibilityOf(property) !== 'public'
|
||||
|| hasModifier(property, ts.SyntaxKind.StaticKeyword)
|
||||
|| !hasModifier(property, ts.SyntaxKind.ReadonlyKeyword)) {
|
||||
this.fail(property, 'typertGateway must be a public readonly instance field')
|
||||
}
|
||||
if (property.initializer === undefined
|
||||
|| !ts.isCallExpression(property.initializer)
|
||||
|| !this.isTypeMetaSymbol(property.initializer.expression, 'bindTypeRTGateway')) {
|
||||
this.fail(property, 'typertGateway must call bindTypeRTGateway()')
|
||||
}
|
||||
const call = property.initializer
|
||||
if (call.arguments.length < 2 || call.arguments.length > 3) {
|
||||
this.fail(call, 'bindTypeRTGateway() requires this, service key, and an optional options object')
|
||||
}
|
||||
if (call.arguments[0]?.kind !== ts.SyntaxKind.ThisKeyword) {
|
||||
this.fail(call.arguments[0] ?? call, 'bindTypeRTGateway() first argument must be this')
|
||||
}
|
||||
const serviceArgument = call.arguments[1]
|
||||
if (serviceArgument === undefined) this.fail(call, 'bindTypeRTGateway() service key must be a string literal')
|
||||
const service = stringLiteralValue(serviceArgument)
|
||||
if (service === undefined) this.fail(serviceArgument, 'bindTypeRTGateway() service key must be a string literal')
|
||||
let namespace = service
|
||||
const options = call.arguments[2]
|
||||
if (options !== undefined) {
|
||||
if (!ts.isObjectLiteralExpression(options)) {
|
||||
this.fail(options, 'bindTypeRTGateway() options must be an object literal')
|
||||
}
|
||||
for (const propertyOption of options.properties) {
|
||||
if (!ts.isPropertyAssignment(propertyOption)
|
||||
|| memberName(propertyOption.name) !== 'namespace') {
|
||||
this.fail(propertyOption, 'bindTypeRTGateway() only supports a namespace option')
|
||||
}
|
||||
const value = stringLiteralValue(propertyOption.initializer)
|
||||
if (value === undefined) this.fail(propertyOption.initializer, 'Gateway namespace must be a string literal')
|
||||
namespace = value
|
||||
}
|
||||
}
|
||||
if (!isRemoteSegment(service)) this.fail(serviceArgument, 'Gateway service key must be nonempty and must not contain "/"')
|
||||
if (!isRemoteSegment(namespace)) this.fail(options ?? call, 'Gateway namespace must be nonempty and must not contain "/"')
|
||||
return { service, namespace, site: property }
|
||||
}
|
||||
|
||||
private remoteMarker(
|
||||
member: ts.ClassElement,
|
||||
):
|
||||
| { readonly kind: 'direct'; readonly exportName?: string }
|
||||
| { readonly kind: 'context'; readonly context: string; readonly exportName?: string }
|
||||
| undefined {
|
||||
let found:
|
||||
| { readonly kind: 'direct'; readonly exportName?: string }
|
||||
| { readonly kind: 'context'; readonly context: string; readonly exportName?: string }
|
||||
| undefined
|
||||
for (const decorator of ts.canHaveDecorators(member) ? ts.getDecorators(member) ?? [] : []) {
|
||||
const expression = decorator.expression
|
||||
let marker: typeof found
|
||||
if (this.isTypeMetaSymbol(expression, 'Remote')) {
|
||||
marker = { kind: 'direct' }
|
||||
} else if (ts.isCallExpression(expression)
|
||||
&& this.isTypeMetaSymbol(expression.expression, 'Remote')) {
|
||||
if (expression.arguments.length !== 1) this.fail(expression, 'Remote() requires one exported method name')
|
||||
const exportName = stringLiteralValue(expression.arguments[0])
|
||||
if (exportName === undefined || !isRemoteSegment(exportName)) {
|
||||
this.fail(expression.arguments[0] ?? expression, 'Remote() name must be a nonempty string literal without "/"')
|
||||
}
|
||||
marker = { kind: 'direct', exportName }
|
||||
} else if (ts.isCallExpression(expression)
|
||||
&& this.isTypeMetaSymbol(expression.expression, 'RemoteContext')) {
|
||||
if (expression.arguments.length < 1 || expression.arguments.length > 2) {
|
||||
this.fail(expression, 'RemoteContext() requires a Context key and optional exported method name')
|
||||
}
|
||||
const context = stringLiteralValue(expression.arguments[0])
|
||||
if (context === undefined || !isRemoteSegment(context)) {
|
||||
this.fail(expression.arguments[0] ?? expression, 'RemoteContext() key must be a nonempty string literal without "/"')
|
||||
}
|
||||
const exportArgument = expression.arguments[1]
|
||||
const exportName = exportArgument === undefined ? undefined : stringLiteralValue(exportArgument)
|
||||
if (exportArgument !== undefined && (exportName === undefined || !isRemoteSegment(exportName))) {
|
||||
this.fail(exportArgument, 'RemoteContext() name must be a nonempty string literal without "/"')
|
||||
}
|
||||
marker = { kind: 'context', context, ...exportName === undefined ? {} : { exportName } }
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
if (found !== undefined) this.fail(decorator, 'a method can have only one Remote invocation decorator')
|
||||
found = marker
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
private remoteResultType(method: ts.MethodDeclaration): ts.TypeNode {
|
||||
const authored = this.requiredType(method, method.type, 'return')
|
||||
if (!ts.isTypeReferenceNode(authored)) return authored
|
||||
const symbol = this.checker.getSymbolAtLocation(authored.typeName)
|
||||
const resolved = symbol === undefined ? undefined : this.resolveSymbol(symbol)
|
||||
const resultType = authored.typeArguments?.[0]
|
||||
if (resolved?.name !== 'Promise' || resultType === undefined || authored.typeArguments?.length !== 1) return authored
|
||||
const declaration = preferredDeclaration(resolved)
|
||||
if (declaration === undefined || !isStandardLibraryFile(declaration.getSourceFile().fileName)) return authored
|
||||
return resultType
|
||||
}
|
||||
|
||||
private lookupDeclarations(): readonly StaticLookupDeclaration[] {
|
||||
if (this.staticLookups !== undefined) return this.staticLookups
|
||||
const byKey = new Map<string, StaticLookupDeclaration>()
|
||||
const byHost = new Map<SymbolId, StaticLookupDeclaration>()
|
||||
for (const declaration of this.typeMetaMapMembers('TypeRTLookupMap')) {
|
||||
if (!ts.isPropertySignature(declaration) || declaration.type === undefined) {
|
||||
this.fail(declaration, 'TypeRTLookupMap entries must be required properties')
|
||||
}
|
||||
const key = memberName(declaration.name)
|
||||
if (!isRemoteSegment(key)) this.fail(declaration.name, 'TypeRTLookupMap key must be nonempty and must not contain "/"')
|
||||
if (!ts.isTypeReferenceNode(declaration.type)
|
||||
|| !this.isTypeMetaSymbol(declaration.type.typeName, 'TypeRTLookup')
|
||||
|| declaration.type.typeArguments?.length !== 2) {
|
||||
this.fail(declaration.type, 'TypeRTLookupMap values must be TypeRTLookup<Host, Wire>')
|
||||
}
|
||||
const hostType = declaration.type.typeArguments[0]
|
||||
const wireType = declaration.type.typeArguments[1]
|
||||
if (hostType === undefined || wireType === undefined) {
|
||||
this.fail(declaration.type, 'TypeRTLookupMap values must be TypeRTLookup<Host, Wire>')
|
||||
}
|
||||
const host = this.symbolAtType(hostType)
|
||||
if (host === undefined) this.fail(hostType, 'TypeRTLookup Host must be a named type')
|
||||
const entry: StaticLookupDeclaration = {
|
||||
key,
|
||||
hostSymbol: this.symbolId(host),
|
||||
wireType,
|
||||
site: declaration,
|
||||
}
|
||||
if (byKey.has(key)) this.fail(declaration, `duplicate TypeRTLookupMap key ${key}`)
|
||||
if (byHost.has(entry.hostSymbol)) this.fail(declaration, `Host type ${host.name} has more than one TypeRT lookup`)
|
||||
byKey.set(key, entry)
|
||||
byHost.set(entry.hostSymbol, entry)
|
||||
}
|
||||
this.staticLookups = [...byKey.values()]
|
||||
return this.staticLookups
|
||||
}
|
||||
|
||||
private contextDeclarations(): ReadonlyMap<string, StaticContextDeclaration> {
|
||||
if (this.staticContexts !== undefined) return this.staticContexts
|
||||
const result = new Map<string, StaticContextDeclaration>()
|
||||
for (const declaration of this.typeMetaMapMembers('TypeRTContextMap')) {
|
||||
if (!ts.isPropertySignature(declaration) || declaration.type === undefined) {
|
||||
this.fail(declaration, 'TypeRTContextMap entries must be required properties')
|
||||
}
|
||||
const key = memberName(declaration.name)
|
||||
if (!isRemoteSegment(key)) this.fail(declaration.name, 'TypeRTContextMap key must be nonempty and must not contain "/"')
|
||||
if (!ts.isTypeReferenceNode(declaration.type)
|
||||
|| !this.isTypeMetaSymbol(declaration.type.typeName, 'TypeRTContext')
|
||||
|| declaration.type.typeArguments?.length !== 1) {
|
||||
this.fail(declaration.type, 'TypeRTContextMap values must be TypeRTContext<Wire>')
|
||||
}
|
||||
if (result.has(key)) this.fail(declaration, `duplicate TypeRTContextMap key ${key}`)
|
||||
const wireType = declaration.type.typeArguments[0]
|
||||
if (wireType === undefined) this.fail(declaration.type, 'TypeRTContextMap values must be TypeRTContext<Wire>')
|
||||
result.set(key, {
|
||||
key,
|
||||
wireType,
|
||||
site: declaration,
|
||||
})
|
||||
}
|
||||
this.staticContexts = result
|
||||
return result
|
||||
}
|
||||
|
||||
private typeMetaMapMembers(name: 'TypeRTLookupMap' | 'TypeRTContextMap'): ts.TypeElement[] {
|
||||
const result: ts.TypeElement[] = []
|
||||
for (const sourceFile of this.program.getSourceFiles()) {
|
||||
for (const statement of sourceFile.statements) {
|
||||
if (!ts.isModuleDeclaration(statement)
|
||||
|| !ts.isStringLiteral(statement.name)
|
||||
|| statement.name.text !== '@deepseek-ai/dsh-type-meta'
|
||||
|| statement.body === undefined
|
||||
|| !ts.isModuleBlock(statement.body)) continue
|
||||
for (const nested of statement.body.statements) {
|
||||
if (ts.isInterfaceDeclaration(nested) && nested.name.text === name) result.push(...nested.members)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private remoteBoundary(
|
||||
authoredType: ts.TypeNode,
|
||||
fallbackTypeSymbol: string,
|
||||
requireNamed: boolean,
|
||||
): RemoteBoundaryModel {
|
||||
const type = this.convertType(authoredType)
|
||||
const codecType = this.resolvedRemoteCodecType(authoredType)
|
||||
const rootSymbol = this.namedWorkspaceType(authoredType)
|
||||
if (rootSymbol !== undefined) {
|
||||
const imported = this.publicRemoteType(rootSymbol, authoredType)
|
||||
return {
|
||||
type,
|
||||
codecType,
|
||||
typeSymbol: `${imported.specifier}#${imported.name}`,
|
||||
imports: [imported],
|
||||
}
|
||||
}
|
||||
if (requireNamed) this.fail(authoredType, 'lookup and Context wire types must be named public types')
|
||||
const imports = new Map<SymbolId, RemoteTypeImportModel>()
|
||||
const visit = (node: ts.Node): void => {
|
||||
if ((ts.isTypeReferenceNode(node) || ts.isImportTypeNode(node))) {
|
||||
const symbol = ts.isTypeReferenceNode(node)
|
||||
? this.checker.getSymbolAtLocation(node.typeName)
|
||||
: node.qualifier === undefined ? undefined : this.checker.getSymbolAtLocation(node.qualifier)
|
||||
if (symbol !== undefined) {
|
||||
const resolved = this.resolveSymbol(symbol)
|
||||
const declaration = preferredDeclaration(resolved)
|
||||
if (declaration !== undefined
|
||||
&& !isStandardLibraryFile(declaration.getSourceFile().fileName)
|
||||
&& this.registrationForFile(declaration.getSourceFile().fileName) !== undefined) {
|
||||
const imported = this.publicRemoteType(resolved, node)
|
||||
imports.set(imported.symbol, imported)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
visit(authoredType)
|
||||
return {
|
||||
type,
|
||||
codecType,
|
||||
typeSymbol: fallbackTypeSymbol,
|
||||
imports: [...imports.values()].sort((left, right) =>
|
||||
left.specifier.localeCompare(right.specifier) || left.name.localeCompare(right.name)),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project one authored Remote boundary through the complete face Program.
|
||||
* Consumer declarations retain the authored alias, while codecs use this
|
||||
* concrete graph so declaration-merged mapped and conditional types are
|
||||
* validated without teaching the compiler-independent emitter TypeScript's
|
||||
* type evaluator.
|
||||
*/
|
||||
private resolvedRemoteCodecType(authoredType: ts.TypeNode): TypeNodeId {
|
||||
const completed = new Map<ts.Type, TypeNodeId>()
|
||||
const active = new Map<ts.Type, TypeNodeId>()
|
||||
const recursiveDeclarations = new Map<ts.Type, SymbolId>()
|
||||
const convert = (type: ts.Type): TypeNodeId => {
|
||||
const cached = completed.get(type)
|
||||
if (cached !== undefined) return cached
|
||||
const activeId = active.get(type)
|
||||
if (activeId !== undefined) {
|
||||
if (this.checker.isArrayType(type) || this.checker.isArrayLikeType(type)) {
|
||||
const element = this.checker.getIndexTypeOfType(type, ts.IndexKind.Number)
|
||||
const elementId = element === undefined ? undefined : active.get(element)
|
||||
if (element !== undefined && elementId !== undefined) {
|
||||
return this.addNode(authoredType, {
|
||||
kind: 'array',
|
||||
element: this.resolvedCycleReference(
|
||||
element,
|
||||
authoredType,
|
||||
elementId,
|
||||
recursiveDeclarations,
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
return this.resolvedCycleReference(type, authoredType, activeId, recursiveDeclarations)
|
||||
}
|
||||
const id = this.allocateNodeId(authoredType)
|
||||
active.set(type, id)
|
||||
try {
|
||||
const add = (model: TypeNodeInput): TypeNodeId => {
|
||||
this.nodes.set(id, { id, ...model })
|
||||
completed.set(type, id)
|
||||
return id
|
||||
}
|
||||
const flags = type.flags
|
||||
if ((flags & ts.TypeFlags.Any) !== 0) return add({ kind: 'keyword', name: 'any' })
|
||||
if ((flags & ts.TypeFlags.Unknown) !== 0) return add({ kind: 'keyword', name: 'unknown' })
|
||||
if ((flags & ts.TypeFlags.Never) !== 0) return add({ kind: 'keyword', name: 'never' })
|
||||
if ((flags & ts.TypeFlags.String) !== 0) return add({ kind: 'keyword', name: 'string' })
|
||||
if ((flags & ts.TypeFlags.Number) !== 0) return add({ kind: 'keyword', name: 'number' })
|
||||
if ((flags & ts.TypeFlags.BigInt) !== 0) return add({ kind: 'keyword', name: 'bigint' })
|
||||
if ((flags & ts.TypeFlags.Boolean) !== 0) return add({ kind: 'keyword', name: 'boolean' })
|
||||
if ((flags & ts.TypeFlags.ESSymbol) !== 0) return add({ kind: 'keyword', name: 'symbol' })
|
||||
if ((flags & ts.TypeFlags.Undefined) !== 0) return add({ kind: 'keyword', name: 'undefined' })
|
||||
if ((flags & ts.TypeFlags.Void) !== 0) return add({ kind: 'keyword', name: 'void' })
|
||||
if ((flags & ts.TypeFlags.Null) !== 0) return add({ kind: 'literal', value: null, text: 'null' })
|
||||
if ((flags & ts.TypeFlags.StringLiteral) !== 0) {
|
||||
const value = (type as ts.StringLiteralType).value
|
||||
return add({ kind: 'literal', value, text: JSON.stringify(value) })
|
||||
}
|
||||
if ((flags & ts.TypeFlags.NumberLiteral) !== 0) {
|
||||
const value = (type as ts.NumberLiteralType).value
|
||||
return add({ kind: 'literal', value, text: String(value) })
|
||||
}
|
||||
if ((flags & ts.TypeFlags.BigIntLiteral) !== 0) {
|
||||
const value = (type as ts.BigIntLiteralType).value
|
||||
const text = `${value.negative ? '-' : ''}${value.base10Value}n`
|
||||
return add({ kind: 'literal', value: BigInt(`${value.negative ? '-' : ''}${value.base10Value}`), text })
|
||||
}
|
||||
if ((flags & ts.TypeFlags.BooleanLiteral) !== 0) {
|
||||
const value = (type as ts.Type & { readonly intrinsicName?: string }).intrinsicName === 'true'
|
||||
return add({ kind: 'literal', value, text: String(value) })
|
||||
}
|
||||
if (type.isUnionOrIntersection()) {
|
||||
return add({
|
||||
kind: (flags & ts.TypeFlags.Union) !== 0 ? 'union' : 'intersection',
|
||||
types: type.types.map(convert),
|
||||
})
|
||||
}
|
||||
if ((flags & ts.TypeFlags.TypeParameter) !== 0) {
|
||||
this.fail(authoredType, 'Remote codec contains an unresolved type parameter')
|
||||
}
|
||||
if ((flags & ts.TypeFlags.Object) === 0) {
|
||||
this.fail(
|
||||
authoredType,
|
||||
`Remote codec type ${this.checker.typeToString(type, authoredType, ts.TypeFormatFlags.NoTruncation)} has no concrete Zod projection`,
|
||||
)
|
||||
}
|
||||
if (this.checker.isTupleType(type)) {
|
||||
const reference = type as ts.TypeReference
|
||||
const target = reference.target as ts.TupleType
|
||||
const arguments_ = this.checker.getTypeArguments(reference)
|
||||
return add({
|
||||
kind: 'tuple',
|
||||
elements: arguments_.map((argument, index) => {
|
||||
const elementFlags = target.elementFlags[index] ?? ts.ElementFlags.Required
|
||||
return {
|
||||
type: convert(argument),
|
||||
optional: (elementFlags & ts.ElementFlags.Optional) !== 0,
|
||||
rest: (elementFlags & (ts.ElementFlags.Rest | ts.ElementFlags.Variadic)) !== 0,
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
if (this.checker.isArrayType(type) || this.checker.isArrayLikeType(type)) {
|
||||
const element = this.checker.getIndexTypeOfType(type, ts.IndexKind.Number)
|
||||
if (element === undefined) this.fail(authoredType, 'Remote codec array has no element type')
|
||||
return add({ kind: 'array', element: convert(element) })
|
||||
}
|
||||
if (type.getCallSignatures().length > 0 || type.getConstructSignatures().length > 0) {
|
||||
this.fail(authoredType, 'Remote codec cannot contain callable or constructable values')
|
||||
}
|
||||
const members: MemberModel[] = []
|
||||
for (const property of this.checker.getPropertiesOfType(type)) {
|
||||
const declaration = property.valueDeclaration ?? property.declarations?.[0]
|
||||
const propertyType = this.checker.getTypeOfSymbolAtLocation(property, declaration ?? authoredType)
|
||||
const symbolKey = property.getName()
|
||||
members.push({
|
||||
...EMPTY_DOCUMENTATION,
|
||||
id: `${id}#${symbolKey}`,
|
||||
name: symbolKey,
|
||||
...(symbolKey.startsWith('__@') ? { computed: 'symbol' as const } : {}),
|
||||
optional: (property.flags & ts.SymbolFlags.Optional) !== 0,
|
||||
readonly: declaration !== undefined && hasModifier(declaration, ts.SyntaxKind.ReadonlyKeyword),
|
||||
async: false,
|
||||
abstract: false,
|
||||
static: false,
|
||||
visibility: 'public',
|
||||
location: this.location(authoredType),
|
||||
text: '',
|
||||
kind: 'property',
|
||||
type: convert(propertyType),
|
||||
})
|
||||
}
|
||||
for (const [index, info] of this.checker.getIndexInfosOfType(type).entries()) {
|
||||
members.push({
|
||||
...EMPTY_DOCUMENTATION,
|
||||
id: `${id}#index:${String(index)}`,
|
||||
name: '(index)',
|
||||
optional: false,
|
||||
readonly: info.isReadonly,
|
||||
async: false,
|
||||
abstract: false,
|
||||
static: false,
|
||||
visibility: 'public',
|
||||
location: this.location(authoredType),
|
||||
text: '',
|
||||
kind: 'index',
|
||||
signature: {
|
||||
typeParameters: [],
|
||||
parameters: [{
|
||||
name: 'key',
|
||||
binding: 'identifier',
|
||||
type: convert(info.keyType),
|
||||
optional: false,
|
||||
rest: false,
|
||||
receiver: false,
|
||||
}],
|
||||
returns: convert(info.type),
|
||||
},
|
||||
})
|
||||
}
|
||||
return add({ kind: 'object', members })
|
||||
} finally {
|
||||
active.delete(type)
|
||||
}
|
||||
}
|
||||
return convert(this.checker.getTypeFromTypeNode(authoredType))
|
||||
}
|
||||
|
||||
private resolvedCycleReference(
|
||||
type: ts.Type,
|
||||
site: ts.TypeNode,
|
||||
resolvedType: TypeNodeId,
|
||||
recursiveDeclarations: Map<ts.Type, SymbolId>,
|
||||
): TypeNodeId {
|
||||
const symbol = type.aliasSymbol ?? type.getSymbol()
|
||||
if (symbol === undefined) this.fail(site, 'Remote codec contains an unnamed recursive type')
|
||||
const resolved = this.resolveSymbol(symbol)
|
||||
const declaration = preferredDeclaration(resolved)
|
||||
if (declaration === undefined || isStandardLibraryFile(declaration.getSourceFile().fileName)) {
|
||||
this.fail(site, `Remote codec recursive type ${resolved.name} has no workspace declaration`)
|
||||
}
|
||||
const owner = this.registrationForFile(declaration.getSourceFile().fileName)
|
||||
if (owner === undefined) this.fail(site, `Remote codec recursive type ${resolved.name} is not owned by this face`)
|
||||
let id = recursiveDeclarations.get(type)
|
||||
if (id === undefined) {
|
||||
id = `${this.symbolId(resolved)}#remote-codec:${resolvedType}`
|
||||
recursiveDeclarations.set(type, id)
|
||||
this.declarations.set(id, {
|
||||
...EMPTY_DOCUMENTATION,
|
||||
id,
|
||||
package: owner.name,
|
||||
name: `${resolved.name}RemoteCodec`,
|
||||
kind: 'alias',
|
||||
abstract: false,
|
||||
exported: false,
|
||||
location: this.location(declaration),
|
||||
text: '',
|
||||
typeParameters: [],
|
||||
extends: [],
|
||||
implements: [],
|
||||
members: [],
|
||||
type: resolvedType,
|
||||
})
|
||||
}
|
||||
return this.addNode(site, {
|
||||
kind: 'reference',
|
||||
name: `${resolved.name}RemoteCodec`,
|
||||
target: { kind: 'declaration', symbol: id },
|
||||
arguments: [],
|
||||
})
|
||||
}
|
||||
|
||||
private namedWorkspaceType(node: ts.TypeNode): ts.Symbol | undefined {
|
||||
if (!ts.isTypeReferenceNode(node) && !ts.isImportTypeNode(node)) return undefined
|
||||
const symbol = ts.isTypeReferenceNode(node)
|
||||
? this.checker.getSymbolAtLocation(node.typeName)
|
||||
: node.qualifier === undefined ? undefined : this.checker.getSymbolAtLocation(node.qualifier)
|
||||
if (symbol === undefined) return undefined
|
||||
const resolved = this.resolveSymbol(symbol)
|
||||
const declaration = preferredDeclaration(resolved)
|
||||
if (declaration === undefined
|
||||
|| isStandardLibraryFile(declaration.getSourceFile().fileName)
|
||||
|| this.registrationForFile(declaration.getSourceFile().fileName) === undefined) return undefined
|
||||
return resolved
|
||||
}
|
||||
|
||||
private publicRemoteType(symbol: ts.Symbol, site: ts.Node): RemoteTypeImportModel {
|
||||
const declaration = preferredDeclaration(symbol)
|
||||
if (declaration === undefined) this.fail(site, `type ${symbol.name} has no declaration`)
|
||||
const registration = this.registrationForFile(declaration.getSourceFile().fileName)
|
||||
if (registration === undefined) this.fail(site, `type ${symbol.name} is not owned by a workspace package`)
|
||||
const candidates: RemoteTypeImportModel[] = []
|
||||
for (const [subpath, target] of packageExportTargets(registration.manifest)) {
|
||||
if (subpath === '.' || subpath === './package.json' || subpath === './typert'
|
||||
|| subpath === './client/typert' || subpath === './remote' || target.includes('*')) continue
|
||||
const sourceFile = this.sourceFiles.get(realPath(sourcePathForExport(registration.root, target)))
|
||||
if (sourceFile === undefined) continue
|
||||
const moduleSymbol = this.checker.getSymbolAtLocation(sourceFile)
|
||||
if (moduleSymbol === undefined) continue
|
||||
for (const exported of this.checker.getExportsOfModule(moduleSymbol)) {
|
||||
if (this.resolveSymbol(exported) !== symbol) continue
|
||||
candidates.push({
|
||||
symbol: this.symbolId(symbol),
|
||||
specifier: packageExportSpecifier(registration.name, subpath),
|
||||
name: exported.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
const selected = candidates.sort((left, right) =>
|
||||
left.specifier.localeCompare(right.specifier) || left.name.localeCompare(right.name))[0]
|
||||
if (selected === undefined) {
|
||||
this.fail(site, `Remote boundary type ${symbol.name} must be exported from a public non-root type subpath`)
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
private isWorkspaceClass(symbol: ts.Symbol): boolean {
|
||||
const declaration = preferredDeclaration(symbol)
|
||||
return declaration !== undefined
|
||||
&& ts.isClassDeclaration(declaration)
|
||||
&& this.registrationForFile(declaration.getSourceFile().fileName) !== undefined
|
||||
}
|
||||
|
||||
private isTypeMetaSymbol(node: ts.Node, name: string): boolean {
|
||||
const symbol = this.checker.getSymbolAtLocation(node)
|
||||
if (symbol === undefined) return false
|
||||
const resolved = this.resolveSymbol(symbol)
|
||||
if (resolved.name !== name) return false
|
||||
const declaration = preferredDeclaration(resolved)
|
||||
if (declaration === undefined) return false
|
||||
const registration = this.registrationForFile(declaration.getSourceFile().fileName)
|
||||
if (registration?.name === '@deepseek-ai/dsh-type-meta') return true
|
||||
for (let current: ts.Node | undefined = declaration; current !== undefined; current = optionalParent(current)) {
|
||||
if (ts.isModuleDeclaration(current)
|
||||
&& ts.isStringLiteral(current.name)
|
||||
&& current.name.text === '@deepseek-ai/dsh-type-meta') return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private validateInvocationIdentity(packages: readonly PackageModel[]): void {
|
||||
const endpoints = new Map<string, InvocationModel>()
|
||||
const ids = new Map<string, InvocationModel>()
|
||||
for (const invocation of packages.flatMap(packageModel => packageModel.invocations)) {
|
||||
const endpoint = `${invocation.namespace}/${invocation.method}`
|
||||
const existingEndpoint = endpoints.get(endpoint)
|
||||
if (existingEndpoint !== undefined) {
|
||||
throw new TypertAnalysisError(
|
||||
`typert(${this.face}): ${invocation.location.file}:${String(invocation.location.line)}:${String(invocation.location.column)}: Remote endpoint ${endpoint} conflicts with ${existingEndpoint.id}`,
|
||||
)
|
||||
}
|
||||
const existingId = ids.get(invocation.id)
|
||||
if (existingId !== undefined) {
|
||||
throw new TypertAnalysisError(
|
||||
`typert(${this.face}): ${invocation.location.file}:${String(invocation.location.line)}:${String(invocation.location.column)}: Remote invocation id ${invocation.id} conflicts with ${existingId.id}`,
|
||||
)
|
||||
}
|
||||
endpoints.set(endpoint, invocation)
|
||||
ids.set(invocation.id, invocation)
|
||||
}
|
||||
}
|
||||
|
||||
private collectEvents(events: ts.InterfaceDeclaration): EventModel[] {
|
||||
const result: EventModel[] = []
|
||||
for (const member of events.members) {
|
||||
@@ -1015,6 +1777,11 @@ class FaceAnalyzer {
|
||||
): MemberModel[] {
|
||||
const result: MemberModel[] = []
|
||||
for (const member of members) {
|
||||
if (ts.isPropertyDeclaration(member)
|
||||
&& memberName(member.name) === 'typertGateway'
|
||||
&& member.initializer !== undefined
|
||||
&& ts.isCallExpression(member.initializer)
|
||||
&& this.isTypeMetaSymbol(member.initializer.expression, 'bindTypeRTGateway')) continue
|
||||
const visibility = visibilityOf(member)
|
||||
const isStatic = hasModifier(member, ts.SyntaxKind.StaticKeyword)
|
||||
if (visibility !== 'public' || isStatic || ts.isConstructorDeclaration(member)) continue
|
||||
@@ -1045,17 +1812,19 @@ class FaceAnalyzer {
|
||||
visibility: MemberVisibility,
|
||||
isStatic: boolean,
|
||||
): MemberBase {
|
||||
const name = member.name !== undefined
|
||||
? memberName(member.name)
|
||||
: ts.isCallSignatureDeclaration(member)
|
||||
? '(call)'
|
||||
: ts.isConstructSignatureDeclaration(member)
|
||||
? '(construct)'
|
||||
: '(index)'
|
||||
const identity = member.name !== undefined
|
||||
? this.memberIdentity(member.name)
|
||||
: {
|
||||
name: ts.isCallSignatureDeclaration(member)
|
||||
? '(call)'
|
||||
: ts.isConstructSignatureDeclaration(member)
|
||||
? '(construct)'
|
||||
: '(index)',
|
||||
}
|
||||
return {
|
||||
...documentationOf(member),
|
||||
id: `${ownerId}#${name}@${String(member.getStart())}`,
|
||||
name,
|
||||
id: `${ownerId}#${identity.name}@${String(member.getStart())}`,
|
||||
...identity,
|
||||
optional: 'questionToken' in member && member.questionToken !== undefined,
|
||||
readonly: hasModifier(member, ts.SyntaxKind.ReadonlyKeyword),
|
||||
async: hasModifier(member, ts.SyntaxKind.AsyncKeyword),
|
||||
@@ -1067,6 +1836,20 @@ class FaceAnalyzer {
|
||||
}
|
||||
}
|
||||
|
||||
private memberIdentity(name: ts.PropertyName): Pick<MemberBase, 'name' | 'jsonName' | 'computed'> {
|
||||
if (!ts.isComputedPropertyName(name)) return { name: memberName(name) }
|
||||
const expression = name.expression
|
||||
if (ts.isStringLiteral(expression) || ts.isNumericLiteral(expression)
|
||||
|| ts.isNoSubstitutionTemplateLiteral(expression)) {
|
||||
return { name: memberName(name), jsonName: expression.text }
|
||||
}
|
||||
const type = this.checker.getTypeAtLocation(expression)
|
||||
return {
|
||||
name: memberName(name),
|
||||
computed: (type.flags & ts.TypeFlags.UniqueESSymbol) !== 0 ? 'symbol' : 'dynamic',
|
||||
}
|
||||
}
|
||||
|
||||
private signature(
|
||||
node: ts.SignatureDeclarationBase,
|
||||
explicitReturn: ts.TypeNode | undefined,
|
||||
@@ -1570,7 +2353,23 @@ function sourceFileHasSurface(sourceFile: ts.SourceFile): boolean {
|
||||
|| ts.isInterfaceDeclaration(statement)
|
||||
|| ts.isTypeAliasDeclaration(statement)
|
||||
|| ts.isEnumDeclaration(statement))
|
||||
&& typertMode(statement) !== undefined) return true
|
||||
&& (typertMode(statement) !== undefined || typertServiceTag(statement) !== undefined)) return true
|
||||
if (ts.isClassDeclaration(statement)) {
|
||||
for (const member of statement.members) {
|
||||
if (ts.isPropertyDeclaration(member)
|
||||
&& memberName(member.name) === 'typertGateway'
|
||||
&& member.initializer !== undefined
|
||||
&& ts.isCallExpression(member.initializer)
|
||||
&& expressionName(member.initializer.expression) === 'bindTypeRTGateway') return true
|
||||
for (const decorator of ts.canHaveDecorators(member) ? ts.getDecorators(member) ?? [] : []) {
|
||||
const expression = ts.isCallExpression(decorator.expression)
|
||||
? decorator.expression.expression
|
||||
: decorator.expression
|
||||
const name = expressionName(expression)
|
||||
if (name === 'Remote' || name === 'RemoteContext') return true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!ts.isModuleDeclaration(statement)
|
||||
|| !ts.isStringLiteral(statement.name)
|
||||
|| statement.name.text !== 'cordis'
|
||||
@@ -1588,6 +2387,7 @@ function hasPackageSurface(model: PackageModel): boolean {
|
||||
|| model.events.length > 0
|
||||
|| model.objects.length > 0
|
||||
|| model.schemas.length > 0
|
||||
|| model.invocations.length > 0
|
||||
}
|
||||
|
||||
function isDualFacePackage(manifest: Record<string, unknown>): boolean {
|
||||
@@ -1599,7 +2399,9 @@ function isDualFacePackage(manifest: Record<string, unknown>): boolean {
|
||||
function hostExportSubpaths(manifest: Record<string, unknown>): string[] {
|
||||
return packageExportTargets(manifest)
|
||||
.map(([subpath]) => subpath)
|
||||
.filter(subpath => subpath !== './client' && !subpath.startsWith('./client/'))
|
||||
.filter(subpath => subpath !== './client'
|
||||
&& !subpath.startsWith('./client/')
|
||||
&& subpath !== './remote')
|
||||
}
|
||||
|
||||
function clientExportSubpaths(manifest: Record<string, unknown>): string[] {
|
||||
@@ -1668,6 +2470,10 @@ function preferredDeclaration(symbol: ts.Symbol): ts.Declaration | undefined {
|
||||
?? symbol.declarations?.[0]
|
||||
}
|
||||
|
||||
function optionalParent(node: ts.Node): ts.Node | undefined {
|
||||
return (node as ts.Node & { readonly parent?: ts.Node }).parent
|
||||
}
|
||||
|
||||
function isTypeDeclaration(
|
||||
node: ts.Node,
|
||||
): node is ts.ClassDeclaration | ts.InterfaceDeclaration | ts.TypeAliasDeclaration | ts.EnumDeclaration {
|
||||
@@ -1822,6 +2628,11 @@ function typertMode(node: ts.Node): 'object' | 'schema' | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function typertServiceTag(node: ts.Node): ts.JSDocTag | undefined {
|
||||
return ts.getJSDocTags(node).find(tag => tag.tagName.text === 'typert'
|
||||
&& (ts.getTextOfJSDocComment(tag.comment) ?? '').trim().split(/\s+/, 1)[0] === 'service')
|
||||
}
|
||||
|
||||
function memberName(name: ts.PropertyName | ts.BindingName): string {
|
||||
if (ts.isIdentifier(name) || ts.isPrivateIdentifier(name) || ts.isStringLiteral(name)
|
||||
|| ts.isNumericLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name)) return name.text
|
||||
@@ -1829,6 +2640,26 @@ function memberName(name: ts.PropertyName | ts.BindingName): string {
|
||||
return name.getText()
|
||||
}
|
||||
|
||||
function stringLiteralValue(node: ts.Node | undefined): string | undefined {
|
||||
return node !== undefined && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
|
||||
? node.text
|
||||
: undefined
|
||||
}
|
||||
|
||||
function isRemoteSegment(value: string): boolean {
|
||||
return value.length > 0 && !value.includes('/')
|
||||
}
|
||||
|
||||
function expressionName(node: ts.Expression): string | undefined {
|
||||
if (ts.isIdentifier(node)) return node.text
|
||||
if (ts.isPropertyAccessExpression(node)) return node.name.text
|
||||
return undefined
|
||||
}
|
||||
|
||||
function packageExportSpecifier(packageName: string, subpath: string): string {
|
||||
return subpath === '.' ? packageName : `${packageName}${subpath.slice(1)}`
|
||||
}
|
||||
|
||||
function visibilityOf(node: ts.Node): MemberVisibility {
|
||||
if ('name' in node && node.name !== undefined && ts.isPrivateIdentifier(node.name as ts.Node)) return 'private'
|
||||
if (hasModifier(node, ts.SyntaxKind.PrivateKeyword)) return 'private'
|
||||
|
||||
@@ -231,7 +231,7 @@ export class CordisCatalogProjector {
|
||||
for (const service of packageModel.services) {
|
||||
const declaration = this.renderer.declaration(service.symbol)
|
||||
if (declaration.kind !== 'class'
|
||||
|| !/^packages\/[^/]+\/[^/]+\/src\/index\.ts$/.test(service.location.file)
|
||||
|| !/^packages\/[^/]+\/[^/]+\/src\/[^/]+\.ts$/.test(service.location.file)
|
||||
|| declaration.location.file !== service.location.file) continue
|
||||
const doc = parseJsDoc(declaration.jsDoc ?? '').doc
|
||||
const source = pointer(declaration.location)
|
||||
|
||||
@@ -4,11 +4,17 @@
|
||||
* @module @deepseek-ai/dsh-typert-generator/emitter
|
||||
*/
|
||||
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { posix } from 'node:path'
|
||||
import { GenMapping, addMapping, toEncodedMap } from '@jridgewell/gen-mapping'
|
||||
import type {
|
||||
DocumentationModel,
|
||||
FaceModel,
|
||||
InvocationModel,
|
||||
MemberModel,
|
||||
PackageModel,
|
||||
RemoteBoundaryModel,
|
||||
RemoteTypeImportModel,
|
||||
SchemaModel,
|
||||
SymbolId,
|
||||
TypeDeclarationModel,
|
||||
@@ -29,6 +35,14 @@ export interface ModelEmitResult {
|
||||
readonly exports: readonly string[]
|
||||
readonly js: string
|
||||
readonly dts: string
|
||||
readonly remote?: RemoteModelEmitResult
|
||||
}
|
||||
|
||||
/** Host-for-Client Remote contribution generated from the Host Program. */
|
||||
export interface RemoteModelEmitResult {
|
||||
readonly js: string
|
||||
readonly dts: string
|
||||
readonly dtsMap: string
|
||||
}
|
||||
|
||||
interface RuntimeMemberModel {
|
||||
@@ -92,7 +106,11 @@ export class FaceModelEmitter {
|
||||
if (packageModel === undefined) {
|
||||
throw new TypertEmitError(`typert emitter(${this.face.face}): package ${packageName} is not modeled on this face`)
|
||||
}
|
||||
const schemas = new SchemaEmitter(this.renderer, packageModel.schemas)
|
||||
const schemas = new SchemaEmitter(
|
||||
this.renderer,
|
||||
packageModel.schemas,
|
||||
invocationBoundaryRoots(packageModel.invocations),
|
||||
)
|
||||
const schemaArtifact = schemas.emit()
|
||||
const runtimeModel = this.runtimeModel(packageModel)
|
||||
const js = this.renderJs(packageModel, schemaArtifact, runtimeModel)
|
||||
@@ -103,6 +121,9 @@ export class FaceModelEmitter {
|
||||
exports: packageModel.schemas.map(schema => schema.export.name),
|
||||
js,
|
||||
dts,
|
||||
...(this.face.face === 'host' && packageModel.invocations.length > 0
|
||||
? { remote: this.emitRemote(packageModel) }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,6 +205,11 @@ export class FaceModelEmitter {
|
||||
lines.push(` { name: ${quote(schema.exportName)}, schema: ${schema.exportName} },`)
|
||||
}
|
||||
lines.push(' ],')
|
||||
lines.push(' invocations: [')
|
||||
for (const invocation of packageModel.invocations) {
|
||||
lines.push(`${indent(this.invocationLiteral(invocation, schemas), 4)},`)
|
||||
}
|
||||
lines.push(' ],')
|
||||
lines.push(` model: ${indent(model, 2).trimStart()},`)
|
||||
lines.push('}')
|
||||
return `${lines.join('\n')}\n`
|
||||
@@ -215,6 +241,246 @@ export class FaceModelEmitter {
|
||||
lines.push('export declare const TYPERT: unknown')
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
private emitRemote(packageModel: PackageModel): RemoteModelEmitResult {
|
||||
const schemas = new SchemaEmitter(
|
||||
this.renderer,
|
||||
[],
|
||||
invocationBoundaryRoots(packageModel.invocations),
|
||||
).emit()
|
||||
const lines = [
|
||||
'/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */',
|
||||
]
|
||||
if (schemas.definitions.length > 0) lines.push('import { z } from \'zod\'', '')
|
||||
lines.push(...schemas.definitions)
|
||||
if (schemas.definitions.length > 0) lines.push('')
|
||||
lines.push('export const TYPERT_REMOTE = {')
|
||||
lines.push(` package: ${quote(packageModel.name)},`)
|
||||
lines.push(' descriptors: [')
|
||||
for (const invocation of packageModel.invocations) {
|
||||
lines.push(`${indent(this.invocationLiteral(invocation, schemas), 4)},`)
|
||||
}
|
||||
lines.push(' ],')
|
||||
lines.push('}')
|
||||
lines.push('')
|
||||
lines.push('export default TYPERT_REMOTE')
|
||||
const declaration = this.renderRemoteDts(packageModel)
|
||||
return {
|
||||
js: `${lines.join('\n')}\n`,
|
||||
...declaration,
|
||||
}
|
||||
}
|
||||
|
||||
private invocationLiteral(invocation: InvocationModel, schemas: SchemaArtifact): string {
|
||||
const lines = [
|
||||
'{',
|
||||
` id: ${quote(invocation.id)},`,
|
||||
` service: ${quote(invocation.service)},`,
|
||||
` namespace: ${quote(invocation.namespace)},`,
|
||||
` method: ${quote(invocation.method)},`,
|
||||
]
|
||||
if (invocation.implementation !== undefined) {
|
||||
lines.push(` implementation: ${quote(invocation.implementation)},`)
|
||||
}
|
||||
if (invocation.invocation.kind === 'direct') {
|
||||
lines.push(' invocation: { kind: \'direct\' },')
|
||||
} else {
|
||||
lines.push(' invocation: {')
|
||||
lines.push(' kind: \'context\',')
|
||||
lines.push(` context: ${quote(invocation.invocation.context)},`)
|
||||
lines.push(` wire: ${quote(invocation.invocation.wire)},`)
|
||||
lines.push(` codec: ${indent(strictCodec(
|
||||
invocation.invocation.boundary,
|
||||
schemas.boundary(contextBoundaryKey(invocation)),
|
||||
), 4).trimStart()},`)
|
||||
lines.push(' },')
|
||||
}
|
||||
if (invocation.scope !== undefined) {
|
||||
lines.push(' scope: {')
|
||||
lines.push(` context: ${quote(invocation.scope.context)},`)
|
||||
lines.push(` wire: ${quote(invocation.scope.wire)},`)
|
||||
lines.push(' },')
|
||||
}
|
||||
lines.push(' parameters: [')
|
||||
invocation.parameters.forEach((parameter, index) => {
|
||||
lines.push(' {')
|
||||
lines.push(` name: ${quote(parameter.name)},`)
|
||||
lines.push(` wire: ${quote(parameter.wire)},`)
|
||||
lines.push(` source: ${quote(parameter.source)},`)
|
||||
if (parameter.lookup !== undefined) lines.push(` lookup: ${quote(parameter.lookup)},`)
|
||||
lines.push(` codec: ${indent(strictCodec(
|
||||
parameter.boundary,
|
||||
schemas.boundary(parameterBoundaryKey(invocation, index)),
|
||||
), 6).trimStart()},`)
|
||||
lines.push(' },')
|
||||
})
|
||||
lines.push(' ],')
|
||||
lines.push(` result: ${indent(strictCodec(
|
||||
invocation.result,
|
||||
schemas.boundary(resultBoundaryKey(invocation)),
|
||||
), 2).trimStart()},`)
|
||||
lines.push(` sourceLocation: ${JSON.stringify(invocation.location)},`)
|
||||
lines.push('}')
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
private renderRemoteDts(packageModel: PackageModel): Pick<RemoteModelEmitResult, 'dts' | 'dtsMap'> {
|
||||
const imports = remoteImports(packageModel.invocations)
|
||||
const referenceNames = allocateRemoteImportNames(imports)
|
||||
const grouped = new Map<string, { readonly name: string; readonly local: string }[]>()
|
||||
for (const imported of imports) {
|
||||
const values = grouped.get(imported.specifier) ?? []
|
||||
values.push({
|
||||
name: imported.name,
|
||||
local: referenceNames.get(imported.symbol) as string,
|
||||
})
|
||||
grouped.set(imported.specifier, values)
|
||||
}
|
||||
const lines = [
|
||||
'/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */',
|
||||
'import type {',
|
||||
' TypeRTRemoteContribution,',
|
||||
'} from \'@deepseek-ai/dsh-type-meta\'',
|
||||
]
|
||||
const sourceMap = new GenMapping({ file: 'typert.remote-client.d.ts' })
|
||||
for (const [specifier, values] of [...grouped].sort(([left], [right]) => left.localeCompare(right))) {
|
||||
const names = values.sort((left, right) => left.local.localeCompare(right.local)).map(value =>
|
||||
value.name === value.local ? value.name : `${value.name} as ${value.local}`)
|
||||
lines.push(`import type { ${names.join(', ')} } from ${quote(specifier)}`)
|
||||
}
|
||||
lines.push('')
|
||||
lines.push('declare module \'@deepseek-ai/dsh-type-meta\' {')
|
||||
const direct = packageModel.invocations.filter(invocation => invocation.invocation.kind === 'direct')
|
||||
const scoped = packageModel.invocations.filter(invocation =>
|
||||
invocation.invocation.kind === 'context' || invocation.scope !== undefined)
|
||||
if (direct.length > 0) {
|
||||
for (const namespace of uniqueNamespaces(direct)) {
|
||||
lines.push(` interface ${remoteNamespaceInterface(namespace)} {`)
|
||||
for (const invocation of direct.filter(candidate => candidate.namespace === namespace)) {
|
||||
this.pushRemoteNamespaceSignature(lines, sourceMap, packageModel, invocation, referenceNames)
|
||||
}
|
||||
lines.push(' }')
|
||||
}
|
||||
lines.push(' interface TypeRTRemoteMap {')
|
||||
for (const invocation of direct) {
|
||||
this.pushRemoteSignature(lines, sourceMap, packageModel, invocation, referenceNames, false)
|
||||
}
|
||||
lines.push(' }')
|
||||
lines.push(' interface TypeRTRemoteNamespaceMap {')
|
||||
for (const namespace of uniqueNamespaces(direct)) {
|
||||
lines.push(` ${quote(namespace)}: ${remoteNamespaceInterface(namespace)}`)
|
||||
}
|
||||
lines.push(' }')
|
||||
}
|
||||
if (scoped.length > 0) {
|
||||
lines.push(' interface TypeRTRemoteContextMap {')
|
||||
for (const invocation of scoped) {
|
||||
this.pushRemoteSignature(lines, sourceMap, packageModel, invocation, referenceNames, true)
|
||||
}
|
||||
lines.push(' }')
|
||||
}
|
||||
lines.push('}')
|
||||
lines.push('')
|
||||
lines.push('export declare const TYPERT_REMOTE: TypeRTRemoteContribution')
|
||||
lines.push('export default TYPERT_REMOTE')
|
||||
lines.push('//# sourceMappingURL=typert.remote-client.d.ts.map')
|
||||
return {
|
||||
dts: `${lines.join('\n')}\n`,
|
||||
dtsMap: `${JSON.stringify(toEncodedMap(sourceMap))}\n`,
|
||||
}
|
||||
}
|
||||
|
||||
private pushRemoteSignature(
|
||||
lines: string[],
|
||||
sourceMap: GenMapping,
|
||||
packageModel: PackageModel,
|
||||
invocation: InvocationModel,
|
||||
referenceNames: ReadonlyMap<SymbolId, string>,
|
||||
scoped: boolean,
|
||||
): void {
|
||||
const signature = this.remoteSignature(invocation, referenceNames, scoped)
|
||||
const line = ` ${signature}`
|
||||
lines.push(line)
|
||||
const generatedLine = lines.length
|
||||
const keyLength = signature.indexOf(': (')
|
||||
if (keyLength < 0) throw new TypertEmitError(`Remote signature ${invocation.id} has no property delimiter`)
|
||||
const source = remoteDeclarationSource(packageModel, invocation)
|
||||
addMapping(sourceMap, {
|
||||
generated: { line: generatedLine, column: 4 },
|
||||
source,
|
||||
original: { line: invocation.location.line, column: invocation.location.column - 1 },
|
||||
name: invocation.method,
|
||||
})
|
||||
addMapping(sourceMap, {
|
||||
generated: { line: generatedLine, column: 4 + keyLength },
|
||||
})
|
||||
}
|
||||
|
||||
private pushRemoteNamespaceSignature(
|
||||
lines: string[],
|
||||
sourceMap: GenMapping,
|
||||
packageModel: PackageModel,
|
||||
invocation: InvocationModel,
|
||||
referenceNames: ReadonlyMap<SymbolId, string>,
|
||||
): void {
|
||||
const signature = `${invocation.method}: ${this.remoteFunctionType(invocation, referenceNames, false)}`
|
||||
lines.push(` ${signature}`)
|
||||
const generatedLine = lines.length
|
||||
const source = remoteDeclarationSource(packageModel, invocation)
|
||||
addMapping(sourceMap, {
|
||||
generated: { line: generatedLine, column: 4 },
|
||||
source,
|
||||
original: { line: invocation.location.line, column: invocation.location.column - 1 },
|
||||
name: invocation.method,
|
||||
})
|
||||
addMapping(sourceMap, {
|
||||
generated: { line: generatedLine, column: 4 + invocation.method.length },
|
||||
})
|
||||
}
|
||||
|
||||
private remoteSignature(
|
||||
invocation: InvocationModel,
|
||||
referenceNames: ReadonlyMap<SymbolId, string>,
|
||||
scoped: boolean,
|
||||
): string {
|
||||
const context = invocation.invocation.kind === 'context'
|
||||
? invocation.invocation.context
|
||||
: invocation.scope?.context
|
||||
const key = scoped
|
||||
? `${context as string}:${invocation.namespace}/${invocation.method}`
|
||||
: `${invocation.namespace}/${invocation.method}`
|
||||
return `${quote(key)}: ${this.remoteFunctionType(invocation, referenceNames, scoped)}`
|
||||
}
|
||||
|
||||
private remoteFunctionType(
|
||||
invocation: InvocationModel,
|
||||
referenceNames: ReadonlyMap<SymbolId, string>,
|
||||
scoped: boolean,
|
||||
): string {
|
||||
const parameters = invocation.parameters.filter(parameter =>
|
||||
!scoped || invocation.invocation.kind === 'context' || parameter.wire !== invocation.scope?.wire).map(parameter =>
|
||||
`${safeIdentifier(parameter.wire)}: ${this.renderer.renderType(parameter.boundary.type, referenceNames)}`)
|
||||
const result = this.renderer.renderType(invocation.result.type, referenceNames)
|
||||
return `(${parameters.join(', ')}) => Promise<${result}>`
|
||||
}
|
||||
}
|
||||
|
||||
function remoteDeclarationSource(packageModel: PackageModel, invocation: InvocationModel): string {
|
||||
const relativeSource = posix.relative(packageModel.root, invocation.location.file)
|
||||
if (relativeSource === '' || relativeSource === '..' || relativeSource.startsWith('../') || posix.isAbsolute(relativeSource)) {
|
||||
throw new TypertEmitError(
|
||||
`Remote declaration ${invocation.id} is outside its package root ${packageModel.root}`,
|
||||
)
|
||||
}
|
||||
return posix.join('..', relativeSource)
|
||||
}
|
||||
|
||||
function uniqueNamespaces(invocations: readonly InvocationModel[]): string[] {
|
||||
return [...new Set(invocations.map(invocation => invocation.namespace))].sort()
|
||||
}
|
||||
|
||||
function remoteNamespaceInterface(namespace: string): string {
|
||||
return `TypeRTRemoteNamespace$${Buffer.from(namespace, 'utf8').toString('hex')}`
|
||||
}
|
||||
|
||||
interface SchemaExport {
|
||||
@@ -226,15 +492,23 @@ interface SchemaExport {
|
||||
interface SchemaArtifact {
|
||||
readonly definitions: readonly string[]
|
||||
readonly exports: readonly SchemaExport[]
|
||||
boundary(key: string): string
|
||||
}
|
||||
|
||||
interface BoundarySchemaRoot {
|
||||
readonly key: string
|
||||
readonly type: TypeNodeId
|
||||
}
|
||||
|
||||
class SchemaEmitter {
|
||||
private readonly names = new Map<SymbolId, string>()
|
||||
private readonly boundaryNames = new Map<string, string>()
|
||||
private readonly declarations: TypeDeclarationModel[]
|
||||
|
||||
constructor(
|
||||
private readonly renderer: TypeGraphRenderer,
|
||||
private readonly schemas: readonly SchemaModel[],
|
||||
private readonly boundaries: readonly BoundarySchemaRoot[],
|
||||
) {
|
||||
const declarations = new Map<SymbolId, TypeDeclarationModel>()
|
||||
for (const schema of schemas) {
|
||||
@@ -242,6 +516,11 @@ class SchemaEmitter {
|
||||
declarations.set(declaration.id, declaration)
|
||||
}
|
||||
}
|
||||
for (const boundary of boundaries) {
|
||||
for (const declaration of renderer.declarationClosureForTypes([boundary.type])) {
|
||||
declarations.set(declaration.id, declaration)
|
||||
}
|
||||
}
|
||||
this.declarations = renderer.graph.declarations.filter(declaration => declarations.has(declaration.id))
|
||||
const identifiers = new Set<string>()
|
||||
for (const declaration of this.declarations) {
|
||||
@@ -252,65 +531,92 @@ class SchemaEmitter {
|
||||
identifiers.add(name)
|
||||
this.names.set(declaration.id, name)
|
||||
}
|
||||
for (const boundary of boundaries) {
|
||||
const base = `${safeIdentifier(boundary.key)}$schema`
|
||||
let name = base
|
||||
let suffix = 2
|
||||
while (identifiers.has(name)) name = `${base}${String(suffix++)}`
|
||||
identifiers.add(name)
|
||||
this.boundaryNames.set(boundary.key, name)
|
||||
}
|
||||
}
|
||||
|
||||
emit(): SchemaArtifact {
|
||||
const definitions = this.declarations.map((declaration) => {
|
||||
if (declaration.typeParameters.length > 0) {
|
||||
this.fail(declaration.name, 'generic declarations require a schema-factory projection')
|
||||
}
|
||||
return `const ${this.schemaName(declaration.id)} = ${this.declarationSchema(declaration)}`
|
||||
})
|
||||
const definitions = this.declarations.map(declaration => this.declarationDefinition(declaration))
|
||||
for (const boundary of this.boundaries) {
|
||||
definitions.push(`const ${this.boundaryName(boundary.key)} = ${this.typeSchema(boundary.type)}`)
|
||||
}
|
||||
const exports = this.schemas.map((model): SchemaExport => ({
|
||||
model,
|
||||
exportName: safeIdentifier(model.export.name),
|
||||
internalName: this.schemaName(model.symbol),
|
||||
internalName: this.exportSchemaName(model),
|
||||
}))
|
||||
return { definitions, exports }
|
||||
return {
|
||||
definitions,
|
||||
exports,
|
||||
boundary: key => this.boundaryName(key),
|
||||
}
|
||||
}
|
||||
|
||||
private declarationSchema(declaration: TypeDeclarationModel): string {
|
||||
private declarationDefinition(declaration: TypeDeclarationModel): string {
|
||||
const name = this.schemaName(declaration.id)
|
||||
if (declaration.typeParameters.length === 0) {
|
||||
return `const ${name} = ${this.declarationSchema(declaration, new Map())}`
|
||||
}
|
||||
const parameters = declaration.typeParameters.map((parameter, index) =>
|
||||
[`type${String(index)}$schema`, parameter.id] as const)
|
||||
const substitutions = new Map(parameters.map(([schema, id]) => [id, schema]))
|
||||
return `const ${name} = (${parameters.map(([schema]) => schema).join(', ')}) => ${this.declarationSchema(declaration, substitutions)}`
|
||||
}
|
||||
|
||||
private declarationSchema(
|
||||
declaration: TypeDeclarationModel,
|
||||
substitutions: ReadonlyMap<string, string>,
|
||||
): string {
|
||||
if (declaration.kind === 'enum') {
|
||||
this.fail(declaration.name, 'enum declarations have no Zod projection')
|
||||
}
|
||||
if (declaration.kind === 'alias') {
|
||||
if (declaration.type === undefined) this.fail(declaration.name, 'alias has no modeled type')
|
||||
return this.describe(this.typeSchema(declaration.type), declaration)
|
||||
return this.describe(this.typeSchema(declaration.type, substitutions), declaration)
|
||||
}
|
||||
const own = this.objectSchema(declaration.members, declaration.name)
|
||||
const own = this.objectSchema(declaration.members, declaration.name, substitutions)
|
||||
let result = own
|
||||
for (const heritage of declaration.extends) {
|
||||
result = `z.intersection(${this.typeSchema(heritage)}, ${result})`
|
||||
result = `z.intersection(${this.typeSchema(heritage, substitutions)}, ${result})`
|
||||
}
|
||||
return this.describe(result, declaration)
|
||||
}
|
||||
|
||||
private typeSchema(id: TypeNodeId): string {
|
||||
private typeSchema(id: TypeNodeId, substitutions: ReadonlyMap<string, string> = new Map()): string {
|
||||
const node = this.renderer.node(id)
|
||||
switch (node.kind) {
|
||||
case 'keyword': return this.keywordSchema(node.name)
|
||||
case 'literal': return `z.literal(${node.text})`
|
||||
case 'parenthesized': return this.typeSchema(node.type)
|
||||
case 'reference': return this.referenceSchema(node)
|
||||
case 'parenthesized': return this.typeSchema(node.type, substitutions)
|
||||
case 'reference': return this.referenceSchema(node, substitutions)
|
||||
case 'union': {
|
||||
if (node.types.length === 0) return 'z.never()'
|
||||
if (node.types.length === 1) return this.typeSchema(node.types[0] as TypeNodeId)
|
||||
return `z.union([${node.types.map(type => this.typeSchema(type)).join(', ')}])`
|
||||
if (node.types.length === 1) return this.typeSchema(node.types[0] as TypeNodeId, substitutions)
|
||||
return `z.union([${node.types.map(type => this.typeSchema(type, substitutions)).join(', ')}])`
|
||||
}
|
||||
case 'intersection': {
|
||||
const [head, ...tail] = node.types
|
||||
if (head === undefined) return 'z.unknown()'
|
||||
return tail.reduce((left, right) => `z.intersection(${left}, ${this.typeSchema(right)})`, this.typeSchema(head))
|
||||
return tail.reduce(
|
||||
(left, right) => `z.intersection(${left}, ${this.typeSchema(right, substitutions)})`,
|
||||
this.typeSchema(head, substitutions),
|
||||
)
|
||||
}
|
||||
case 'array': return `z.array(${this.typeSchema(node.element)})`
|
||||
case 'array': return `z.array(${this.typeSchema(node.element, substitutions)})`
|
||||
case 'tuple': {
|
||||
const fixed = node.elements.filter(element => !element.rest)
|
||||
const rest = node.elements.find(element => element.rest)
|
||||
let schema = `z.tuple([${fixed.map(element => this.optional(this.typeSchema(element.type), element.optional)).join(', ')}])`
|
||||
if (rest !== undefined) schema += `.rest(${this.tupleRestSchema(rest.type)})`
|
||||
let schema = `z.tuple([${fixed.map(element => this.optional(this.typeSchema(element.type, substitutions), element.optional)).join(', ')}])`
|
||||
if (rest !== undefined) schema += `.rest(${this.tupleRestSchema(rest.type, substitutions)})`
|
||||
return schema
|
||||
}
|
||||
case 'object': return this.objectSchema(node.members, id)
|
||||
case 'object': return this.objectSchema(node.members, id, substitutions)
|
||||
case 'operator':
|
||||
case 'indexed-access':
|
||||
case 'conditional':
|
||||
@@ -326,9 +632,27 @@ class SchemaEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
private referenceSchema(node: Extract<TypeNodeModel, { kind: 'reference' }>): string {
|
||||
private referenceSchema(
|
||||
node: Extract<TypeNodeModel, { kind: 'reference' }>,
|
||||
substitutions: ReadonlyMap<string, string>,
|
||||
): string {
|
||||
if (node.target.kind === 'declaration') {
|
||||
return `z.lazy(() => ${this.schemaName(node.target.symbol)})`
|
||||
const name = this.schemaName(node.target.symbol)
|
||||
const declaration = this.renderer.declaration(node.target.symbol)
|
||||
if (declaration.typeParameters.length === 0) {
|
||||
if (node.arguments.length > 0) {
|
||||
this.fail(node.name, `non-generic declaration received ${String(node.arguments.length)} type arguments`)
|
||||
}
|
||||
return `z.lazy(() => ${name})`
|
||||
}
|
||||
const arguments_ = this.declarationArguments(node, declaration, substitutions)
|
||||
return `z.lazy(() => ${name}(${arguments_.join(', ')}))`
|
||||
}
|
||||
if (node.target.kind === 'type-parameter') {
|
||||
if (node.arguments.length > 0) this.fail(node.name, 'type parameter reference cannot receive type arguments')
|
||||
const schema = substitutions.get(node.target.parameter)
|
||||
if (schema === undefined) this.fail(node.name, 'type parameter has no schema substitution')
|
||||
return schema
|
||||
}
|
||||
if (node.target.kind === 'standard') {
|
||||
switch (node.target.name) {
|
||||
@@ -336,13 +660,16 @@ class SchemaEmitter {
|
||||
case 'ReadonlyArray': {
|
||||
const element = node.arguments[0]
|
||||
if (element === undefined) this.fail(node.name, 'array reference has no element type')
|
||||
return this.readonly(`z.array(${this.typeSchema(element)})`, node.target.name === 'ReadonlyArray')
|
||||
return this.readonly(
|
||||
`z.array(${this.typeSchema(element, substitutions)})`,
|
||||
node.target.name === 'ReadonlyArray',
|
||||
)
|
||||
}
|
||||
case 'Record': {
|
||||
const key = node.arguments[0]
|
||||
const value = node.arguments[1]
|
||||
if (key === undefined || value === undefined) this.fail(node.name, 'Record requires key and value types')
|
||||
return `z.record(${this.typeSchema(key)}, ${this.typeSchema(value)})`
|
||||
return `z.record(${this.typeSchema(key, substitutions)}, ${this.typeSchema(value, substitutions)})`
|
||||
}
|
||||
case 'Date': return 'z.date()'
|
||||
default: this.fail(node.name, `standard type ${node.target.name} has no Zod projection`)
|
||||
@@ -351,31 +678,97 @@ class SchemaEmitter {
|
||||
this.fail(node.name, `${node.target.kind} reference has no Zod projection`)
|
||||
}
|
||||
|
||||
private tupleRestSchema(id: TypeNodeId): string {
|
||||
private declarationArguments(
|
||||
node: Extract<TypeNodeModel, { kind: 'reference' }>,
|
||||
declaration: TypeDeclarationModel,
|
||||
substitutions: ReadonlyMap<string, string>,
|
||||
): string[] {
|
||||
if (node.arguments.length > declaration.typeParameters.length) {
|
||||
this.fail(
|
||||
node.name,
|
||||
`generic declaration accepts ${String(declaration.typeParameters.length)} type arguments but received ${String(node.arguments.length)}`,
|
||||
)
|
||||
}
|
||||
const resolved = new Map(substitutions)
|
||||
const arguments_: string[] = []
|
||||
for (const [index, parameter] of declaration.typeParameters.entries()) {
|
||||
const argument = node.arguments[index]
|
||||
const schema = argument === undefined
|
||||
? parameter.default === undefined
|
||||
? this.fail(node.name, `missing type argument ${parameter.name}`)
|
||||
: this.typeSchema(parameter.default, resolved)
|
||||
: this.typeSchema(argument, substitutions)
|
||||
arguments_.push(schema)
|
||||
resolved.set(parameter.id, schema)
|
||||
}
|
||||
return arguments_
|
||||
}
|
||||
|
||||
private tupleRestSchema(id: TypeNodeId, substitutions: ReadonlyMap<string, string>): string {
|
||||
const node = this.renderer.node(id)
|
||||
if (node.kind === 'array') return this.typeSchema(node.element)
|
||||
if (node.kind === 'array') return this.typeSchema(node.element, substitutions)
|
||||
if (node.kind === 'reference'
|
||||
&& node.target.kind === 'standard'
|
||||
&& (node.target.name === 'Array' || node.target.name === 'ReadonlyArray')) {
|
||||
const element = node.arguments[0]
|
||||
if (element === undefined) this.fail(node.name, 'tuple rest array has no element type')
|
||||
return this.typeSchema(element)
|
||||
return this.typeSchema(element, substitutions)
|
||||
}
|
||||
this.fail(id, 'tuple rest element must retain an array type')
|
||||
}
|
||||
|
||||
private objectSchema(members: readonly MemberModel[], subject: string): string {
|
||||
private objectSchema(
|
||||
members: readonly MemberModel[],
|
||||
subject: string,
|
||||
substitutions: ReadonlyMap<string, string>,
|
||||
): string {
|
||||
const properties: string[] = []
|
||||
const indices: string[] = []
|
||||
let symbolMembers = 0
|
||||
for (const member of members) {
|
||||
if (member.static || member.visibility !== 'public') continue
|
||||
if (member.computed === 'symbol') {
|
||||
symbolMembers++
|
||||
continue
|
||||
}
|
||||
if (member.computed === 'dynamic') {
|
||||
this.fail(subject, `computed member ${member.name} has no fixed JSON property name`)
|
||||
}
|
||||
if (member.kind === 'index') {
|
||||
const parameter = member.signature.parameters[0]
|
||||
if (member.signature.parameters.length !== 1 || parameter === undefined) {
|
||||
this.fail(subject, 'index signature must have exactly one key parameter')
|
||||
}
|
||||
indices.push(this.readonly(
|
||||
`z.record(${this.typeSchema(parameter.type, substitutions)}, ${this.typeSchema(member.signature.returns, substitutions)})`,
|
||||
member.readonly,
|
||||
))
|
||||
continue
|
||||
}
|
||||
if (member.kind !== 'property') this.fail(subject, `${member.kind} member ${member.name} is not data-schema projectable`)
|
||||
const property = this.describe(
|
||||
this.optional(this.readonly(this.typeSchema(member.type), member.readonly), member.optional),
|
||||
this.optional(this.readonly(this.typeSchema(member.type, substitutions), member.readonly), member.optional),
|
||||
member,
|
||||
)
|
||||
properties.push(`${quote(member.name)}: ${property}`)
|
||||
properties.push(`${quote(member.jsonName ?? member.name)}: ${property}`)
|
||||
}
|
||||
return `z.object({${properties.length === 0 ? '' : `\n${properties.map(property => ` ${property},`).join('\n')}\n`}})`
|
||||
if (indices.length > 1) this.fail(subject, 'object type has more than one JSON index signature')
|
||||
// A unique-symbol-only object is a compile-time marker and imposes no JSON shape.
|
||||
if (properties.length === 0 && indices.length === 0 && symbolMembers > 0) return 'z.unknown()'
|
||||
const object = `z.object({${properties.length === 0 ? '' : `\n${properties.map(property => ` ${property},`).join('\n')}\n`}})`
|
||||
const index = indices[0]
|
||||
if (index === undefined) return object
|
||||
if (properties.length === 0) return index
|
||||
return `z.intersection(${object}, ${index})`
|
||||
}
|
||||
|
||||
private exportSchemaName(model: SchemaModel): string {
|
||||
const name = this.schemaName(model.symbol)
|
||||
const declaration = this.renderer.declaration(model.symbol)
|
||||
if (declaration.typeParameters.length > 0) {
|
||||
this.fail(model.export.name, 'generic schema exports require a concrete declaration')
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
private keywordSchema(name: string): string {
|
||||
@@ -401,6 +794,12 @@ class SchemaEmitter {
|
||||
return name
|
||||
}
|
||||
|
||||
private boundaryName(key: string): string {
|
||||
const name = this.boundaryNames.get(key)
|
||||
if (name === undefined) this.fail(key, 'invocation boundary is outside the selected schema roots')
|
||||
return name
|
||||
}
|
||||
|
||||
private describe(schema: string, documentation: DocumentationModel): string {
|
||||
return documentation.description === undefined ? schema : `${schema}.describe(${quote(documentation.description)})`
|
||||
}
|
||||
@@ -431,6 +830,77 @@ function documentationLiteral(documentation: DocumentationModel): DocumentationM
|
||||
}
|
||||
}
|
||||
|
||||
function invocationBoundaryRoots(invocations: readonly InvocationModel[]): BoundarySchemaRoot[] {
|
||||
const result: BoundarySchemaRoot[] = []
|
||||
for (const invocation of invocations) {
|
||||
if (invocation.invocation.kind === 'context') {
|
||||
result.push({ key: contextBoundaryKey(invocation), type: invocation.invocation.boundary.codecType })
|
||||
}
|
||||
invocation.parameters.forEach((parameter, index) => {
|
||||
result.push({ key: parameterBoundaryKey(invocation, index), type: parameter.boundary.codecType })
|
||||
})
|
||||
result.push({ key: resultBoundaryKey(invocation), type: invocation.result.codecType })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function contextBoundaryKey(invocation: InvocationModel): string {
|
||||
return `${invocation.id}:context`
|
||||
}
|
||||
|
||||
function parameterBoundaryKey(invocation: InvocationModel, index: number): string {
|
||||
return `${invocation.id}:parameter:${String(index)}`
|
||||
}
|
||||
|
||||
function resultBoundaryKey(invocation: InvocationModel): string {
|
||||
return `${invocation.id}:result`
|
||||
}
|
||||
|
||||
function strictCodec(boundary: RemoteBoundaryModel, schema: string): string {
|
||||
return [
|
||||
'{',
|
||||
' mode: \'strict\',',
|
||||
` typeSymbol: ${quote(boundary.typeSymbol)},`,
|
||||
` schema: ${schema},`,
|
||||
'}',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function remoteImports(invocations: readonly InvocationModel[]): RemoteTypeImportModel[] {
|
||||
const imports = new Map<SymbolId, RemoteTypeImportModel>()
|
||||
const add = (boundary: RemoteBoundaryModel): void => {
|
||||
for (const imported of boundary.imports) {
|
||||
const current = imports.get(imported.symbol)
|
||||
if (current !== undefined
|
||||
&& (current.specifier !== imported.specifier || current.name !== imported.name)) {
|
||||
throw new TypertEmitError(`typert Remote emitter: symbol ${imported.symbol} has inconsistent public imports`)
|
||||
}
|
||||
imports.set(imported.symbol, imported)
|
||||
}
|
||||
}
|
||||
for (const invocation of invocations) {
|
||||
if (invocation.invocation.kind === 'context') add(invocation.invocation.boundary)
|
||||
for (const parameter of invocation.parameters) add(parameter.boundary)
|
||||
add(invocation.result)
|
||||
}
|
||||
return [...imports.values()].sort((left, right) =>
|
||||
left.specifier.localeCompare(right.specifier) || left.name.localeCompare(right.name))
|
||||
}
|
||||
|
||||
function allocateRemoteImportNames(imports: readonly RemoteTypeImportModel[]): ReadonlyMap<SymbolId, string> {
|
||||
const used = new Set(['TypeRTRemoteContribution', 'TYPERT_REMOTE'])
|
||||
const names = new Map<SymbolId, string>()
|
||||
for (const imported of imports) {
|
||||
const base = safeIdentifier(imported.name)
|
||||
let name = base
|
||||
let suffix = 2
|
||||
while (used.has(name)) name = `${base}$remote${String(suffix++)}`
|
||||
used.add(name)
|
||||
names.set(imported.symbol, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
function packageExportSpecifier(packageName: string, subpath: string): string {
|
||||
return subpath === '.' ? packageName : `${packageName}${subpath.slice(1)}`
|
||||
}
|
||||
|
||||
@@ -94,6 +94,56 @@ export interface SchemaModel extends DocumentationModel {
|
||||
readonly type: TypeNodeId
|
||||
}
|
||||
|
||||
/** One public business type import retained for a generated Remote declaration. */
|
||||
export interface RemoteTypeImportModel {
|
||||
readonly symbol: SymbolId
|
||||
readonly specifier: string
|
||||
readonly name: string
|
||||
}
|
||||
|
||||
/** One strict wire boundary and the public symbols needed to name it. */
|
||||
export interface RemoteBoundaryModel {
|
||||
/** Authored public type retained for generated consumer declarations. */
|
||||
readonly type: TypeNodeId
|
||||
/** Checker-resolved projection used only to emit the runtime codec. */
|
||||
readonly codecType: TypeNodeId
|
||||
readonly typeSymbol: string
|
||||
readonly imports: readonly RemoteTypeImportModel[]
|
||||
}
|
||||
|
||||
/** One ordered business argument projected onto a Remote wire field. */
|
||||
export interface InvocationParameterModel {
|
||||
readonly name: string
|
||||
readonly wire: string
|
||||
readonly source: 'json' | 'lookup'
|
||||
readonly lookup?: string
|
||||
readonly boundary: RemoteBoundaryModel
|
||||
}
|
||||
|
||||
/** One strictly analyzed Host method exported through TypeRT Gateway. */
|
||||
export interface InvocationModel {
|
||||
readonly id: string
|
||||
readonly service: string
|
||||
readonly namespace: string
|
||||
readonly method: string
|
||||
readonly implementation?: string
|
||||
readonly invocation:
|
||||
| { readonly kind: 'direct' }
|
||||
| {
|
||||
readonly kind: 'context'
|
||||
readonly context: string
|
||||
readonly wire: string
|
||||
readonly boundary: RemoteBoundaryModel
|
||||
}
|
||||
readonly scope?: {
|
||||
readonly context: string
|
||||
readonly wire: string
|
||||
}
|
||||
readonly parameters: readonly InvocationParameterModel[]
|
||||
readonly result: RemoteBoundaryModel
|
||||
readonly location: SourceLocation
|
||||
}
|
||||
|
||||
/** Business semantics discovered in one package on one face. */
|
||||
export interface PackageModel {
|
||||
readonly name: string
|
||||
@@ -103,6 +153,7 @@ export interface PackageModel {
|
||||
readonly events: readonly EventModel[]
|
||||
readonly objects: readonly ObjectModel[]
|
||||
readonly schemas: readonly SchemaModel[]
|
||||
readonly invocations: readonly InvocationModel[]
|
||||
}
|
||||
|
||||
/** One explicit import/re-export edge between independently compiled faces. */
|
||||
@@ -173,6 +224,10 @@ export interface SignatureModel {
|
||||
export interface MemberBase extends DocumentationModel {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
/** JSON property name when a literal computed key differs from source text. */
|
||||
readonly jsonName?: string
|
||||
/** Non-literal computed keys; symbol keys are erased from JSON schemas. */
|
||||
readonly computed?: 'symbol' | 'dynamic'
|
||||
readonly optional: boolean
|
||||
readonly readonly: boolean
|
||||
readonly async: boolean
|
||||
|
||||
@@ -81,32 +81,35 @@ export class TypeGraphRenderer {
|
||||
/**
|
||||
* Render one type expression from the retained source structure.
|
||||
* @param id - type node id.
|
||||
* @param references - optional generated names for declaration references.
|
||||
* @returns TypeScript type text.
|
||||
*/
|
||||
renderType(id: TypeNodeId): string {
|
||||
renderType(id: TypeNodeId, references?: ReadonlyMap<SymbolId, string>): string {
|
||||
const node = this.node(id)
|
||||
switch (node.kind) {
|
||||
case 'keyword': return node.name
|
||||
case 'literal': return node.text
|
||||
case 'parenthesized': return `(${this.renderType(node.type)})`
|
||||
case 'parenthesized': return `(${this.renderType(node.type, references)})`
|
||||
case 'reference': {
|
||||
const name = node.target.kind === 'type-parameter'
|
||||
? this.parameterNames.get(node.target.parameter) ?? node.name
|
||||
: node.name
|
||||
: node.target.kind === 'declaration'
|
||||
? references?.get(node.target.symbol) ?? node.name
|
||||
: node.name
|
||||
return node.arguments.length === 0
|
||||
? name
|
||||
: `${name}<${node.arguments.map(argument => this.renderType(argument)).join(', ')}>`
|
||||
: `${name}<${node.arguments.map(argument => this.renderType(argument, references)).join(', ')}>`
|
||||
}
|
||||
case 'union': return node.types.map(type => this.renderType(type)).join(' | ')
|
||||
case 'intersection': return node.types.map(type => this.renderType(type)).join(' & ')
|
||||
case 'union': return node.types.map(type => this.renderType(type, references)).join(' | ')
|
||||
case 'intersection': return node.types.map(type => this.renderType(type, references)).join(' & ')
|
||||
case 'array': {
|
||||
const element = this.renderType(node.element)
|
||||
const element = this.renderType(node.element, references)
|
||||
const wrapped = needsArrayParentheses(this.node(node.element)) ? `(${element})` : element
|
||||
return `${wrapped}[]`
|
||||
}
|
||||
case 'tuple': {
|
||||
const elements = node.elements.map((element) => {
|
||||
const type = this.renderType(element.type)
|
||||
const type = this.renderType(element.type, references)
|
||||
if (element.name !== undefined) {
|
||||
return `${element.rest ? '...' : ''}${element.name}${element.optional ? '?' : ''}: ${type}`
|
||||
}
|
||||
@@ -114,34 +117,34 @@ export class TypeGraphRenderer {
|
||||
})
|
||||
return `[${elements.join(', ')}]`
|
||||
}
|
||||
case 'object': return this.renderObject(node.members)
|
||||
case 'function': return `${this.renderSignatureHead(node.signature)} => ${this.renderType(node.signature.returns)}`
|
||||
case 'constructor': return `${node.abstract ? 'abstract ' : ''}new ${this.renderSignatureHead(node.signature)} => ${this.renderType(node.signature.returns)}`
|
||||
case 'indexed-access': return `${this.renderType(node.object)}[${this.renderType(node.index)}]`
|
||||
case 'operator': return `${node.operator} ${this.renderType(node.type)}`
|
||||
case 'object': return this.renderObject(node.members, references)
|
||||
case 'function': return `${this.renderSignatureHead(node.signature, references)} => ${this.renderType(node.signature.returns, references)}`
|
||||
case 'constructor': return `${node.abstract ? 'abstract ' : ''}new ${this.renderSignatureHead(node.signature, references)} => ${this.renderType(node.signature.returns, references)}`
|
||||
case 'indexed-access': return `${this.renderType(node.object, references)}[${this.renderType(node.index, references)}]`
|
||||
case 'operator': return `${node.operator} ${this.renderType(node.type, references)}`
|
||||
case 'conditional': {
|
||||
return `${this.renderType(node.check)} extends ${this.renderType(node.extends)} ? ${this.renderType(node.whenTrue)} : ${this.renderType(node.whenFalse)}`
|
||||
return `${this.renderType(node.check, references)} extends ${this.renderType(node.extends, references)} ? ${this.renderType(node.whenTrue, references)} : ${this.renderType(node.whenFalse, references)}`
|
||||
}
|
||||
case 'infer': return `infer ${this.renderTypeParameter(node.parameter, false)}`
|
||||
case 'infer': return `infer ${this.renderTypeParameter(node.parameter, false, references)}`
|
||||
case 'mapped': {
|
||||
const readonly = node.readonly === 'preserve' ? '' : node.readonly === 'remove' ? '-readonly ' : 'readonly '
|
||||
const optional = node.optional === 'preserve' ? '' : node.optional === 'remove' ? '-?' : '?'
|
||||
if (node.parameter.constraint === undefined) {
|
||||
throw new TypeGraphRenderError(`mapped type parameter ${node.parameter.name} has no constraint`)
|
||||
}
|
||||
const parameter = `${node.parameter.name} in ${this.renderType(node.parameter.constraint)}`
|
||||
const nameType = node.nameType === undefined ? '' : ` as ${this.renderType(node.nameType)}`
|
||||
const value = node.value === undefined ? 'unknown' : this.renderType(node.value)
|
||||
const parameter = `${node.parameter.name} in ${this.renderType(node.parameter.constraint, references)}`
|
||||
const nameType = node.nameType === undefined ? '' : ` as ${this.renderType(node.nameType, references)}`
|
||||
const value = node.value === undefined ? 'unknown' : this.renderType(node.value, references)
|
||||
return `{ ${readonly}[${parameter}${nameType}]${optional}: ${value} }`
|
||||
}
|
||||
case 'template-literal': {
|
||||
const spans = node.spans.map(span => `\${${this.renderType(span.type)}}${escapeTemplate(span.text)}`).join('')
|
||||
const spans = node.spans.map(span => `\${${this.renderType(span.type, references)}}${escapeTemplate(span.text)}`).join('')
|
||||
return `\`${escapeTemplate(node.head)}${spans}\``
|
||||
}
|
||||
case 'type-query': {
|
||||
const argumentsText = node.arguments.length === 0
|
||||
? ''
|
||||
: `<${node.arguments.map(argument => this.renderType(argument)).join(', ')}>`
|
||||
: `<${node.arguments.map(argument => this.renderType(argument, references)).join(', ')}>`
|
||||
return `typeof ${node.expression}${argumentsText}`
|
||||
}
|
||||
case 'import-type': {
|
||||
@@ -149,14 +152,14 @@ export class TypeGraphRenderer {
|
||||
const imported = `import(${quote(node.module)}${attributes})${node.qualifier === undefined ? '' : `.${node.qualifier}`}`
|
||||
const argumentsText = node.arguments.length === 0
|
||||
? ''
|
||||
: `<${node.arguments.map(argument => this.renderType(argument)).join(', ')}>`
|
||||
: `<${node.arguments.map(argument => this.renderType(argument, references)).join(', ')}>`
|
||||
return `${node.typeof ? 'typeof ' : ''}${imported}${argumentsText}`
|
||||
}
|
||||
case 'predicate': {
|
||||
const assertion = node.asserts ? 'asserts ' : ''
|
||||
return node.type === undefined
|
||||
? `${assertion}${node.parameter}`
|
||||
: `${assertion}${node.parameter} is ${this.renderType(node.type)}`
|
||||
: `${assertion}${node.parameter} is ${this.renderType(node.type, references)}`
|
||||
}
|
||||
case 'this': return 'this'
|
||||
default: return assertNever(node)
|
||||
@@ -166,34 +169,36 @@ export class TypeGraphRenderer {
|
||||
/**
|
||||
* Render a callable signature without a member name.
|
||||
* @param signature - modeled signature.
|
||||
* @param references - optional generated names for declaration references.
|
||||
* @returns parameter list and return type.
|
||||
*/
|
||||
renderSignature(signature: SignatureModel): string {
|
||||
return `${this.renderSignatureHead(signature)}: ${this.renderType(signature.returns)}`
|
||||
renderSignature(signature: SignatureModel, references?: ReadonlyMap<SymbolId, string>): string {
|
||||
return `${this.renderSignatureHead(signature, references)}: ${this.renderType(signature.returns, references)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one class/interface member as a body-free declaration.
|
||||
* @param member - modeled member.
|
||||
* @param sourceModifiers - retain source-only modifiers for reflection text.
|
||||
* @param references - optional generated names for declaration references.
|
||||
* @returns one-line TypeScript member text.
|
||||
*/
|
||||
renderMember(member: MemberModel, sourceModifiers = false): string {
|
||||
renderMember(member: MemberModel, sourceModifiers = false, references?: ReadonlyMap<SymbolId, string>): string {
|
||||
if (sourceModifiers) return member.text
|
||||
const name = renderPropertyName(member.name)
|
||||
const optional = member.optional ? '?' : ''
|
||||
const readonly = member.readonly ? 'readonly ' : ''
|
||||
const abstract = member.abstract ? 'abstract ' : ''
|
||||
switch (member.kind) {
|
||||
case 'property': return `${abstract}${readonly}${name}${optional}: ${this.renderType(member.type)}`
|
||||
case 'method': return `${abstract}${name}${optional}${this.renderSignature(member.signature)}`
|
||||
case 'getter': return `${abstract}get ${name}()${this.renderReturn(member.signature)}`
|
||||
case 'setter': return `${abstract}set ${name}${this.renderSignatureHead(member.signature)}`
|
||||
case 'call': return this.renderSignature(member.signature)
|
||||
case 'construct': return `new ${this.renderSignature(member.signature)}`
|
||||
case 'property': return `${abstract}${readonly}${name}${optional}: ${this.renderType(member.type, references)}`
|
||||
case 'method': return `${abstract}${name}${optional}${this.renderSignature(member.signature, references)}`
|
||||
case 'getter': return `${abstract}get ${name}()${this.renderReturn(member.signature, references)}`
|
||||
case 'setter': return `${abstract}set ${name}${this.renderSignatureHead(member.signature, references)}`
|
||||
case 'call': return this.renderSignature(member.signature, references)
|
||||
case 'construct': return `new ${this.renderSignature(member.signature, references)}`
|
||||
case 'index': {
|
||||
const parameters = member.signature.parameters.map(parameter => this.renderParameter(parameter)).join(', ')
|
||||
return `${readonly}[${parameters}]: ${this.renderType(member.signature.returns)}`
|
||||
const parameters = member.signature.parameters.map(parameter => this.renderParameter(parameter, references)).join(', ')
|
||||
return `${readonly}[${parameters}]: ${this.renderType(member.signature.returns, references)}`
|
||||
}
|
||||
default: return assertNever(member)
|
||||
}
|
||||
@@ -290,38 +295,42 @@ export class TypeGraphRenderer {
|
||||
return this.graph.declarations.filter(declaration => found.has(declaration.id))
|
||||
}
|
||||
|
||||
private renderSignatureHead(signature: SignatureModel): string {
|
||||
return `${this.renderTypeParameters(signature.typeParameters)}(${signature.parameters.map(parameter => this.renderParameter(parameter)).join(', ')})`
|
||||
private renderSignatureHead(signature: SignatureModel, references?: ReadonlyMap<SymbolId, string>): string {
|
||||
return `${this.renderTypeParameters(signature.typeParameters, references)}(${signature.parameters.map(parameter => this.renderParameter(parameter, references)).join(', ')})`
|
||||
}
|
||||
|
||||
private renderReturn(signature: SignatureModel): string {
|
||||
return `: ${this.renderType(signature.returns)}`
|
||||
private renderReturn(signature: SignatureModel, references?: ReadonlyMap<SymbolId, string>): string {
|
||||
return `: ${this.renderType(signature.returns, references)}`
|
||||
}
|
||||
|
||||
private renderParameter(parameter: ParameterModel): string {
|
||||
private renderParameter(parameter: ParameterModel, references?: ReadonlyMap<SymbolId, string>): string {
|
||||
const name = parameter.binding === 'identifier' ? renderPropertyName(parameter.name) : parameter.name
|
||||
const optional = parameter.initializer === undefined && parameter.optional && !parameter.rest ? '?' : ''
|
||||
const initializer = parameter.initializer === undefined ? '' : ` = ${parameter.initializer}`
|
||||
return `${parameter.rest ? '...' : ''}${name}${optional}: ${this.renderType(parameter.type)}${initializer}`
|
||||
return `${parameter.rest ? '...' : ''}${name}${optional}: ${this.renderType(parameter.type, references)}${initializer}`
|
||||
}
|
||||
|
||||
private renderTypeParameters(parameters: readonly TypeParameterModel[]): string {
|
||||
private renderTypeParameters(parameters: readonly TypeParameterModel[], references?: ReadonlyMap<SymbolId, string>): string {
|
||||
return parameters.length === 0
|
||||
? ''
|
||||
: `<${parameters.map(parameter => this.renderTypeParameter(parameter, true)).join(', ')}>`
|
||||
: `<${parameters.map(parameter => this.renderTypeParameter(parameter, true, references)).join(', ')}>`
|
||||
}
|
||||
|
||||
private renderTypeParameter(parameter: TypeParameterModel, includeDefault: boolean): string {
|
||||
private renderTypeParameter(
|
||||
parameter: TypeParameterModel,
|
||||
includeDefault: boolean,
|
||||
references?: ReadonlyMap<SymbolId, string>,
|
||||
): string {
|
||||
const variance = parameter.variance === undefined ? '' : `${parameter.variance === 'in-out' ? 'in out' : parameter.variance} `
|
||||
const constModifier = parameter.const ? 'const ' : ''
|
||||
const constraint = parameter.constraint === undefined ? '' : ` extends ${this.renderType(parameter.constraint)}`
|
||||
const fallback = !includeDefault || parameter.default === undefined ? '' : ` = ${this.renderType(parameter.default)}`
|
||||
const constraint = parameter.constraint === undefined ? '' : ` extends ${this.renderType(parameter.constraint, references)}`
|
||||
const fallback = !includeDefault || parameter.default === undefined ? '' : ` = ${this.renderType(parameter.default, references)}`
|
||||
return `${constModifier}${variance}${parameter.name}${constraint}${fallback}`
|
||||
}
|
||||
|
||||
private renderObject(members: readonly MemberModel[]): string {
|
||||
private renderObject(members: readonly MemberModel[], references?: ReadonlyMap<SymbolId, string>): string {
|
||||
if (members.length === 0) return '{}'
|
||||
return `{ ${members.map(member => `${this.renderMember(member)};`).join(' ')} }`
|
||||
return `{ ${members.map(member => `${this.renderMember(member, false, references)};`).join(' ')} }`
|
||||
}
|
||||
|
||||
private indexParameters(parameters: readonly TypeParameterModel[]): void {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Optional tsdown (rolldown) plugin face of the typert generator. When added
|
||||
* to a workspace tsdown config, it runs after each opted-in package bundle is
|
||||
* written and re-emits its model-driven face artifact at the package output
|
||||
* root. Packages without a Typert export are skipped.
|
||||
* root. Packages without a Typert or Remote export are skipped.
|
||||
* @module @deepseek-ai/dsh-typert-generator/tsdown
|
||||
*/
|
||||
|
||||
@@ -10,6 +10,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { WorkspaceTypertGenerator } from './workspace.ts'
|
||||
import type { WorkspaceEmitResult } from './workspace.ts'
|
||||
import type { TypertFace } from './model.ts'
|
||||
|
||||
/** The subset of the rolldown output-plugin contract this plugin uses (structural; avoids a rolldown type dependency). */
|
||||
interface TypertPlugin {
|
||||
@@ -17,21 +18,37 @@ interface TypertPlugin {
|
||||
writeBundle: (options: { dir?: string }) => void
|
||||
}
|
||||
|
||||
/** Generation scope selected by a tsdown build phase. */
|
||||
export interface TypertPluginOptions {
|
||||
/** Package mode emits only the package being bundled; workspace mode emits every explicit contributor once. */
|
||||
readonly mode?: 'package' | 'workspace'
|
||||
/** Independent TypeScript program faces included in this phase. */
|
||||
readonly faces?: readonly TypertFace[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the typert generation plugin for the root tsdown config.
|
||||
* @returns a rolldown-compatible plugin that emits `lib/typert.<face>.js` and `.d.ts` for contributing packages.
|
||||
* @param pluginOptions - package/workspace emission mode and independent program faces.
|
||||
* @returns a rolldown-compatible plugin that emits local face and Host-for-Client Remote artifacts.
|
||||
*/
|
||||
export function typertPlugin(): TypertPlugin {
|
||||
export function typertPlugin(pluginOptions: TypertPluginOptions = {}): TypertPlugin {
|
||||
const artifactsByRoot = new Map<string, readonly WorkspaceEmitResult[]>()
|
||||
const emittedWorkspaces = new Set<string>()
|
||||
return {
|
||||
name: 'dsh-typert-generator',
|
||||
writeBundle(options) {
|
||||
writeBundle(bundleOptions) {
|
||||
// options.dir is the package's absolute outDir (<package>/lib); its
|
||||
// nearest package.json owns the bundle even when a custom config writes
|
||||
// a nested output such as <package>/lib/dev.
|
||||
if (options.dir === undefined) return
|
||||
const root = workspaceRoot(options.dir)
|
||||
const packageDir = packageRoot(options.dir, root)
|
||||
if (bundleOptions.dir === undefined) return
|
||||
const root = workspaceRoot(bundleOptions.dir)
|
||||
if (emittedWorkspaces.has(root)) return
|
||||
if (pluginOptions.mode === 'workspace') {
|
||||
emitWorkspace(root, pluginOptions.faces)
|
||||
emittedWorkspaces.add(root)
|
||||
return
|
||||
}
|
||||
const packageDir = packageRoot(bundleOptions.dir, root)
|
||||
if (packageDir === undefined) return
|
||||
const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as {
|
||||
name?: string
|
||||
@@ -40,22 +57,54 @@ export function typertPlugin(): TypertPlugin {
|
||||
if (manifest.name === undefined || !hasTypertExport(manifest.exports)) return
|
||||
let artifacts = artifactsByRoot.get(root)
|
||||
if (artifacts === undefined) {
|
||||
artifacts = new WorkspaceTypertGenerator(root).generate()
|
||||
const generator = new WorkspaceTypertGenerator(root)
|
||||
artifacts = pluginOptions.faces === undefined
|
||||
? generator.generate()
|
||||
: generator.generate(undefined, pluginOptions.faces)
|
||||
artifactsByRoot.set(root, artifacts)
|
||||
}
|
||||
const output = join(packageDir, 'lib')
|
||||
mkdirSync(output, { recursive: true })
|
||||
for (const artifact of artifacts.filter(candidate => candidate.package === manifest.name)) {
|
||||
writeFileSync(join(output, `typert.${artifact.face}.js`), artifact.js)
|
||||
writeFileSync(join(output, `typert.${artifact.face}.d.ts`), artifact.dts)
|
||||
}
|
||||
emitArtifacts(packageDir, artifacts.filter(candidate => candidate.package === manifest.name))
|
||||
},
|
||||
}
|
||||
|
||||
function emitWorkspace(root: string, faces: readonly TypertFace[] | undefined): void {
|
||||
const generator = new WorkspaceTypertGenerator(root)
|
||||
const packages = generator.discover(faces)
|
||||
.filter(candidate => hasTypertExport(readManifest(join(root, candidate.root)).exports))
|
||||
.map(candidate => candidate.package)
|
||||
if (packages.length === 0) return
|
||||
for (const artifact of generator.generate(packages, faces)) {
|
||||
emitArtifacts(join(root, artifact.packageRoot), [artifact])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function emitArtifacts(packageDir: string, artifacts: readonly WorkspaceEmitResult[]): void {
|
||||
const output = join(packageDir, 'lib')
|
||||
mkdirSync(output, { recursive: true })
|
||||
for (const artifact of artifacts) {
|
||||
writeFileSync(join(output, `typert.${artifact.face}.js`), artifact.js)
|
||||
writeFileSync(join(output, `typert.${artifact.face}.d.ts`), artifact.dts)
|
||||
if (artifact.remote !== undefined) {
|
||||
writeFileSync(join(output, 'typert.remote-client.js'), artifact.remote.js)
|
||||
writeFileSync(join(output, 'typert.remote-client.d.ts'), artifact.remote.dts)
|
||||
writeFileSync(join(output, 'typert.remote-client.d.ts.map'), artifact.remote.dtsMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readManifest(packageDir: string): { name?: string; exports?: unknown } {
|
||||
return JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as {
|
||||
name?: string
|
||||
exports?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
function hasTypertExport(exportsField: unknown): boolean {
|
||||
if (exportsField === null || typeof exportsField !== 'object' || Array.isArray(exportsField)) return false
|
||||
return Object.hasOwn(exportsField, './typert') || Object.hasOwn(exportsField, './client/typert')
|
||||
return Object.hasOwn(exportsField, './typert')
|
||||
|| Object.hasOwn(exportsField, './client/typert')
|
||||
|| Object.hasOwn(exportsField, './remote')
|
||||
}
|
||||
|
||||
function packageRoot(start: string, workspace: string): string | undefined {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { TypertAnalysisError, WorkspaceAnalyzer } from './analyzer.ts'
|
||||
import type { DiscoveredTypertPackage } from './analyzer.ts'
|
||||
import { FaceModelEmitter } from './emitter.ts'
|
||||
import type { ModelEmitResult } from './emitter.ts'
|
||||
import type { TypertFace } from './model.ts'
|
||||
|
||||
/** One emitted artifact paired with its source package root. */
|
||||
export interface WorkspaceEmitResult extends ModelEmitResult {
|
||||
@@ -26,20 +27,29 @@ export class WorkspaceTypertGenerator {
|
||||
/**
|
||||
* Find public package faces that contribute Cordis services/events or
|
||||
* explicitly tagged Typert roots.
|
||||
* @param faces - optional independent program faces to inspect.
|
||||
* @returns discovered packages in stable package-name order.
|
||||
*/
|
||||
discover(): DiscoveredTypertPackage[] {
|
||||
return new WorkspaceAnalyzer({ root: this.root }).discoverPackages()
|
||||
discover(faces?: readonly TypertFace[]): DiscoveredTypertPackage[] {
|
||||
return new WorkspaceAnalyzer({
|
||||
root: this.root,
|
||||
...(faces === undefined ? {} : { faces }),
|
||||
}).discoverPackages()
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate all discovered contributors, or an explicit package subset.
|
||||
* @param packages - optional exact package names for a focused pass.
|
||||
* @param faces - optional independent program faces to analyze.
|
||||
* @returns one artifact per package face.
|
||||
*/
|
||||
generate(packages?: readonly string[]): WorkspaceEmitResult[] {
|
||||
const selected = packages ?? this.discover().map(candidate => candidate.package)
|
||||
const workspace = new WorkspaceAnalyzer({ root: this.root, packages: selected }).analyze()
|
||||
generate(packages?: readonly string[], faces?: readonly TypertFace[]): WorkspaceEmitResult[] {
|
||||
const selected = packages ?? this.discover(faces).map(candidate => candidate.package)
|
||||
const workspace = new WorkspaceAnalyzer({
|
||||
root: this.root,
|
||||
packages: selected,
|
||||
...(faces === undefined ? {} : { faces }),
|
||||
}).analyze()
|
||||
const artifacts: WorkspaceEmitResult[] = []
|
||||
for (const face of workspace.faces) {
|
||||
const emitter = new FaceModelEmitter(face)
|
||||
@@ -80,6 +90,28 @@ export class WorkspaceTypertGenerator {
|
||||
throw new TypertAnalysisError(`typert(${artifact.face}): ${artifact.package} package files must include ${file}`)
|
||||
}
|
||||
}
|
||||
if (artifact.remote === undefined) return
|
||||
const remoteExpected = {
|
||||
types: './lib/typert.remote-client.d.ts',
|
||||
default: './lib/typert.remote-client.js',
|
||||
}
|
||||
const remoteActual = manifest.exports !== null && typeof manifest.exports === 'object'
|
||||
? (manifest.exports as Record<string, unknown>)['./remote']
|
||||
: undefined
|
||||
if (!sameExport(remoteActual, remoteExpected)) {
|
||||
throw new TypertAnalysisError(
|
||||
`typert(host): ${artifact.package} must export ./remote as ${JSON.stringify(remoteExpected)}`,
|
||||
)
|
||||
}
|
||||
for (const file of [
|
||||
'lib/typert.remote-client.js',
|
||||
'lib/typert.remote-client.d.ts',
|
||||
'lib/typert.remote-client.d.ts.map',
|
||||
]) {
|
||||
if (!files.includes(file)) {
|
||||
throw new TypertAnalysisError(`typert(host): ${artifact.package} package files must include ${file}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ export const TYPERT = {
|
||||
schemas: [
|
||||
{ name: 'Payload', schema: Payload },
|
||||
],
|
||||
invocations: [
|
||||
],
|
||||
model: {
|
||||
"services": [
|
||||
{
|
||||
@@ -3815,6 +3817,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro
|
||||
"abstract": false,
|
||||
"async": false,
|
||||
"id": "type:packages/host/src/models.ts:123:11#1#['computed']@3756",
|
||||
"jsonName": "computed",
|
||||
"kind": "property",
|
||||
"location": {
|
||||
"column": 5,
|
||||
@@ -5634,6 +5637,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro
|
||||
"symbol": "@fixture/host:packages/host/src/models.ts#Variance",
|
||||
},
|
||||
],
|
||||
"invocations": [],
|
||||
"name": "@fixture/host",
|
||||
"objects": [
|
||||
{
|
||||
@@ -6449,6 +6453,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro
|
||||
"symbol": "<external>:../../../../../../node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/classic/schemas.d.cts#ZodType",
|
||||
},
|
||||
],
|
||||
"invocations": [],
|
||||
"name": "@fixture/client",
|
||||
"objects": [],
|
||||
"root": "packages/client",
|
||||
|
||||
5
packages/typert/generator/tests/fixtures/remote-model/package.json
vendored
Normal file
5
packages/typert/generator/tests/fixtures/remote-model/package.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "@fixture/remote-workspace",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
9
packages/typert/generator/tests/fixtures/remote-model/packages/domain/package.json
vendored
Normal file
9
packages/typert/generator/tests/fixtures/remote-model/packages/domain/package.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@fixture/domain",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./types": "./src/types.ts"
|
||||
}
|
||||
}
|
||||
19
packages/typert/generator/tests/fixtures/remote-model/packages/domain/src/index.ts
vendored
Normal file
19
packages/typert/generator/tests/fixtures/remote-model/packages/domain/src/index.ts
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { TypeRTContext, TypeRTLookup } from '@deepseek-ai/dsh-type-meta'
|
||||
import type { AgentId } from './types.ts'
|
||||
|
||||
/** Host-only live Agent object. */
|
||||
export class Agent {
|
||||
constructor(readonly id: AgentId) {}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-type-meta' {
|
||||
interface TypeRTLookupMap {
|
||||
agent: TypeRTLookup<Agent, AgentId>
|
||||
}
|
||||
|
||||
interface TypeRTContextMap {
|
||||
agent: TypeRTContext<AgentId>
|
||||
}
|
||||
}
|
||||
|
||||
export type { AgentId } from './types.ts'
|
||||
2
packages/typert/generator/tests/fixtures/remote-model/packages/domain/src/types.ts
vendored
Normal file
2
packages/typert/generator/tests/fixtures/remote-model/packages/domain/src/types.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/** Stable Agent identity crossing the Remote boundary. */
|
||||
export type AgentId = string
|
||||
11
packages/typert/generator/tests/fixtures/remote-model/packages/domain/tsconfig.json
vendored
Normal file
11
packages/typert/generator/tests/fixtures/remote-model/packages/domain/tsconfig.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
24
packages/typert/generator/tests/fixtures/remote-model/packages/remote/package.json
vendored
Normal file
24
packages/typert/generator/tests/fixtures/remote-model/packages/remote/package.json
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@fixture/remote",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./types": "./src/types.ts",
|
||||
"./typert": {
|
||||
"types": "./lib/typert.host.d.ts",
|
||||
"default": "./lib/typert.host.js"
|
||||
},
|
||||
"./remote": {
|
||||
"types": "./lib/typert.remote-client.d.ts",
|
||||
"default": "./lib/typert.remote-client.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"lib/typert.host.js",
|
||||
"lib/typert.host.d.ts",
|
||||
"lib/typert.remote-client.js",
|
||||
"lib/typert.remote-client.d.ts",
|
||||
"lib/typert.remote-client.d.ts.map"
|
||||
]
|
||||
}
|
||||
30
packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts
vendored
Normal file
30
packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Remote, RemoteContext, bindTypeRTGateway } from '@deepseek-ai/dsh-type-meta'
|
||||
import type { Agent } from '@fixture/domain'
|
||||
import type {
|
||||
CreateGoalRequest,
|
||||
CreateGoalResult,
|
||||
RenameGoalRequest,
|
||||
RenameGoalResult,
|
||||
} from './types.ts'
|
||||
|
||||
/** Remote-only business Service with no Cordis declaration merge. */
|
||||
export class GoalService {
|
||||
readonly typertGateway = bindTypeRTGateway(this, 'goals')
|
||||
|
||||
@Remote
|
||||
async create(agent: Agent, request: CreateGoalRequest): Promise<CreateGoalResult> {
|
||||
return { ref: `${agent.id}:${request.title}` }
|
||||
}
|
||||
|
||||
@RemoteContext('agent')
|
||||
rename(request: RenameGoalRequest): RenameGoalResult {
|
||||
return { renamed: request.title.length > 0 }
|
||||
}
|
||||
}
|
||||
|
||||
export type {
|
||||
CreateGoalRequest,
|
||||
CreateGoalResult,
|
||||
RenameGoalRequest,
|
||||
RenameGoalResult,
|
||||
} from './types.ts'
|
||||
20
packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/types.ts
vendored
Normal file
20
packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/types.ts
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
/** Input accepted by Goal creation. */
|
||||
export interface CreateGoalRequest {
|
||||
readonly title: string
|
||||
}
|
||||
|
||||
/** Wire-safe Goal creation result. */
|
||||
export interface CreateGoalResult {
|
||||
readonly ref: string
|
||||
}
|
||||
|
||||
/** Input accepted by scoped Goal renaming. */
|
||||
export interface RenameGoalRequest {
|
||||
readonly ref: string
|
||||
readonly title: string
|
||||
}
|
||||
|
||||
/** Wire-safe Goal rename result. */
|
||||
export interface RenameGoalResult {
|
||||
readonly renamed: boolean
|
||||
}
|
||||
14
packages/typert/generator/tests/fixtures/remote-model/packages/remote/tsconfig.json
vendored
Normal file
14
packages/typert/generator/tests/fixtures/remote-model/packages/remote/tsconfig.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../domain" }
|
||||
]
|
||||
}
|
||||
20
packages/typert/generator/tests/fixtures/remote-model/tsconfig.base.json
vendored
Normal file
20
packages/typert/generator/tests/fixtures/remote-model/tsconfig.base.json
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2024",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"composite": true,
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"ignoreDeprecations": "6.0",
|
||||
"paths": {
|
||||
"@deepseek-ai/dsh-type-meta": ["./type-meta.d.ts"],
|
||||
"@fixture/domain": ["./packages/domain/src/index.ts"],
|
||||
"@fixture/domain/*": ["./packages/domain/src/*"],
|
||||
"@fixture/remote": ["./packages/remote/src/index.ts"],
|
||||
"@fixture/remote/*": ["./packages/remote/src/*"]
|
||||
},
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
8
packages/typert/generator/tests/fixtures/remote-model/tsconfig.host.json
vendored
Normal file
8
packages/typert/generator/tests/fixtures/remote-model/tsconfig.host.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./packages/domain" },
|
||||
{ "path": "./packages/remote" }
|
||||
]
|
||||
}
|
||||
45
packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts
vendored
Normal file
45
packages/typert/generator/tests/fixtures/remote-model/type-meta.d.ts
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
declare module '@deepseek-ai/dsh-type-meta' {
|
||||
export interface TypeRTLookup<Host, Wire> {
|
||||
readonly host: Host
|
||||
readonly wire: Wire
|
||||
}
|
||||
|
||||
export interface TypeRTContext<Wire> {
|
||||
readonly wire: Wire
|
||||
}
|
||||
|
||||
export interface TypeRTLookupMap {}
|
||||
export interface TypeRTContextMap {}
|
||||
export interface TypeRTRemoteMap {}
|
||||
export interface TypeRTRemoteContextMap {}
|
||||
|
||||
export type TypeRTRemoteNamespace<Namespace extends string> = {
|
||||
[Endpoint in keyof TypeRTRemoteMap as Endpoint extends `${Namespace}/${infer Method}`
|
||||
? Method
|
||||
: never]: TypeRTRemoteMap[Endpoint]
|
||||
}
|
||||
|
||||
export interface TypeRTRemoteNamespaceMap {}
|
||||
|
||||
export interface TypeRTRemoteContribution {
|
||||
readonly package: string
|
||||
readonly descriptors: readonly unknown[]
|
||||
}
|
||||
|
||||
export function bindTypeRTGateway<Service extends object>(
|
||||
service: Service,
|
||||
serviceKey: string,
|
||||
options?: { readonly namespace?: string },
|
||||
): { readonly service: Service; readonly serviceKey: string; readonly namespace: string }
|
||||
|
||||
export function Remote<This extends object, Args extends unknown[], Result>(
|
||||
method: (this: This, ...args: Args) => Result,
|
||||
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
|
||||
): void
|
||||
|
||||
export function RemoteContext(key: Extract<keyof TypeRTContextMap, string>):
|
||||
<This extends object, Args extends unknown[], Result>(
|
||||
method: (this: This, ...args: Args) => Result,
|
||||
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
|
||||
) => void
|
||||
}
|
||||
486
packages/typert/generator/tests/remote-model.spec.ts
Normal file
486
packages/typert/generator/tests/remote-model.spec.ts
Normal file
@@ -0,0 +1,486 @@
|
||||
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { WorkspaceAnalyzer } from '../src/analyzer.ts'
|
||||
import type { InvocationModel } from '../src/model.ts'
|
||||
import { WorkspaceTypertGenerator } from '../src/workspace.ts'
|
||||
|
||||
const fixtureRoot = resolve(import.meta.dirname, 'fixtures/remote-model')
|
||||
const temporaryRoots: string[] = []
|
||||
|
||||
interface RuntimeSchema {
|
||||
safeParse(value: unknown): { readonly success: boolean }
|
||||
}
|
||||
|
||||
interface RuntimeDescriptor {
|
||||
readonly id: string
|
||||
readonly parameters: readonly {
|
||||
readonly wire: string
|
||||
readonly codec: { readonly schema: RuntimeSchema }
|
||||
}[]
|
||||
readonly result: { readonly schema: RuntimeSchema }
|
||||
}
|
||||
|
||||
interface RuntimeRemoteModule {
|
||||
readonly TYPERT_REMOTE: {
|
||||
readonly package: string
|
||||
readonly descriptors: readonly RuntimeDescriptor[]
|
||||
}
|
||||
}
|
||||
|
||||
interface RemoteDeclarationMap {
|
||||
readonly file: string
|
||||
readonly names: readonly string[]
|
||||
readonly sources: readonly string[]
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('Remote model generation', { timeout: 60_000 }, () => {
|
||||
it('discovers a Remote-only package and emits strict direct and Context descriptors', async () => {
|
||||
const generator = new WorkspaceTypertGenerator(fixtureRoot)
|
||||
|
||||
expect(generator.discover()).toEqual([{
|
||||
package: '@fixture/remote',
|
||||
root: 'packages/remote',
|
||||
faces: ['host'],
|
||||
}])
|
||||
|
||||
const [artifact] = generator.generate()
|
||||
expect(artifact).toBeDefined()
|
||||
expect(artifact).toMatchObject({
|
||||
package: '@fixture/remote',
|
||||
face: 'host',
|
||||
packageRoot: 'packages/remote',
|
||||
})
|
||||
|
||||
const model = remotePackage(fixtureRoot)
|
||||
expect(model.services).toEqual([])
|
||||
expect(model.invocations).toHaveLength(2)
|
||||
expect(model.invocations[0]).toMatchObject({
|
||||
id: '@fixture/remote#goals/create',
|
||||
service: 'goals',
|
||||
namespace: 'goals',
|
||||
method: 'create',
|
||||
invocation: { kind: 'direct' },
|
||||
scope: { context: 'agent', wire: 'agentId' },
|
||||
parameters: [
|
||||
{
|
||||
name: 'agent',
|
||||
wire: 'agentId',
|
||||
source: 'lookup',
|
||||
lookup: 'agent',
|
||||
boundary: { typeSymbol: '@fixture/domain/types#AgentId' },
|
||||
},
|
||||
{
|
||||
name: 'request',
|
||||
wire: 'request',
|
||||
source: 'json',
|
||||
boundary: { typeSymbol: '@fixture/remote/types#CreateGoalRequest' },
|
||||
},
|
||||
],
|
||||
result: { typeSymbol: '@fixture/remote/types#CreateGoalResult' },
|
||||
})
|
||||
expect(model.invocations[1]).toMatchObject({
|
||||
id: '@fixture/remote#goals/rename',
|
||||
service: 'goals',
|
||||
namespace: 'goals',
|
||||
method: 'rename',
|
||||
invocation: {
|
||||
kind: 'context',
|
||||
context: 'agent',
|
||||
wire: 'agentId',
|
||||
boundary: { typeSymbol: '@fixture/domain/types#AgentId' },
|
||||
},
|
||||
parameters: [{
|
||||
name: 'request',
|
||||
wire: 'request',
|
||||
source: 'json',
|
||||
boundary: { typeSymbol: '@fixture/remote/types#RenameGoalRequest' },
|
||||
}],
|
||||
result: { typeSymbol: '@fixture/remote/types#RenameGoalResult' },
|
||||
})
|
||||
|
||||
expect(artifact?.js).toContain('invocations: [')
|
||||
expect(artifact?.remote?.dts).toContain(
|
||||
"'goals/create': (agentId: AgentId, request: CreateGoalRequest) => Promise<CreateGoalResult>",
|
||||
)
|
||||
expect(artifact?.remote?.dts).toContain('interface TypeRTRemoteNamespace$676f616c73 {\n create:')
|
||||
expect(artifact?.remote?.dts).toContain("'goals': TypeRTRemoteNamespace$676f616c73")
|
||||
expect(artifact?.remote?.dts).toContain(
|
||||
"'agent:goals/create': (request: CreateGoalRequest) => Promise<CreateGoalResult>",
|
||||
)
|
||||
expect(artifact?.remote?.dts).toContain(
|
||||
"'agent:goals/rename': (request: RenameGoalRequest) => Promise<RenameGoalResult>",
|
||||
)
|
||||
|
||||
const remoteJs = artifact?.remote?.js
|
||||
if (remoteJs === undefined) throw new Error('Remote fixture emitted no Host-for-Client JavaScript')
|
||||
const executable = remoteJs.replace("from 'zod'", `from ${JSON.stringify(import.meta.resolve('zod'))}`)
|
||||
const generated = await import(`data:text/javascript,${encodeURIComponent(executable)}`) as RuntimeRemoteModule
|
||||
expect(generated.TYPERT_REMOTE.package).toBe('@fixture/remote')
|
||||
const create = generated.TYPERT_REMOTE.descriptors[0]
|
||||
expect(create?.parameters[1]?.codec.schema.safeParse({ title: 'ship' }).success).toBe(true)
|
||||
expect(create?.parameters[1]?.codec.schema.safeParse({ title: 1 }).success).toBe(false)
|
||||
expect(create?.result.schema.safeParse({ ref: 'goal-1' }).success).toBe(true)
|
||||
expect(create?.result.schema.safeParse({ ref: 1 }).success).toBe(false)
|
||||
|
||||
const declarationMap = JSON.parse(artifact?.remote?.dtsMap ?? '') as RemoteDeclarationMap
|
||||
expect(declarationMap).toMatchObject({
|
||||
file: 'typert.remote-client.d.ts',
|
||||
sources: ['../src/index.ts'],
|
||||
})
|
||||
expect(declarationMap.names).toContain('create')
|
||||
|
||||
assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap)
|
||||
})
|
||||
|
||||
it('evaluates declaration-merged mapped and conditional boundaries for codecs without widening consumer types', async () => {
|
||||
const root = copyFixture()
|
||||
editFile(root, 'packages/remote/src/types.ts', source => `${source}
|
||||
|
||||
/** Recursive JSON fixture used by the concrete codec projection. */
|
||||
export type Json = null | boolean | number | string | Json[] | { [key: string]: Json }
|
||||
|
||||
/** Merge-extensible operation table represented by concrete fixture entries. */
|
||||
export interface GenericRemoteMap {
|
||||
ship: {
|
||||
readonly request: { readonly count: number; readonly meta: Json }
|
||||
readonly result: { readonly accepted: boolean }
|
||||
}
|
||||
cancel: {
|
||||
readonly request: { readonly reason: string }
|
||||
readonly result: { readonly cancelled: boolean }
|
||||
}
|
||||
}
|
||||
|
||||
type GenericRemoteKey = Extract<keyof GenericRemoteMap, string>
|
||||
type RequestOf<K extends GenericRemoteKey> = GenericRemoteMap[K] extends { readonly request: infer Request }
|
||||
? Request
|
||||
: never
|
||||
type ResultOf<K extends GenericRemoteKey> = GenericRemoteMap[K] extends { readonly result: infer Result }
|
||||
? Result
|
||||
: never
|
||||
|
||||
/** Strict request union retained in the generated Client declaration. */
|
||||
export type GenericRequest = {
|
||||
[K in GenericRemoteKey]: { readonly kind: K; readonly payload: RequestOf<K> }
|
||||
}[GenericRemoteKey]
|
||||
|
||||
/** Strict result union retained in the generated Client declaration. */
|
||||
export type GenericResult = {
|
||||
[K in GenericRemoteKey]: { readonly kind: K; readonly value: ResultOf<K> }
|
||||
}[GenericRemoteKey]
|
||||
`)
|
||||
editFile(root, 'packages/remote/src/index.ts', source => source
|
||||
.replace(
|
||||
' RenameGoalResult,\n',
|
||||
' RenameGoalResult,\n GenericRequest,\n GenericResult,\n',
|
||||
)
|
||||
.replace(
|
||||
' rename(request: RenameGoalRequest): RenameGoalResult {\n return { renamed: request.title.length > 0 }\n }\n}',
|
||||
` rename(request: RenameGoalRequest): RenameGoalResult {
|
||||
return { renamed: request.title.length > 0 }
|
||||
}
|
||||
|
||||
@Remote
|
||||
dispatch(request: GenericRequest): GenericResult {
|
||||
if (request.kind === 'ship') return { kind: 'ship', value: { accepted: request.payload.count > 0 } }
|
||||
return { kind: 'cancel', value: { cancelled: request.payload.reason.length > 0 } }
|
||||
}
|
||||
}`,
|
||||
))
|
||||
|
||||
const [artifact] = new WorkspaceTypertGenerator(root).generate()
|
||||
expect(artifact?.remote?.dts).toContain(
|
||||
"'goals/dispatch': (request: GenericRequest) => Promise<GenericResult>",
|
||||
)
|
||||
const remoteJs = artifact?.remote?.js
|
||||
if (remoteJs === undefined) throw new Error('generic Remote fixture emitted no Host-for-Client JavaScript')
|
||||
const executable = remoteJs.replace("from 'zod'", `from ${JSON.stringify(import.meta.resolve('zod'))}`)
|
||||
const generated = await import(`data:text/javascript,${encodeURIComponent(executable)}`) as RuntimeRemoteModule
|
||||
const dispatch = generated.TYPERT_REMOTE.descriptors.find(descriptor => descriptor.id.endsWith('/dispatch'))
|
||||
const schema = dispatch?.parameters[0]?.codec.schema
|
||||
expect(schema?.safeParse({ kind: 'ship', payload: { count: 2, meta: { nested: [true, null] } } }).success).toBe(true)
|
||||
expect(schema?.safeParse({ kind: 'ship', payload: { count: '2', meta: {} } }).success).toBe(false)
|
||||
expect(schema?.safeParse({ kind: 'cancel', payload: { reason: 'obsolete' } }).success).toBe(true)
|
||||
expect(schema?.safeParse({ kind: 'unknown', payload: {} }).success).toBe(false)
|
||||
expect(dispatch?.result.schema.safeParse({ kind: 'ship', value: { accepted: true } }).success).toBe(true)
|
||||
expect(dispatch?.result.schema.safeParse({ kind: 'ship', value: { cancelled: true } }).success).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'missing binding',
|
||||
edit: (source: string) => source.replace(" readonly typertGateway = bindTypeRTGateway(this, 'goals')\n\n", ''),
|
||||
message: 'Remote methods require readonly typertGateway',
|
||||
},
|
||||
{
|
||||
name: 'private method',
|
||||
edit: (source: string) => source.replace(' async create(', ' private async create('),
|
||||
message: 'Remote decorators require a public instance method',
|
||||
},
|
||||
{
|
||||
name: 'static method',
|
||||
edit: (source: string) => source.replace(' async create(', ' static async create('),
|
||||
message: 'Remote decorators require a public instance method',
|
||||
},
|
||||
{
|
||||
name: 'abstract method',
|
||||
edit: (source: string) => source
|
||||
.replace('export class GoalService', 'export abstract class GoalService')
|
||||
.replace(
|
||||
' async create(agent: Agent, request: CreateGoalRequest): Promise<CreateGoalResult> {\n return { ref: `${agent.id}:${request.title}` }\n }',
|
||||
' abstract create(agent: Agent, request: CreateGoalRequest): Promise<CreateGoalResult>',
|
||||
),
|
||||
message: 'Remote methods must have a concrete implementation',
|
||||
},
|
||||
{
|
||||
name: 'generic method',
|
||||
edit: (source: string) => source.replace(' async create(', ' async create<Value>('),
|
||||
message: 'generic Remote methods are not supported',
|
||||
},
|
||||
{
|
||||
name: 'destructured parameter',
|
||||
edit: (source: string) => source.replace('request: CreateGoalRequest', '{ title }: CreateGoalRequest'),
|
||||
message: 'Remote parameters must use identifier bindings',
|
||||
},
|
||||
{
|
||||
name: 'rest parameter',
|
||||
edit: (source: string) => source.replace('request: CreateGoalRequest', '...request: [CreateGoalRequest]'),
|
||||
message: 'Remote parameters cannot be rest parameters',
|
||||
},
|
||||
{
|
||||
name: 'default parameter',
|
||||
edit: (source: string) => source.replace(
|
||||
'request: CreateGoalRequest',
|
||||
"request: CreateGoalRequest = { title: '' }",
|
||||
),
|
||||
message: 'Remote parameters cannot have default values',
|
||||
},
|
||||
{
|
||||
name: 'optional parameter',
|
||||
edit: (source: string) => source.replace('request: CreateGoalRequest', 'request?: CreateGoalRequest'),
|
||||
message: 'Remote parameters cannot be optional',
|
||||
},
|
||||
])('rejects $name', ({ edit, message }) => {
|
||||
const root = copyFixture()
|
||||
editFile(root, 'packages/remote/src/index.ts', edit)
|
||||
|
||||
expect(() => analyzeRemote(root, false)).toThrow(new RegExp(message))
|
||||
})
|
||||
|
||||
it('rejects a workspace class parameter without a lookup declaration', () => {
|
||||
const root = copyFixture()
|
||||
editFile(root, 'packages/domain/src/index.ts', source => source.replace(
|
||||
' interface TypeRTLookupMap {\n agent: TypeRTLookup<Agent, AgentId>\n }\n\n',
|
||||
'',
|
||||
))
|
||||
|
||||
expect(() => analyzeRemote(root, false)).toThrow(/non-JSON class parameter Agent requires a TypeRTLookupMap entry/)
|
||||
})
|
||||
|
||||
it('rejects a Remote Context without a static Context declaration', () => {
|
||||
const root = copyFixture()
|
||||
editFile(root, 'packages/remote/src/index.ts', source => source.replace("@RemoteContext('agent')", "@RemoteContext('missing')"))
|
||||
|
||||
expect(() => analyzeRemote(root, false)).toThrow(/Remote Context missing has no TypeRTContextMap entry/)
|
||||
})
|
||||
|
||||
it('rejects a direct scoped projection whose Context and lookup wire symbols differ', () => {
|
||||
const root = copyFixture()
|
||||
editFile(root, 'packages/domain/src/types.ts', source => `${source}\n/** Deliberately distinct Context identity for the failure fixture. */\nexport type OtherAgentId = string\n`)
|
||||
editFile(root, 'packages/domain/src/index.ts', source => source
|
||||
.replace("import type { AgentId } from './types.ts'", "import type { AgentId, OtherAgentId } from './types.ts'")
|
||||
.replace('agent: TypeRTContext<AgentId>', 'agent: TypeRTContext<OtherAgentId>'))
|
||||
|
||||
expect(() => analyzeRemote(root, false)).toThrow(/Remote scope agent wire type .* does not match lookup wire type/)
|
||||
})
|
||||
|
||||
it('rejects duplicate endpoints across Remote services', () => {
|
||||
const root = copyFixture()
|
||||
editFile(root, 'packages/remote/src/index.ts', source => `${source}
|
||||
export class DuplicateGoalService {
|
||||
readonly typertGateway = bindTypeRTGateway(this, 'duplicate', { namespace: 'goals' })
|
||||
|
||||
@Remote
|
||||
create(request: CreateGoalRequest): CreateGoalResult {
|
||||
return { ref: request.title }
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
expect(() => analyzeRemote(root, false)).toThrow(/Remote endpoint goals\/create conflicts/)
|
||||
})
|
||||
})
|
||||
|
||||
function analyzeRemote(root: string, checkDiagnostics = true): ReturnType<WorkspaceAnalyzer['analyze']> {
|
||||
return new WorkspaceAnalyzer({ root, checkDiagnostics }).analyze()
|
||||
}
|
||||
|
||||
function remotePackage(root: string): {
|
||||
readonly services: readonly unknown[]
|
||||
readonly invocations: readonly InvocationModel[]
|
||||
} {
|
||||
const host = analyzeRemote(root).faces.find(face => face.face === 'host')
|
||||
const packageModel = host?.packages.find(candidate => candidate.name === '@fixture/remote')
|
||||
if (packageModel === undefined) throw new Error('Remote fixture package was not modeled on the host face')
|
||||
return packageModel
|
||||
}
|
||||
|
||||
function copyFixture(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-typert-remote-model-'))
|
||||
cpSync(fixtureRoot, root, { recursive: true })
|
||||
temporaryRoots.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
function editFile(root: string, relativePath: string, edit: (source: string) => string): void {
|
||||
const path = join(root, relativePath)
|
||||
const source = readFileSync(path, 'utf8')
|
||||
const result = edit(source)
|
||||
if (result === source) throw new Error(`fixture edit made no change to ${relativePath}`)
|
||||
writeFileSync(path, result)
|
||||
}
|
||||
|
||||
function assertRemoteConsumerTypechecks(dts: string | undefined, dtsMap: string | undefined): void {
|
||||
if (dts === undefined) throw new Error('Remote fixture emitted no Host-for-Client declaration')
|
||||
if (dtsMap === undefined) throw new Error('Remote fixture emitted no Host-for-Client declaration map')
|
||||
const consumerRoot = copyFixture()
|
||||
const declarationPath = join(consumerRoot, 'packages/remote/lib/typert.remote-client.d.ts')
|
||||
const declarationMapPath = `${declarationPath}.map`
|
||||
const consumerPath = join(consumerRoot, 'consumer.ts')
|
||||
mkdirSync(join(consumerRoot, 'packages/remote/lib'), { recursive: true })
|
||||
writeFileSync(declarationPath, dts, { flush: true })
|
||||
writeFileSync(declarationMapPath, dtsMap, { flush: true })
|
||||
assertRemoteConsumerWithoutImportHasNoNamespace(consumerRoot)
|
||||
const consumerSource = `
|
||||
import remote from '@fixture/remote/remote'
|
||||
import type {
|
||||
TypeRTRemoteContribution,
|
||||
TypeRTRemoteContextMap,
|
||||
TypeRTRemoteMap,
|
||||
TypeRTRemoteNamespaceMap,
|
||||
} from '@deepseek-ai/dsh-type-meta'
|
||||
import type { CreateGoalResult, RenameGoalResult } from '@fixture/remote/types'
|
||||
|
||||
const contribution: TypeRTRemoteContribution = remote
|
||||
declare const create: TypeRTRemoteMap['goals/create']
|
||||
declare const createScoped: TypeRTRemoteContextMap['agent:goals/create']
|
||||
declare const rename: TypeRTRemoteContextMap['agent:goals/rename']
|
||||
const created: Promise<CreateGoalResult> = create('agent-1', { title: 'ship' })
|
||||
const createdScoped: Promise<CreateGoalResult> = createScoped({ title: 'ship' })
|
||||
const renamed: Promise<RenameGoalResult> = rename({ ref: 'goal-1', title: 'land' })
|
||||
declare const ctx: { api: TypeRTRemoteNamespaceMap }
|
||||
const navigated: Promise<CreateGoalResult> = ctx.api.goals.create('agent-1', { title: 'navigate' })
|
||||
void contribution
|
||||
void created
|
||||
void createdScoped
|
||||
void renamed
|
||||
void navigated
|
||||
`
|
||||
writeFileSync(consumerPath, consumerSource)
|
||||
const configPath = join(consumerRoot, 'tsconfig.consumer.json')
|
||||
writeFileSync(configPath, JSON.stringify({
|
||||
extends: './tsconfig.base.json',
|
||||
compilerOptions: {
|
||||
composite: false,
|
||||
skipLibCheck: false,
|
||||
paths: {
|
||||
'@deepseek-ai/dsh-type-meta': ['./type-meta.d.ts'],
|
||||
'@fixture/domain/types': ['./packages/domain/src/types.ts'],
|
||||
'@fixture/remote/types': ['./packages/remote/src/types.ts'],
|
||||
'@fixture/remote/remote': ['./packages/remote/lib/typert.remote-client.d.ts'],
|
||||
},
|
||||
},
|
||||
files: ['./consumer.ts'],
|
||||
}, null, 2))
|
||||
const config = ts.readConfigFile(configPath, file => ts.sys.readFile(file))
|
||||
if (config.error !== undefined) throw new Error(formatDiagnostics([config.error]))
|
||||
const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, consumerRoot, undefined, configPath)
|
||||
const program = ts.createProgram(parsed.fileNames, parsed.options)
|
||||
const diagnostics = ts.getPreEmitDiagnostics(program)
|
||||
expect(diagnostics, formatDiagnostics(diagnostics)).toEqual([])
|
||||
|
||||
const languageService = ts.createLanguageService({
|
||||
getCompilationSettings: () => parsed.options,
|
||||
getCurrentDirectory: () => consumerRoot,
|
||||
getDefaultLibFileName: options => ts.getDefaultLibFilePath(options),
|
||||
getScriptFileNames: () => parsed.fileNames,
|
||||
getScriptSnapshot: (fileName) => {
|
||||
const source = ts.sys.readFile(fileName)
|
||||
return source === undefined ? undefined : ts.ScriptSnapshot.fromString(source)
|
||||
},
|
||||
getScriptVersion: () => '0',
|
||||
directoryExists: path => ts.sys.directoryExists(path),
|
||||
fileExists: path => ts.sys.fileExists(path),
|
||||
getDirectories: path => ts.sys.getDirectories(path),
|
||||
readDirectory: (path, extensions, exclude, include, depth) =>
|
||||
ts.sys.readDirectory(path, extensions, exclude, include, depth),
|
||||
readFile: path => ts.sys.readFile(path),
|
||||
realpath: path => ts.sys.realpath?.(path) ?? path,
|
||||
})
|
||||
const navigation = 'ctx.api.goals.create'
|
||||
const position = consumerSource.indexOf(navigation) + navigation.lastIndexOf('create') + 1
|
||||
const definitions = languageService.getDefinitionAtPosition(consumerPath, position)
|
||||
const generatedDefinition = definitions?.find(candidate => candidate.fileName === declarationPath)
|
||||
if (generatedDefinition === undefined) {
|
||||
throw new Error(`generated Remote definition not found: ${JSON.stringify(definitions, null, 2)}`)
|
||||
}
|
||||
const sourceMapper = (languageService as unknown as {
|
||||
getSourceMapper(): {
|
||||
tryGetSourcePosition(location: { readonly fileName: string; readonly pos: number }):
|
||||
{ readonly fileName: string; readonly pos: number } | undefined
|
||||
}
|
||||
}).getSourceMapper()
|
||||
const definition = sourceMapper.tryGetSourcePosition({
|
||||
fileName: generatedDefinition.fileName,
|
||||
pos: generatedDefinition.textSpan.start,
|
||||
})
|
||||
languageService.dispose()
|
||||
if (definition === undefined || !definition.fileName.endsWith('/packages/remote/src/index.ts')) {
|
||||
throw new Error(`generated Remote definition did not map to its Host source: ${JSON.stringify(definition)}`)
|
||||
}
|
||||
const hostSource = readFileSync(join(consumerRoot, 'packages/remote/src/index.ts'), 'utf8')
|
||||
expect(hostSource.slice(definition.pos, definition.pos + generatedDefinition.textSpan.length)).toBe('create')
|
||||
}
|
||||
|
||||
function assertRemoteConsumerWithoutImportHasNoNamespace(consumerRoot: string): void {
|
||||
const consumerPath = join(consumerRoot, 'consumer-without-remote.ts')
|
||||
writeFileSync(consumerPath, `
|
||||
import type { TypeRTRemoteNamespaceMap } from '@deepseek-ai/dsh-type-meta'
|
||||
declare const ctx: { api: TypeRTRemoteNamespaceMap }
|
||||
ctx.api.goals.create('agent-1', { title: 'must not compile' })
|
||||
`)
|
||||
const configPath = join(consumerRoot, 'tsconfig.consumer-without-remote.json')
|
||||
writeFileSync(configPath, JSON.stringify({
|
||||
extends: './tsconfig.base.json',
|
||||
compilerOptions: {
|
||||
composite: false,
|
||||
skipLibCheck: false,
|
||||
paths: {
|
||||
'@deepseek-ai/dsh-type-meta': ['./type-meta.d.ts'],
|
||||
},
|
||||
},
|
||||
files: ['./consumer-without-remote.ts'],
|
||||
}, null, 2))
|
||||
const config = ts.readConfigFile(configPath, file => ts.sys.readFile(file))
|
||||
if (config.error !== undefined) throw new Error(formatDiagnostics([config.error]))
|
||||
const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, consumerRoot, undefined, configPath)
|
||||
const diagnostics = ts.getPreEmitDiagnostics(ts.createProgram(parsed.fileNames, parsed.options))
|
||||
expect(diagnostics).toHaveLength(1)
|
||||
expect(diagnostics[0]?.code).toBe(2339)
|
||||
expect(ts.flattenDiagnosticMessageText(diagnostics[0]?.messageText ?? '', '\n')).toContain("Property 'goals' does not exist")
|
||||
}
|
||||
|
||||
function formatDiagnostics(diagnostics: readonly ts.Diagnostic[]): string {
|
||||
return ts.formatDiagnosticsWithColorAndContext(diagnostics, {
|
||||
getCanonicalFileName: file => file,
|
||||
getCurrentDirectory: () => process.cwd(),
|
||||
getNewLine: () => '\n',
|
||||
})
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
FaceModel,
|
||||
KeywordTypeName,
|
||||
MemberModel,
|
||||
SignatureMemberModel,
|
||||
SignatureModel,
|
||||
TypeDeclarationModel,
|
||||
TypeNodeModel,
|
||||
@@ -356,6 +357,149 @@ describe('SchemaEmitter supported projection matrix', () => {
|
||||
expect(inheritedSchema.safeParse({ current: 1 }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('instantiates generic aliases, nested references, defaults, and recursive declarations', async () => {
|
||||
const box = declaration('Box', 'interface', {
|
||||
typeParameters: [{ id: 'box:value', name: 'Value', const: false }],
|
||||
members: [property('value', 'box:value-reference')],
|
||||
})
|
||||
const wrapper = declaration('Wrapper', 'alias', {
|
||||
typeParameters: [
|
||||
{ id: 'wrapper:value', name: 'Value', const: false },
|
||||
{ id: 'wrapper:items', name: 'Items', const: false, default: 'wrapper:default-items' },
|
||||
],
|
||||
type: 'wrapper:box-reference',
|
||||
})
|
||||
const recursive = declaration('Recursive', 'interface', {
|
||||
typeParameters: [{ id: 'recursive:value', name: 'Value', const: false }],
|
||||
members: [
|
||||
property('value', 'recursive:value-reference'),
|
||||
property('next', 'recursive:self-reference', { optional: true }),
|
||||
],
|
||||
})
|
||||
const schema = await loadSchema(emit([
|
||||
{
|
||||
id: 'root',
|
||||
kind: 'object',
|
||||
members: [
|
||||
property('wrapped', 'root:wrapper-reference'),
|
||||
property('recursive', 'root:recursive-reference'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'root:wrapper-reference',
|
||||
kind: 'reference',
|
||||
name: 'Wrapper',
|
||||
target: { kind: 'declaration', symbol: 'Wrapper' },
|
||||
arguments: ['string'],
|
||||
},
|
||||
{
|
||||
id: 'root:recursive-reference',
|
||||
kind: 'reference',
|
||||
name: 'Recursive',
|
||||
target: { kind: 'declaration', symbol: 'Recursive' },
|
||||
arguments: ['number'],
|
||||
},
|
||||
{
|
||||
id: 'wrapper:box-reference',
|
||||
kind: 'reference',
|
||||
name: 'Box',
|
||||
target: { kind: 'declaration', symbol: 'Box' },
|
||||
arguments: ['wrapper:items-reference'],
|
||||
},
|
||||
{
|
||||
id: 'wrapper:default-items',
|
||||
kind: 'reference',
|
||||
name: 'ReadonlyArray',
|
||||
target: { kind: 'standard', name: 'ReadonlyArray' },
|
||||
arguments: ['wrapper:value-reference'],
|
||||
},
|
||||
{
|
||||
id: 'wrapper:value-reference',
|
||||
kind: 'reference',
|
||||
name: 'Value',
|
||||
target: { kind: 'type-parameter', parameter: 'wrapper:value' },
|
||||
arguments: [],
|
||||
},
|
||||
{
|
||||
id: 'wrapper:items-reference',
|
||||
kind: 'reference',
|
||||
name: 'Items',
|
||||
target: { kind: 'type-parameter', parameter: 'wrapper:items' },
|
||||
arguments: [],
|
||||
},
|
||||
{
|
||||
id: 'box:value-reference',
|
||||
kind: 'reference',
|
||||
name: 'Value',
|
||||
target: { kind: 'type-parameter', parameter: 'box:value' },
|
||||
arguments: [],
|
||||
},
|
||||
{
|
||||
id: 'recursive:value-reference',
|
||||
kind: 'reference',
|
||||
name: 'Value',
|
||||
target: { kind: 'type-parameter', parameter: 'recursive:value' },
|
||||
arguments: [],
|
||||
},
|
||||
{
|
||||
id: 'recursive:self-reference',
|
||||
kind: 'reference',
|
||||
name: 'Recursive',
|
||||
target: { kind: 'declaration', symbol: 'Recursive' },
|
||||
arguments: ['recursive:value-reference'],
|
||||
},
|
||||
keyword('string', 'string'),
|
||||
keyword('number', 'number'),
|
||||
], undefined, [box, wrapper, recursive]))
|
||||
|
||||
expect(schema.safeParse({
|
||||
wrapped: { value: ['one', 'two'] },
|
||||
recursive: { value: 1, next: { value: 2 } },
|
||||
}).success).toBe(true)
|
||||
expect(schema.safeParse({
|
||||
wrapped: { value: [1] },
|
||||
recursive: { value: 1 },
|
||||
}).success).toBe(false)
|
||||
expect(schema.safeParse({
|
||||
wrapped: { value: ['one'] },
|
||||
recursive: { value: 'one' },
|
||||
}).success).toBe(false)
|
||||
})
|
||||
|
||||
it('erases unique-symbol nominal members without naming a branding utility', async () => {
|
||||
const nominal = declaration('Nominal', 'alias', {
|
||||
typeParameters: [{ id: 'nominal:brand', name: 'Brand', const: false }],
|
||||
type: 'nominal:intersection',
|
||||
})
|
||||
const symbolMember = {
|
||||
...property('[TOKEN]', 'nominal:brand-reference', { readonly: true }),
|
||||
computed: 'symbol',
|
||||
} as const
|
||||
const schema = await loadSchema(emit([
|
||||
{
|
||||
id: 'root',
|
||||
kind: 'reference',
|
||||
name: 'Nominal',
|
||||
target: { kind: 'declaration', symbol: 'Nominal' },
|
||||
arguments: ['brand'],
|
||||
},
|
||||
{ id: 'brand', kind: 'literal', value: 'Fixture', text: "'Fixture'" },
|
||||
{ id: 'nominal:intersection', kind: 'intersection', types: ['string', 'nominal:marker'] },
|
||||
keyword('string', 'string'),
|
||||
{ id: 'nominal:marker', kind: 'object', members: [symbolMember] },
|
||||
{
|
||||
id: 'nominal:brand-reference',
|
||||
kind: 'reference',
|
||||
name: 'Brand',
|
||||
target: { kind: 'type-parameter', parameter: 'nominal:brand' },
|
||||
arguments: [],
|
||||
},
|
||||
], undefined, [nominal]))
|
||||
|
||||
expect(schema.safeParse('fixture-id').success).toBe(true)
|
||||
expect(schema.safeParse(1).success).toBe(false)
|
||||
})
|
||||
|
||||
it('classifies every TypeNode kind and executes every supported kind', () => {
|
||||
const expected = Object.entries(ZOD_NODE_SUPPORT)
|
||||
.filter(([, support]) => support === 'supported')
|
||||
@@ -373,7 +517,6 @@ describe('SchemaEmitter unsupported projection matrix', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
['type-parameter', { kind: 'type-parameter', parameter: 'parameter' }],
|
||||
['cross-face', { kind: 'cross-face', face: 'client', package: '@fixture/client', subpath: '.', name: 'Value' }],
|
||||
['external', { kind: 'external', module: 'external', subpath: '.', name: 'Value' }],
|
||||
] as const)('rejects %s references explicitly', (kind, target) => {
|
||||
@@ -386,7 +529,33 @@ describe('SchemaEmitter unsupported projection matrix', () => {
|
||||
}])).toThrow(`typert Zod emitter: Value: ${kind} reference has no Zod projection`)
|
||||
})
|
||||
|
||||
it('rejects unsupported standard references, generic declarations, and enums', () => {
|
||||
it('rejects unbound type parameters, incomplete generic applications, and generic schema exports', () => {
|
||||
expect(() => emit([{
|
||||
id: 'root',
|
||||
kind: 'reference',
|
||||
name: 'Value',
|
||||
target: { kind: 'type-parameter', parameter: 'parameter' },
|
||||
arguments: [],
|
||||
}])).toThrow('type parameter has no schema substitution')
|
||||
|
||||
const generic = declaration('Generic', 'interface', {
|
||||
typeParameters: [{ id: 'parameter', name: 'Value', const: false }],
|
||||
})
|
||||
expect(() => emit([{
|
||||
id: 'root',
|
||||
kind: 'reference',
|
||||
name: 'Generic',
|
||||
target: { kind: 'declaration', symbol: 'Generic' },
|
||||
arguments: [],
|
||||
}], undefined, [generic])).toThrow('missing type argument Value')
|
||||
|
||||
const genericRoot = declaration('Root', 'interface', {
|
||||
typeParameters: [{ id: 'root:parameter', name: 'Value', const: false }],
|
||||
})
|
||||
expect(() => emit([], genericRoot)).toThrow('generic schema exports require a concrete declaration')
|
||||
})
|
||||
|
||||
it('rejects unsupported standard references and enums', () => {
|
||||
const intrinsic = { id: 'root', kind: 'keyword', name: 'intrinsic' } as unknown as TypeNodeModel
|
||||
expect(() => emit([intrinsic]))
|
||||
.toThrow('keyword intrinsic has no Zod projection')
|
||||
@@ -399,17 +568,6 @@ describe('SchemaEmitter unsupported projection matrix', () => {
|
||||
arguments: [],
|
||||
}])).toThrow('standard type Promise has no Zod projection')
|
||||
|
||||
const generic = declaration('Generic', 'interface', {
|
||||
typeParameters: [{ id: 'parameter', name: 'Value', const: false }],
|
||||
})
|
||||
expect(() => emit([{
|
||||
id: 'root',
|
||||
kind: 'reference',
|
||||
name: 'Generic',
|
||||
target: { kind: 'declaration', symbol: 'Generic' },
|
||||
arguments: [],
|
||||
}], undefined, [generic])).toThrow('generic declarations require a schema-factory projection')
|
||||
|
||||
const enumeration = declaration('Enumeration', 'enum', {
|
||||
enumMembers: [{ ...documentation, name: 'Value', initializer: "'value'", location }],
|
||||
})
|
||||
@@ -481,6 +639,7 @@ describe('SchemaEmitter unsupported projection matrix', () => {
|
||||
}],
|
||||
objects: [],
|
||||
schemas: [],
|
||||
invocations: [],
|
||||
}],
|
||||
}
|
||||
expect(() => new FaceModelEmitter(eventFace).emit('@fixture/schema'))
|
||||
@@ -513,6 +672,7 @@ describe('SchemaEmitter unsupported projection matrix', () => {
|
||||
}],
|
||||
objects: [],
|
||||
schemas: [],
|
||||
invocations: [],
|
||||
}],
|
||||
}
|
||||
|
||||
@@ -555,7 +715,33 @@ describe('SchemaEmitter unsupported projection matrix', () => {
|
||||
expect(artifact.dts).toContain("from '@fixture/schema/secondary'")
|
||||
})
|
||||
|
||||
it.each(['method', 'getter', 'setter', 'call', 'construct', 'index'] as const)(
|
||||
it('emits JSON index signatures as record schemas', async () => {
|
||||
const root = declaration('Root', 'interface', {
|
||||
members: [indexMember('key', 'value')],
|
||||
})
|
||||
const schema = await loadSchema(emit([
|
||||
keyword('key', 'string'),
|
||||
keyword('value', 'number'),
|
||||
], root))
|
||||
|
||||
expect(schema.safeParse({ one: 1, two: 2 }).success).toBe(true)
|
||||
expect(schema.safeParse({ one: '1' }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects more than one JSON index signature', () => {
|
||||
const root = declaration('Root', 'interface', {
|
||||
members: [indexMember('key', 'value'), indexMember('other-key', 'other-value')],
|
||||
})
|
||||
|
||||
expect(() => emit([
|
||||
keyword('key', 'string'),
|
||||
keyword('value', 'number'),
|
||||
keyword('other-key', 'string'),
|
||||
keyword('other-value', 'boolean'),
|
||||
], root)).toThrow('object type has more than one JSON index signature')
|
||||
})
|
||||
|
||||
it.each(['method', 'getter', 'setter', 'call', 'construct'] as const)(
|
||||
'rejects %s members on data-schema objects',
|
||||
(kind) => {
|
||||
expect(() => emit([
|
||||
@@ -608,6 +794,10 @@ function property(
|
||||
}
|
||||
}
|
||||
|
||||
function signatureMember(kind: 'index'): SignatureMemberModel
|
||||
function signatureMember(
|
||||
kind: Exclude<MemberModel['kind'], 'property' | 'index'>,
|
||||
): MemberModel
|
||||
function signatureMember(kind: Exclude<MemberModel['kind'], 'property'>): MemberModel {
|
||||
return {
|
||||
...documentation,
|
||||
@@ -626,6 +816,24 @@ function signatureMember(kind: Exclude<MemberModel['kind'], 'property'>): Member
|
||||
}
|
||||
}
|
||||
|
||||
function indexMember(key: string, value: string): SignatureMemberModel {
|
||||
return {
|
||||
...signatureMember('index'),
|
||||
signature: {
|
||||
typeParameters: [],
|
||||
parameters: [{
|
||||
name: 'key',
|
||||
binding: 'identifier',
|
||||
type: key,
|
||||
optional: false,
|
||||
rest: false,
|
||||
receiver: false,
|
||||
}],
|
||||
returns: value,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function declaration(
|
||||
name: string,
|
||||
kind: TypeDeclarationModel['kind'],
|
||||
@@ -684,6 +892,7 @@ function emit(
|
||||
symbol: 'Root',
|
||||
type: 'schema-reference',
|
||||
}],
|
||||
invocations: [],
|
||||
}],
|
||||
}
|
||||
return new FaceModelEmitter(face).emit('@fixture/schema').js
|
||||
@@ -710,6 +919,7 @@ function schemaFace(
|
||||
symbol,
|
||||
type: 'root',
|
||||
}],
|
||||
invocations: [],
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ describe('model-driven dsh-tools generation', () => {
|
||||
TYPE_API.find(type => type.name === 'ToolDefinition'),
|
||||
)
|
||||
|
||||
dispose()
|
||||
await dispose()
|
||||
expect(ctx.typert.getPackage('@deepseek-ai/dsh-tools', 'host')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,11 @@ const generated = vi.hoisted(() => vi.fn(() => [
|
||||
exports: [],
|
||||
js: 'export const host = true\n',
|
||||
dts: 'export declare const host: true\n',
|
||||
remote: {
|
||||
js: 'export const remote = true\n',
|
||||
dts: 'export declare const remote: true\n//# sourceMappingURL=typert.remote-client.d.ts.map\n',
|
||||
dtsMap: '{"version":3}\n',
|
||||
},
|
||||
},
|
||||
{
|
||||
package: '@deepseek-ai/dsh-tools',
|
||||
@@ -21,10 +26,30 @@ const generated = vi.hoisted(() => vi.fn(() => [
|
||||
js: 'export const client = true\n',
|
||||
dts: 'export declare const client: true\n',
|
||||
},
|
||||
{
|
||||
package: '@fixture/remote-only',
|
||||
packageRoot: 'packages/remote-only',
|
||||
face: 'host' as const,
|
||||
exports: [],
|
||||
js: 'export const local = true\n',
|
||||
dts: 'export declare const local: true\n',
|
||||
remote: {
|
||||
js: 'export const remoteOnly = true\n',
|
||||
dts: 'export declare const remoteOnly: true\n//# sourceMappingURL=typert.remote-client.d.ts.map\n',
|
||||
dtsMap: '{"version":3}\n',
|
||||
},
|
||||
},
|
||||
]))
|
||||
|
||||
const discovered = vi.hoisted(() => vi.fn(() => [
|
||||
{ package: '@deepseek-ai/dsh-tools', root: 'packages/core/tools', faces: ['host'] },
|
||||
{ package: '@fixture/ignored', root: 'packages/ignored', faces: ['host'] },
|
||||
{ package: '@fixture/remote-only', root: 'packages/remote-only', faces: ['host'] },
|
||||
]))
|
||||
|
||||
vi.mock('../src/workspace.ts', () => ({
|
||||
WorkspaceTypertGenerator: class {
|
||||
discover = discovered
|
||||
generate = generated
|
||||
},
|
||||
}))
|
||||
@@ -33,6 +58,7 @@ const { typertPlugin } = await import('../src/tsdown-plugin.ts')
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
discovered.mockClear()
|
||||
generated.mockClear()
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
@@ -80,9 +106,64 @@ describe('typertPlugin', () => {
|
||||
expect(readFileSync(join(packageLib, 'typert.host.d.ts'), 'utf8')).toBe('export declare const host: true\n')
|
||||
expect(readFileSync(join(packageLib, 'typert.client.js'), 'utf8')).toBe('export const client = true\n')
|
||||
expect(existsSync(join(packageLib, 'typert.client.d.ts'))).toBe(true)
|
||||
expect(readFileSync(join(packageLib, 'typert.remote-client.js'), 'utf8')).toBe('export const remote = true\n')
|
||||
expect(readFileSync(join(packageLib, 'typert.remote-client.d.ts'), 'utf8'))
|
||||
.toBe('export declare const remote: true\n//# sourceMappingURL=typert.remote-client.d.ts.map\n')
|
||||
expect(readFileSync(join(packageLib, 'typert.remote-client.d.ts.map'), 'utf8'))
|
||||
.toBe('{"version":3}\n')
|
||||
expect(readFileSync(join(root, 'packages/client-tools/lib/typert.client.js'), 'utf8'))
|
||||
.toBe('export const client = true\n')
|
||||
})
|
||||
|
||||
it('generates a package opted in only through its Remote export', async () => {
|
||||
const root = await workspace()
|
||||
const output = await packageOutput(root, 'remote-only', {
|
||||
name: '@fixture/remote-only',
|
||||
exports: { './remote': './lib/typert.remote-client.js' },
|
||||
})
|
||||
|
||||
typertPlugin().writeBundle({ dir: output })
|
||||
|
||||
const packageLib = join(root, 'packages', 'remote-only', 'lib')
|
||||
expect(generated).toHaveBeenCalledOnce()
|
||||
expect(readFileSync(join(packageLib, 'typert.remote-client.js'), 'utf8'))
|
||||
.toBe('export const remoteOnly = true\n')
|
||||
expect(readFileSync(join(packageLib, 'typert.remote-client.d.ts'), 'utf8'))
|
||||
.toBe('export declare const remoteOnly: true\n//# sourceMappingURL=typert.remote-client.d.ts.map\n')
|
||||
expect(readFileSync(join(packageLib, 'typert.remote-client.d.ts.map'), 'utf8'))
|
||||
.toBe('{"version":3}\n')
|
||||
})
|
||||
|
||||
it('emits every explicit workspace contributor once from a host-only prepass', async () => {
|
||||
const root = await workspace()
|
||||
const trigger = await packageOutput(root, 'generator', { name: '@deepseek-ai/dsh-typert-generator' })
|
||||
await packageOutput(root, 'core/tools', {
|
||||
name: '@deepseek-ai/dsh-tools',
|
||||
exports: { './typert': './lib/typert.host.js' },
|
||||
})
|
||||
await packageOutput(root, 'ignored', { name: '@fixture/ignored' })
|
||||
await packageOutput(root, 'remote-only', {
|
||||
name: '@fixture/remote-only',
|
||||
exports: { './remote': './lib/typert.remote-client.js' },
|
||||
})
|
||||
|
||||
const plugin = typertPlugin({ mode: 'workspace', faces: ['host'] })
|
||||
plugin.writeBundle({ dir: trigger })
|
||||
plugin.writeBundle({ dir: join(root, 'packages/core/tools/lib/dev') })
|
||||
|
||||
expect(discovered).toHaveBeenCalledOnce()
|
||||
expect(discovered).toHaveBeenCalledWith(['host'])
|
||||
expect(generated).toHaveBeenCalledOnce()
|
||||
expect(generated).toHaveBeenCalledWith(
|
||||
['@deepseek-ai/dsh-tools', '@fixture/remote-only'],
|
||||
['host'],
|
||||
)
|
||||
expect(readFileSync(join(root, 'packages/core/tools/lib/typert.host.js'), 'utf8'))
|
||||
.toBe('export const host = true\n')
|
||||
expect(readFileSync(join(root, 'packages/remote-only/lib/typert.remote-client.js'), 'utf8'))
|
||||
.toBe('export const remoteOnly = true\n')
|
||||
expect(existsSync(join(root, 'packages/ignored/lib/typert.host.js'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
async function workspace(): Promise<string> {
|
||||
|
||||
@@ -201,6 +201,53 @@ describe('WorkspaceAnalyzer', { timeout: 60_000 }, () => {
|
||||
expect(batched).toEqual(direct)
|
||||
})
|
||||
|
||||
it('discovers an explicitly keyed service implementation without a Context merge', () => {
|
||||
const root = copyFixture('explicit-service-')
|
||||
addExplicitServicePackage(root, 'service detached')
|
||||
const analyzer = new WorkspaceAnalyzer({ root })
|
||||
|
||||
expect(analyzer.discoverPackages()).toContainEqual({
|
||||
package: '@fixture/explicit-service',
|
||||
root: 'packages/explicit-service',
|
||||
faces: ['host'],
|
||||
})
|
||||
const model = new WorkspaceAnalyzer({ root, packages: ['@fixture/explicit-service'] }).analyze()
|
||||
const service = model.faces[0]?.packages[0]?.services[0]
|
||||
expect(service).toMatchObject({ key: 'detached', export: { name: 'DetachedService' } })
|
||||
})
|
||||
|
||||
it('prefers an explicitly keyed implementation over its protocol Context merge', () => {
|
||||
const root = copyFixture('explicit-service-protocol-')
|
||||
addExplicitServicePackage(root, 'service detached', true)
|
||||
const model = new WorkspaceAnalyzer({
|
||||
root,
|
||||
packages: ['@fixture/explicit-service'],
|
||||
}).analyze()
|
||||
const service = model.faces[0]?.packages[0]?.services[0]
|
||||
|
||||
expect(service).toMatchObject({
|
||||
key: 'detached',
|
||||
export: { name: 'DetachedService' },
|
||||
location: { file: 'packages/explicit-service/src/index.ts' },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an explicit service implementation without one valid key', () => {
|
||||
const missing = copyFixture('explicit-service-missing-')
|
||||
addExplicitServicePackage(missing, 'service')
|
||||
expect(() => new WorkspaceAnalyzer({
|
||||
root: missing,
|
||||
packages: ['@fixture/explicit-service'],
|
||||
}).analyze()).toThrow('@typert service requires exactly one nonempty Cordis service key')
|
||||
|
||||
const invalid = copyFixture('explicit-service-invalid-')
|
||||
addExplicitServicePackage(invalid, 'service bad/key')
|
||||
expect(() => new WorkspaceAnalyzer({
|
||||
root: invalid,
|
||||
packages: ['@fixture/explicit-service'],
|
||||
}).analyze()).toThrow('@typert service requires exactly one nonempty Cordis service key')
|
||||
})
|
||||
|
||||
it('indexes authored top-level exports without promoting them to graph roots', () => {
|
||||
const declarations = new WorkspaceAnalyzer({ root: fixtureRoot }).indexSourceDeclarations()
|
||||
const agent = declarations.find(declaration => declaration.name === 'Agent')
|
||||
@@ -1178,6 +1225,57 @@ function addSameFacePackage(root: string, specifier: string, importedName: strin
|
||||
writeFileSync(aggregatePath, `${JSON.stringify(aggregate, null, 2)}\n`)
|
||||
}
|
||||
|
||||
function addExplicitServicePackage(root: string, annotation: string, withProtocol = false): void {
|
||||
const packageRoot = join(root, 'packages/explicit-service')
|
||||
mkdirSync(join(packageRoot, 'src'), { recursive: true })
|
||||
writeFileSync(join(packageRoot, 'package.json'), JSON.stringify({
|
||||
name: '@fixture/explicit-service',
|
||||
private: true,
|
||||
type: 'module',
|
||||
exports: {
|
||||
'.': {
|
||||
types: './lib/types/index.d.ts',
|
||||
default: './lib/index.js',
|
||||
},
|
||||
},
|
||||
}, null, 2))
|
||||
writeFileSync(join(packageRoot, 'tsconfig.json'), JSON.stringify({
|
||||
extends: '../../tsconfig.base.json',
|
||||
compilerOptions: { rootDir: 'src', outDir: 'lib/types' },
|
||||
include: ['src'],
|
||||
}, null, 2))
|
||||
if (withProtocol) {
|
||||
writeFileSync(join(packageRoot, 'src/types.ts'), [
|
||||
'/** Public detached Service protocol. */',
|
||||
'export interface DetachedProtocol {',
|
||||
' /** Report protocol readiness. */',
|
||||
' ready(): boolean',
|
||||
'}',
|
||||
"declare module 'cordis' {",
|
||||
' interface Context { detached: DetachedProtocol }',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
}
|
||||
writeFileSync(join(packageRoot, 'src/index.ts'), [
|
||||
"import { Service } from 'cordis'",
|
||||
...(withProtocol ? ["export type { DetachedProtocol } from './types.ts'"] : []),
|
||||
'/**',
|
||||
' * Service implementation discovered independently of its protocol package.',
|
||||
` * @typert ${annotation}`,
|
||||
' */',
|
||||
'export class DetachedService extends Service {',
|
||||
' /** Report readiness. */',
|
||||
' ready(): boolean { return true }',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
const aggregatePath = join(root, 'tsconfig.host.json')
|
||||
const aggregate = JSON.parse(readFileSync(aggregatePath, 'utf8')) as { references: { path: string }[] }
|
||||
aggregate.references.push({ path: './packages/explicit-service' })
|
||||
writeFileSync(aggregatePath, `${JSON.stringify(aggregate, null, 2)}\n`)
|
||||
}
|
||||
|
||||
describe('FaceModelEmitter', { timeout: 60_000 }, () => {
|
||||
it('emits runnable Zod JavaScript, precise declarations, and runtime package metadata', async () => {
|
||||
const model = new WorkspaceAnalyzer({ root: fixtureRoot }).analyze()
|
||||
|
||||
@@ -135,6 +135,11 @@ export function validateTypertManifest(pkgName: string, exported: unknown): Type
|
||||
requireMembers(pkgName, object.members, `object "${object.name as string}"`)
|
||||
requireTypes(pkgName, object.types, `object "${object.name as string}"`)
|
||||
}
|
||||
if (manifest.invocations !== undefined) {
|
||||
for (const value of requireArray(pkgName, manifest.invocations, 'TYPERT.invocations')) {
|
||||
requireInvocation(pkgName, value)
|
||||
}
|
||||
}
|
||||
return manifest as unknown as TypertContribution
|
||||
}
|
||||
|
||||
@@ -184,6 +189,88 @@ function requireTypes(pkgName: string, value: unknown, subject: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function requireInvocation(pkgName: string, value: unknown): void {
|
||||
const invocation = requireObject(pkgName, value, 'invocation')
|
||||
for (const key of ['id', 'service', 'namespace', 'method'] as const) {
|
||||
requireString(pkgName, invocation, key, 'invocation')
|
||||
}
|
||||
const id = invocation.id as string
|
||||
const receiver = requireObject(pkgName, invocation.invocation, `invocation "${id}" receiver`)
|
||||
if (receiver.kind === 'context') {
|
||||
requireString(pkgName, receiver, 'context', `invocation "${id}" Context receiver`)
|
||||
requireString(pkgName, receiver, 'wire', `invocation "${id}" Context receiver`)
|
||||
requireStrictCodec(pkgName, receiver.codec, `invocation "${id}" Context codec`)
|
||||
} else if (receiver.kind !== 'direct') {
|
||||
throw new Error(`typert-loader: ${pkgName} invocation "${id}" receiver kind must be "direct" or "context"`)
|
||||
}
|
||||
const wires = new Set<string>()
|
||||
const parameters = new Map<string, Record<string, unknown>>()
|
||||
let lookupCount = 0
|
||||
for (const valueParameter of requireArray(pkgName, invocation.parameters, `invocation "${id}" parameters`)) {
|
||||
const parameter = requireObject(pkgName, valueParameter, `invocation "${id}" parameter`)
|
||||
requireString(pkgName, parameter, 'name', `invocation "${id}" parameter`)
|
||||
requireString(pkgName, parameter, 'wire', `invocation "${id}" parameter`)
|
||||
const wire = parameter.wire as string
|
||||
if (wires.has(wire)) {
|
||||
throw new Error(`typert-loader: ${pkgName} invocation "${id}" repeats wire field "${wire}"`)
|
||||
}
|
||||
wires.add(wire)
|
||||
if (parameter.source === 'lookup') {
|
||||
lookupCount += 1
|
||||
requireString(pkgName, parameter, 'lookup', `invocation "${id}" lookup parameter`)
|
||||
} else if (parameter.source === 'json') {
|
||||
if (parameter.lookup !== undefined) {
|
||||
throw new Error(`typert-loader: ${pkgName} invocation "${id}" JSON parameter declares a lookup`)
|
||||
}
|
||||
} else {
|
||||
throw new Error(`typert-loader: ${pkgName} invocation "${id}" parameter source must be "json" or "lookup"`)
|
||||
}
|
||||
parameters.set(wire, parameter)
|
||||
requireStrictCodec(pkgName, parameter.codec, `invocation "${id}" parameter codec`)
|
||||
}
|
||||
if (invocation.scope !== undefined) {
|
||||
if (receiver.kind !== 'direct') {
|
||||
throw new Error(`typert-loader: ${pkgName} invocation "${id}" Context receiver cannot declare a direct scope projection`)
|
||||
}
|
||||
const scope = requireObject(pkgName, invocation.scope, `invocation "${id}" scope`)
|
||||
requireString(pkgName, scope, 'context', `invocation "${id}" scope`)
|
||||
requireString(pkgName, scope, 'wire', `invocation "${id}" scope`)
|
||||
const parameter = parameters.get(scope.wire as string)
|
||||
if (lookupCount !== 1 || parameter?.source !== 'lookup' || parameter.lookup !== scope.context) {
|
||||
throw new Error(
|
||||
`typert-loader: ${pkgName} invocation "${id}" scope wire "${scope.wire as string}" must select its only lookup parameter`,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (receiver.kind === 'context' && wires.has(receiver.wire as string)) {
|
||||
throw new Error(`typert-loader: ${pkgName} invocation "${id}" repeats Context wire field "${receiver.wire as string}"`)
|
||||
}
|
||||
requireStrictCodec(pkgName, invocation.result, `invocation "${id}" result codec`)
|
||||
if (invocation.sourceLocation !== undefined) {
|
||||
const location = requireObject(pkgName, invocation.sourceLocation, `invocation "${id}" sourceLocation`)
|
||||
requireString(pkgName, location, 'file', `invocation "${id}" sourceLocation`)
|
||||
for (const key of ['line', 'column'] as const) {
|
||||
if (!Number.isInteger(location[key]) || (location[key] as number) < 1) {
|
||||
throw new Error(`typert-loader: ${pkgName} invocation "${id}" sourceLocation.${key} must be a positive integer`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function requireStrictCodec(pkgName: string, value: unknown, subject: string): void {
|
||||
const codec = requireObject(pkgName, value, subject)
|
||||
if (codec.mode !== 'strict') {
|
||||
throw new Error(`typert-loader: ${pkgName} ${subject} must use a strict codec`)
|
||||
}
|
||||
requireString(pkgName, codec, 'typeSymbol', subject)
|
||||
if (typeof codec.schema !== 'object'
|
||||
|| codec.schema === null
|
||||
|| !('_zod' in codec.schema)
|
||||
|| typeof (codec.schema as { parse?: unknown }).parse !== 'function') {
|
||||
throw new Error(`typert-loader: ${pkgName} ${subject} is not backed by a zod v4 schema`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan current Loader entries during activation, then follow entry mounts and
|
||||
* unmounts for this plugin's lifetime.
|
||||
@@ -202,7 +289,7 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
const configured = new Set((config as ResolvedConfig).packages)
|
||||
|
||||
// Registered contributions by entry name; the disposer withdraws the entry's registration.
|
||||
const registered = new Map<string, () => void>()
|
||||
const registered = new Map<string, () => Promise<void>>()
|
||||
// In-flight import/register tasks by entry name.
|
||||
const pending = new Map<string, Promise<void>>()
|
||||
// Artifact paths by package name. Negative verdicts (unresolvable specifier —
|
||||
@@ -279,7 +366,7 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
const dispose = registered.get(entryName)
|
||||
if (dispose !== undefined) {
|
||||
registered.delete(entryName)
|
||||
dispose()
|
||||
return dispose()
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
@@ -8,6 +9,7 @@ import Loader from '@cordisjs/plugin-loader'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
import * as typertLoader from '@deepseek-ai/dsh-typert-loader'
|
||||
import { validateTypertManifest } from '@deepseek-ai/dsh-typert-loader'
|
||||
import { z } from 'zod'
|
||||
|
||||
let root: string | undefined
|
||||
let context: Context | undefined
|
||||
@@ -63,12 +65,45 @@ function typertSource(pkgName: string, entryName: string): string {
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function invocationTypertSource(pkgName: string): string {
|
||||
return [
|
||||
'import { z } from \'zod\'',
|
||||
'const Text = z.string()',
|
||||
'export const TYPERT = {',
|
||||
` package: '${pkgName}',`,
|
||||
' face: \'host\',',
|
||||
' schemas: [],',
|
||||
' model: { services: [], events: [], objects: [] },',
|
||||
' invocations: [{',
|
||||
` id: '${pkgName}#goals/create',`,
|
||||
' service: \'goals\', namespace: \'goals\', method: \'create\',',
|
||||
' invocation: { kind: \'direct\' },',
|
||||
' parameters: [{',
|
||||
' name: \'request\', wire: \'request\', source: \'json\',',
|
||||
` codec: { mode: 'strict', typeSymbol: '${pkgName}/types#Request', schema: Text },`,
|
||||
' }],',
|
||||
` result: { mode: 'strict', typeSymbol: '${pkgName}/types#Result', schema: Text },`,
|
||||
' sourceLocation: { file: \'src/index.ts\', line: 8, column: 3 },',
|
||||
' }],',
|
||||
'}',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** Boot a real Loader over a fixture root; plugin modules resolve from its node_modules. */
|
||||
async function boot(): Promise<Context> {
|
||||
context = new Context()
|
||||
context.baseUrl = pathToFileURL(join(root as string, 'cordis.yml')).href
|
||||
await context.plugin(TypertRegistry)
|
||||
await context.plugin(Loader)
|
||||
const fixtureRequire = createRequire(context.baseUrl)
|
||||
context.loader.internal = {
|
||||
version: 'v2',
|
||||
async import(specifier: string) {
|
||||
const module: unknown = await import(pathToFileURL(fixtureRequire.resolve(specifier)).href)
|
||||
return module
|
||||
},
|
||||
} as unknown as NonNullable<typeof context.loader.internal>
|
||||
// zod must be resolvable from the fixture packages; link the workspace copy.
|
||||
await mkdir(join(root as string, 'node_modules'), { recursive: true })
|
||||
return context
|
||||
@@ -105,6 +140,33 @@ describe('typert loader', () => {
|
||||
expect(ctx.typert.getPackage('@fixture/nested')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('registers a strict invocation into the local registry and withdraws it with the loader', LOADER_TEST_TIMEOUT, async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-'))
|
||||
await linkZod(root)
|
||||
await writePackage(root, '@fixture/invocation', {
|
||||
typertSource: invocationTypertSource('@fixture/invocation'),
|
||||
})
|
||||
const ctx = await boot()
|
||||
|
||||
const fiber = mountTypertLoader(ctx, { packages: ['@fixture/invocation'] })
|
||||
await fiber
|
||||
|
||||
const descriptor = ctx.typert.local.get('goals/create')
|
||||
expect(descriptor).toMatchObject({
|
||||
id: '@fixture/invocation#goals/create',
|
||||
invocation: { kind: 'direct' },
|
||||
parameters: [{ wire: 'request', source: 'json' }],
|
||||
sourceLocation: { file: 'src/index.ts', line: 8, column: 3 },
|
||||
})
|
||||
expect(descriptor?.parameters[0]?.codec.mode).toBe('strict')
|
||||
if (descriptor?.parameters[0]?.codec.mode === 'strict') {
|
||||
expect(descriptor.parameters[0].codec.schema.parse('request')).toBe('request')
|
||||
}
|
||||
|
||||
await fiber.dispose()
|
||||
expect(ctx.typert.local.get('goals/create')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('fails loud when an explicit package is absent or has no Typert export', LOADER_TEST_TIMEOUT, async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-'))
|
||||
await writePackage(root, '@fixture/plain')
|
||||
@@ -427,8 +489,156 @@ describe('validateTypertManifest', () => {
|
||||
model: { ...complete.model, objects: [{ ...complete.model.objects[0], exportName: '' }] },
|
||||
})).toThrow('object has a missing or empty exportName')
|
||||
})
|
||||
|
||||
it('validates strict invocation descriptors and accepts legacy manifests without them', () => {
|
||||
const legacy = completeManifest(zodish)
|
||||
expect(validateTypertManifest('pkg', legacy)).toBe(legacy)
|
||||
|
||||
const descriptor = strictInvocation()
|
||||
const manifest = { ...legacy, invocations: [descriptor] }
|
||||
expect(validateTypertManifest('pkg', manifest)).toBe(manifest)
|
||||
const scoped = {
|
||||
...descriptor,
|
||||
scope: { context: 'agent', wire: 'agentId' },
|
||||
parameters: [{
|
||||
name: 'agent',
|
||||
wire: 'agentId',
|
||||
source: 'lookup',
|
||||
lookup: 'agent',
|
||||
codec: strictCodec('pkg#AgentId'),
|
||||
}, ...descriptor.parameters],
|
||||
}
|
||||
expect(validateTypertManifest('pkg', { ...legacy, invocations: [scoped] }).invocations)
|
||||
.toEqual([scoped])
|
||||
|
||||
expect(() => validateTypertManifest('pkg', { ...legacy, invocations: {} }))
|
||||
.toThrow('TYPERT.invocations must be an array')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
invocations: [{ ...descriptor, invocation: { kind: 'future' } }],
|
||||
})).toThrow('receiver kind must be "direct" or "context"')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
invocations: [{ ...descriptor, result: { mode: 'src-json' } }],
|
||||
})).toThrow('result codec must use a strict codec')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
invocations: [{ ...descriptor, result: { mode: 'strict', typeSymbol: 'pkg#Result', schema: zodish } }],
|
||||
})).toThrow('result codec is not backed by a zod v4 schema')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
invocations: [{
|
||||
...descriptor,
|
||||
parameters: [{ ...descriptor.parameters[0], source: 'future' }],
|
||||
}],
|
||||
})).toThrow('parameter source must be "json" or "lookup"')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
invocations: [{
|
||||
...descriptor,
|
||||
parameters: [{ ...descriptor.parameters[0], source: 'lookup' }],
|
||||
}],
|
||||
})).toThrow('lookup parameter has a missing or empty lookup')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
invocations: [{
|
||||
...descriptor,
|
||||
parameters: [{ ...descriptor.parameters[0], lookup: 'agent' }],
|
||||
}],
|
||||
})).toThrow('JSON parameter declares a lookup')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
invocations: [{
|
||||
...descriptor,
|
||||
parameters: [descriptor.parameters[0], { ...descriptor.parameters[0], name: 'again' }],
|
||||
}],
|
||||
})).toThrow('repeats wire field "request"')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
invocations: [{
|
||||
...descriptor,
|
||||
invocation: {
|
||||
kind: 'context',
|
||||
context: 'agent',
|
||||
wire: 'request',
|
||||
codec: strictCodec('pkg#AgentId'),
|
||||
},
|
||||
}],
|
||||
})).toThrow('repeats Context wire field "request"')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
invocations: [{ ...scoped, scope: null }],
|
||||
})).toThrow('scope must be an object')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
invocations: [{ ...scoped, scope: { wire: 'agentId' } }],
|
||||
})).toThrow('scope has a missing or empty context')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
invocations: [{ ...scoped, scope: { context: 'agent' } }],
|
||||
})).toThrow('scope has a missing or empty wire')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
invocations: [{
|
||||
...scoped,
|
||||
invocation: {
|
||||
kind: 'context',
|
||||
context: 'agent',
|
||||
wire: 'scopeId',
|
||||
codec: strictCodec('pkg#AgentId'),
|
||||
},
|
||||
}],
|
||||
})).toThrow('Context receiver cannot declare a direct scope projection')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
invocations: [{ ...scoped, scope: { context: 'agent', wire: 'missingId' } }],
|
||||
})).toThrow('must select its only lookup parameter')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
invocations: [{
|
||||
...scoped,
|
||||
parameters: [...scoped.parameters, {
|
||||
name: 'other',
|
||||
wire: 'otherId',
|
||||
source: 'lookup',
|
||||
lookup: 'agent',
|
||||
codec: strictCodec('pkg#AgentId'),
|
||||
}],
|
||||
}],
|
||||
})).toThrow('must select its only lookup parameter')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
invocations: [{ ...scoped, scope: { context: 'other', wire: 'agentId' } }],
|
||||
})).toThrow('must select its only lookup parameter')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
invocations: [{ ...descriptor, sourceLocation: { file: 'src/index.ts', line: 0, column: 1 } }],
|
||||
})).toThrow('sourceLocation.line must be a positive integer')
|
||||
})
|
||||
})
|
||||
|
||||
function strictCodec(typeSymbol: string) {
|
||||
return { mode: 'strict', typeSymbol, schema: z.string() }
|
||||
}
|
||||
|
||||
function strictInvocation() {
|
||||
return {
|
||||
id: 'pkg#goals/create',
|
||||
service: 'goals',
|
||||
namespace: 'goals',
|
||||
method: 'create',
|
||||
invocation: { kind: 'direct' },
|
||||
parameters: [{
|
||||
name: 'request',
|
||||
wire: 'request',
|
||||
source: 'json',
|
||||
codec: strictCodec('pkg#Request'),
|
||||
}],
|
||||
result: strictCodec('pkg#Result'),
|
||||
sourceLocation: { file: 'src/index.ts', line: 1, column: 1 },
|
||||
}
|
||||
}
|
||||
|
||||
function completeManifest(zodish: object) {
|
||||
const member = { name: 'member', signature: 'member(): void', kind: 'method' }
|
||||
const type = { name: 'Value', declaration: 'export interface Value {}' }
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./lib/types/types.d.ts",
|
||||
"default": "./lib/types/types.js"
|
||||
@@ -22,14 +26,25 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-type-meta": "workspace:^",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
15
packages/typert/registry/src/client/index.ts
Normal file
15
packages/typert/registry/src/client/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/** Browser face of the shared TypeRT runtime registry. */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { TypertRegistry } from '../service.ts'
|
||||
|
||||
/** Required services: none; this is the Client reflection root. */
|
||||
export const inject: string[] = []
|
||||
|
||||
/**
|
||||
* Install the same registry implementation used by the Host face.
|
||||
* @param ctx - Client Cordis root.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
new TypertRegistry(ctx)
|
||||
}
|
||||
@@ -1,12 +1,7 @@
|
||||
/**
|
||||
* Runtime registry for generated Typert contributions. It owns live Zod
|
||||
* instances and generated package reflection, but performs no TypeScript
|
||||
* analysis or schema generation.
|
||||
* @module @deepseek-ai/dsh-typert-registry
|
||||
*/
|
||||
/** Host entry for the shared TypeRT runtime registry. */
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import { z } from 'zod'
|
||||
import type { z } from 'zod'
|
||||
import type { TypeRTDisposer } from '@deepseek-ai/dsh-type-meta'
|
||||
import type {
|
||||
TypertContribution,
|
||||
TypertFace,
|
||||
@@ -16,204 +11,17 @@ import type {
|
||||
TypertSchemaRecord,
|
||||
} from './types.ts'
|
||||
|
||||
export type {
|
||||
TypertContribution,
|
||||
TypertDocTag,
|
||||
TypertDocumentation,
|
||||
TypertEventModel,
|
||||
TypertFace,
|
||||
TypertMemberModel,
|
||||
TypertObjectModel,
|
||||
TypertPackageFilter,
|
||||
TypertPackageModel,
|
||||
TypertPackageRecord,
|
||||
TypertSchema,
|
||||
TypertSchemaFilter,
|
||||
TypertSchemaRecord,
|
||||
TypertServiceModel,
|
||||
TypertTypeModel,
|
||||
} from './types.ts'
|
||||
export { default, TypertRegistry, typertEndpoint, typertKey, typertPackageKey } from './service.ts'
|
||||
export type * from './types.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
typert: TypertRegistry
|
||||
declare module '@deepseek-ai/dsh-type-meta' {
|
||||
interface TypeRTService {
|
||||
register(contribution: TypertContribution): TypeRTDisposer
|
||||
get(key: string): TypertSchemaRecord | undefined
|
||||
resolve(key: string): TypertSchemaRecord
|
||||
list(filter?: TypertSchemaFilter): TypertSchemaRecord[]
|
||||
getPackage(packageName: string, face?: TypertFace): TypertPackageRecord | undefined
|
||||
listPackages(filter?: TypertPackageFilter): TypertPackageRecord[]
|
||||
toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the global key of one generated schema.
|
||||
* @param packageName - contributing npm package.
|
||||
* @param name - schema export name.
|
||||
* @returns `<package>#<name>`.
|
||||
*/
|
||||
export function typertKey(packageName: string, name: string): string {
|
||||
return `${packageName}#${name}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the identity of one package-face model.
|
||||
* @param packageName - contributing npm package.
|
||||
* @param face - independently compiled face.
|
||||
* @returns `<package>#<face>`.
|
||||
*/
|
||||
export function typertPackageKey(packageName: string, face: TypertFace): string {
|
||||
return `${packageName}#${face}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of generated schemas and package reflection.
|
||||
* @typert service
|
||||
*/
|
||||
export class TypertRegistry extends Service {
|
||||
private readonly schemas = new Map<string, TypertSchemaRecord>()
|
||||
private readonly packages = new Map<string, TypertPackageRecord>()
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'typert')
|
||||
}
|
||||
|
||||
/**
|
||||
* Register one generated contribution atomically for the calling fiber.
|
||||
* Duplicate package-face identities or schema keys reject the whole batch.
|
||||
* @param contribution - generated schemas and package metadata.
|
||||
* @returns the exact effect disposer that removes this contribution.
|
||||
*/
|
||||
register(contribution: TypertContribution): () => void {
|
||||
const packageRecord = this.validatePackage(contribution)
|
||||
const schemaRecords = this.validateSchemas(contribution)
|
||||
const { schemas, packages } = this
|
||||
const dispose = this.ctx.effect(function* () {
|
||||
packages.set(packageRecord.key, packageRecord)
|
||||
for (const record of schemaRecords) schemas.set(record.key, record)
|
||||
yield () => {
|
||||
packages.delete(packageRecord.key)
|
||||
for (const record of schemaRecords) schemas.delete(record.key)
|
||||
}
|
||||
}, 'typert.register()')
|
||||
// oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; preserve Cordis disposer identity
|
||||
return dispose
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up one schema by `<package>#<name>`.
|
||||
* @param key - global schema key.
|
||||
* @returns the live schema record, or `undefined` when absent.
|
||||
*/
|
||||
get(key: string): TypertSchemaRecord | undefined {
|
||||
return this.schemas.get(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one required schema.
|
||||
* @param key - global schema key.
|
||||
* @returns the live schema record.
|
||||
* @throws when the key is malformed, the package face is absent, or the schema is not contributed.
|
||||
*/
|
||||
resolve(key: string): TypertSchemaRecord {
|
||||
const record = this.schemas.get(key)
|
||||
if (record !== undefined) return record
|
||||
const hash = key.indexOf('#')
|
||||
if (hash <= 0 || hash === key.length - 1) {
|
||||
throw new Error(`typert: invalid schema key "${key}" — expected "<package>#<name>"`)
|
||||
}
|
||||
const packageName = key.slice(0, hash)
|
||||
if ([...this.packages.values()].some(candidate => candidate.package === packageName)) {
|
||||
throw new Error(
|
||||
`typert: cannot resolve "${key}" — package "${packageName}" is registered but contributes no schema named "${key.slice(hash + 1)}"`,
|
||||
)
|
||||
}
|
||||
throw new Error(`typert: cannot resolve "${key}" — package "${packageName}" has no registered contribution`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate live schemas in registration order.
|
||||
* @param filter - optional package and face restriction.
|
||||
* @returns matching schema records.
|
||||
*/
|
||||
list(filter: TypertSchemaFilter = {}): TypertSchemaRecord[] {
|
||||
return [...this.schemas.values()].filter(record => matches(record, filter))
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up generated reflection for one package face.
|
||||
* @param packageName - exact npm package name.
|
||||
* @param face - face to query; defaults to the host runtime.
|
||||
* @returns the live package record, or `undefined` when absent.
|
||||
*/
|
||||
getPackage(packageName: string, face: TypertFace = 'host'): TypertPackageRecord | undefined {
|
||||
return this.packages.get(typertPackageKey(packageName, face))
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate generated package reflection in registration order.
|
||||
* @param filter - optional package and face restriction.
|
||||
* @returns matching package records.
|
||||
*/
|
||||
listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[] {
|
||||
return [...this.packages.values()].filter(record => matches(record, filter))
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a live Zod schema to JSON Schema without caching the result.
|
||||
* @param key - global schema key.
|
||||
* @param params - Zod projection parameters.
|
||||
* @returns a fresh JSON Schema document.
|
||||
*/
|
||||
toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema {
|
||||
return z.toJSONSchema(this.resolve(key).schema, params)
|
||||
}
|
||||
|
||||
private validatePackage(contribution: TypertContribution): TypertPackageRecord {
|
||||
validateSegment('package name', contribution.package)
|
||||
const face: unknown = contribution.face
|
||||
if (face !== 'host' && face !== 'client') {
|
||||
throw new Error(`typert: invalid face ${JSON.stringify(face)} — expected "host" or "client"`)
|
||||
}
|
||||
const key = typertPackageKey(contribution.package, contribution.face)
|
||||
if (this.packages.has(key)) {
|
||||
throw new Error(`typert: package face "${key}" is already registered`)
|
||||
}
|
||||
return {
|
||||
package: contribution.package,
|
||||
face,
|
||||
key,
|
||||
model: contribution.model,
|
||||
}
|
||||
}
|
||||
|
||||
private validateSchemas(contribution: TypertContribution): TypertSchemaRecord[] {
|
||||
const records: TypertSchemaRecord[] = []
|
||||
const batch = new Set<string>()
|
||||
for (const schema of contribution.schemas) {
|
||||
validateSegment('schema name', schema.name)
|
||||
const key = typertKey(contribution.package, schema.name)
|
||||
if (batch.has(key) || this.schemas.has(key)) {
|
||||
throw new Error(`typert: schema "${key}" is already registered`)
|
||||
}
|
||||
batch.add(key)
|
||||
records.push({
|
||||
...schema,
|
||||
package: contribution.package,
|
||||
face: contribution.face,
|
||||
key,
|
||||
})
|
||||
}
|
||||
return records
|
||||
}
|
||||
}
|
||||
|
||||
function matches(
|
||||
record: { readonly package: string; readonly face: TypertFace },
|
||||
filter: { readonly package?: string; readonly face?: TypertFace },
|
||||
): boolean {
|
||||
return (filter.package === undefined || record.package === filter.package)
|
||||
&& (filter.face === undefined || record.face === filter.face)
|
||||
}
|
||||
|
||||
function validateSegment(subject: string, value: string): void {
|
||||
if (value.length === 0 || value.includes('#')) {
|
||||
throw new Error(`typert: invalid ${subject} "${value}" — must be nonempty and must not contain "#"`)
|
||||
}
|
||||
}
|
||||
|
||||
export default TypertRegistry
|
||||
|
||||
584
packages/typert/registry/src/service.ts
Normal file
584
packages/typert/registry/src/service.ts
Normal file
@@ -0,0 +1,584 @@
|
||||
/**
|
||||
* Runtime registry for generated TypeRT reflection, Remote invocations, and
|
||||
* dependency-inverted lookup/Context providers. It performs no TypeScript
|
||||
* analysis or schema generation.
|
||||
* @module @deepseek-ai/dsh-typert-registry
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import { z } from 'zod'
|
||||
import type {
|
||||
InvocationDescriptor,
|
||||
TypeRTClientContextBinder,
|
||||
TypeRTContextMap,
|
||||
TypeRTContextRegistry,
|
||||
TypeRTContextWire,
|
||||
TypeRTDisposer,
|
||||
TypeRTHostContextProvider,
|
||||
TypeRTLocalRegistry,
|
||||
TypeRTLookupHost,
|
||||
TypeRTLookupMap,
|
||||
TypeRTLookupProvider,
|
||||
TypeRTLookupRegistry,
|
||||
TypeRTLookupWire,
|
||||
TypeRTRemoteContribution,
|
||||
TypeRTRemoteRegistry,
|
||||
TypeRTRegistryChange,
|
||||
TypeRTRegistryListener,
|
||||
TypeRTService,
|
||||
} from '@deepseek-ai/dsh-type-meta'
|
||||
import type {
|
||||
TypertContribution,
|
||||
TypertFace,
|
||||
TypertPackageFilter,
|
||||
TypertPackageRecord,
|
||||
TypertSchemaFilter,
|
||||
TypertSchemaRecord,
|
||||
} from './types.ts'
|
||||
|
||||
/**
|
||||
* Compose the global key of one generated schema.
|
||||
* @param packageName - contributing npm package.
|
||||
* @param name - schema export name.
|
||||
* @returns `<package>#<name>`.
|
||||
*/
|
||||
export function typertKey(packageName: string, name: string): string {
|
||||
return `${packageName}#${name}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the identity of one package-face model.
|
||||
* @param packageName - contributing npm package.
|
||||
* @param face - independently compiled face.
|
||||
* @returns `<package>#<face>`.
|
||||
*/
|
||||
export function typertPackageKey(packageName: string, face: TypertFace): string {
|
||||
return `${packageName}#${face}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the endpoint key used by local and Remote invocation registries.
|
||||
* @param descriptor - invocation whose namespace and method form the endpoint.
|
||||
* @returns `<namespace>/<method>`.
|
||||
*/
|
||||
export function typertEndpoint(descriptor: Pick<InvocationDescriptor, 'namespace' | 'method'>): string {
|
||||
return `${descriptor.namespace}/${descriptor.method}`
|
||||
}
|
||||
|
||||
interface DescriptorEntry {
|
||||
readonly descriptor: InvocationDescriptor
|
||||
readonly owner: object
|
||||
}
|
||||
|
||||
interface ProviderEntry<Provider> {
|
||||
readonly provider: Provider
|
||||
readonly owner: object
|
||||
}
|
||||
|
||||
type ReportObserverError = (change: TypeRTRegistryChange, error: unknown) => void
|
||||
|
||||
class ChangeSource {
|
||||
private readonly listeners = new Set<TypeRTRegistryListener>()
|
||||
|
||||
constructor(private readonly report: ReportObserverError) {}
|
||||
|
||||
subscribe(ctx: Context, listener: TypeRTRegistryListener): TypeRTDisposer {
|
||||
const { listeners } = this
|
||||
return ctx.effect(function* () {
|
||||
listeners.add(listener)
|
||||
yield () => { listeners.delete(listener) }
|
||||
}, 'typert registry subscription')
|
||||
}
|
||||
|
||||
emit(change: TypeRTRegistryChange): void {
|
||||
for (const listener of [...this.listeners]) {
|
||||
try {
|
||||
listener(change)
|
||||
} catch (error) {
|
||||
this.report(change, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DescriptorStore {
|
||||
private readonly entries = new Map<string, DescriptorEntry>()
|
||||
private readonly ids = new Map<string, DescriptorEntry>()
|
||||
private readonly history = new Set<string>()
|
||||
private readonly changes: ChangeSource
|
||||
|
||||
constructor(
|
||||
private readonly kind: 'local' | 'remote',
|
||||
report: ReportObserverError,
|
||||
) {
|
||||
this.changes = new ChangeSource(report)
|
||||
}
|
||||
|
||||
validate(descriptors: readonly InvocationDescriptor[]): void {
|
||||
const endpoints = new Set<string>()
|
||||
const ids = new Set<string>()
|
||||
for (const descriptor of descriptors) {
|
||||
validateInvocation(descriptor)
|
||||
const endpoint = typertEndpoint(descriptor)
|
||||
if (endpoints.has(endpoint) || this.entries.has(endpoint)) {
|
||||
throw new Error(`typert: ${this.kind} endpoint "${endpoint}" is already registered`)
|
||||
}
|
||||
if (ids.has(descriptor.id) || this.ids.has(descriptor.id)) {
|
||||
throw new Error(`typert: ${this.kind} invocation id "${descriptor.id}" is already registered`)
|
||||
}
|
||||
endpoints.add(endpoint)
|
||||
ids.add(descriptor.id)
|
||||
}
|
||||
}
|
||||
|
||||
commit(owner: object, descriptors: readonly InvocationDescriptor[]): void {
|
||||
for (const descriptor of descriptors) {
|
||||
const entry = { descriptor, owner }
|
||||
const endpoint = typertEndpoint(descriptor)
|
||||
this.entries.set(endpoint, entry)
|
||||
this.ids.set(descriptor.id, entry)
|
||||
this.history.add(endpoint)
|
||||
}
|
||||
for (const descriptor of descriptors) {
|
||||
this.changes.emit({ kind: this.kind, key: typertEndpoint(descriptor) })
|
||||
}
|
||||
}
|
||||
|
||||
withdraw(owner: object, descriptors: readonly InvocationDescriptor[]): void {
|
||||
const removed: string[] = []
|
||||
for (const descriptor of descriptors) {
|
||||
const endpoint = typertEndpoint(descriptor)
|
||||
const entry = this.entries.get(endpoint)
|
||||
if (entry?.owner !== owner) continue
|
||||
this.entries.delete(endpoint)
|
||||
if (this.ids.get(descriptor.id) === entry) this.ids.delete(descriptor.id)
|
||||
removed.push(endpoint)
|
||||
}
|
||||
for (const endpoint of removed) this.changes.emit({ kind: this.kind, key: endpoint })
|
||||
}
|
||||
|
||||
get(endpoint: string): InvocationDescriptor | undefined {
|
||||
return this.entries.get(endpoint)?.descriptor
|
||||
}
|
||||
|
||||
hasSeen(endpoint: string): boolean {
|
||||
return this.history.has(endpoint)
|
||||
}
|
||||
|
||||
list(): readonly InvocationDescriptor[] {
|
||||
return [...this.entries.values()].map(entry => entry.descriptor)
|
||||
}
|
||||
|
||||
subscribe(ctx: Context, listener: TypeRTRegistryListener): TypeRTDisposer {
|
||||
return this.changes.subscribe(ctx, listener)
|
||||
}
|
||||
}
|
||||
|
||||
class RemoteStore {
|
||||
private readonly packages = new Map<string, object>()
|
||||
|
||||
constructor(private readonly descriptors: DescriptorStore) {}
|
||||
|
||||
view(ctx: Context): TypeRTRemoteRegistry {
|
||||
return {
|
||||
register: contribution => this.register(ctx, contribution),
|
||||
get: endpoint => this.descriptors.get(endpoint),
|
||||
list: () => this.descriptors.list(),
|
||||
subscribe: listener => this.descriptors.subscribe(ctx, listener),
|
||||
}
|
||||
}
|
||||
|
||||
private register(ctx: Context, contribution: TypeRTRemoteContribution): TypeRTDisposer {
|
||||
validateSegment('Remote package name', contribution.package)
|
||||
if (this.packages.has(contribution.package)) {
|
||||
throw new Error(`typert: Remote package "${contribution.package}" is already registered`)
|
||||
}
|
||||
this.descriptors.validate(contribution.descriptors)
|
||||
const owner = {}
|
||||
const { packages, descriptors } = this
|
||||
return ctx.effect(function* () {
|
||||
packages.set(contribution.package, owner)
|
||||
descriptors.commit(owner, contribution.descriptors)
|
||||
yield () => {
|
||||
if (packages.get(contribution.package) === owner) packages.delete(contribution.package)
|
||||
descriptors.withdraw(owner, contribution.descriptors)
|
||||
}
|
||||
}, `typert.remotes.register(${JSON.stringify(contribution.package)})`)
|
||||
}
|
||||
}
|
||||
|
||||
class LookupStore {
|
||||
private readonly providers = new Map<string, ProviderEntry<TypeRTLookupProvider>>()
|
||||
private readonly changes: ChangeSource
|
||||
|
||||
constructor(report: ReportObserverError) {
|
||||
this.changes = new ChangeSource(report)
|
||||
}
|
||||
|
||||
view(ctx: Context): TypeRTLookupRegistry {
|
||||
return {
|
||||
register: <K extends Extract<keyof TypeRTLookupMap, string>>(
|
||||
key: K,
|
||||
provider: TypeRTLookupProvider<
|
||||
TypeRTLookupHost<TypeRTLookupMap[K]>,
|
||||
TypeRTLookupWire<TypeRTLookupMap[K]>
|
||||
>,
|
||||
) => this.register(ctx, key, provider),
|
||||
get: key => this.providers.get(key)?.provider,
|
||||
keys: () => [...this.providers.keys()],
|
||||
subscribe: listener => this.changes.subscribe(ctx, listener),
|
||||
}
|
||||
}
|
||||
|
||||
private register<Host, Wire>(ctx: Context, key: string, provider: TypeRTLookupProvider<Host, Wire>): TypeRTDisposer {
|
||||
validateSegment('lookup key', key)
|
||||
validateSegment('lookup parameter', provider.parameter)
|
||||
validateWireName('lookup wire field', provider.wire)
|
||||
validateNonempty('lookup Host type symbol', provider.hostTypeSymbol)
|
||||
validateNonempty('lookup wire type symbol', provider.wireTypeSymbol)
|
||||
if (this.providers.has(key)) throw new Error(`typert: lookup "${key}" is already registered`)
|
||||
const owner = {}
|
||||
const entry: ProviderEntry<TypeRTLookupProvider> = { provider, owner }
|
||||
const { providers, changes } = this
|
||||
return ctx.effect(function* () {
|
||||
providers.set(key, entry)
|
||||
changes.emit({ kind: 'lookup', key })
|
||||
yield () => {
|
||||
if (providers.get(key) !== entry) return
|
||||
providers.delete(key)
|
||||
changes.emit({ kind: 'lookup', key })
|
||||
}
|
||||
}, `typert.lookups.register(${JSON.stringify(key)})`)
|
||||
}
|
||||
}
|
||||
|
||||
class ContextStore {
|
||||
private readonly hosts = new Map<string, ProviderEntry<TypeRTHostContextProvider>>()
|
||||
private readonly clients = new Map<string, ProviderEntry<TypeRTClientContextBinder>>()
|
||||
private readonly changes: ChangeSource
|
||||
|
||||
constructor(report: ReportObserverError) {
|
||||
this.changes = new ChangeSource(report)
|
||||
}
|
||||
|
||||
view(ctx: Context): TypeRTContextRegistry {
|
||||
return {
|
||||
registerHost: <K extends Extract<keyof TypeRTContextMap, string>>(
|
||||
key: K,
|
||||
provider: TypeRTHostContextProvider<TypeRTContextWire<TypeRTContextMap[K]>>,
|
||||
) => this.registerHost(ctx, key, provider),
|
||||
registerClient: <K extends Extract<keyof TypeRTContextMap, string>>(
|
||||
key: K,
|
||||
binder: TypeRTClientContextBinder<TypeRTContextWire<TypeRTContextMap[K]>>,
|
||||
) => this.registerClient(ctx, key, binder),
|
||||
getHost: key => this.hosts.get(key)?.provider,
|
||||
getClient: key => this.clients.get(key)?.provider,
|
||||
subscribe: listener => this.changes.subscribe(ctx, listener),
|
||||
}
|
||||
}
|
||||
|
||||
private registerHost<Wire>(ctx: Context, key: string, provider: TypeRTHostContextProvider<Wire>): TypeRTDisposer {
|
||||
validateSegment('Context key', key)
|
||||
validateWireName('Context wire field', provider.wire)
|
||||
validateNonempty('Context wire type symbol', provider.wireTypeSymbol)
|
||||
return this.registerProvider(ctx, this.hosts, 'host-context', key, provider)
|
||||
}
|
||||
|
||||
private registerClient<Wire>(ctx: Context, key: string, binder: TypeRTClientContextBinder<Wire>): TypeRTDisposer {
|
||||
validateSegment('Context key', key)
|
||||
return this.registerProvider(ctx, this.clients, 'client-context', key, binder)
|
||||
}
|
||||
|
||||
private registerProvider<Provider>(
|
||||
ctx: Context,
|
||||
table: Map<string, ProviderEntry<Provider>>,
|
||||
kind: 'host-context' | 'client-context',
|
||||
key: string,
|
||||
provider: Provider,
|
||||
): TypeRTDisposer {
|
||||
if (table.has(key)) throw new Error(`typert: ${kind} provider "${key}" is already registered`)
|
||||
const entry: ProviderEntry<Provider> = { provider, owner: {} }
|
||||
const { changes } = this
|
||||
return ctx.effect(function* () {
|
||||
table.set(key, entry)
|
||||
changes.emit({ kind, key })
|
||||
yield () => {
|
||||
if (table.get(key) !== entry) return
|
||||
table.delete(key)
|
||||
changes.emit({ kind, key })
|
||||
}
|
||||
}, `typert.contexts.register(${JSON.stringify(key)})`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of generated schemas, package reflection, invocations, and Remote
|
||||
* dependency providers.
|
||||
* @typert service typert
|
||||
*/
|
||||
export class TypertRegistry extends Service implements TypeRTService {
|
||||
private readonly schemas = new Map<string, TypertSchemaRecord>()
|
||||
private readonly packages = new Map<string, TypertPackageRecord>()
|
||||
private readonly localStore: DescriptorStore
|
||||
private readonly remoteStore: RemoteStore
|
||||
private readonly lookupStore: LookupStore
|
||||
private readonly contextStore: ContextStore
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'typert')
|
||||
const report: ReportObserverError = (change, error) => {
|
||||
ctx.logger.warn(`typert: ${change.kind} observer for "${change.key}" failed`)
|
||||
ctx.logger.warn(error)
|
||||
}
|
||||
this.localStore = new DescriptorStore('local', report)
|
||||
this.remoteStore = new RemoteStore(new DescriptorStore('remote', report))
|
||||
this.lookupStore = new LookupStore(report)
|
||||
this.contextStore = new ContextStore(report)
|
||||
}
|
||||
|
||||
/** Current-environment invocation definitions. */
|
||||
get local(): TypeRTLocalRegistry {
|
||||
const ctx = this.ctx
|
||||
return {
|
||||
get: endpoint => this.localStore.get(endpoint),
|
||||
hasSeen: endpoint => this.localStore.hasSeen(endpoint),
|
||||
list: () => this.localStore.list(),
|
||||
subscribe: listener => this.localStore.subscribe(ctx, listener),
|
||||
}
|
||||
}
|
||||
|
||||
/** Consumer-selected Remote definitions. */
|
||||
get remotes(): TypeRTRemoteRegistry {
|
||||
return this.remoteStore.view(this.ctx)
|
||||
}
|
||||
|
||||
/** Host object lookup providers. */
|
||||
get lookups(): TypeRTLookupRegistry {
|
||||
return this.lookupStore.view(this.ctx)
|
||||
}
|
||||
|
||||
/** Host Context providers and Client Context binders. */
|
||||
get contexts(): TypeRTContextRegistry {
|
||||
return this.contextStore.view(this.ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register one generated contribution atomically for the calling fiber.
|
||||
* Duplicate package-face identities, schemas, invocation ids, or endpoints
|
||||
* reject the whole batch.
|
||||
* @param contribution - generated schemas, reflection, and Host invocations.
|
||||
* @returns the exact effect disposer that removes this contribution.
|
||||
*/
|
||||
register(contribution: TypertContribution): TypeRTDisposer {
|
||||
const packageRecord = this.validatePackage(contribution)
|
||||
const schemaRecords = this.validateSchemas(contribution)
|
||||
const invocations = contribution.invocations ?? []
|
||||
this.localStore.validate(invocations)
|
||||
const owner = {}
|
||||
const { schemas, packages, localStore } = this
|
||||
return this.ctx.effect(function* () {
|
||||
packages.set(packageRecord.key, packageRecord)
|
||||
for (const record of schemaRecords) schemas.set(record.key, record)
|
||||
localStore.commit(owner, invocations)
|
||||
yield () => {
|
||||
if (packages.get(packageRecord.key) === packageRecord) packages.delete(packageRecord.key)
|
||||
for (const record of schemaRecords) {
|
||||
if (schemas.get(record.key) === record) schemas.delete(record.key)
|
||||
}
|
||||
localStore.withdraw(owner, invocations)
|
||||
}
|
||||
}, 'typert.register()')
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up one schema by `<package>#<name>`.
|
||||
* @param key - global schema key.
|
||||
* @returns the live schema record, or `undefined` when absent.
|
||||
*/
|
||||
get(key: string): TypertSchemaRecord | undefined {
|
||||
return this.schemas.get(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one required schema.
|
||||
* @param key - global schema key.
|
||||
* @returns the live schema record.
|
||||
* @throws when the key is malformed, the package face is absent, or the schema is not contributed.
|
||||
*/
|
||||
resolve(key: string): TypertSchemaRecord {
|
||||
const record = this.schemas.get(key)
|
||||
if (record !== undefined) return record
|
||||
const hash = key.indexOf('#')
|
||||
if (hash <= 0 || hash === key.length - 1) {
|
||||
throw new Error(`typert: invalid schema key "${key}" — expected "<package>#<name>"`)
|
||||
}
|
||||
const packageName = key.slice(0, hash)
|
||||
if ([...this.packages.values()].some(candidate => candidate.package === packageName)) {
|
||||
throw new Error(
|
||||
`typert: cannot resolve "${key}" — package "${packageName}" is registered but contributes no schema named "${key.slice(hash + 1)}"`,
|
||||
)
|
||||
}
|
||||
throw new Error(`typert: cannot resolve "${key}" — package "${packageName}" has no registered contribution`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate live schemas in registration order.
|
||||
* @param filter - optional package and face restriction.
|
||||
* @returns matching schema records.
|
||||
*/
|
||||
list(filter: TypertSchemaFilter = {}): TypertSchemaRecord[] {
|
||||
return [...this.schemas.values()].filter(record => matches(record, filter))
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up generated reflection for one package face.
|
||||
* @param packageName - exact npm package name.
|
||||
* @param face - face to query; defaults to the host runtime.
|
||||
* @returns the live package record, or `undefined` when absent.
|
||||
*/
|
||||
getPackage(packageName: string, face: TypertFace = 'host'): TypertPackageRecord | undefined {
|
||||
return this.packages.get(typertPackageKey(packageName, face))
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate generated package reflection in registration order.
|
||||
* @param filter - optional package and face restriction.
|
||||
* @returns matching package records.
|
||||
*/
|
||||
listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[] {
|
||||
return [...this.packages.values()].filter(record => matches(record, filter))
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a live Zod schema to JSON Schema without caching the result.
|
||||
* @param key - global schema key.
|
||||
* @param params - Zod projection parameters.
|
||||
* @returns a fresh JSON Schema document.
|
||||
*/
|
||||
toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema {
|
||||
return z.toJSONSchema(this.resolve(key).schema, params)
|
||||
}
|
||||
|
||||
private validatePackage(contribution: TypertContribution): TypertPackageRecord {
|
||||
validateSegment('package name', contribution.package)
|
||||
const face: unknown = contribution.face
|
||||
if (face !== 'host' && face !== 'client') {
|
||||
throw new Error(`typert: invalid face ${JSON.stringify(face)} — expected "host" or "client"`)
|
||||
}
|
||||
const key = typertPackageKey(contribution.package, contribution.face)
|
||||
if (this.packages.has(key)) {
|
||||
throw new Error(`typert: package face "${key}" is already registered`)
|
||||
}
|
||||
return {
|
||||
package: contribution.package,
|
||||
face,
|
||||
key,
|
||||
model: contribution.model,
|
||||
}
|
||||
}
|
||||
|
||||
private validateSchemas(contribution: TypertContribution): TypertSchemaRecord[] {
|
||||
const records: TypertSchemaRecord[] = []
|
||||
const batch = new Set<string>()
|
||||
for (const schema of contribution.schemas) {
|
||||
validateSegment('schema name', schema.name)
|
||||
const key = typertKey(contribution.package, schema.name)
|
||||
if (batch.has(key) || this.schemas.has(key)) {
|
||||
throw new Error(`typert: schema "${key}" is already registered`)
|
||||
}
|
||||
batch.add(key)
|
||||
records.push({
|
||||
...schema,
|
||||
package: contribution.package,
|
||||
face: contribution.face,
|
||||
key,
|
||||
})
|
||||
}
|
||||
return records
|
||||
}
|
||||
}
|
||||
|
||||
function matches(
|
||||
record: { readonly package: string; readonly face: TypertFace },
|
||||
filter: { readonly package?: string; readonly face?: TypertFace },
|
||||
): boolean {
|
||||
return (filter.package === undefined || record.package === filter.package)
|
||||
&& (filter.face === undefined || record.face === filter.face)
|
||||
}
|
||||
|
||||
function validateInvocation(descriptor: InvocationDescriptor): void {
|
||||
validateNonempty('invocation id', descriptor.id)
|
||||
validateSegment('invocation service key', descriptor.service)
|
||||
validateWireName('invocation namespace', descriptor.namespace)
|
||||
validateWireName('invocation method', descriptor.method)
|
||||
if (descriptor.implementation !== undefined) {
|
||||
validateWireName('invocation implementation method', descriptor.implementation)
|
||||
}
|
||||
validateCodec(descriptor.result, `${descriptor.id} result`)
|
||||
const wires = new Set<string>()
|
||||
for (const parameter of descriptor.parameters) {
|
||||
validateWireName('parameter name', parameter.name)
|
||||
validateWireName('parameter wire field', parameter.wire)
|
||||
if (wires.has(parameter.wire)) {
|
||||
throw new Error(`typert: invocation "${descriptor.id}" repeats wire field "${parameter.wire}"`)
|
||||
}
|
||||
wires.add(parameter.wire)
|
||||
if (parameter.source === 'lookup') {
|
||||
if (parameter.lookup === undefined) {
|
||||
throw new Error(`typert: invocation "${descriptor.id}" lookup parameter "${parameter.name}" has no lookup key`)
|
||||
}
|
||||
validateSegment('lookup key', parameter.lookup)
|
||||
} else if (parameter.lookup !== undefined) {
|
||||
throw new Error(`typert: invocation "${descriptor.id}" JSON parameter "${parameter.name}" declares a lookup key`)
|
||||
}
|
||||
validateCodec(parameter.codec, `${descriptor.id} parameter ${parameter.name}`)
|
||||
}
|
||||
if (descriptor.scope !== undefined) {
|
||||
if (descriptor.invocation.kind !== 'direct') {
|
||||
throw new Error(`typert: invocation "${descriptor.id}" Context receiver cannot declare a direct scope projection`)
|
||||
}
|
||||
validateSegment('scope Context key', descriptor.scope.context)
|
||||
validateWireName('scope wire field', descriptor.scope.wire)
|
||||
const lookups = descriptor.parameters.filter(candidate => candidate.source === 'lookup')
|
||||
const parameter = lookups.length === 1 ? lookups[0] : undefined
|
||||
if (parameter === undefined || parameter.wire !== descriptor.scope.wire
|
||||
|| parameter.lookup !== descriptor.scope.context) {
|
||||
throw new Error(
|
||||
`typert: invocation "${descriptor.id}" scope wire "${descriptor.scope.wire}" must select its only lookup parameter`,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (descriptor.invocation.kind === 'context') {
|
||||
validateSegment('Context key', descriptor.invocation.context)
|
||||
validateWireName('Context wire field', descriptor.invocation.wire)
|
||||
if (wires.has(descriptor.invocation.wire)) {
|
||||
throw new Error(`typert: invocation "${descriptor.id}" repeats wire field "${descriptor.invocation.wire}"`)
|
||||
}
|
||||
validateCodec(descriptor.invocation.codec, `${descriptor.id} Context`)
|
||||
}
|
||||
}
|
||||
|
||||
function validateCodec(codec: InvocationDescriptor['result'], subject: string): void {
|
||||
if (codec.mode === 'src-json') return
|
||||
validateNonempty(`${subject} type symbol`, codec.typeSymbol)
|
||||
if (typeof codec.schema.parse !== 'function') {
|
||||
throw new Error(`typert: ${subject} strict codec has no parse() method`)
|
||||
}
|
||||
}
|
||||
|
||||
function validateWireName(subject: string, value: string): void {
|
||||
validateSegment(subject, value)
|
||||
if (value.includes('/')) throw new Error(`typert: invalid ${subject} "${value}" — must not contain "/"`)
|
||||
}
|
||||
|
||||
function validateSegment(subject: string, value: string): void {
|
||||
if (value.length === 0 || value.includes('#')) {
|
||||
throw new Error(`typert: invalid ${subject} "${value}" — must be nonempty and must not contain "#"`)
|
||||
}
|
||||
}
|
||||
|
||||
function validateNonempty(subject: string, value: string): void {
|
||||
if (value.length === 0) throw new Error(`typert: invalid ${subject} — must be nonempty`)
|
||||
}
|
||||
|
||||
export default TypertRegistry
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { z } from 'zod'
|
||||
import type { InvocationDescriptor } from '@deepseek-ai/dsh-type-meta'
|
||||
|
||||
/** Independently compiled side that produced a contribution. */
|
||||
export type TypertFace = 'host' | 'client'
|
||||
@@ -82,6 +83,13 @@ export interface TypertContribution {
|
||||
readonly face: TypertFace
|
||||
readonly schemas: readonly TypertSchema[]
|
||||
readonly model: TypertPackageModel
|
||||
/** Host invocation definitions; absent on artifacts generated before Remote support. */
|
||||
readonly invocations?: readonly InvocationDescriptor[]
|
||||
}
|
||||
|
||||
/** Generated Host contribution with strict Remote invocation definitions. */
|
||||
export interface TypertLocalContribution extends TypertContribution {
|
||||
readonly invocations: readonly InvocationDescriptor[]
|
||||
}
|
||||
|
||||
/** A live schema plus its contribution identity. */
|
||||
|
||||
@@ -2,10 +2,27 @@ import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { z } from 'zod'
|
||||
import TypertRegistry, {
|
||||
typertEndpoint,
|
||||
typertKey,
|
||||
typertPackageKey,
|
||||
type TypertContribution,
|
||||
} from '@deepseek-ai/dsh-typert-registry'
|
||||
import type {
|
||||
InvocationDescriptor,
|
||||
TypeRTContext,
|
||||
TypeRTLookup,
|
||||
TypeRTRemoteContribution,
|
||||
} from '@deepseek-ai/dsh-type-meta'
|
||||
|
||||
declare module '@deepseek-ai/dsh-type-meta' {
|
||||
interface TypeRTLookupMap {
|
||||
fixture: TypeRTLookup<{ readonly id: string }, string>
|
||||
}
|
||||
|
||||
interface TypeRTContextMap {
|
||||
registryFixture: TypeRTContext<string>
|
||||
}
|
||||
}
|
||||
|
||||
async function makeCtx(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
@@ -42,6 +59,42 @@ function toolsContribution(schema: z.ZodType = z.object({ name: z.string() })):
|
||||
}
|
||||
}
|
||||
|
||||
function invocation(id = '@fixture/remote#goals/create'): InvocationDescriptor {
|
||||
return {
|
||||
id,
|
||||
service: 'goals',
|
||||
namespace: 'goals',
|
||||
method: 'create',
|
||||
invocation: { kind: 'direct' },
|
||||
parameters: [{
|
||||
name: 'request',
|
||||
wire: 'request',
|
||||
source: 'json',
|
||||
codec: { mode: 'src-json' },
|
||||
}],
|
||||
result: { mode: 'src-json' },
|
||||
}
|
||||
}
|
||||
|
||||
function scopedInvocation(): InvocationDescriptor {
|
||||
return {
|
||||
...invocation('@fixture/remote#goals/create-scoped'),
|
||||
scope: { context: 'fixture', wire: 'agentId' },
|
||||
parameters: [{
|
||||
name: 'agent',
|
||||
wire: 'agentId',
|
||||
source: 'lookup',
|
||||
lookup: 'fixture',
|
||||
codec: { mode: 'src-json' },
|
||||
}, {
|
||||
name: 'request',
|
||||
wire: 'request',
|
||||
source: 'json',
|
||||
codec: { mode: 'src-json' },
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
describe('TypertRegistry', () => {
|
||||
it('registers and queries generated schemas separately from package reflection', async () => {
|
||||
const ctx = await makeCtx()
|
||||
@@ -69,7 +122,7 @@ describe('TypertRegistry', () => {
|
||||
const dispose = ctx.typert.register(toolsContribution())
|
||||
expect(ctx.typert.getPackage('@deepseek-ai/dsh-tools')).toBeDefined()
|
||||
|
||||
dispose()
|
||||
await dispose()
|
||||
|
||||
expect(ctx.typert.get('@deepseek-ai/dsh-tools#ToolInput')).toBeUndefined()
|
||||
expect(ctx.typert.getPackage('@deepseek-ai/dsh-tools')).toBeUndefined()
|
||||
@@ -145,4 +198,133 @@ describe('TypertRegistry', () => {
|
||||
expect(projected).toMatchObject({ type: 'object', properties: { name: { type: 'string' } } })
|
||||
expect(ctx.typert.toJSONSchema('@deepseek-ai/dsh-tools#ToolInput')).not.toBe(projected)
|
||||
})
|
||||
|
||||
it('registers local invocations atomically with generated reflection', async () => {
|
||||
const ctx = await makeCtx()
|
||||
const descriptor = invocation()
|
||||
const contribution = { ...toolsContribution(), invocations: [descriptor] }
|
||||
const changes: string[] = []
|
||||
ctx.typert.local.subscribe((change) => { changes.push(`${change.kind}:${change.key}`) })
|
||||
|
||||
expect(ctx.typert.local.hasSeen('goals/create')).toBe(false)
|
||||
const dispose = ctx.typert.register(contribution)
|
||||
|
||||
expect(typertEndpoint(descriptor)).toBe('goals/create')
|
||||
expect(ctx.typert.local.get('goals/create')).toBe(descriptor)
|
||||
expect(ctx.typert.local.hasSeen('goals/create')).toBe(true)
|
||||
expect(ctx.typert.local.list()).toEqual([descriptor])
|
||||
expect(changes).toEqual(['local:goals/create'])
|
||||
|
||||
await dispose()
|
||||
expect(ctx.typert.local.list()).toEqual([])
|
||||
expect(ctx.typert.local.hasSeen('goals/create')).toBe(true)
|
||||
expect(ctx.typert.getPackage('@deepseek-ai/dsh-tools')).toBeUndefined()
|
||||
expect(changes).toEqual(['local:goals/create', 'local:goals/create'])
|
||||
})
|
||||
|
||||
it('mounts Remote contributions in the calling fiber and withdraws them exactly', async () => {
|
||||
const ctx = await makeCtx()
|
||||
const descriptor = invocation()
|
||||
const contribution: TypeRTRemoteContribution = {
|
||||
package: '@fixture/remote',
|
||||
descriptors: [descriptor],
|
||||
}
|
||||
const changes: string[] = []
|
||||
ctx.typert.remotes.subscribe((change) => { changes.push(`${change.kind}:${change.key}`) })
|
||||
const fiber = ctx.plugin(Object.assign(
|
||||
(child: Context) => { child.typert.remotes.register(contribution) },
|
||||
{ inject: ['typert'] },
|
||||
))
|
||||
await fiber
|
||||
|
||||
expect(ctx.typert.remotes.get('goals/create')).toBe(descriptor)
|
||||
expect(() => ctx.typert.remotes.register(contribution)).toThrow('Remote package')
|
||||
|
||||
await fiber.dispose()
|
||||
expect(ctx.typert.remotes.list()).toEqual([])
|
||||
expect(changes).toEqual(['remote:goals/create', 'remote:goals/create'])
|
||||
})
|
||||
|
||||
it('accepts only a direct scope selecting its unique lookup parameter', async () => {
|
||||
const ctx = await makeCtx()
|
||||
const descriptor = scopedInvocation()
|
||||
const dispose = ctx.typert.remotes.register({ package: '@fixture/scoped', descriptors: [descriptor] })
|
||||
expect(ctx.typert.remotes.get('goals/create')).toBe(descriptor)
|
||||
await dispose()
|
||||
|
||||
const cases: readonly [InvocationDescriptor, string][] = [
|
||||
[{
|
||||
...descriptor,
|
||||
invocation: {
|
||||
kind: 'context',
|
||||
context: 'fixture',
|
||||
wire: 'scopeId',
|
||||
codec: { mode: 'src-json' },
|
||||
},
|
||||
}, 'Context receiver cannot declare a direct scope projection'],
|
||||
[{ ...descriptor, scope: { context: 'fixture', wire: 'missingId' } }, 'must select its only lookup parameter'],
|
||||
[{
|
||||
...descriptor,
|
||||
parameters: [...descriptor.parameters, {
|
||||
name: 'other',
|
||||
wire: 'otherId',
|
||||
source: 'lookup',
|
||||
lookup: 'fixture',
|
||||
codec: { mode: 'src-json' },
|
||||
}],
|
||||
}, 'must select its only lookup parameter'],
|
||||
[{ ...descriptor, scope: { context: 'other', wire: 'agentId' } }, 'must select its only lookup parameter'],
|
||||
]
|
||||
for (const [index, [candidate, message]] of cases.entries()) {
|
||||
expect(() => ctx.typert.remotes.register({
|
||||
package: `@fixture/rejected-${String(index)}`,
|
||||
descriptors: [candidate],
|
||||
})).toThrow(message)
|
||||
}
|
||||
expect(ctx.typert.remotes.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('registers lookup and Context providers without domain branches', async () => {
|
||||
const ctx = await makeCtx()
|
||||
const object = { id: 'agent-1' }
|
||||
const scoped = ctx.extend()
|
||||
const disposeLookup = ctx.typert.lookups.register('fixture', {
|
||||
parameter: 'agent',
|
||||
wire: 'agentId',
|
||||
hostTypeSymbol: '@fixture/agent#Agent',
|
||||
wireTypeSymbol: '@fixture/session#SessionId',
|
||||
resolve: id => id === object.id ? object : undefined,
|
||||
})
|
||||
const disposeHost = ctx.typert.contexts.registerHost('registryFixture', {
|
||||
wire: 'agentId',
|
||||
wireTypeSymbol: '@fixture/session#SessionId',
|
||||
resolve: id => id === object.id ? scoped : undefined,
|
||||
})
|
||||
const disposeClient = ctx.typert.contexts.registerClient('registryFixture', {
|
||||
identity: candidate => candidate === scoped ? object.id : undefined,
|
||||
})
|
||||
|
||||
expect(ctx.typert.lookups.get('fixture')?.resolve('agent-1')).toBe(object)
|
||||
expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('agent-1')).toBe(scoped)
|
||||
expect(ctx.typert.contexts.getClient('registryFixture')?.identity(scoped)).toBe('agent-1')
|
||||
|
||||
await Promise.all([disposeClient(), disposeHost(), disposeLookup()])
|
||||
expect(ctx.typert.lookups.keys()).toEqual([])
|
||||
expect(ctx.typert.contexts.getHost('registryFixture')).toBeUndefined()
|
||||
expect(ctx.typert.contexts.getClient('registryFixture')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('contains change-listener failures and still notifies later listeners', async () => {
|
||||
const ctx = await makeCtx()
|
||||
const warnings: unknown[] = []
|
||||
ctx.logger.warn = ((message: unknown) => { warnings.push(message) }) as typeof ctx.logger.warn
|
||||
let observed = 0
|
||||
ctx.typert.remotes.subscribe(() => { throw new Error('observer failed') })
|
||||
ctx.typert.remotes.subscribe(() => { observed += 1 })
|
||||
|
||||
ctx.typert.remotes.register({ package: '@fixture/remote', descriptors: [invocation()] })
|
||||
|
||||
expect(observed).toBe(1)
|
||||
expect(warnings.map(String)).toContain('Error: observer failed')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../type-meta"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,25 +1,3 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
import { clientBundle } from '../../client/tsdown.client.ts'
|
||||
|
||||
/** Build the registry and its invariant companion as independent bundles. */
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: ['lib/types/index.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
},
|
||||
{
|
||||
entry: ['lib/types/invariant.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
},
|
||||
])
|
||||
export default clientBundle('@deepseek-ai/dsh-typert-registry', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
|
||||
6
packages/typert/type-meta/README.i18n.yaml
Normal file
6
packages/typert/type-meta/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/typert/type-meta/README.md
|
||||
README.md: 9dd8dadd07b219c7471c8851262958d4d9e96a43
|
||||
README.zh.md: 5716f56d988c6d2dd9cd237346c3b02ec9ae7c4e
|
||||
33
packages/typert/type-meta/README.md
Normal file
33
packages/typert/type-meta/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# @deepseek-ai/dsh-type-meta
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Compiler-independent declarations shared by business packages, generated TypeRT artifacts, the Host Gateway, and Client API. This package owns Remote decorators, the explicit Service binding, merge-extensible protocol maps, invocation descriptors, codecs, and provider contracts; it does not run TypeScript analysis or provide a Cordis service.
|
||||
|
||||
## Remote declarations
|
||||
|
||||
- `@Remote` marks a public instance method for direct invocation on its registered Cordis Service.
|
||||
- `@RemoteContext(key)` marks a method whose receiver is selected from a merge-declared scoped Context kind.
|
||||
- `bindTypeRTGateway(this, serviceKey, options?)` creates the visible, frozen binding between a Service instance, its exact Cordis key, and its wire namespace.
|
||||
- `remoteMethods(service)` returns a detached declaration-order snapshot used by the Gateway's SRC fallback.
|
||||
|
||||
Decorator initializers retain markers in a module-private `WeakMap` keyed by the Service prototype. They do not add constructor symbols, prototype properties, parameter metadata, or runtime reflection fields. The Service opts in explicitly through its `typertGateway` binding field.
|
||||
|
||||
## TypeRT protocol
|
||||
|
||||
Business packages extend `TypeRTLookupMap` and `TypeRTContextMap` to associate Host objects or scoped Contexts with their wire identities. Generated artifacts extend `TypeRTRemoteMap`, `TypeRTRemoteContextMap`, and `TypeRTRemoteNamespaceMap` so Client imports expose only selected Remote methods. `InvocationDescriptor` is the shared runtime form consumed by the registry, Gateway, and Client API.
|
||||
|
||||
Lookup and Context packages own both sides of their contract: declaration merging supplies the static association, while runtime providers register identity resolution with `ctx.typert`. Strict codecs carry generated schemas; `src-json` codecs identify the weaker source-launch path.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as this protocol package declares application reflection and registers no model surface.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct effect.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- Decorator markers contain only the method name and direct or Context invocation mode. Parameter, result, lookup, and schema reflection require the TypeRT build pipeline.
|
||||
- Remote decorators accept only public, non-static instance methods with string names. SRC execution cannot represent overloaded, destructured, defaulted, or rest-parameter signatures.
|
||||
33
packages/typert/type-meta/README.zh.md
Normal file
33
packages/typert/type-meta/README.zh.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# @deepseek-ai/dsh-type-meta
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
该包提供不依赖编译器的声明,由业务包、生成的 TypeRT 产物、Host Gateway 和 Client API 共享。它负责 Remote 装饰器、显式服务绑定、可通过声明合并扩展的协议映射、调用描述符、编解码器和提供方契约;它不执行 TypeScript 分析,也不提供 Cordis 服务。
|
||||
|
||||
## Remote 声明
|
||||
|
||||
- `@Remote` 将公开实例方法标记为可在其注册的 Cordis 服务上直接调用。
|
||||
- `@RemoteContext(key)` 标记接收者选自合并声明的作用域 Context 类型的方法。
|
||||
- `bindTypeRTGateway(this, serviceKey, options?)` 在服务实例、其准确的 Cordis key 与协议命名空间之间创建可见且冻结的绑定。
|
||||
- `remoteMethods(service)` 返回按声明顺序排列、与内部状态分离的快照,供 Gateway 的 SRC 回退路径使用。
|
||||
|
||||
装饰器初始化器将标记保存在以服务 prototype 为键的模块私有 `WeakMap` 中。它们不会在构造函数上添加 symbol,也不会添加 prototype 属性、参数元数据或运行时反射字段。服务通过自身的 `typertGateway` 绑定字段显式接入。
|
||||
|
||||
## TypeRT 协议
|
||||
|
||||
业务包扩展 `TypeRTLookupMap` 和 `TypeRTContextMap`,以关联 Host 对象或作用域 Context 与其协议身份。生成的产物扩展 `TypeRTRemoteMap`、`TypeRTRemoteContextMap` 和 `TypeRTRemoteNamespaceMap`,使 Client 导入后仅暴露选定的 Remote 方法。`InvocationDescriptor` 是供注册表、Gateway 和 Client API 使用的共享运行时形式。
|
||||
|
||||
查找包与 Context 包同时负责其契约的两侧:声明合并提供静态关联,运行时提供方则向 `ctx.typert` 注册身份解析。严格编解码器携带生成的 schema;`src-json` 编解码器标识约束更弱的源码启动路径。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无,因为该协议包声明应用反射,不注册任何模型接口。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无直接影响。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- 装饰器标记仅包含方法名,以及直接调用或 Context 调用模式。参数、结果、查找和 schema 反射需要 TypeRT 构建流水线。
|
||||
- Remote 装饰器只接受具有字符串名称的公开、非静态实例方法。SRC 执行无法表示重载签名,以及包含解构参数、默认参数或剩余参数的方法签名。
|
||||
42
packages/typert/type-meta/package.json
Normal file
42
packages/typert/type-meta/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-type-meta",
|
||||
"description": "Compiler-independent Remote metadata and TypeRT provider protocols",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./lib/types/types.d.ts",
|
||||
"default": "./lib/types/types.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
223
packages/typert/type-meta/src/index.ts
Normal file
223
packages/typert/type-meta/src/index.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Remote decorators and explicit Gateway bindings backed only by private
|
||||
* module state. Strict reflection remains a TypeRT compiler responsibility.
|
||||
* @module @deepseek-ai/dsh-type-meta
|
||||
*/
|
||||
|
||||
import type { TypeRTContextMap } from './types.ts'
|
||||
|
||||
export type {
|
||||
InvocationDescriptor,
|
||||
InvocationParameterDescriptor,
|
||||
InvocationSourceLocation,
|
||||
TypeRTClientContextBinder,
|
||||
TypeRTCodec,
|
||||
TypeRTContext,
|
||||
TypeRTContextMap,
|
||||
TypeRTContextRegistry,
|
||||
TypeRTContextWire,
|
||||
TypeRTDisposer,
|
||||
TypeRTHostContextProvider,
|
||||
TypeRTLocalRegistry,
|
||||
TypeRTLookup,
|
||||
TypeRTLookupHost,
|
||||
TypeRTLookupMap,
|
||||
TypeRTLookupProvider,
|
||||
TypeRTLookupRegistry,
|
||||
TypeRTLookupWire,
|
||||
TypeRTRemoteContextApi,
|
||||
TypeRTRemoteContextMap,
|
||||
TypeRTRemoteContextNamespace,
|
||||
TypeRTRemoteContribution,
|
||||
TypeRTRemoteMap,
|
||||
TypeRTRemoteNamespace,
|
||||
TypeRTRemoteNamespaceMap,
|
||||
TypeRTRemoteRegistry,
|
||||
TypeRTRegistryChange,
|
||||
TypeRTRegistryListener,
|
||||
TypeRTSchema,
|
||||
TypeRTService,
|
||||
} from './types.ts'
|
||||
|
||||
/** Options for an explicit Service-to-Gateway binding. */
|
||||
export interface TypeRTGatewayBindingOptions {
|
||||
/** Wire namespace; defaults to the Cordis service key. */
|
||||
readonly namespace?: string
|
||||
}
|
||||
|
||||
/** Visible declaration that one Service participates in TypeRT Gateway export. */
|
||||
export interface TypeRTGatewayBinding<Service extends object = object> {
|
||||
readonly service: Service
|
||||
readonly serviceKey: string
|
||||
readonly namespace: string
|
||||
}
|
||||
|
||||
/** Invocation mode recorded by a Remote method decorator. */
|
||||
export type RemoteInvocationMarker =
|
||||
| { readonly kind: 'direct' }
|
||||
| { readonly kind: 'context'; readonly context: string }
|
||||
|
||||
/** One decorator marker discovered for a live Service instance. */
|
||||
export interface RemoteMethodMarker {
|
||||
/** Public instance method carrying the implementation. */
|
||||
readonly method: string
|
||||
/** Endpoint method when it differs from the implementation member. */
|
||||
readonly exportName?: string
|
||||
readonly invocation: RemoteInvocationMarker
|
||||
}
|
||||
|
||||
type RemoteMethodDecorator = <This extends object, Args extends unknown[], Result>(
|
||||
method: (this: This, ...args: Args) => Result,
|
||||
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
|
||||
) => void
|
||||
|
||||
interface RemoteInitializerContext<This extends object> {
|
||||
readonly private: boolean
|
||||
readonly static: boolean
|
||||
readonly name: string | symbol
|
||||
addInitializer(initializer: (this: This) => void): void
|
||||
}
|
||||
|
||||
interface StoredRemoteMethodMarker {
|
||||
readonly exportName?: string
|
||||
readonly invocation: RemoteInvocationMarker
|
||||
}
|
||||
|
||||
const markers = new WeakMap<object, Map<string, StoredRemoteMethodMarker>>()
|
||||
|
||||
/**
|
||||
* Bind one visible Service field to a Cordis key and Remote namespace.
|
||||
* @param service - owning Service instance, normally `this`.
|
||||
* @param serviceKey - exact Cordis service key.
|
||||
* @param options - optional distinct wire namespace.
|
||||
* @returns a frozen, inspectable binding with no compiler-injected metadata.
|
||||
*/
|
||||
export function bindTypeRTGateway<Service extends object>(
|
||||
service: Service,
|
||||
serviceKey: string,
|
||||
options: TypeRTGatewayBindingOptions = {},
|
||||
): TypeRTGatewayBinding<Service> {
|
||||
validateName('service key', serviceKey)
|
||||
const namespace = options.namespace ?? serviceKey
|
||||
validateName('namespace', namespace)
|
||||
return Object.freeze({ service, serviceKey, namespace })
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark one public instance method as a direct Remote invocation.
|
||||
* @param _method - decorated method; retained only by the class itself.
|
||||
* @param context - standard decorator context used to schedule private marking.
|
||||
*/
|
||||
export function Remote<This extends object, Args extends unknown[], Result>(
|
||||
_method: (this: This, ...args: Args) => Result,
|
||||
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
|
||||
): void
|
||||
/**
|
||||
* Mark one public instance method under a distinct exported method name.
|
||||
* @param exportName - Remote endpoint method, without a namespace or slash.
|
||||
* @returns a standard method decorator.
|
||||
*/
|
||||
export function Remote(exportName: string): RemoteMethodDecorator
|
||||
export function Remote<This extends object, Args extends unknown[], Result>(
|
||||
methodOrExportName: string | ((this: This, ...args: Args) => Result),
|
||||
context?: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
|
||||
): void | RemoteMethodDecorator {
|
||||
if (typeof methodOrExportName === 'string') {
|
||||
validateName('Remote export name', methodOrExportName)
|
||||
return function <DecoratorThis extends object, DecoratorArgs extends unknown[], DecoratorResult>(
|
||||
_method: (this: DecoratorThis, ...args: DecoratorArgs) => DecoratorResult,
|
||||
decoratorContext: ClassMethodDecoratorContext<
|
||||
DecoratorThis,
|
||||
(this: DecoratorThis, ...args: DecoratorArgs) => DecoratorResult
|
||||
>,
|
||||
): void {
|
||||
addMarkerInitializer(decoratorContext, { kind: 'direct' }, methodOrExportName)
|
||||
}
|
||||
}
|
||||
if (context === undefined) throw new TypeError('type-meta: Remote decorator context is missing')
|
||||
addMarkerInitializer(context, { kind: 'direct' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a decorator for a method resolved from one scoped Remote Context.
|
||||
* @param key - merge-declared Context key.
|
||||
* @param exportName - optional Remote export name; defaults to the method name.
|
||||
* @returns a standard method decorator that records only private module state.
|
||||
*/
|
||||
export function RemoteContext(
|
||||
key: Extract<keyof TypeRTContextMap, string>,
|
||||
exportName?: string,
|
||||
): RemoteMethodDecorator {
|
||||
validateName('Context key', key)
|
||||
if (exportName !== undefined) validateName('Remote export name', exportName)
|
||||
return function <This extends object, Args extends unknown[], Result>(
|
||||
_method: (this: This, ...args: Args) => Result,
|
||||
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
|
||||
): void {
|
||||
addMarkerInitializer(context, { kind: 'context', context: key }, exportName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Remote markers attached to a live Service by decorator initializers.
|
||||
* The returned snapshot cannot mutate the private marker table.
|
||||
* @param service - live Service instance.
|
||||
* @returns markers in class declaration order.
|
||||
*/
|
||||
export function remoteMethods(service: object): readonly RemoteMethodMarker[] {
|
||||
const prototype = Object.getPrototypeOf(service) as object | null
|
||||
if (prototype === null) return []
|
||||
return [...(markers.get(prototype) ?? [])].map(([method, marker]) => ({ method, ...marker }))
|
||||
}
|
||||
|
||||
function addMarkerInitializer<This extends object>(
|
||||
context: RemoteInitializerContext<This>,
|
||||
invocation: RemoteInvocationMarker,
|
||||
exportName?: string,
|
||||
): void {
|
||||
if (context.private || context.static || typeof context.name !== 'string') {
|
||||
throw new TypeError('type-meta: Remote decorators require a public instance method with a string name')
|
||||
}
|
||||
const method = context.name
|
||||
context.addInitializer(function (this: This) {
|
||||
const prototype = Object.getPrototypeOf(this) as object | null
|
||||
if (prototype === null) {
|
||||
throw new TypeError(`type-meta: cannot mark Remote method "${method}" on an object without a prototype`)
|
||||
}
|
||||
mark(prototype, method, invocation, exportName)
|
||||
})
|
||||
}
|
||||
|
||||
function mark(
|
||||
prototype: object,
|
||||
method: string,
|
||||
invocation: RemoteInvocationMarker,
|
||||
exportName?: string,
|
||||
): void {
|
||||
let table = markers.get(prototype)
|
||||
if (table === undefined) {
|
||||
table = new Map()
|
||||
markers.set(prototype, table)
|
||||
}
|
||||
const marker: StoredRemoteMethodMarker = {
|
||||
...(exportName === undefined || exportName === method ? {} : { exportName }),
|
||||
invocation: Object.freeze(invocation),
|
||||
}
|
||||
const current = table.get(method)
|
||||
if (current !== undefined) {
|
||||
if (current.exportName === marker.exportName && sameInvocation(current.invocation, invocation)) return
|
||||
throw new Error(`type-meta: Remote method "${method}" has conflicting invocation markers`)
|
||||
}
|
||||
table.set(method, Object.freeze(marker))
|
||||
}
|
||||
|
||||
function sameInvocation(left: RemoteInvocationMarker, right: RemoteInvocationMarker): boolean {
|
||||
return left.kind === right.kind
|
||||
&& (left.kind === 'direct' || (right.kind === 'context' && left.context === right.context))
|
||||
}
|
||||
|
||||
function validateName(subject: string, value: string): void {
|
||||
if (value.length === 0 || value.includes('/')) {
|
||||
throw new TypeError(`type-meta: ${subject} must be nonempty and must not contain "/"`)
|
||||
}
|
||||
}
|
||||
30
packages/typert/type-meta/src/invariant.ts
Normal file
30
packages/typert/type-meta/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-type-meta`.
|
||||
* @module @deepseek-ai/dsh-type-meta/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-type-meta'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'type-meta-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: decorators retain private immutable declarations and
|
||||
* bindings are frozen values with no independent event stream to cross-check.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
358
packages/typert/type-meta/src/types.ts
Normal file
358
packages/typert/type-meta/src/types.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* Compiler-independent TypeRT protocol shared by business packages, generated
|
||||
* Remote artifacts, the Host Gateway, and Client API implementations.
|
||||
* @module @deepseek-ai/dsh-type-meta/types
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
declare const LOOKUP_HOST: unique symbol
|
||||
declare const LOOKUP_WIRE: unique symbol
|
||||
declare const CONTEXT_WIRE: unique symbol
|
||||
|
||||
/** Type-level association between a Host object and its wire identity. */
|
||||
export interface TypeRTLookup<Host, Wire> {
|
||||
readonly [LOOKUP_HOST]: Host
|
||||
readonly [LOOKUP_WIRE]: Wire
|
||||
}
|
||||
|
||||
/** Extract the Host object associated with one lookup declaration. */
|
||||
export type TypeRTLookupHost<Lookup> = Lookup extends TypeRTLookup<infer Host, infer _Wire> ? Host : never
|
||||
|
||||
/** Extract the wire identity associated with one lookup declaration. */
|
||||
export type TypeRTLookupWire<Lookup> = Lookup extends TypeRTLookup<infer _Host, infer Wire> ? Wire : never
|
||||
|
||||
/** Type-level association between a scoped Context kind and its wire identity. */
|
||||
export interface TypeRTContext<Wire> {
|
||||
readonly [CONTEXT_WIRE]: Wire
|
||||
}
|
||||
|
||||
/** Extract the wire identity associated with one scoped Context declaration. */
|
||||
export type TypeRTContextWire<ContextType> = ContextType extends TypeRTContext<infer Wire> ? Wire : never
|
||||
|
||||
/** Merge-extensible Host object lookup declarations. */
|
||||
export interface TypeRTLookupMap {}
|
||||
|
||||
/** Merge-extensible scoped Context declarations. */
|
||||
export interface TypeRTContextMap {}
|
||||
|
||||
/** Merge-extensible direct Remote method signatures generated for consumers. */
|
||||
export interface TypeRTRemoteMap {}
|
||||
|
||||
/** Merge-extensible scoped Remote method signatures generated for consumers. */
|
||||
export interface TypeRTRemoteContextMap {}
|
||||
|
||||
/**
|
||||
* Resolve one direct Remote namespace from the generated flat endpoint map.
|
||||
* @template Namespace - wire namespace before the endpoint slash.
|
||||
*/
|
||||
export type TypeRTRemoteNamespace<Namespace extends string> = {
|
||||
[Endpoint in keyof TypeRTRemoteMap as Endpoint extends `${Namespace}/${infer Method}`
|
||||
? Method
|
||||
: never]: TypeRTRemoteMap[Endpoint]
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one scoped Remote namespace across every generated Context kind.
|
||||
* The calling Cordis Context supplies the concrete identity at runtime.
|
||||
* @template Namespace - wire namespace between the Context prefix and method.
|
||||
*/
|
||||
export type TypeRTRemoteContextNamespace<
|
||||
Namespace extends string,
|
||||
ContextKey extends string = string,
|
||||
> = {
|
||||
[Endpoint in keyof TypeRTRemoteContextMap as Endpoint extends `${ContextKey}:${Namespace}/${infer Method}`
|
||||
? Method
|
||||
: never]: TypeRTRemoteContextMap[Endpoint]
|
||||
}
|
||||
|
||||
type TypeRTRemoteContextNamespaceKey<
|
||||
ContextKey extends string,
|
||||
Endpoint = keyof TypeRTRemoteContextMap,
|
||||
> = Endpoint extends `${ContextKey}:${infer Namespace}/${string}` ? Namespace : never
|
||||
|
||||
/** Generated scoped Remote namespaces available to one Context kind. */
|
||||
export type TypeRTRemoteContextApi<ContextKey extends string> = {
|
||||
[Namespace in TypeRTRemoteContextNamespaceKey<ContextKey>]:
|
||||
TypeRTRemoteContextNamespace<Namespace, ContextKey>
|
||||
}
|
||||
|
||||
/** Merge-extensible direct namespace surface generated for Client API services. */
|
||||
export interface TypeRTRemoteNamespaceMap {}
|
||||
|
||||
/** Awaitable disposer returned by Cordis-owned TypeRT registrations. */
|
||||
export type TypeRTDisposer = () => Promise<void>
|
||||
|
||||
type StringKeyOf<Value> = Extract<keyof Value, string>
|
||||
|
||||
/** Minimal runtime-schema capability carried by strict generated codecs. */
|
||||
export interface TypeRTSchema<Output = unknown> {
|
||||
/**
|
||||
* Parse and validate one boundary value.
|
||||
* @param value - untrusted boundary value.
|
||||
* @returns the validated value.
|
||||
*/
|
||||
parse(value: unknown): Output
|
||||
}
|
||||
|
||||
/** Codec attached to one invocation parameter or result. */
|
||||
export type TypeRTCodec =
|
||||
| {
|
||||
readonly mode: 'strict'
|
||||
readonly typeSymbol: string
|
||||
readonly schema: TypeRTSchema
|
||||
}
|
||||
| {
|
||||
readonly mode: 'src-json'
|
||||
}
|
||||
|
||||
/** One ordered business parameter in a Remote invocation. */
|
||||
export interface InvocationParameterDescriptor {
|
||||
/** Source-level parameter name. */
|
||||
readonly name: string
|
||||
/** Required key in the wire `args` object. */
|
||||
readonly wire: string
|
||||
/** Whether the value is JSON or requires a registered Host lookup. */
|
||||
readonly source: 'json' | 'lookup'
|
||||
/** Lookup key when `source` is `lookup`. */
|
||||
readonly lookup?: string
|
||||
/** Boundary codec for the wire representation. */
|
||||
readonly codec: TypeRTCodec
|
||||
}
|
||||
|
||||
/** Source position retained for diagnostics from generated definitions. */
|
||||
export interface InvocationSourceLocation {
|
||||
readonly file: string
|
||||
readonly line: number
|
||||
readonly column: number
|
||||
}
|
||||
|
||||
/** Carrier-independent description of one exported method invocation. */
|
||||
export interface InvocationDescriptor {
|
||||
/** Globally stable generated identity. */
|
||||
readonly id: string
|
||||
/** Cordis service key owning the method. */
|
||||
readonly service: string
|
||||
/** Wire namespace, defaulting to the service key. */
|
||||
readonly namespace: string
|
||||
/** Public instance method name. */
|
||||
readonly method: string
|
||||
/** Service member invoked when the exported method name is an alias. */
|
||||
readonly implementation?: string
|
||||
/** Receiver selection mode. */
|
||||
readonly invocation:
|
||||
| { readonly kind: 'direct' }
|
||||
| {
|
||||
readonly kind: 'context'
|
||||
readonly context: string
|
||||
readonly wire: string
|
||||
readonly codec: TypeRTCodec
|
||||
}
|
||||
/** Optional consuming-Context projection for one direct lookup parameter. */
|
||||
readonly scope?: {
|
||||
/** Context kind whose Client binder supplies the identity. */
|
||||
readonly context: string
|
||||
/** Lookup parameter wire field replaced by the Context identity. */
|
||||
readonly wire: string
|
||||
}
|
||||
/** Ordered business parameters. */
|
||||
readonly parameters: readonly InvocationParameterDescriptor[]
|
||||
/** Codec for the resolved method result. */
|
||||
readonly result: TypeRTCodec
|
||||
/** Source declaration used only for diagnostics. */
|
||||
readonly sourceLocation?: InvocationSourceLocation
|
||||
}
|
||||
|
||||
/** Generated Host contract selected explicitly by a Client assembly. */
|
||||
export interface TypeRTRemoteContribution {
|
||||
/** npm package that owns the Remote methods. */
|
||||
readonly package: string
|
||||
/** Consumer-side invocation descriptors generated from that package. */
|
||||
readonly descriptors: readonly InvocationDescriptor[]
|
||||
}
|
||||
|
||||
/** Runtime resolver for one declared Host object lookup. */
|
||||
export interface TypeRTLookupProvider<Host = unknown, Wire = unknown> {
|
||||
/** Source parameter name recognized by the SRC weak parser. */
|
||||
readonly parameter: string
|
||||
/** Wire field replacing the Host object parameter. */
|
||||
readonly wire: string
|
||||
/** Canonical Host type symbol used by strict generation. */
|
||||
readonly hostTypeSymbol: string
|
||||
/** Canonical wire type symbol used by strict generation. */
|
||||
readonly wireTypeSymbol: string
|
||||
/**
|
||||
* Resolve a wire identity to the current live Host object.
|
||||
* @param id - validated wire identity.
|
||||
* @returns the live object, or `undefined` when it is unavailable.
|
||||
*/
|
||||
resolve(id: Wire): Host | undefined
|
||||
}
|
||||
|
||||
/** Host resolver for one scoped Remote Context kind. */
|
||||
export interface TypeRTHostContextProvider<Wire = unknown> {
|
||||
/** Wire field carrying the Context identity. */
|
||||
readonly wire: string
|
||||
/** Canonical wire type symbol used by strict generation. */
|
||||
readonly wireTypeSymbol: string
|
||||
/**
|
||||
* Resolve a wire identity to its live scoped Context.
|
||||
* @param id - validated wire identity.
|
||||
* @returns the scoped Context, or `undefined` when unavailable.
|
||||
*/
|
||||
resolve(id: Wire): Context | undefined
|
||||
}
|
||||
|
||||
/** Client resolver for the identity carried by the calling scoped Context. */
|
||||
export interface TypeRTClientContextBinder<Wire = unknown> {
|
||||
/**
|
||||
* Read the Remote identity represented by a calling Context.
|
||||
* @param ctx - Context rebound by the Cordis service tracker.
|
||||
* @returns the wire identity, or `undefined` when the Context has the wrong scope.
|
||||
*/
|
||||
identity(ctx: Context): Wire | undefined
|
||||
}
|
||||
|
||||
/** Notification emitted after a TypeRT runtime registry changes. */
|
||||
export interface TypeRTRegistryChange {
|
||||
readonly kind: 'local' | 'remote' | 'lookup' | 'host-context' | 'client-context'
|
||||
readonly key: string
|
||||
}
|
||||
|
||||
/** Listener for one TypeRT runtime registry. */
|
||||
export type TypeRTRegistryListener = (change: TypeRTRegistryChange) => void
|
||||
|
||||
/** Current-environment invocation definitions. */
|
||||
export interface TypeRTLocalRegistry {
|
||||
/**
|
||||
* Look up one invocation by `<namespace>/<method>`.
|
||||
* @param endpoint - canonical endpoint.
|
||||
* @returns the live descriptor, or `undefined` when absent.
|
||||
*/
|
||||
get(endpoint: string): InvocationDescriptor | undefined
|
||||
/**
|
||||
* Report whether a strict definition has existed during this TypeRT Service lifetime.
|
||||
* @param endpoint - canonical endpoint.
|
||||
* @returns `true` after the endpoint has been registered at least once, even if withdrawn.
|
||||
*/
|
||||
hasSeen(endpoint: string): boolean
|
||||
/** @returns a registration-order snapshot of local descriptors. */
|
||||
list(): readonly InvocationDescriptor[]
|
||||
/**
|
||||
* Observe later local-definition changes.
|
||||
* @param listener - synchronous contained observer.
|
||||
* @returns disposer for this subscription.
|
||||
*/
|
||||
subscribe(listener: TypeRTRegistryListener): TypeRTDisposer
|
||||
}
|
||||
|
||||
/** Consumer-selected Remote contribution registry. */
|
||||
export interface TypeRTRemoteRegistry {
|
||||
/**
|
||||
* Register one generated contribution for the calling Cordis fiber.
|
||||
* @param contribution - generated Remote descriptors.
|
||||
* @returns disposer withdrawing the exact contribution.
|
||||
*/
|
||||
register(contribution: TypeRTRemoteContribution): TypeRTDisposer
|
||||
/**
|
||||
* Look up one Remote descriptor by endpoint.
|
||||
* @param endpoint - canonical endpoint.
|
||||
* @returns the descriptor, or `undefined` when unmounted.
|
||||
*/
|
||||
get(endpoint: string): InvocationDescriptor | undefined
|
||||
/** @returns a registration-order snapshot of Remote descriptors. */
|
||||
list(): readonly InvocationDescriptor[]
|
||||
/**
|
||||
* Observe later Remote contribution changes.
|
||||
* @param listener - synchronous contained observer.
|
||||
* @returns disposer for this subscription.
|
||||
*/
|
||||
subscribe(listener: TypeRTRegistryListener): TypeRTDisposer
|
||||
}
|
||||
|
||||
/** Runtime registry for Host object lookup providers. */
|
||||
export interface TypeRTLookupRegistry {
|
||||
/**
|
||||
* Register one provider under its merge-declared key.
|
||||
* @param key - lookup key.
|
||||
* @param provider - owning package's live resolver.
|
||||
* @returns disposer withdrawing the exact provider.
|
||||
*/
|
||||
register<K extends StringKeyOf<TypeRTLookupMap>>(
|
||||
key: K,
|
||||
provider: TypeRTLookupProvider<
|
||||
TypeRTLookupHost<TypeRTLookupMap[K]>,
|
||||
TypeRTLookupWire<TypeRTLookupMap[K]>
|
||||
>,
|
||||
): TypeRTDisposer
|
||||
/**
|
||||
* Look up one provider by runtime key.
|
||||
* @param key - descriptor lookup key.
|
||||
* @returns the live provider, or `undefined` when absent.
|
||||
*/
|
||||
get(key: string): TypeRTLookupProvider | undefined
|
||||
/** @returns a snapshot of registered provider keys. */
|
||||
keys(): readonly string[]
|
||||
/**
|
||||
* Observe later lookup changes.
|
||||
* @param listener - synchronous contained observer.
|
||||
* @returns disposer for this subscription.
|
||||
*/
|
||||
subscribe(listener: TypeRTRegistryListener): TypeRTDisposer
|
||||
}
|
||||
|
||||
/** Runtime registry for Host Context resolvers and Client Context binders. */
|
||||
export interface TypeRTContextRegistry {
|
||||
/**
|
||||
* Register a Host Context resolver.
|
||||
* @param key - merge-declared Context key.
|
||||
* @param provider - owning package's Host resolver.
|
||||
* @returns disposer withdrawing the exact provider.
|
||||
*/
|
||||
registerHost<K extends StringKeyOf<TypeRTContextMap>>(
|
||||
key: K,
|
||||
provider: TypeRTHostContextProvider<TypeRTContextWire<TypeRTContextMap[K]>>,
|
||||
): TypeRTDisposer
|
||||
/**
|
||||
* Register a Client Context identity binder.
|
||||
* @param key - merge-declared Context key.
|
||||
* @param binder - Client scope identity resolver.
|
||||
* @returns disposer withdrawing the exact binder.
|
||||
*/
|
||||
registerClient<K extends StringKeyOf<TypeRTContextMap>>(
|
||||
key: K,
|
||||
binder: TypeRTClientContextBinder<TypeRTContextWire<TypeRTContextMap[K]>>,
|
||||
): TypeRTDisposer
|
||||
/**
|
||||
* Look up a Host Context resolver.
|
||||
* @param key - descriptor Context key.
|
||||
* @returns the provider, or `undefined` when absent.
|
||||
*/
|
||||
getHost(key: string): TypeRTHostContextProvider | undefined
|
||||
/**
|
||||
* Look up a Client Context binder.
|
||||
* @param key - descriptor Context key.
|
||||
* @returns the binder, or `undefined` when absent.
|
||||
*/
|
||||
getClient(key: string): TypeRTClientContextBinder | undefined
|
||||
/**
|
||||
* Observe later Context provider changes.
|
||||
* @param listener - synchronous contained observer.
|
||||
* @returns disposer for this subscription.
|
||||
*/
|
||||
subscribe(listener: TypeRTRegistryListener): TypeRTDisposer
|
||||
}
|
||||
|
||||
/** Minimal TypeRT runtime consumed through dependency inversion. */
|
||||
export interface TypeRTService {
|
||||
readonly local: TypeRTLocalRegistry
|
||||
readonly remotes: TypeRTRemoteRegistry
|
||||
readonly lookups: TypeRTLookupRegistry
|
||||
readonly contexts: TypeRTContextRegistry
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
typert: TypeRTService
|
||||
}
|
||||
}
|
||||
29
packages/typert/type-meta/tests/fixtures/source-launch.ts
vendored
Normal file
29
packages/typert/type-meta/tests/fixtures/source-launch.ts
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
bindTypeRTGateway,
|
||||
Remote,
|
||||
RemoteContext,
|
||||
remoteMethods,
|
||||
} from '@deepseek-ai/dsh-type-meta'
|
||||
|
||||
class Goals {
|
||||
readonly typertGateway = bindTypeRTGateway(this, 'goals')
|
||||
|
||||
@Remote
|
||||
create(value: string): string {
|
||||
return value
|
||||
}
|
||||
|
||||
@RemoteContext('agent')
|
||||
scoped(value: string): string {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
const methods = remoteMethods(new Goals())
|
||||
const actual = JSON.stringify(methods)
|
||||
const expected = JSON.stringify([
|
||||
{ method: 'create', invocation: { kind: 'direct' } },
|
||||
{ method: 'scoped', invocation: { kind: 'context', context: 'agent' } },
|
||||
])
|
||||
if (actual !== expected) throw new Error(`unexpected Remote declarations: ${actual}`)
|
||||
process.stdout.write(actual)
|
||||
132
packages/typert/type-meta/tests/type-meta.spec.ts
Normal file
132
packages/typert/type-meta/tests/type-meta.spec.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
bindTypeRTGateway,
|
||||
Remote,
|
||||
RemoteContext,
|
||||
remoteMethods,
|
||||
type TypeRTContext,
|
||||
} from '@deepseek-ai/dsh-type-meta'
|
||||
|
||||
declare module '@deepseek-ai/dsh-type-meta' {
|
||||
interface TypeRTContextMap {
|
||||
metaFixture: TypeRTContext<string>
|
||||
}
|
||||
}
|
||||
|
||||
describe('type-meta Remote declarations', () => {
|
||||
it('executes standard decorator syntax through the Vitest source transform', () => {
|
||||
class Goals {
|
||||
readonly typertGateway = bindTypeRTGateway(this, 'goals')
|
||||
|
||||
@Remote
|
||||
create(value: string): string {
|
||||
return value
|
||||
}
|
||||
|
||||
@RemoteContext('metaFixture')
|
||||
scoped(value: string): string {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
const goals = new Goals()
|
||||
expect(remoteMethods(goals)).toEqual([
|
||||
{ method: 'create', invocation: { kind: 'direct' } },
|
||||
{ method: 'scoped', invocation: { kind: 'context', context: 'metaFixture' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('executes standard decorator syntax through the TSX source launcher', () => {
|
||||
const fixture = fileURLToPath(new URL('./fixtures/source-launch.ts', import.meta.url))
|
||||
const output = execFileSync(process.execPath, ['--import', 'tsx/esm', fixture], { encoding: 'utf8' })
|
||||
expect(JSON.parse(output)).toEqual([
|
||||
{ method: 'create', invocation: { kind: 'direct' } },
|
||||
{ method: 'scoped', invocation: { kind: 'context', context: 'agent' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps decorator markers in private module state', () => {
|
||||
class Goals {
|
||||
readonly typertGateway = bindTypeRTGateway(this, 'goals')
|
||||
|
||||
create(agent: object, request: object): object {
|
||||
return { agent, request }
|
||||
}
|
||||
|
||||
scoped(request: object): object {
|
||||
return request
|
||||
}
|
||||
}
|
||||
|
||||
const initializers: Array<(this: Goals) => void> = []
|
||||
Remote(
|
||||
Reflect.get(Goals.prototype, 'create') as (this: Goals, ...args: unknown[]) => unknown,
|
||||
methodContext('create', initializers),
|
||||
)
|
||||
RemoteContext('metaFixture')(
|
||||
Reflect.get(Goals.prototype, 'scoped') as (this: Goals, ...args: unknown[]) => unknown,
|
||||
methodContext('scoped', initializers),
|
||||
)
|
||||
|
||||
const goals = new Goals()
|
||||
for (const initialize of initializers) initialize.call(goals)
|
||||
expect(goals.typertGateway).toEqual({ service: goals, serviceKey: 'goals', namespace: 'goals' })
|
||||
expect(Object.isFrozen(goals.typertGateway)).toBe(true)
|
||||
expect(remoteMethods(goals)).toEqual([
|
||||
{ method: 'create', invocation: { kind: 'direct' } },
|
||||
{ method: 'scoped', invocation: { kind: 'context', context: 'metaFixture' } },
|
||||
])
|
||||
expect(Reflect.ownKeys(Goals)).toEqual(['length', 'name', 'prototype'])
|
||||
expect(Reflect.ownKeys(Goals.prototype)).toEqual(['constructor', 'create', 'scoped'])
|
||||
})
|
||||
|
||||
it('keeps markers idempotent across instances and returns detached snapshots', () => {
|
||||
class Service {
|
||||
run(value: string): string {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
const initializers: Array<(this: Service) => void> = []
|
||||
Remote(
|
||||
Reflect.get(Service.prototype, 'run') as (this: Service, ...args: unknown[]) => unknown,
|
||||
methodContext('run', initializers),
|
||||
)
|
||||
|
||||
const first = new Service()
|
||||
const second = new Service()
|
||||
for (const initialize of initializers) {
|
||||
initialize.call(first)
|
||||
initialize.call(second)
|
||||
}
|
||||
const snapshot = remoteMethods(first)
|
||||
expect(remoteMethods(second)).toEqual(snapshot)
|
||||
;(snapshot as unknown as { method: string }[])[0]!.method = 'changed'
|
||||
expect(remoteMethods(first)).toEqual([{ method: 'run', invocation: { kind: 'direct' } }])
|
||||
})
|
||||
|
||||
it('rejects ambiguous binding names', () => {
|
||||
expect(() => bindTypeRTGateway({}, '')).toThrow('service key')
|
||||
expect(() => bindTypeRTGateway({}, 'goals', { namespace: 'api/goals' })).toThrow('namespace')
|
||||
})
|
||||
})
|
||||
|
||||
function methodContext<This extends object>(
|
||||
name: string,
|
||||
initializers: Array<(this: This) => void>,
|
||||
): ClassMethodDecoratorContext<This, (this: This, ...args: unknown[]) => unknown> {
|
||||
return {
|
||||
kind: 'method',
|
||||
name,
|
||||
static: false,
|
||||
private: false,
|
||||
metadata: {},
|
||||
access: {
|
||||
has: object => name in object,
|
||||
get: object => (object as Record<string, unknown>)[name] as (this: This, ...args: unknown[]) => unknown,
|
||||
},
|
||||
addInitializer: (initializer) => { initializers.push(initializer) },
|
||||
}
|
||||
}
|
||||
21
packages/typert/type-meta/tsconfig.json
Normal file
21
packages/typert/type-meta/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
61
pnpm-lock.yaml
generated
61
pnpm-lock.yaml
generated
@@ -916,6 +916,9 @@ importers:
|
||||
'@deepseek-ai/dsh-goal-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../goal/goal-session
|
||||
'@deepseek-ai/dsh-host-api-gateway':
|
||||
specifier: workspace:^
|
||||
version: link:../../host/api-gateway
|
||||
'@deepseek-ai/dsh-llm':
|
||||
specifier: workspace:^
|
||||
version: link:../../llm/llm
|
||||
@@ -1054,6 +1057,12 @@ importers:
|
||||
'@deepseek-ai/dsh-tools':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/tools
|
||||
'@deepseek-ai/dsh-typert-loader':
|
||||
specifier: workspace:^
|
||||
version: link:../../typert/loader
|
||||
'@deepseek-ai/dsh-typert-registry':
|
||||
specifier: workspace:^
|
||||
version: link:../../typert/registry
|
||||
'@deepseek-ai/dsh-user-approval':
|
||||
specifier: workspace:^
|
||||
version: link:../../ui/user-approval
|
||||
@@ -2790,6 +2799,12 @@ importers:
|
||||
'@deepseek-ai/dsh-system-prompt':
|
||||
specifier: workspace:^
|
||||
version: link:../system-prompt
|
||||
'@deepseek-ai/dsh-type-meta':
|
||||
specifier: workspace:^
|
||||
version: link:../../typert/type-meta
|
||||
'@deepseek-ai/dsh-typert-registry':
|
||||
specifier: workspace:^
|
||||
version: link:../../typert/registry
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: link:../../../vendor/cordis
|
||||
@@ -2854,6 +2869,12 @@ importers:
|
||||
'@deepseek-ai/dsh-scope':
|
||||
specifier: workspace:^
|
||||
version: link:../scope
|
||||
'@deepseek-ai/dsh-type-meta':
|
||||
specifier: workspace:^
|
||||
version: link:../../typert/type-meta
|
||||
'@deepseek-ai/dsh-typert-registry':
|
||||
specifier: workspace:^
|
||||
version: link:../../typert/registry
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: link:../../../vendor/cordis
|
||||
@@ -3706,6 +3727,31 @@ importers:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: link:../../../vendor/cordis
|
||||
|
||||
packages/host/api-gateway:
|
||||
dependencies:
|
||||
'@deepseek-ai/dsh-type-meta':
|
||||
specifier: workspace:^
|
||||
version: link:../../typert/type-meta
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-client-connection':
|
||||
specifier: workspace:^
|
||||
version: link:../../client/connection
|
||||
'@deepseek-ai/dsh-host-webserver':
|
||||
specifier: workspace:^
|
||||
version: link:../webserver
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../support/invariants
|
||||
'@deepseek-ai/dsh-typert-registry':
|
||||
specifier: workspace:^
|
||||
version: link:../../typert/registry
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: link:../../../vendor/cordis
|
||||
zod:
|
||||
specifier: ^4.4.3
|
||||
version: 4.4.3
|
||||
|
||||
packages/host/apiproxy:
|
||||
dependencies:
|
||||
'@deepseek-ai/dsh-agent':
|
||||
@@ -6096,6 +6142,9 @@ importers:
|
||||
|
||||
packages/typert/generator:
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping':
|
||||
specifier: ^0.3.13
|
||||
version: 0.3.13
|
||||
typescript:
|
||||
specifier: ^6.0.3
|
||||
version: 6.0.3
|
||||
@@ -6140,6 +6189,9 @@ importers:
|
||||
|
||||
packages/typert/registry:
|
||||
dependencies:
|
||||
'@deepseek-ai/dsh-type-meta':
|
||||
specifier: workspace:^
|
||||
version: link:../type-meta
|
||||
zod:
|
||||
specifier: ^4.4.3
|
||||
version: 4.4.3
|
||||
@@ -6151,6 +6203,15 @@ importers:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: link:../../../vendor/cordis
|
||||
|
||||
packages/typert/type-meta:
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../support/invariants
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: link:../../../vendor/cordis
|
||||
|
||||
packages/ui/app-boot:
|
||||
dependencies:
|
||||
js-yaml:
|
||||
|
||||
@@ -59,6 +59,13 @@ describe('client bundle purity gate', () => {
|
||||
expect(resolveId('@deepseek-ai/dsh-brand')).toBeNull()
|
||||
})
|
||||
|
||||
it('lets exact generated Remote contributions inline without admitting their package implementation', () => {
|
||||
expect(resolveId('@deepseek-ai/dsh-goal/remote')).toBeNull()
|
||||
expect(() => resolveId('@deepseek-ai/dsh-goal')).toThrow(/purity/)
|
||||
expect(() => resolveId('@deepseek-ai/dsh-goal/client')).toThrow(/purity/)
|
||||
expect(() => resolveId('@deepseek-ai/dsh-goal/remote/nested')).toThrow(/purity/)
|
||||
})
|
||||
|
||||
it('throws on any other @deepseek-ai leak', () => {
|
||||
expect(() => resolveId('@deepseek-ai/dsh-agent')).toThrow(/purity/)
|
||||
expect(() => resolveId('@deepseek-ai/dsh-client-web')).toThrow(/purity/)
|
||||
|
||||
@@ -276,6 +276,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
|
||||
TypertPackageRecord: 'registry package record is owned by packages/typert/registry/README.md',
|
||||
TypertSchemaFilter: 'registry schema query filter is owned by packages/typert/registry/README.md',
|
||||
TypertSchemaRecord: 'registry schema record is owned by packages/typert/registry/README.md',
|
||||
TypeRTDisposer: 'TypeRT lifecycle contract is owned by packages/typert/type-meta/README.md',
|
||||
'z.core.JSONSchema.BaseSchema': 'zod projection output is owned by the zod v4 API',
|
||||
'z.core.ToJSONSchemaParams': 'zod projection parameters are owned by the zod v4 API',
|
||||
InvariantInstaller: 'service-local contribution contract is owned by packages/support/invariants/README.md',
|
||||
@@ -287,6 +288,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
|
||||
ThemeTokens: 'service-local token dictionary is owned by packages/client/ui-theme/src/index.ts',
|
||||
Translate: 'service-local bound translator is owned by packages/client/i18n/src/index.ts',
|
||||
InvariantRegistration: 'service-local lifecycle handle is owned by packages/support/invariants/README.md',
|
||||
InvokeRemoteRequest: 'gateway invocation contract is owned by packages/host/api-gateway/README.md',
|
||||
PresetOption: 'deployment menu metadata is owned by packages/ui/permission/README.md',
|
||||
PresetSpec: 'deployment preset composition is owned by packages/ui/permission/README.md',
|
||||
KnobState: 'projection unit state shape is owned by packages/ui/permission/README.md',
|
||||
|
||||
@@ -140,8 +140,15 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
pkg: 'typert-registry',
|
||||
title: 'Runtime type registry',
|
||||
mode: 'core',
|
||||
consumers: ['typert-loader'],
|
||||
note: 'Plugins register live zod contributions directly or through dsh-typert-loader; runtime consumers query schemas and reflection metadata at their own edges.',
|
||||
consumers: ['typert-loader', 'api-gateway'],
|
||||
note: 'Plugins register live zod contributions directly or through dsh-typert-loader; the API gateway consumes invocation descriptors and providers, while other runtime consumers query schemas and reflection metadata at their own edges.',
|
||||
},
|
||||
{
|
||||
key: 'typertGateway',
|
||||
pkg: 'api-gateway',
|
||||
title: 'TypeRT Host invocation gateway',
|
||||
mode: 'core',
|
||||
note: 'Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier.',
|
||||
},
|
||||
{
|
||||
key: 'sessionPersistence',
|
||||
|
||||
@@ -125,6 +125,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/support/loader-smoke': { kind: 'none', reason: 'The test harness observes child-process streams without changing live requests.' },
|
||||
'packages/support/llm-mock-server': { kind: 'none', reason: 'The test server substitutes provider wire behavior without invoking a real model.' },
|
||||
'packages/support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' },
|
||||
'packages/host/api-gateway': { kind: 'none', reason: 'Remote dispatch infrastructure; invoked business methods own any model-visible effect.' },
|
||||
'packages/typert/type-meta': { kind: 'none', reason: 'Compiler-independent Remote protocol declarations; registers no model surface.' },
|
||||
'packages/typert/generator': { kind: 'none', reason: 'The build-time generator runs outside any agent runtime and touches no model request.' },
|
||||
'packages/tasks/tasks': { kind: 'indirect', reason: 'Producer and control-surface plugins own all model rendering over the task registry.' },
|
||||
'packages/tasks/tasks-local': { kind: 'indirect', reason: 'The registry backend delegates model rendering to producer plugins and dsh-tool-tasks.' },
|
||||
|
||||
@@ -40,6 +40,13 @@
|
||||
"@cordisjs/plugin-logger-console": ["./vendor/logger-console/src"],
|
||||
"@deepseek-ai/dsh-invariants": ["./packages/support/invariants/src/index.ts"],
|
||||
"@deepseek-ai/dsh-typert-registry": ["./packages/typert/registry/src/index.ts"],
|
||||
"@deepseek-ai/dsh-typert-registry/client": ["./packages/typert/registry/src/client/index.ts"],
|
||||
"@deepseek-ai/dsh-host-api-gateway": ["./packages/host/api-gateway/src/index.ts"],
|
||||
"@deepseek-ai/dsh-host-api-gateway/client": ["./packages/host/api-gateway/src/client/index.ts"],
|
||||
"@deepseek-ai/dsh-host-api-gateway/invariant": ["./packages/host/api-gateway/src/invariant.ts"],
|
||||
"@deepseek-ai/dsh-host-api-gateway/types": ["./packages/host/api-gateway/src/types.ts"],
|
||||
"@deepseek-ai/dsh-type-meta": ["./packages/typert/type-meta/src/index.ts"],
|
||||
"@deepseek-ai/dsh-type-meta/types": ["./packages/typert/type-meta/src/types.ts"],
|
||||
"@deepseek-ai/dsh-typert-loader": ["./packages/typert/loader/src/index.ts"],
|
||||
"@deepseek-ai/dsh-session/invariant": ["./packages/core/session/src/invariant.ts"],
|
||||
"@deepseek-ai/dsh-typert-registry/types": ["./packages/typert/registry/src/types.ts"],
|
||||
@@ -68,7 +75,6 @@
|
||||
"@deepseek-ai/dsh-tool-subagent-control/list-agents": ["./packages/subagent/tool-subagent-control/src/list-agents.ts"],
|
||||
"@deepseek-ai/dsh-user-approval/types": ["./packages/ui/user-approval/src/types.ts"],
|
||||
"@deepseek-ai/dsh-user-interaction/types": ["./packages/ui/user-interaction/src/types.ts"],
|
||||
"@deepseek-ai/dsh-agent/brand": ["./packages/core/agent/src/brand.ts"],
|
||||
"@deepseek-ai/dsh-agent/invariant": ["./packages/core/agent/src/invariant.ts"],
|
||||
"@deepseek-ai/dsh-scope/invariant": ["./packages/core/scope/src/invariant.ts"],
|
||||
"@deepseek-ai/dsh-agent-loop/invariant": ["./packages/core/agent-loop/src/invariant.ts"],
|
||||
@@ -145,6 +151,8 @@
|
||||
"@deepseek-ai/dsh-client-schema-form/invariant": ["./packages/client/schema-form/src/invariant.ts"],
|
||||
"@deepseek-ai/dsh-client-web-react": ["./packages/client/web-react/src"],
|
||||
"@deepseek-ai/dsh-client-connection": ["./packages/client/connection/src"],
|
||||
"@deepseek-ai/dsh-client-remotes": ["./packages/client/remotes/src"],
|
||||
"@deepseek-ai/dsh-client-remotes/client": ["./packages/client/remotes/src/client/index.ts"],
|
||||
"@deepseek-ai/dsh-client-hmr": ["./packages/client/hmr/src"],
|
||||
"@deepseek-ai/dsh-client-modules": ["./packages/client/modules/src"],
|
||||
"@deepseek-ai/dsh-client-runtime": ["./packages/client/runtime/src"],
|
||||
|
||||
@@ -51,6 +51,8 @@
|
||||
{ "path": "./packages/client/modules" },
|
||||
{ "path": "./packages/client/hmr" },
|
||||
{ "path": "./packages/client/connection" },
|
||||
{ "path": "./packages/typert/registry" },
|
||||
{ "path": "./packages/host/api-gateway" },
|
||||
{ "path": "./packages/client/runtime" },
|
||||
{ "path": "./packages/client/test-runtime" },
|
||||
{ "path": "./packages/client/ui-layout" },
|
||||
|
||||
@@ -100,7 +100,9 @@
|
||||
{ "path": "./packages/llm/token-meter" },
|
||||
{ "path": "./packages/core/session" },
|
||||
{ "path": "./packages/core/scope" },
|
||||
{ "path": "./packages/typert/type-meta" },
|
||||
{ "path": "./packages/typert/registry" },
|
||||
{ "path": "./packages/host/api-gateway" },
|
||||
{ "path": "./packages/typert/loader" },
|
||||
{ "path": "./packages/session-persistence/session-persistence" },
|
||||
{ "path": "./packages/session-persistence/session-checkpoint-policy" },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
import { typertPlugin } from './packages/typert/generator/lib/types/tsdown-plugin.js'
|
||||
|
||||
/**
|
||||
* JS bundling for vendored Cordis and Harness TypeScript packages.
|
||||
@@ -27,4 +28,7 @@ export default defineConfig({
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
// The final pass sees both independent TypeScript faces. Workspace mode
|
||||
// writes only packages that explicitly publish a Typert/Remote subpath.
|
||||
plugins: [typertPlugin({ mode: 'workspace' })],
|
||||
})
|
||||
|
||||
20
tsdown.typert-host.config.ts
Normal file
20
tsdown.typert-host.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
import { typertPlugin } from './packages/typert/generator/lib/types/tsdown-plugin.js'
|
||||
|
||||
/**
|
||||
* Host-only TypeRT contract prepass. The generator and its project references
|
||||
* are compiled first; the plugin then analyzes Host source and emits local and
|
||||
* Host-for-Client artifacts before either aggregate consumes Remote subpaths.
|
||||
*/
|
||||
export default defineConfig({
|
||||
workspace: ['packages/typert/generator'],
|
||||
entry: ['lib/types/{index,invariant}.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
plugins: [typertPlugin({ mode: 'workspace', faces: ['host'] })],
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import tsconfigPaths from 'vite-tsconfig-paths'
|
||||
import { resolvePwshPath } from './packages/bash/pwsh-local/src/resolve.ts'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import ts from 'typescript'
|
||||
import { vitestExecArgv } from './vitest.shared.ts'
|
||||
import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './scripts/coverage-exempt.ts'
|
||||
|
||||
@@ -17,6 +18,29 @@ const uncoveredLocationsReporter = fileURLToPath(new URL('./scripts/coverage-unc
|
||||
// map applies to every test file. paths must win over package exports so built
|
||||
// lib/ never loads a second module-singleton copy.
|
||||
const pathsPlugin = (): ReturnType<typeof tsconfigPaths> => tsconfigPaths({ projects: ['./tsconfig.base.json'] })
|
||||
const decoratorSyntax = /^\s*@[A-Za-z_$][\w$]*/m
|
||||
|
||||
const standardDecoratorPlugin = () => ({
|
||||
name: 'dsh-standard-decorators',
|
||||
enforce: 'pre' as const,
|
||||
transform(code: string, id: string) {
|
||||
const file = id.split('?', 1)[0]!
|
||||
if (!/\.[cm]?tsx?$/.test(file) || !decoratorSyntax.test(code)) return
|
||||
const result = ts.transpileModule(code, {
|
||||
fileName: file,
|
||||
compilerOptions: {
|
||||
target: ts.ScriptTarget.ES2024,
|
||||
module: ts.ModuleKind.ESNext,
|
||||
jsx: file.endsWith('x') ? ts.JsxEmit.ReactJSX : undefined,
|
||||
sourceMap: true,
|
||||
},
|
||||
})
|
||||
return {
|
||||
code: result.outputText.replace(/\n?\/\/# sourceMappingURL=.*$/u, '\n'),
|
||||
map: result.sourceMapText,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const windowsUnsupportedPackages = process.platform === 'win32'
|
||||
? [
|
||||
@@ -88,7 +112,7 @@ const processBoundTests = [
|
||||
]
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [pathsPlugin()],
|
||||
plugins: [pathsPlugin(), standardDecoratorPlugin()],
|
||||
test: {
|
||||
setupFiles: ['./scripts/test-invariants.ts'],
|
||||
// .tsx: client component specs (jsdom via per-file @vitest-environment pragma).
|
||||
@@ -99,7 +123,7 @@ export default defineConfig({
|
||||
// always fork.
|
||||
projects: [
|
||||
{
|
||||
plugins: [pathsPlugin()],
|
||||
plugins: [pathsPlugin(), standardDecoratorPlugin()],
|
||||
test: {
|
||||
name: 'thread-safe',
|
||||
execArgv: vitestExecArgv,
|
||||
@@ -119,7 +143,7 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins: [pathsPlugin()],
|
||||
plugins: [pathsPlugin(), standardDecoratorPlugin()],
|
||||
test: {
|
||||
name: 'process-bound',
|
||||
execArgv: vitestExecArgv,
|
||||
|
||||
Reference in New Issue
Block a user