feat: add TypeRT remote gateway infrastructure

This commit is contained in:
imccyu
2026-08-07 21:47:14 +08:00
parent effd8e1ebd
commit 64a963da0b
98 changed files with 7812 additions and 444 deletions
+9
View File
@@ -18,6 +18,12 @@ flowchart LR
cfg --> plugin_dsh_base_llm cfg --> plugin_dsh_base_llm
plugin_dsh_base_session["session<br/>@deepseek-ai/dsh-session"] plugin_dsh_base_session["session<br/>@deepseek-ai/dsh-session"]
cfg --> plugin_dsh_base_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"] plugin_dsh_base_session_title["session-title<br/>@deepseek-ai/dsh-session-title"]
cfg --> plugin_dsh_base_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"] 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` | | `repository-plugins` | `@deepseek-ai/dsh-repository-plugin` |
| `llm` | `@deepseek-ai/dsh-llm` | | `llm` | `@deepseek-ai/dsh-llm` |
| `session` | `@deepseek-ai/dsh-session` | | `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` | `@deepseek-ai/dsh-session-title` |
| `session-title-llm` | `@deepseek-ai/dsh-session-title-first-message-llm` | | `session-title-llm` | `@deepseek-ai/dsh-session-title-first-message-llm` |
| `user-interaction` | `@deepseek-ai/dsh-user-interaction` | | `user-interaction` | `@deepseek-ai/dsh-user-interaction` |
+6 -1
View File
@@ -32,6 +32,8 @@ flowchart LR
pkg_typert_registry["typert-registry"] pkg_typert_registry["typert-registry"]
svc_typert["ctx.typert<br/>Runtime type registry"] svc_typert["ctx.typert<br/>Runtime type registry"]
pkg_typert_loader["typert-loader"] 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"] svc_sessionPersistence["ctx.sessionPersistence<br/>Durable session persistence seam"]
pkg_session_persistence_jsonl["session-persistence-jsonl"] pkg_session_persistence_jsonl["session-persistence-jsonl"]
pkg_session_persistence_sqlite["session-persistence-sqlite"] pkg_session_persistence_sqlite["session-persistence-sqlite"]
@@ -171,6 +173,7 @@ flowchart LR
pkg_acp --> svc_approval pkg_acp --> svc_approval
pkg_agent --> svc_agents pkg_agent --> svc_agents
pkg_agent_loop --> svc_agentLoop pkg_agent_loop --> svc_agentLoop
pkg_api_gateway --> svc_typertGateway
pkg_approval --> svc_approval pkg_approval --> svc_approval
pkg_bash --> svc_bash pkg_bash --> svc_bash
pkg_bash_env --> svc_bashEnv pkg_bash_env --> svc_bashEnv
@@ -347,6 +350,7 @@ flowchart LR
svc_tools --> pkg_tool_subagent svc_tools --> pkg_tool_subagent
svc_tools --> pkg_tool_todo svc_tools --> pkg_tool_todo
svc_tools --> pkg_tool_web svc_tools --> pkg_tool_web
svc_typert --> pkg_api_gateway
svc_typert --> pkg_typert_loader svc_typert --> pkg_typert_loader
svc_userInteraction --> pkg_tool_ask_user svc_userInteraction --> pkg_tool_ask_user
svc_web --> pkg_tool_web 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.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.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.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.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.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. | | `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. |
+6 -2
View File
@@ -291,7 +291,7 @@ Source: [`packages/examples/cli-demo/src/index.ts:26`](../packages/examples/cli-
## `@deepseek-ai/dsh-client-connection` ## `@deepseek-ai/dsh-client-connection`
Requires: `httpServer` · `apiProxy` Requires: `httpServer`
```ts config-catalog ```ts config-catalog
/** Plugin config: the deployment's non-loopback serving authorities. */ /** 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` ## `@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-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-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-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-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-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)) - `@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-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-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-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-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-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)) - `@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-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-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-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-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))
+4 -4
View File
@@ -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) 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 ### `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) 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 ### `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) 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 ### `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) 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/*` ## `settings/*`
+24 -7
View File
@@ -216,7 +216,7 @@ roots(): Agent[]
Types: [Agent](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) 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` ## `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) 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` ## `ctx.sessionTitle` — `SessionTitleService`
@@ -2527,16 +2527,17 @@ Source: [`packages/core/tools/src/index.ts:739`](../../packages/core/tools/src/i
## `ctx.typert` — `TypertRegistry` ## `ctx.typert` — `TypertRegistry`
Registry of generated schemas and package reflection. Registry of generated schemas, package reflection, invocations, and Remote dependency providers.
```ts cordis-catalog ```ts cordis-catalog
/** /**
* Register one generated contribution atomically for the calling fiber. * Register one generated contribution atomically for the calling fiber.
* Duplicate package-face identities or schema keys reject the whole batch. * Duplicate package-face identities, schemas, invocation ids, or endpoints
* @param contribution - generated schemas and package metadata. * reject the whole batch.
* @param contribution - generated schemas, reflection, and Host invocations.
* @returns the exact effect disposer that removes this contribution. * @returns the exact effect disposer that removes this contribution.
*/ */
register(contribution: TypertContribution): () => void register(contribution: TypertContribution): TypeRTDisposer
/** /**
* Look up one schema by `<package>#<name>`. * 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 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` ## `ctx.userInteraction` — `UserInteractionService`
+12 -2
View File
@@ -211,6 +211,7 @@ flowchart TD
end end
subgraph group_host["packages/host"] subgraph group_host["packages/host"]
pkg_frontend_static["frontend-static"] pkg_frontend_static["frontend-static"]
pkg_host_api_gateway["host-api-gateway"]
pkg_host_apiproxy["host-apiproxy"] pkg_host_apiproxy["host-apiproxy"]
pkg_host_directory_picker["host-directory-picker"] pkg_host_directory_picker["host-directory-picker"]
pkg_host_directory_picker_auto["host-directory-picker-auto"] pkg_host_directory_picker_auto["host-directory-picker-auto"]
@@ -272,6 +273,7 @@ flowchart TD
pkg_session_telemetry_otel["session-telemetry-otel"] pkg_session_telemetry_otel["session-telemetry-otel"]
end end
subgraph group_typert["packages/typert"] subgraph group_typert["packages/typert"]
pkg_type_meta["type-meta"]
pkg_typert_generator["typert-generator"] pkg_typert_generator["typert-generator"]
pkg_typert_loader["typert-loader"] pkg_typert_loader["typert-loader"]
pkg_typert_registry["typert-registry"] pkg_typert_registry["typert-registry"]
@@ -311,6 +313,7 @@ flowchart TD
pkg_host_webserver --> pkg_invariants pkg_host_webserver --> pkg_invariants
pkg_storage --> pkg_invariants pkg_storage --> pkg_invariants
pkg_subprocess --> pkg_invariants pkg_subprocess --> pkg_invariants
pkg_type_meta --> pkg_invariants
pkg_typert_generator --> pkg_invariants pkg_typert_generator --> pkg_invariants
pkg_typert_registry --> pkg_invariants pkg_typert_registry --> pkg_invariants
pkg_llm --> pkg_brand pkg_llm --> pkg_brand
@@ -374,6 +377,7 @@ flowchart TD
pkg_session --> pkg_invariants pkg_session --> pkg_invariants
pkg_session --> pkg_llm pkg_session --> pkg_llm
pkg_session --> pkg_scope pkg_session --> pkg_scope
pkg_session --> pkg_type_meta
pkg_system_prompt --> pkg_invariants pkg_system_prompt --> pkg_invariants
pkg_system_prompt --> pkg_llm pkg_system_prompt --> pkg_llm
pkg_system_prompt --> pkg_scope pkg_system_prompt --> pkg_scope
@@ -420,6 +424,9 @@ flowchart TD
pkg_credentials_local --> pkg_credentials pkg_credentials_local --> pkg_credentials
pkg_credentials_local --> pkg_invariants pkg_credentials_local --> pkg_invariants
pkg_credentials_local --> pkg_paths 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_brand
pkg_lsp --> pkg_invariants pkg_lsp --> pkg_invariants
pkg_lsp --> pkg_llm pkg_lsp --> pkg_llm
@@ -434,6 +441,7 @@ flowchart TD
pkg_agent --> pkg_scope pkg_agent --> pkg_scope
pkg_agent --> pkg_session pkg_agent --> pkg_session
pkg_agent --> pkg_system_prompt pkg_agent --> pkg_system_prompt
pkg_agent --> pkg_type_meta
pkg_bash --> pkg_invariants pkg_bash --> pkg_invariants
pkg_bash --> pkg_sandbox pkg_bash --> pkg_sandbox
pkg_bash --> pkg_subprocess pkg_bash --> pkg_subprocess
@@ -1154,6 +1162,7 @@ flowchart TD
| [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) |
| [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) |
| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`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-generator`](../packages/typert/generator) | `typert` | [`invariants`](../packages/support/invariants) |
| [`typert-registry`](../packages/typert/registry) | `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) | | [`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) | | [`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-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) | | [`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) | | [`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) | | [`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) | | [`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-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) | | [`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) | | [`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) | | [`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) | | [`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) | | [`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) | | [`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) | | [`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) | | [`compact`](../packages/compact/compact) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
+4 -1
View File
@@ -15,7 +15,10 @@
], ],
"scripts": { "scripts": {
"build": "npm run build:lib && npm run build:web", "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", "build:web": "pnpm --filter @deepseek-ai/dsh-frontend run build",
"clean": "tsx scripts/clean.ts", "clean": "tsx scripts/clean.ts",
"change-scope": "tsx scripts/change-scope.ts", "change-scope": "tsx scripts/change-scope.ts",
+9
View File
@@ -34,6 +34,15 @@
- id: session - id: session
name: '@deepseek-ai/dsh-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 - id: session-title
name: '@deepseek-ai/dsh-session-title' name: '@deepseek-ai/dsh-session-title'
config: config:
+3
View File
@@ -49,6 +49,7 @@
"@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-fs-sandbox": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-goal-session": "workspace:^", "@deepseek-ai/dsh-goal-session": "workspace:^",
"@deepseek-ai/dsh-host-api-gateway": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^",
@@ -95,6 +96,8 @@
"@deepseek-ai/dsh-tool-web": "workspace:^", "@deepseek-ai/dsh-tool-web": "workspace:^",
"@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-tool-workflow": "workspace:^",
"@deepseek-ai/dsh-tools": "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-approval": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^",
"@deepseek-ai/dsh-web": "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 { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts'
import { FixtureApiClient } from './fixture.ts' import { FixtureApiClient } from './fixture.ts'
import { WebApiClient } from './web-api-client.ts' import { WebApiClient } from './web-api-client.ts'
import { createUnavailableConnectionRpc, createWebConnectionRpc } from './rpc.ts'
import { isLoopbackHostname } from '../loopback-hostname.ts' import { isLoopbackHostname } from '../loopback-hostname.ts'
import type { ClientConnectionRpc } from '../rpc.ts'
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ---- // ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
export type { export type {
@@ -36,6 +38,7 @@ export {
// Connection loop types are public through ConnectionHandle.start; the // Connection loop types are public through ConnectionHandle.start; the
// controller remains package-internal. // controller remains package-internal.
export type { ConnectionConfig, ConnectionSinks, ConnectionState } export type { ConnectionConfig, ConnectionSinks, ConnectionState }
export type { ClientConnectionRpc } from '../rpc.ts'
/** Required services (none — this is the wire root). */ /** Required services (none — this is the wire root). */
@@ -51,6 +54,8 @@ export interface ConnectionHandle {
readonly api: IApiClient readonly api: IApiClient
/** Whether the current page authority is loopback; non-browser contexts default to true. */ /** Whether the current page authority is loopback; non-browser contexts default to true. */
readonly isLoopback: boolean 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. * Start the connect/pump/reconnect loop with the consumer's frame sinks.
* One consumer owns the streams (the runtime object layer); a second call * 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 pageLocation = typeof location === 'undefined' ? undefined : location
const fixture = pageLocation !== undefined && new URLSearchParams(pageLocation.search).has('fixture') const fixture = pageLocation !== undefined && new URLSearchParams(pageLocation.search).has('fixture')
const api: IApiClient = fixture ? new FixtureApiClient() : new WebApiClient() const api: IApiClient = fixture ? new FixtureApiClient() : new WebApiClient()
const rpc = fixture ? createUnavailableConnectionRpc() : createWebConnectionRpc()
let started = false let started = false
const handle: ConnectionHandle = { const handle: ConnectionHandle = {
api, api,
isLoopback: pageLocation === undefined || isLoopbackHostname(pageLocation.hostname), isLoopback: pageLocation === undefined || isLoopbackHostname(pageLocation.hostname),
rpc,
start(sinks, config) { start(sinks, config) {
if (started) throw new Error('connection: the stream loop is already owned by another consumer') if (started) throw new Error('connection: the stream loop is already owned by another consumer')
started = true started = true
@@ -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}`)}`)
}
}
+57 -44
View File
@@ -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 { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts'
import { bridge } from './http-bridge.ts' import { bridge } from './http-bridge.ts'
import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts' import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts'
import { HostConnectionService } from './rpc-host.ts'
import { rejectWebSocketUpgrade, WebSocketDownlinks } from './websocket-downlink.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' export { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts'
/** Stable Cordis plugin name. */ /** Stable Cordis plugin name. */
export const name = 'client-connection' export const name = 'client-connection'
/** Services required before mounting the route. */ /** Services required before providing Connection; legacy `/api` attaches when apiProxy is present. */
export const inject = ['httpServer', 'apiProxy'] export const inject = ['httpServer']
/** Plugin config: the deployment's non-loopback serving authorities. */ /** Plugin config: the deployment's non-loopback serving authorities. */
export interface ConnectionConfig { 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 // Config boundary: a malformed entry fails the load loudly here rather than
// silently authorizing its hostname prefix at request time. // silently authorizing its hostname prefix at request time.
for (const entry of trustedHosts) assertTrustedAuthority(entry) for (const entry of trustedHosts) assertTrustedAuthority(entry)
const apiHandler = toFetchHandler(ctx.apiProxy) new HostConnectionService(ctx, trustedHosts)
const downlinks = new WebSocketDownlinks(ctx.apiProxy) ctx.inject(['apiProxy'], (apiCtx) => {
const route: WebRoute = { const apiHandler = toFetchHandler(apiCtx.apiProxy)
kind: 'prefix', const downlinks = new WebSocketDownlinks(apiCtx.apiProxy)
path: API_PATH, const route: WebRoute = {
handler: async (req, res) => { kind: 'prefix',
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname path: API_PATH,
const method = pathname.startsWith(`${API_PATH}/`) handler: async (req, res) => {
? pathname.slice(API_PATH.length + 1) const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
: undefined const method = pathname.startsWith(`${API_PATH}/`)
const allowed = method !== undefined && PRIVILEGED_METHODS.has(method) ? pathname.slice(API_PATH.length + 1)
? isTrustedApiRequest(req, []) : undefined
: isTrustedApiRequest(req, trustedHosts) const allowed = method !== undefined && PRIVILEGED_METHODS.has(method)
if (!allowed) { ? isTrustedApiRequest(req, [])
res.writeHead(403) : isTrustedApiRequest(req, trustedHosts)
res.end('forbidden') if (!allowed) {
return res.writeHead(403)
} res.end('forbidden')
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)
return 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`) }
} apiCtx.effect(() => apiCtx.httpServer.register(route), 'client-connection: /api route')
ctx.effect(() => () => downlinks.close(), 'client-connection: WebSocket downlinks') const registerDownlink = (
registerDownlink(MUX_EVENTS_PATH, (req, socket, head) => { downlinks.handleMux(req, socket, head) }) path: string,
registerDownlink(HOST_EVENTS_PATH, (req, socket, head) => { downlinks.handleHost(req, socket, head) }) 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
View 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
View 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).toHaveLength(1)
expect(sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED) 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 { AddressInfo } from 'node:net'
import type { IncomingMessage, ServerResponse } from 'node:http' import type { IncomingMessage, ServerResponse } from 'node:http'
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api' 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 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. */ /** Structural httpServer fake recording both route registries. */
function fakeHttpServer( function fakeHttpServer(
@@ -17,6 +18,9 @@ function fakeHttpServer(
): Pick<HttpServerService, 'register' | 'registerUpgrade' | 'tapIndex' | 'port'> { ): Pick<HttpServerService, 'register' | 'registerUpgrade' | 'tapIndex' | 'port'> {
return { return {
register(route) { register(route) {
if (routes.some(candidate => candidate.kind === route.kind && candidate.path === route.path)) {
throw new Error(`duplicate route ${route.path}`)
}
routes.push(route) routes.push(route)
return () => { routes.splice(routes.indexOf(route), 1) } return () => { routes.splice(routes.indexOf(route), 1) }
}, },
@@ -36,15 +40,25 @@ function fakeRequest(headers: Record<string, string>, url = `${API_PATH}/session
return request 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. */ /** Response recorder compatible with both the fence's short-circuit and the bridge. */
function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } { function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } {
const state: { status?: number; body?: unknown } = {} const state: { status?: number; body?: unknown } = {}
const chunks: Buffer[] = []
const response = Object.assign(new EventEmitter(), { const response = Object.assign(new EventEmitter(), {
writableEnded: false, writableEnded: false,
writeHead(value: number) { state.status = value; return this }, 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) { 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 this.writableEnded = true
return this return this
}, },
@@ -173,6 +187,78 @@ describe('connection node half', () => {
expect(declared.state.status).toBe(404) expect(declared.state.status).toBe(404)
await dispose() 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', () => { describe('connection node half over a real HTTP server', () => {
@@ -27,6 +27,9 @@ async function mount(): Promise<Bench> {
const handle: ConnectionHandle = { const handle: ConnectionHandle = {
api, api,
isLoopback: true, isLoopback: true,
rpc: {
call: () => Promise.reject(new Error('unexpected generic RPC call')),
},
start: (sinks) => { start: (sinks) => {
bench.sinks = sinks bench.sinks = sinks
return { stop: () => { bench.stopped += 1 } } return { stop: () => { bench.stopped += 1 } }
@@ -21,6 +21,9 @@ async function mount(): Promise<Bench> {
const handle: ConnectionHandle = { const handle: ConnectionHandle = {
api, api,
isLoopback: true, isLoopback: true,
rpc: {
call: () => Promise.reject(new Error('unexpected generic RPC call')),
},
start: (sinks) => { start: (sinks) => {
bench.sinks = sinks bench.sinks = sinks
return { stop: () => {} } return { stop: () => {} }
+5 -2
View File
@@ -31,6 +31,9 @@ const CSS_VIRTUAL_SUFFIX = '.mjs'
*/ */
export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/ 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 * Documented TEMPORARY exemption, not a platform module (hence not in
* platform.ts): the snapshot-store engine (createSnapshotStore/defineStore/ * platform.ts): the snapshot-store engine (createSnapshotStore/defineStore/
@@ -126,9 +129,9 @@ export function clientBundle(id: string, libEntry: readonly string[]): [UserConf
resolveId(source: string) { resolveId(source: string) {
if (!source.startsWith('@deepseek-ai/')) return null if (!source.startsWith('@deepseek-ai/')) return null
if (CLIENT_EXTERNALS.includes(source)) return null // platform module: external wins 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( 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)', + 'cross-plugin value imports are forbidden; collaborate through cordis services (type-only imports are erased and never reach this gate)',
) )
}, },
+42 -4
View File
@@ -1118,11 +1118,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
}, },
{ {
key: 'typert', key: 'typert',
summary: 'Registry of generated schemas and package reflection.', summary: 'Registry of generated schemas, package reflection, invocations, and Remote dependency providers.',
methods: [ methods: [
{ {
signature: 'register(contribution: TypertContribution): () => void', signature: 'register(contribution: TypertContribution): TypeRTDisposer',
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 */', 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', 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', key: 'userInteraction',
summary: '`ctx.userInteraction`: one active UI provider plus an `ask()` surface.', summary: '`ctx.userInteraction`: one active UI provider plus an `ask()` surface.',
@@ -2057,6 +2067,22 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'InvariantInstaller', name: 'InvariantInstaller',
declaration: 'export interface InvariantInstaller {\n (ctx: Context, fail: InvariantFailure): void | Promise<void>;\n readonly inject?: Inject;\n}', 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', 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}', 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', 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}', 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', 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', name: 'TypertDocTag',
@@ -3077,6 +3111,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'TypertSchema', name: 'TypertSchema',
declaration: 'export interface TypertSchema {\n readonly name: string;\n readonly schema: z.ZodType;\n}', 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', name: 'TypertSchemaFilter',
declaration: 'export interface TypertSchemaFilter {\n readonly package?: string;\n readonly face?: TypertFace;\n}', declaration: 'export interface TypertSchemaFilter {\n readonly package?: string;\n readonly face?: TypertFace;\n}',
+8
View File
@@ -15,12 +15,17 @@
"types": "./lib/types/invariant.d.ts", "types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js" "default": "./lib/invariant.js"
}, },
"./types": {
"types": "./lib/types/types.d.ts",
"default": "./lib/types/types.js"
},
"./src/*": "./src/*", "./src/*": "./src/*",
"./package.json": "./package.json" "./package.json": "./package.json"
}, },
"files": [ "files": [
"lib/index.js", "lib/index.js",
"lib/invariant.js", "lib/invariant.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts" "lib/types/**/*.d.ts"
], ],
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
@@ -30,6 +35,7 @@
"@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^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" "cordis": "^4.0.0-rc.7"
}, },
"devDependencies": { "devDependencies": {
@@ -38,6 +44,8 @@
"@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "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" "cordis": "^4.0.0-rc.7"
} }
} }
+25
View File
@@ -12,6 +12,7 @@ import { isPromise } from 'node:util/types'
import { scopeTarget } from '@deepseek-ai/dsh-scope' import { scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' 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' import type { Agent, AgentOptions } from './types.ts'
export * 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 { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts'
export type { AgentEventDispatch, AgentSubjectEvent } 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' { declare module 'cordis' {
interface Context { interface Context {
agents: AgentRegistry agents: AgentRegistry
@@ -251,6 +262,20 @@ export class AgentRegistry extends Service {
constructor(ctx: Context) { constructor(ctx: Context) {
super(ctx, 'agents') 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 // The `ctx.agent` DX accessor: default `undefined` on every context, so a
// plain plugin context reads cleanly instead of hitting the Cordis // plain plugin context reads cleanly instead of hitting the Cordis
// unknown-property throw. Each Agent.ctx shadows it with an own property // unknown-property throw. Each Agent.ctx shadows it with an own property
+26
View File
@@ -6,6 +6,7 @@ import AgentRegistry, {
agentEvents, agentEvents,
Inbox, Inbox,
} from '@deepseek-ai/dsh-agent' } from '@deepseek-ai/dsh-agent'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import type { import type {
Agent, Agent,
@@ -142,6 +143,31 @@ describe('Inbox', () => {
}) })
describe('AgentRegistry', () => { 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 () => { it('registers exact entries, emits lifecycle events, and unregisters on owner disposal', async () => {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(AgentRegistry) await ctx.plugin(AgentRegistry)
+3
View File
@@ -28,6 +28,9 @@
}, },
{ {
"path": "../../support/invariants" "path": "../../support/invariants"
},
{
"path": "../../typert/type-meta"
} }
] ]
} }
+3
View File
@@ -38,6 +38,7 @@
"@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-type-meta": "^0.0.1",
"cordis": "^4.0.0-rc.7" "cordis": "^4.0.0-rc.7"
}, },
"devDependencies": { "devDependencies": {
@@ -45,6 +46,8 @@
"@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"cordis": "^4.0.0-rc.7" "cordis": "^4.0.0-rc.7"
} }
} }
+16
View File
@@ -13,6 +13,7 @@ import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { Message } from '@deepseek-ai/dsh-llm' import type { Message } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts' 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 type { CreateSessionOptions, EpochHeader, PrepareSessionOptions, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
import { snapshotJsonValue } from './json.ts' import { snapshotJsonValue } from './json.ts'
import { deriveEventMessage, SurfaceManager } from './surface.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. */ /** Validate and freeze one detached creation header in place. */
function validateSessionHeader(id: SessionId, input: unknown): SessionHeader { function validateSessionHeader(id: SessionId, input: unknown): SessionHeader {
if (input === null || typeof input !== 'object' || Array.isArray(input)) { if (input === null || typeof input !== 'object' || Array.isArray(input)) {
@@ -803,6 +810,15 @@ export class SessionStore extends Service {
constructor(ctx: Context) { constructor(ctx: Context) {
super(ctx, 'sessions') 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),
})
})
} }
/** /**
@@ -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()
})
})
+3
View File
@@ -25,6 +25,9 @@
}, },
{ {
"path": "../../support/invariants" "path": "../../support/invariants"
},
{
"path": "../../typert/type-meta"
} }
] ]
} }
@@ -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
View 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
View 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
View 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"
}
}
@@ -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
View 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
@@ -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
View 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
}
}
@@ -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)
})
})
@@ -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
View 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"
}
]
}
@@ -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'])
+5
View File
@@ -72,6 +72,11 @@ export type {
// ---- Errors and ids ---- // ---- Errors and ids ----
export { RpcId, transportError } from './rpc.ts' export { RpcId, transportError } from './rpc.ts'
export type { RpcError, RpcErrorCode, RpcErrorDetailsMap, RpcResult } 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 ---- // ---- Fixed session-search product bounds ----
export { export {
+1
View File
@@ -30,6 +30,7 @@
], ],
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"@jridgewell/gen-mapping": "^0.3.13",
"typescript": "^6.0.3" "typescript": "^6.0.3"
}, },
"peerDependencies": { "peerDependencies": {
+850 -19
View File
@@ -15,6 +15,8 @@ import type {
EnumMemberModel, EnumMemberModel,
ExportModel, ExportModel,
FaceModel, FaceModel,
InvocationModel,
InvocationParameterModel,
JsDocTagModel, JsDocTagModel,
KeywordTypeName, KeywordTypeName,
MemberBase, MemberBase,
@@ -23,6 +25,8 @@ import type {
ObjectModel, ObjectModel,
PackageModel, PackageModel,
ParameterModel, ParameterModel,
RemoteBoundaryModel,
RemoteTypeImportModel,
SchemaModel, SchemaModel,
ServiceModel, ServiceModel,
SignatureModel, SignatureModel,
@@ -122,6 +126,25 @@ interface ModuleIdentity {
readonly subpath: string 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 type ReferenceSite = ts.TypeReferenceNode | ts.ExpressionWithTypeArguments | ts.ImportTypeNode
const EMPTY_DOCUMENTATION: DocumentationModel = { tags: [] } const EMPTY_DOCUMENTATION: DocumentationModel = { tags: [] }
@@ -453,15 +476,11 @@ export class WorkspaceAnalyzer {
config: this.caches.config(configPath), config: this.caches.config(configPath),
manifest, manifest,
} }
const packagePath = slash(relative(this.options.root, packageRoot)) if (isDualFacePackage(manifest)) {
const clientPackage = packagePath === 'packages/client' || packagePath.startsWith('packages/client/')
if (clientPackage && isDualFacePackage(manifest)) {
registrations.push({ ...registration, face: 'host', exportSubpaths: hostExportSubpaths(manifest) }) registrations.push({ ...registration, face: 'host', exportSubpaths: hostExportSubpaths(manifest) })
registrations.push({ ...registration, face: 'client', exportSubpaths: clientExportSubpaths(manifest) }) registrations.push({ ...registration, face: 'client', exportSubpaths: clientExportSubpaths(manifest) })
} else if (clientPackage) {
registrations.push({ ...registration, face: 'client' })
} else { } else {
registrations.push({ ...registration, face: 'host' }) registrations.push(registration)
} }
} }
} }
@@ -480,6 +499,7 @@ export class WorkspaceAnalyzer {
&& subpath !== './package.json' && subpath !== './package.json'
&& subpath !== './typert' && subpath !== './typert'
&& subpath !== './client/typert' && subpath !== './client/typert'
&& subpath !== './remote'
&& !target.endsWith('.json')) && !target.endsWith('.json'))
.map(([, target]) => sourcePathForExport(registration.root, target)) .map(([, target]) => sourcePathForExport(registration.root, target))
.filter(existsSync) .filter(existsSync)
@@ -578,6 +598,8 @@ class FaceAnalyzer {
private readonly nodes = new Map<TypeNodeId, TypeNodeModel>() private readonly nodes = new Map<TypeNodeId, TypeNodeModel>()
private readonly exportsByPackage = new Map<string, ExportRecord[]>() private readonly exportsByPackage = new Map<string, ExportRecord[]>()
private readonly nodeOrdinals = new Map<string, number>() private readonly nodeOrdinals = new Map<string, number>()
private staticLookups: readonly StaticLookupDeclaration[] | undefined
private staticContexts: ReadonlyMap<string, StaticContextDeclaration> | undefined
constructor(options: FaceAnalyzerOptions) { constructor(options: FaceAnalyzerOptions) {
this.root = options.root this.root = options.root
@@ -601,6 +623,7 @@ class FaceAnalyzer {
const packages = this.registrations const packages = this.registrations
.map(registration => this.analyzePackage(registration)) .map(registration => this.analyzePackage(registration))
.filter(hasPackageSurface) .filter(hasPackageSurface)
this.validateInvocationIdentity(packages)
return { return {
face: this.face, face: this.face,
packages, packages,
@@ -634,6 +657,7 @@ class FaceAnalyzer {
} }
} }
} }
const explicitServices = this.collectExplicitServices(records)
const objects: ObjectModel[] = [] const objects: ObjectModel[] = []
const schemas: SchemaModel[] = [] const schemas: SchemaModel[] = []
@@ -672,10 +696,14 @@ class FaceAnalyzer {
root: slash(relative(this.root, registration.root)), root: slash(relative(this.root, registration.root)),
exports: records.map(record => record.model) exports: records.map(record => record.model)
.sort((left, right) => left.subpath.localeCompare(right.subpath) || left.name.localeCompare(right.name)), .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)), 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)), objects: objects.sort((left, right) => left.export.name.localeCompare(right.export.name)),
schemas: schemas.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[] = [] const records: ExportRecord[] = []
for (const [subpath, target] of targets) { for (const [subpath, target] of targets) {
if (target.includes('*') || subpath === './package.json' 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. // Data exports (bundle patch lists, JSON manifests) carry no TypeScript API.
|| target.endsWith('.json') || target.endsWith('.yml') || target.endsWith('.yaml')) continue || target.endsWith('.json') || target.endsWith('.yml') || target.endsWith('.yaml')) continue
const sourcePath = sourcePathForExport(registration.root, target) const sourcePath = sourcePathForExport(registration.root, target)
@@ -849,6 +877,740 @@ class FaceAnalyzer {
return result 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[] { private collectEvents(events: ts.InterfaceDeclaration): EventModel[] {
const result: EventModel[] = [] const result: EventModel[] = []
for (const member of events.members) { for (const member of events.members) {
@@ -1015,6 +1777,11 @@ class FaceAnalyzer {
): MemberModel[] { ): MemberModel[] {
const result: MemberModel[] = [] const result: MemberModel[] = []
for (const member of members) { 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 visibility = visibilityOf(member)
const isStatic = hasModifier(member, ts.SyntaxKind.StaticKeyword) const isStatic = hasModifier(member, ts.SyntaxKind.StaticKeyword)
if (visibility !== 'public' || isStatic || ts.isConstructorDeclaration(member)) continue if (visibility !== 'public' || isStatic || ts.isConstructorDeclaration(member)) continue
@@ -1045,17 +1812,19 @@ class FaceAnalyzer {
visibility: MemberVisibility, visibility: MemberVisibility,
isStatic: boolean, isStatic: boolean,
): MemberBase { ): MemberBase {
const name = member.name !== undefined const identity = member.name !== undefined
? memberName(member.name) ? this.memberIdentity(member.name)
: ts.isCallSignatureDeclaration(member) : {
? '(call)' name: ts.isCallSignatureDeclaration(member)
: ts.isConstructSignatureDeclaration(member) ? '(call)'
? '(construct)' : ts.isConstructSignatureDeclaration(member)
: '(index)' ? '(construct)'
: '(index)',
}
return { return {
...documentationOf(member), ...documentationOf(member),
id: `${ownerId}#${name}@${String(member.getStart())}`, id: `${ownerId}#${identity.name}@${String(member.getStart())}`,
name, ...identity,
optional: 'questionToken' in member && member.questionToken !== undefined, optional: 'questionToken' in member && member.questionToken !== undefined,
readonly: hasModifier(member, ts.SyntaxKind.ReadonlyKeyword), readonly: hasModifier(member, ts.SyntaxKind.ReadonlyKeyword),
async: hasModifier(member, ts.SyntaxKind.AsyncKeyword), 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( private signature(
node: ts.SignatureDeclarationBase, node: ts.SignatureDeclarationBase,
explicitReturn: ts.TypeNode | undefined, explicitReturn: ts.TypeNode | undefined,
@@ -1570,7 +2353,23 @@ function sourceFileHasSurface(sourceFile: ts.SourceFile): boolean {
|| ts.isInterfaceDeclaration(statement) || ts.isInterfaceDeclaration(statement)
|| ts.isTypeAliasDeclaration(statement) || ts.isTypeAliasDeclaration(statement)
|| ts.isEnumDeclaration(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) if (!ts.isModuleDeclaration(statement)
|| !ts.isStringLiteral(statement.name) || !ts.isStringLiteral(statement.name)
|| statement.name.text !== 'cordis' || statement.name.text !== 'cordis'
@@ -1588,6 +2387,7 @@ function hasPackageSurface(model: PackageModel): boolean {
|| model.events.length > 0 || model.events.length > 0
|| model.objects.length > 0 || model.objects.length > 0
|| model.schemas.length > 0 || model.schemas.length > 0
|| model.invocations.length > 0
} }
function isDualFacePackage(manifest: Record<string, unknown>): boolean { 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[] { function hostExportSubpaths(manifest: Record<string, unknown>): string[] {
return packageExportTargets(manifest) return packageExportTargets(manifest)
.map(([subpath]) => subpath) .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[] { function clientExportSubpaths(manifest: Record<string, unknown>): string[] {
@@ -1668,6 +2470,10 @@ function preferredDeclaration(symbol: ts.Symbol): ts.Declaration | undefined {
?? symbol.declarations?.[0] ?? symbol.declarations?.[0]
} }
function optionalParent(node: ts.Node): ts.Node | undefined {
return (node as ts.Node & { readonly parent?: ts.Node }).parent
}
function isTypeDeclaration( function isTypeDeclaration(
node: ts.Node, node: ts.Node,
): node is ts.ClassDeclaration | ts.InterfaceDeclaration | ts.TypeAliasDeclaration | ts.EnumDeclaration { ): node is ts.ClassDeclaration | ts.InterfaceDeclaration | ts.TypeAliasDeclaration | ts.EnumDeclaration {
@@ -1822,6 +2628,11 @@ function typertMode(node: ts.Node): 'object' | 'schema' | undefined {
return 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 { function memberName(name: ts.PropertyName | ts.BindingName): string {
if (ts.isIdentifier(name) || ts.isPrivateIdentifier(name) || ts.isStringLiteral(name) if (ts.isIdentifier(name) || ts.isPrivateIdentifier(name) || ts.isStringLiteral(name)
|| ts.isNumericLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name)) return name.text || ts.isNumericLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name)) return name.text
@@ -1829,6 +2640,26 @@ function memberName(name: ts.PropertyName | ts.BindingName): string {
return name.getText() 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 { function visibilityOf(node: ts.Node): MemberVisibility {
if ('name' in node && node.name !== undefined && ts.isPrivateIdentifier(node.name as ts.Node)) return 'private' if ('name' in node && node.name !== undefined && ts.isPrivateIdentifier(node.name as ts.Node)) return 'private'
if (hasModifier(node, ts.SyntaxKind.PrivateKeyword)) return 'private' if (hasModifier(node, ts.SyntaxKind.PrivateKeyword)) return 'private'
@@ -231,7 +231,7 @@ export class CordisCatalogProjector {
for (const service of packageModel.services) { for (const service of packageModel.services) {
const declaration = this.renderer.declaration(service.symbol) const declaration = this.renderer.declaration(service.symbol)
if (declaration.kind !== 'class' 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 || declaration.location.file !== service.location.file) continue
const doc = parseJsDoc(declaration.jsDoc ?? '').doc const doc = parseJsDoc(declaration.jsDoc ?? '').doc
const source = pointer(declaration.location) const source = pointer(declaration.location)
+504 -34
View File
@@ -4,11 +4,17 @@
* @module @deepseek-ai/dsh-typert-generator/emitter * @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 { import type {
DocumentationModel, DocumentationModel,
FaceModel, FaceModel,
InvocationModel,
MemberModel, MemberModel,
PackageModel, PackageModel,
RemoteBoundaryModel,
RemoteTypeImportModel,
SchemaModel, SchemaModel,
SymbolId, SymbolId,
TypeDeclarationModel, TypeDeclarationModel,
@@ -29,6 +35,14 @@ export interface ModelEmitResult {
readonly exports: readonly string[] readonly exports: readonly string[]
readonly js: string readonly js: string
readonly dts: 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 { interface RuntimeMemberModel {
@@ -92,7 +106,11 @@ export class FaceModelEmitter {
if (packageModel === undefined) { if (packageModel === undefined) {
throw new TypertEmitError(`typert emitter(${this.face.face}): package ${packageName} is not modeled on this face`) 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 schemaArtifact = schemas.emit()
const runtimeModel = this.runtimeModel(packageModel) const runtimeModel = this.runtimeModel(packageModel)
const js = this.renderJs(packageModel, schemaArtifact, runtimeModel) const js = this.renderJs(packageModel, schemaArtifact, runtimeModel)
@@ -103,6 +121,9 @@ export class FaceModelEmitter {
exports: packageModel.schemas.map(schema => schema.export.name), exports: packageModel.schemas.map(schema => schema.export.name),
js, js,
dts, 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(` { name: ${quote(schema.exportName)}, schema: ${schema.exportName} },`)
} }
lines.push(' ],') 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(` model: ${indent(model, 2).trimStart()},`)
lines.push('}') lines.push('}')
return `${lines.join('\n')}\n` return `${lines.join('\n')}\n`
@@ -215,6 +241,246 @@ export class FaceModelEmitter {
lines.push('export declare const TYPERT: unknown') lines.push('export declare const TYPERT: unknown')
return `${lines.join('\n')}\n` 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 { interface SchemaExport {
@@ -226,15 +492,23 @@ interface SchemaExport {
interface SchemaArtifact { interface SchemaArtifact {
readonly definitions: readonly string[] readonly definitions: readonly string[]
readonly exports: readonly SchemaExport[] readonly exports: readonly SchemaExport[]
boundary(key: string): string
}
interface BoundarySchemaRoot {
readonly key: string
readonly type: TypeNodeId
} }
class SchemaEmitter { class SchemaEmitter {
private readonly names = new Map<SymbolId, string>() private readonly names = new Map<SymbolId, string>()
private readonly boundaryNames = new Map<string, string>()
private readonly declarations: TypeDeclarationModel[] private readonly declarations: TypeDeclarationModel[]
constructor( constructor(
private readonly renderer: TypeGraphRenderer, private readonly renderer: TypeGraphRenderer,
private readonly schemas: readonly SchemaModel[], private readonly schemas: readonly SchemaModel[],
private readonly boundaries: readonly BoundarySchemaRoot[],
) { ) {
const declarations = new Map<SymbolId, TypeDeclarationModel>() const declarations = new Map<SymbolId, TypeDeclarationModel>()
for (const schema of schemas) { for (const schema of schemas) {
@@ -242,6 +516,11 @@ class SchemaEmitter {
declarations.set(declaration.id, declaration) 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)) this.declarations = renderer.graph.declarations.filter(declaration => declarations.has(declaration.id))
const identifiers = new Set<string>() const identifiers = new Set<string>()
for (const declaration of this.declarations) { for (const declaration of this.declarations) {
@@ -252,65 +531,92 @@ class SchemaEmitter {
identifiers.add(name) identifiers.add(name)
this.names.set(declaration.id, 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 { emit(): SchemaArtifact {
const definitions = this.declarations.map((declaration) => { const definitions = this.declarations.map(declaration => this.declarationDefinition(declaration))
if (declaration.typeParameters.length > 0) { for (const boundary of this.boundaries) {
this.fail(declaration.name, 'generic declarations require a schema-factory projection') definitions.push(`const ${this.boundaryName(boundary.key)} = ${this.typeSchema(boundary.type)}`)
} }
return `const ${this.schemaName(declaration.id)} = ${this.declarationSchema(declaration)}`
})
const exports = this.schemas.map((model): SchemaExport => ({ const exports = this.schemas.map((model): SchemaExport => ({
model, model,
exportName: safeIdentifier(model.export.name), 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') { if (declaration.kind === 'enum') {
this.fail(declaration.name, 'enum declarations have no Zod projection') this.fail(declaration.name, 'enum declarations have no Zod projection')
} }
if (declaration.kind === 'alias') { if (declaration.kind === 'alias') {
if (declaration.type === undefined) this.fail(declaration.name, 'alias has no modeled type') 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 let result = own
for (const heritage of declaration.extends) { 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) 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) const node = this.renderer.node(id)
switch (node.kind) { switch (node.kind) {
case 'keyword': return this.keywordSchema(node.name) case 'keyword': return this.keywordSchema(node.name)
case 'literal': return `z.literal(${node.text})` case 'literal': return `z.literal(${node.text})`
case 'parenthesized': return this.typeSchema(node.type) case 'parenthesized': return this.typeSchema(node.type, substitutions)
case 'reference': return this.referenceSchema(node) case 'reference': return this.referenceSchema(node, substitutions)
case 'union': { case 'union': {
if (node.types.length === 0) return 'z.never()' if (node.types.length === 0) return 'z.never()'
if (node.types.length === 1) return this.typeSchema(node.types[0] as TypeNodeId) if (node.types.length === 1) return this.typeSchema(node.types[0] as TypeNodeId, substitutions)
return `z.union([${node.types.map(type => this.typeSchema(type)).join(', ')}])` return `z.union([${node.types.map(type => this.typeSchema(type, substitutions)).join(', ')}])`
} }
case 'intersection': { case 'intersection': {
const [head, ...tail] = node.types const [head, ...tail] = node.types
if (head === undefined) return 'z.unknown()' 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': { case 'tuple': {
const fixed = node.elements.filter(element => !element.rest) const fixed = node.elements.filter(element => !element.rest)
const rest = node.elements.find(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(', ')}])` 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)})` if (rest !== undefined) schema += `.rest(${this.tupleRestSchema(rest.type, substitutions)})`
return schema return schema
} }
case 'object': return this.objectSchema(node.members, id) case 'object': return this.objectSchema(node.members, id, substitutions)
case 'operator': case 'operator':
case 'indexed-access': case 'indexed-access':
case 'conditional': 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') { 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') { if (node.target.kind === 'standard') {
switch (node.target.name) { switch (node.target.name) {
@@ -336,13 +660,16 @@ class SchemaEmitter {
case 'ReadonlyArray': { case 'ReadonlyArray': {
const element = node.arguments[0] const element = node.arguments[0]
if (element === undefined) this.fail(node.name, 'array reference has no element type') 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': { case 'Record': {
const key = node.arguments[0] const key = node.arguments[0]
const value = node.arguments[1] const value = node.arguments[1]
if (key === undefined || value === undefined) this.fail(node.name, 'Record requires key and value types') 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()' case 'Date': return 'z.date()'
default: this.fail(node.name, `standard type ${node.target.name} has no Zod projection`) 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`) 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) 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' if (node.kind === 'reference'
&& node.target.kind === 'standard' && node.target.kind === 'standard'
&& (node.target.name === 'Array' || node.target.name === 'ReadonlyArray')) { && (node.target.name === 'Array' || node.target.name === 'ReadonlyArray')) {
const element = node.arguments[0] const element = node.arguments[0]
if (element === undefined) this.fail(node.name, 'tuple rest array has no element type') 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') 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 properties: string[] = []
const indices: string[] = []
let symbolMembers = 0
for (const member of members) { for (const member of members) {
if (member.static || member.visibility !== 'public') continue 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`) if (member.kind !== 'property') this.fail(subject, `${member.kind} member ${member.name} is not data-schema projectable`)
const property = this.describe( 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, 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 { private keywordSchema(name: string): string {
@@ -401,6 +794,12 @@ class SchemaEmitter {
return name 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 { private describe(schema: string, documentation: DocumentationModel): string {
return documentation.description === undefined ? schema : `${schema}.describe(${quote(documentation.description)})` 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 { function packageExportSpecifier(packageName: string, subpath: string): string {
return subpath === '.' ? packageName : `${packageName}${subpath.slice(1)}` return subpath === '.' ? packageName : `${packageName}${subpath.slice(1)}`
} }
+55
View File
@@ -94,6 +94,56 @@ export interface SchemaModel extends DocumentationModel {
readonly type: TypeNodeId 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. */ /** Business semantics discovered in one package on one face. */
export interface PackageModel { export interface PackageModel {
readonly name: string readonly name: string
@@ -103,6 +153,7 @@ export interface PackageModel {
readonly events: readonly EventModel[] readonly events: readonly EventModel[]
readonly objects: readonly ObjectModel[] readonly objects: readonly ObjectModel[]
readonly schemas: readonly SchemaModel[] readonly schemas: readonly SchemaModel[]
readonly invocations: readonly InvocationModel[]
} }
/** One explicit import/re-export edge between independently compiled faces. */ /** One explicit import/re-export edge between independently compiled faces. */
@@ -173,6 +224,10 @@ export interface SignatureModel {
export interface MemberBase extends DocumentationModel { export interface MemberBase extends DocumentationModel {
readonly id: string readonly id: string
readonly name: 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 optional: boolean
readonly readonly: boolean readonly readonly: boolean
readonly async: boolean readonly async: boolean
+55 -46
View File
@@ -81,32 +81,35 @@ export class TypeGraphRenderer {
/** /**
* Render one type expression from the retained source structure. * Render one type expression from the retained source structure.
* @param id - type node id. * @param id - type node id.
* @param references - optional generated names for declaration references.
* @returns TypeScript type text. * @returns TypeScript type text.
*/ */
renderType(id: TypeNodeId): string { renderType(id: TypeNodeId, references?: ReadonlyMap<SymbolId, string>): string {
const node = this.node(id) const node = this.node(id)
switch (node.kind) { switch (node.kind) {
case 'keyword': return node.name case 'keyword': return node.name
case 'literal': return node.text case 'literal': return node.text
case 'parenthesized': return `(${this.renderType(node.type)})` case 'parenthesized': return `(${this.renderType(node.type, references)})`
case 'reference': { case 'reference': {
const name = node.target.kind === 'type-parameter' const name = node.target.kind === 'type-parameter'
? this.parameterNames.get(node.target.parameter) ?? node.name ? 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 return node.arguments.length === 0
? name ? 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 'union': return node.types.map(type => this.renderType(type, references)).join(' | ')
case 'intersection': return node.types.map(type => this.renderType(type)).join(' & ') case 'intersection': return node.types.map(type => this.renderType(type, references)).join(' & ')
case 'array': { case 'array': {
const element = this.renderType(node.element) const element = this.renderType(node.element, references)
const wrapped = needsArrayParentheses(this.node(node.element)) ? `(${element})` : element const wrapped = needsArrayParentheses(this.node(node.element)) ? `(${element})` : element
return `${wrapped}[]` return `${wrapped}[]`
} }
case 'tuple': { case 'tuple': {
const elements = node.elements.map((element) => { const elements = node.elements.map((element) => {
const type = this.renderType(element.type) const type = this.renderType(element.type, references)
if (element.name !== undefined) { if (element.name !== undefined) {
return `${element.rest ? '...' : ''}${element.name}${element.optional ? '?' : ''}: ${type}` return `${element.rest ? '...' : ''}${element.name}${element.optional ? '?' : ''}: ${type}`
} }
@@ -114,34 +117,34 @@ export class TypeGraphRenderer {
}) })
return `[${elements.join(', ')}]` return `[${elements.join(', ')}]`
} }
case 'object': return this.renderObject(node.members) case 'object': return this.renderObject(node.members, references)
case 'function': return `${this.renderSignatureHead(node.signature)} => ${this.renderType(node.signature.returns)}` 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)} => ${this.renderType(node.signature.returns)}` 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)}[${this.renderType(node.index)}]` case 'indexed-access': return `${this.renderType(node.object, references)}[${this.renderType(node.index, references)}]`
case 'operator': return `${node.operator} ${this.renderType(node.type)}` case 'operator': return `${node.operator} ${this.renderType(node.type, references)}`
case 'conditional': { 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': { case 'mapped': {
const readonly = node.readonly === 'preserve' ? '' : node.readonly === 'remove' ? '-readonly ' : 'readonly ' const readonly = node.readonly === 'preserve' ? '' : node.readonly === 'remove' ? '-readonly ' : 'readonly '
const optional = node.optional === 'preserve' ? '' : node.optional === 'remove' ? '-?' : '?' const optional = node.optional === 'preserve' ? '' : node.optional === 'remove' ? '-?' : '?'
if (node.parameter.constraint === undefined) { if (node.parameter.constraint === undefined) {
throw new TypeGraphRenderError(`mapped type parameter ${node.parameter.name} has no constraint`) throw new TypeGraphRenderError(`mapped type parameter ${node.parameter.name} has no constraint`)
} }
const parameter = `${node.parameter.name} in ${this.renderType(node.parameter.constraint)}` const parameter = `${node.parameter.name} in ${this.renderType(node.parameter.constraint, references)}`
const nameType = node.nameType === undefined ? '' : ` as ${this.renderType(node.nameType)}` const nameType = node.nameType === undefined ? '' : ` as ${this.renderType(node.nameType, references)}`
const value = node.value === undefined ? 'unknown' : this.renderType(node.value) const value = node.value === undefined ? 'unknown' : this.renderType(node.value, references)
return `{ ${readonly}[${parameter}${nameType}]${optional}: ${value} }` return `{ ${readonly}[${parameter}${nameType}]${optional}: ${value} }`
} }
case 'template-literal': { 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}\`` return `\`${escapeTemplate(node.head)}${spans}\``
} }
case 'type-query': { case 'type-query': {
const argumentsText = node.arguments.length === 0 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}` return `typeof ${node.expression}${argumentsText}`
} }
case 'import-type': { case 'import-type': {
@@ -149,14 +152,14 @@ export class TypeGraphRenderer {
const imported = `import(${quote(node.module)}${attributes})${node.qualifier === undefined ? '' : `.${node.qualifier}`}` const imported = `import(${quote(node.module)}${attributes})${node.qualifier === undefined ? '' : `.${node.qualifier}`}`
const argumentsText = node.arguments.length === 0 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}` return `${node.typeof ? 'typeof ' : ''}${imported}${argumentsText}`
} }
case 'predicate': { case 'predicate': {
const assertion = node.asserts ? 'asserts ' : '' const assertion = node.asserts ? 'asserts ' : ''
return node.type === undefined return node.type === undefined
? `${assertion}${node.parameter}` ? `${assertion}${node.parameter}`
: `${assertion}${node.parameter} is ${this.renderType(node.type)}` : `${assertion}${node.parameter} is ${this.renderType(node.type, references)}`
} }
case 'this': return 'this' case 'this': return 'this'
default: return assertNever(node) default: return assertNever(node)
@@ -166,34 +169,36 @@ export class TypeGraphRenderer {
/** /**
* Render a callable signature without a member name. * Render a callable signature without a member name.
* @param signature - modeled signature. * @param signature - modeled signature.
* @param references - optional generated names for declaration references.
* @returns parameter list and return type. * @returns parameter list and return type.
*/ */
renderSignature(signature: SignatureModel): string { renderSignature(signature: SignatureModel, references?: ReadonlyMap<SymbolId, string>): string {
return `${this.renderSignatureHead(signature)}: ${this.renderType(signature.returns)}` return `${this.renderSignatureHead(signature, references)}: ${this.renderType(signature.returns, references)}`
} }
/** /**
* Render one class/interface member as a body-free declaration. * Render one class/interface member as a body-free declaration.
* @param member - modeled member. * @param member - modeled member.
* @param sourceModifiers - retain source-only modifiers for reflection text. * @param sourceModifiers - retain source-only modifiers for reflection text.
* @param references - optional generated names for declaration references.
* @returns one-line TypeScript member text. * @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 if (sourceModifiers) return member.text
const name = renderPropertyName(member.name) const name = renderPropertyName(member.name)
const optional = member.optional ? '?' : '' const optional = member.optional ? '?' : ''
const readonly = member.readonly ? 'readonly ' : '' const readonly = member.readonly ? 'readonly ' : ''
const abstract = member.abstract ? 'abstract ' : '' const abstract = member.abstract ? 'abstract ' : ''
switch (member.kind) { switch (member.kind) {
case 'property': return `${abstract}${readonly}${name}${optional}: ${this.renderType(member.type)}` case 'property': return `${abstract}${readonly}${name}${optional}: ${this.renderType(member.type, references)}`
case 'method': return `${abstract}${name}${optional}${this.renderSignature(member.signature)}` case 'method': return `${abstract}${name}${optional}${this.renderSignature(member.signature, references)}`
case 'getter': return `${abstract}get ${name}()${this.renderReturn(member.signature)}` case 'getter': return `${abstract}get ${name}()${this.renderReturn(member.signature, references)}`
case 'setter': return `${abstract}set ${name}${this.renderSignatureHead(member.signature)}` case 'setter': return `${abstract}set ${name}${this.renderSignatureHead(member.signature, references)}`
case 'call': return this.renderSignature(member.signature) case 'call': return this.renderSignature(member.signature, references)
case 'construct': return `new ${this.renderSignature(member.signature)}` case 'construct': return `new ${this.renderSignature(member.signature, references)}`
case 'index': { case 'index': {
const parameters = member.signature.parameters.map(parameter => this.renderParameter(parameter)).join(', ') const parameters = member.signature.parameters.map(parameter => this.renderParameter(parameter, references)).join(', ')
return `${readonly}[${parameters}]: ${this.renderType(member.signature.returns)}` return `${readonly}[${parameters}]: ${this.renderType(member.signature.returns, references)}`
} }
default: return assertNever(member) default: return assertNever(member)
} }
@@ -290,38 +295,42 @@ export class TypeGraphRenderer {
return this.graph.declarations.filter(declaration => found.has(declaration.id)) return this.graph.declarations.filter(declaration => found.has(declaration.id))
} }
private renderSignatureHead(signature: SignatureModel): string { private renderSignatureHead(signature: SignatureModel, references?: ReadonlyMap<SymbolId, string>): string {
return `${this.renderTypeParameters(signature.typeParameters)}(${signature.parameters.map(parameter => this.renderParameter(parameter)).join(', ')})` return `${this.renderTypeParameters(signature.typeParameters, references)}(${signature.parameters.map(parameter => this.renderParameter(parameter, references)).join(', ')})`
} }
private renderReturn(signature: SignatureModel): string { private renderReturn(signature: SignatureModel, references?: ReadonlyMap<SymbolId, string>): string {
return `: ${this.renderType(signature.returns)}` 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 name = parameter.binding === 'identifier' ? renderPropertyName(parameter.name) : parameter.name
const optional = parameter.initializer === undefined && parameter.optional && !parameter.rest ? '?' : '' const optional = parameter.initializer === undefined && parameter.optional && !parameter.rest ? '?' : ''
const initializer = parameter.initializer === undefined ? '' : ` = ${parameter.initializer}` 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 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 variance = parameter.variance === undefined ? '' : `${parameter.variance === 'in-out' ? 'in out' : parameter.variance} `
const constModifier = parameter.const ? 'const ' : '' const constModifier = parameter.const ? 'const ' : ''
const constraint = parameter.constraint === undefined ? '' : ` extends ${this.renderType(parameter.constraint)}` const constraint = parameter.constraint === undefined ? '' : ` extends ${this.renderType(parameter.constraint, references)}`
const fallback = !includeDefault || parameter.default === undefined ? '' : ` = ${this.renderType(parameter.default)}` const fallback = !includeDefault || parameter.default === undefined ? '' : ` = ${this.renderType(parameter.default, references)}`
return `${constModifier}${variance}${parameter.name}${constraint}${fallback}` 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 '{}' 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 { private indexParameters(parameters: readonly TypeParameterModel[]): void {
+64 -15
View File
@@ -2,7 +2,7 @@
* Optional tsdown (rolldown) plugin face of the typert generator. When added * 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 * 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 * 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 * @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 { dirname, join, resolve } from 'node:path'
import { WorkspaceTypertGenerator } from './workspace.ts' import { WorkspaceTypertGenerator } from './workspace.ts'
import type { WorkspaceEmitResult } 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). */ /** The subset of the rolldown output-plugin contract this plugin uses (structural; avoids a rolldown type dependency). */
interface TypertPlugin { interface TypertPlugin {
@@ -17,21 +18,37 @@ interface TypertPlugin {
writeBundle: (options: { dir?: string }) => void 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. * 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 artifactsByRoot = new Map<string, readonly WorkspaceEmitResult[]>()
const emittedWorkspaces = new Set<string>()
return { return {
name: 'dsh-typert-generator', name: 'dsh-typert-generator',
writeBundle(options) { writeBundle(bundleOptions) {
// options.dir is the package's absolute outDir (<package>/lib); its // options.dir is the package's absolute outDir (<package>/lib); its
// nearest package.json owns the bundle even when a custom config writes // nearest package.json owns the bundle even when a custom config writes
// a nested output such as <package>/lib/dev. // a nested output such as <package>/lib/dev.
if (options.dir === undefined) return if (bundleOptions.dir === undefined) return
const root = workspaceRoot(options.dir) const root = workspaceRoot(bundleOptions.dir)
const packageDir = packageRoot(options.dir, root) 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 if (packageDir === undefined) return
const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as { const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as {
name?: string name?: string
@@ -40,22 +57,54 @@ export function typertPlugin(): TypertPlugin {
if (manifest.name === undefined || !hasTypertExport(manifest.exports)) return if (manifest.name === undefined || !hasTypertExport(manifest.exports)) return
let artifacts = artifactsByRoot.get(root) let artifacts = artifactsByRoot.get(root)
if (artifacts === undefined) { 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) artifactsByRoot.set(root, artifacts)
} }
const output = join(packageDir, 'lib') emitArtifacts(packageDir, artifacts.filter(candidate => candidate.package === manifest.name))
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)
}
}, },
} }
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 { function hasTypertExport(exportsField: unknown): boolean {
if (exportsField === null || typeof exportsField !== 'object' || Array.isArray(exportsField)) return false 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 { function packageRoot(start: string, workspace: string): string | undefined {
+37 -5
View File
@@ -9,6 +9,7 @@ import { TypertAnalysisError, WorkspaceAnalyzer } from './analyzer.ts'
import type { DiscoveredTypertPackage } from './analyzer.ts' import type { DiscoveredTypertPackage } from './analyzer.ts'
import { FaceModelEmitter } from './emitter.ts' import { FaceModelEmitter } from './emitter.ts'
import type { ModelEmitResult } from './emitter.ts' import type { ModelEmitResult } from './emitter.ts'
import type { TypertFace } from './model.ts'
/** One emitted artifact paired with its source package root. */ /** One emitted artifact paired with its source package root. */
export interface WorkspaceEmitResult extends ModelEmitResult { export interface WorkspaceEmitResult extends ModelEmitResult {
@@ -26,20 +27,29 @@ export class WorkspaceTypertGenerator {
/** /**
* Find public package faces that contribute Cordis services/events or * Find public package faces that contribute Cordis services/events or
* explicitly tagged Typert roots. * explicitly tagged Typert roots.
* @param faces - optional independent program faces to inspect.
* @returns discovered packages in stable package-name order. * @returns discovered packages in stable package-name order.
*/ */
discover(): DiscoveredTypertPackage[] { discover(faces?: readonly TypertFace[]): DiscoveredTypertPackage[] {
return new WorkspaceAnalyzer({ root: this.root }).discoverPackages() return new WorkspaceAnalyzer({
root: this.root,
...(faces === undefined ? {} : { faces }),
}).discoverPackages()
} }
/** /**
* Generate all discovered contributors, or an explicit package subset. * Generate all discovered contributors, or an explicit package subset.
* @param packages - optional exact package names for a focused pass. * @param packages - optional exact package names for a focused pass.
* @param faces - optional independent program faces to analyze.
* @returns one artifact per package face. * @returns one artifact per package face.
*/ */
generate(packages?: readonly string[]): WorkspaceEmitResult[] { generate(packages?: readonly string[], faces?: readonly TypertFace[]): WorkspaceEmitResult[] {
const selected = packages ?? this.discover().map(candidate => candidate.package) const selected = packages ?? this.discover(faces).map(candidate => candidate.package)
const workspace = new WorkspaceAnalyzer({ root: this.root, packages: selected }).analyze() const workspace = new WorkspaceAnalyzer({
root: this.root,
packages: selected,
...(faces === undefined ? {} : { faces }),
}).analyze()
const artifacts: WorkspaceEmitResult[] = [] const artifacts: WorkspaceEmitResult[] = []
for (const face of workspace.faces) { for (const face of workspace.faces) {
const emitter = new FaceModelEmitter(face) 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}`) 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: [ schemas: [
{ name: 'Payload', schema: Payload }, { name: 'Payload', schema: Payload },
], ],
invocations: [
],
model: { model: {
"services": [ "services": [
{ {
@@ -3815,6 +3817,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro
"abstract": false, "abstract": false,
"async": false, "async": false,
"id": "type:packages/host/src/models.ts:123:11#1#['computed']@3756", "id": "type:packages/host/src/models.ts:123:11#1#['computed']@3756",
"jsonName": "computed",
"kind": "property", "kind": "property",
"location": { "location": {
"column": 5, "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", "symbol": "@fixture/host:packages/host/src/models.ts#Variance",
}, },
], ],
"invocations": [],
"name": "@fixture/host", "name": "@fixture/host",
"objects": [ "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", "symbol": "<external>:../../../../../../node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/classic/schemas.d.cts#ZodType",
}, },
], ],
"invocations": [],
"name": "@fixture/client", "name": "@fixture/client",
"objects": [], "objects": [],
"root": "packages/client", "root": "packages/client",
@@ -0,0 +1,5 @@
{
"name": "@fixture/remote-workspace",
"private": true,
"type": "module"
}
@@ -0,0 +1,9 @@
{
"name": "@fixture/domain",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./types": "./src/types.ts"
}
}
@@ -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'
@@ -0,0 +1,2 @@
/** Stable Agent identity crossing the Remote boundary. */
export type AgentId = string
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"noEmit": false,
"declaration": true,
"emitDeclarationOnly": true
},
"include": ["src"]
}
@@ -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"
]
}
@@ -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'
@@ -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
}
@@ -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" }
]
}
@@ -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
}
}
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.base.json",
"files": [],
"references": [
{ "path": "./packages/domain" },
{ "path": "./packages/remote" }
]
}
@@ -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
}
@@ -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, FaceModel,
KeywordTypeName, KeywordTypeName,
MemberModel, MemberModel,
SignatureMemberModel,
SignatureModel, SignatureModel,
TypeDeclarationModel, TypeDeclarationModel,
TypeNodeModel, TypeNodeModel,
@@ -356,6 +357,149 @@ describe('SchemaEmitter supported projection matrix', () => {
expect(inheritedSchema.safeParse({ current: 1 }).success).toBe(false) 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', () => { it('classifies every TypeNode kind and executes every supported kind', () => {
const expected = Object.entries(ZOD_NODE_SUPPORT) const expected = Object.entries(ZOD_NODE_SUPPORT)
.filter(([, support]) => support === 'supported') .filter(([, support]) => support === 'supported')
@@ -373,7 +517,6 @@ describe('SchemaEmitter unsupported projection matrix', () => {
}) })
it.each([ it.each([
['type-parameter', { kind: 'type-parameter', parameter: 'parameter' }],
['cross-face', { kind: 'cross-face', face: 'client', package: '@fixture/client', subpath: '.', name: 'Value' }], ['cross-face', { kind: 'cross-face', face: 'client', package: '@fixture/client', subpath: '.', name: 'Value' }],
['external', { kind: 'external', module: 'external', subpath: '.', name: 'Value' }], ['external', { kind: 'external', module: 'external', subpath: '.', name: 'Value' }],
] as const)('rejects %s references explicitly', (kind, target) => { ] 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`) }])).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 const intrinsic = { id: 'root', kind: 'keyword', name: 'intrinsic' } as unknown as TypeNodeModel
expect(() => emit([intrinsic])) expect(() => emit([intrinsic]))
.toThrow('keyword intrinsic has no Zod projection') .toThrow('keyword intrinsic has no Zod projection')
@@ -399,17 +568,6 @@ describe('SchemaEmitter unsupported projection matrix', () => {
arguments: [], arguments: [],
}])).toThrow('standard type Promise has no Zod projection') }])).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', { const enumeration = declaration('Enumeration', 'enum', {
enumMembers: [{ ...documentation, name: 'Value', initializer: "'value'", location }], enumMembers: [{ ...documentation, name: 'Value', initializer: "'value'", location }],
}) })
@@ -481,6 +639,7 @@ describe('SchemaEmitter unsupported projection matrix', () => {
}], }],
objects: [], objects: [],
schemas: [], schemas: [],
invocations: [],
}], }],
} }
expect(() => new FaceModelEmitter(eventFace).emit('@fixture/schema')) expect(() => new FaceModelEmitter(eventFace).emit('@fixture/schema'))
@@ -513,6 +672,7 @@ describe('SchemaEmitter unsupported projection matrix', () => {
}], }],
objects: [], objects: [],
schemas: [], schemas: [],
invocations: [],
}], }],
} }
@@ -555,7 +715,33 @@ describe('SchemaEmitter unsupported projection matrix', () => {
expect(artifact.dts).toContain("from '@fixture/schema/secondary'") 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', 'rejects %s members on data-schema objects',
(kind) => { (kind) => {
expect(() => emit([ 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 { function signatureMember(kind: Exclude<MemberModel['kind'], 'property'>): MemberModel {
return { return {
...documentation, ...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( function declaration(
name: string, name: string,
kind: TypeDeclarationModel['kind'], kind: TypeDeclarationModel['kind'],
@@ -684,6 +892,7 @@ function emit(
symbol: 'Root', symbol: 'Root',
type: 'schema-reference', type: 'schema-reference',
}], }],
invocations: [],
}], }],
} }
return new FaceModelEmitter(face).emit('@fixture/schema').js return new FaceModelEmitter(face).emit('@fixture/schema').js
@@ -710,6 +919,7 @@ function schemaFace(
symbol, symbol,
type: 'root', type: 'root',
}], }],
invocations: [],
}], }],
} }
} }
@@ -62,7 +62,7 @@ describe('model-driven dsh-tools generation', () => {
TYPE_API.find(type => type.name === 'ToolDefinition'), TYPE_API.find(type => type.name === 'ToolDefinition'),
) )
dispose() await dispose()
expect(ctx.typert.getPackage('@deepseek-ai/dsh-tools', 'host')).toBeUndefined() expect(ctx.typert.getPackage('@deepseek-ai/dsh-tools', 'host')).toBeUndefined()
}) })
}) })
@@ -12,6 +12,11 @@ const generated = vi.hoisted(() => vi.fn(() => [
exports: [], exports: [],
js: 'export const host = true\n', js: 'export const host = true\n',
dts: 'export declare 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', package: '@deepseek-ai/dsh-tools',
@@ -21,10 +26,30 @@ const generated = vi.hoisted(() => vi.fn(() => [
js: 'export const client = true\n', js: 'export const client = true\n',
dts: 'export declare 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', () => ({ vi.mock('../src/workspace.ts', () => ({
WorkspaceTypertGenerator: class { WorkspaceTypertGenerator: class {
discover = discovered
generate = generated generate = generated
}, },
})) }))
@@ -33,6 +58,7 @@ const { typertPlugin } = await import('../src/tsdown-plugin.ts')
const roots: string[] = [] const roots: string[] = []
afterEach(() => { afterEach(() => {
discovered.mockClear()
generated.mockClear() generated.mockClear()
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) 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.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(readFileSync(join(packageLib, 'typert.client.js'), 'utf8')).toBe('export const client = true\n')
expect(existsSync(join(packageLib, 'typert.client.d.ts'))).toBe(true) 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')) expect(readFileSync(join(root, 'packages/client-tools/lib/typert.client.js'), 'utf8'))
.toBe('export const client = true\n') .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> { async function workspace(): Promise<string> {
@@ -201,6 +201,53 @@ describe('WorkspaceAnalyzer', { timeout: 60_000 }, () => {
expect(batched).toEqual(direct) 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', () => { it('indexes authored top-level exports without promoting them to graph roots', () => {
const declarations = new WorkspaceAnalyzer({ root: fixtureRoot }).indexSourceDeclarations() const declarations = new WorkspaceAnalyzer({ root: fixtureRoot }).indexSourceDeclarations()
const agent = declarations.find(declaration => declaration.name === 'Agent') 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`) 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 }, () => { describe('FaceModelEmitter', { timeout: 60_000 }, () => {
it('emits runnable Zod JavaScript, precise declarations, and runtime package metadata', async () => { it('emits runnable Zod JavaScript, precise declarations, and runtime package metadata', async () => {
const model = new WorkspaceAnalyzer({ root: fixtureRoot }).analyze() const model = new WorkspaceAnalyzer({ root: fixtureRoot }).analyze()
+89 -2
View File
@@ -135,6 +135,11 @@ export function validateTypertManifest(pkgName: string, exported: unknown): Type
requireMembers(pkgName, object.members, `object "${object.name as string}"`) requireMembers(pkgName, object.members, `object "${object.name as string}"`)
requireTypes(pkgName, object.types, `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 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 * Scan current Loader entries during activation, then follow entry mounts and
* unmounts for this plugin's lifetime. * 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) const configured = new Set((config as ResolvedConfig).packages)
// Registered contributions by entry name; the disposer withdraws the entry's registration. // 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. // In-flight import/register tasks by entry name.
const pending = new Map<string, Promise<void>>() const pending = new Map<string, Promise<void>>()
// Artifact paths by package name. Negative verdicts (unresolvable specifier — // 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) const dispose = registered.get(entryName)
if (dispose !== undefined) { if (dispose !== undefined) {
registered.delete(entryName) registered.delete(entryName)
dispose() return dispose()
} }
return undefined return undefined
} }
+210
View File
@@ -1,4 +1,5 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import { pathToFileURL } from 'node:url' import { pathToFileURL } from 'node:url'
@@ -8,6 +9,7 @@ import Loader from '@cordisjs/plugin-loader'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import * as typertLoader from '@deepseek-ai/dsh-typert-loader' import * as typertLoader from '@deepseek-ai/dsh-typert-loader'
import { validateTypertManifest } from '@deepseek-ai/dsh-typert-loader' import { validateTypertManifest } from '@deepseek-ai/dsh-typert-loader'
import { z } from 'zod'
let root: string | undefined let root: string | undefined
let context: Context | undefined let context: Context | undefined
@@ -63,12 +65,45 @@ function typertSource(pkgName: string, entryName: string): string {
].join('\n') ].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. */ /** Boot a real Loader over a fixture root; plugin modules resolve from its node_modules. */
async function boot(): Promise<Context> { async function boot(): Promise<Context> {
context = new Context() context = new Context()
context.baseUrl = pathToFileURL(join(root as string, 'cordis.yml')).href context.baseUrl = pathToFileURL(join(root as string, 'cordis.yml')).href
await context.plugin(TypertRegistry) await context.plugin(TypertRegistry)
await context.plugin(Loader) 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. // zod must be resolvable from the fixture packages; link the workspace copy.
await mkdir(join(root as string, 'node_modules'), { recursive: true }) await mkdir(join(root as string, 'node_modules'), { recursive: true })
return context return context
@@ -105,6 +140,33 @@ describe('typert loader', () => {
expect(ctx.typert.getPackage('@fixture/nested')).toBeUndefined() 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 () => { 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-')) root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-'))
await writePackage(root, '@fixture/plain') await writePackage(root, '@fixture/plain')
@@ -427,8 +489,156 @@ describe('validateTypertManifest', () => {
model: { ...complete.model, objects: [{ ...complete.model.objects[0], exportName: '' }] }, model: { ...complete.model, objects: [{ ...complete.model.objects[0], exportName: '' }] },
})).toThrow('object has a missing or empty 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) { function completeManifest(zodish: object) {
const member = { name: 'member', signature: 'member(): void', kind: 'method' } const member = { name: 'member', signature: 'member(): void', kind: 'method' }
const type = { name: 'Value', declaration: 'export interface Value {}' } const type = { name: 'Value', declaration: 'export interface Value {}' }
+15
View File
@@ -15,6 +15,10 @@
"types": "./lib/types/invariant.d.ts", "types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js" "default": "./lib/invariant.js"
}, },
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./types": { "./types": {
"types": "./lib/types/types.d.ts", "types": "./lib/types/types.d.ts",
"default": "./lib/types/types.js" "default": "./lib/types/types.js"
@@ -22,14 +26,25 @@
"./src/*": "./src/*", "./src/*": "./src/*",
"./package.json": "./package.json" "./package.json": "./package.json"
}, },
"dshClient": {
"inject": [],
"platform": "web",
"immediately": true
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"files": [ "files": [
"lib/index.js", "lib/index.js",
"lib/invariant.js", "lib/invariant.js",
"lib/client.js",
"lib/types/**/*.js", "lib/types/**/*.js",
"lib/types/**/*.d.ts" "lib/types/**/*.d.ts"
], ],
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"@deepseek-ai/dsh-type-meta": "workspace:^",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"peerDependencies": { "peerDependencies": {
@@ -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)
}
+14 -206
View File
@@ -1,12 +1,7 @@
/** /** Host entry for the shared TypeRT runtime registry. */
* 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
*/
import { Context, Service } from 'cordis' import type { z } from 'zod'
import { z } from 'zod' import type { TypeRTDisposer } from '@deepseek-ai/dsh-type-meta'
import type { import type {
TypertContribution, TypertContribution,
TypertFace, TypertFace,
@@ -16,204 +11,17 @@ import type {
TypertSchemaRecord, TypertSchemaRecord,
} from './types.ts' } from './types.ts'
export type { export { default, TypertRegistry, typertEndpoint, typertKey, typertPackageKey } from './service.ts'
TypertContribution, export type * from './types.ts'
TypertDocTag,
TypertDocumentation,
TypertEventModel,
TypertFace,
TypertMemberModel,
TypertObjectModel,
TypertPackageFilter,
TypertPackageModel,
TypertPackageRecord,
TypertSchema,
TypertSchemaFilter,
TypertSchemaRecord,
TypertServiceModel,
TypertTypeModel,
} from './types.ts'
declare module 'cordis' { declare module '@deepseek-ai/dsh-type-meta' {
interface Context { interface TypeRTService {
typert: TypertRegistry 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
View 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
+8
View File
@@ -5,6 +5,7 @@
*/ */
import type { z } from 'zod' import type { z } from 'zod'
import type { InvocationDescriptor } from '@deepseek-ai/dsh-type-meta'
/** Independently compiled side that produced a contribution. */ /** Independently compiled side that produced a contribution. */
export type TypertFace = 'host' | 'client' export type TypertFace = 'host' | 'client'
@@ -82,6 +83,13 @@ export interface TypertContribution {
readonly face: TypertFace readonly face: TypertFace
readonly schemas: readonly TypertSchema[] readonly schemas: readonly TypertSchema[]
readonly model: TypertPackageModel 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. */ /** A live schema plus its contribution identity. */
+183 -1
View File
@@ -2,10 +2,27 @@ import { describe, expect, it } from 'vitest'
import { Context } from 'cordis' import { Context } from 'cordis'
import { z } from 'zod' import { z } from 'zod'
import TypertRegistry, { import TypertRegistry, {
typertEndpoint,
typertKey, typertKey,
typertPackageKey, typertPackageKey,
type TypertContribution, type TypertContribution,
} from '@deepseek-ai/dsh-typert-registry' } 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> { async function makeCtx(): Promise<Context> {
const ctx = new 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', () => { describe('TypertRegistry', () => {
it('registers and queries generated schemas separately from package reflection', async () => { it('registers and queries generated schemas separately from package reflection', async () => {
const ctx = await makeCtx() const ctx = await makeCtx()
@@ -69,7 +122,7 @@ describe('TypertRegistry', () => {
const dispose = ctx.typert.register(toolsContribution()) const dispose = ctx.typert.register(toolsContribution())
expect(ctx.typert.getPackage('@deepseek-ai/dsh-tools')).toBeDefined() 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.get('@deepseek-ai/dsh-tools#ToolInput')).toBeUndefined()
expect(ctx.typert.getPackage('@deepseek-ai/dsh-tools')).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(projected).toMatchObject({ type: 'object', properties: { name: { type: 'string' } } })
expect(ctx.typert.toJSONSchema('@deepseek-ai/dsh-tools#ToolInput')).not.toBe(projected) 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')
})
}) })
+3
View File
@@ -16,6 +16,9 @@
}, },
{ {
"path": "../../support/invariants" "path": "../../support/invariants"
},
{
"path": "../type-meta"
} }
] ]
} }
+2 -24
View File
@@ -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 clientBundle('@deepseek-ai/dsh-typert-registry', ['lib/types/index.js', 'lib/types/invariant.js'])
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,
},
])
@@ -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
View 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
View 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
View 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
View 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 "/"`)
}
}
@@ -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
View 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
}
}
@@ -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)
@@ -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
View 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
View File
@@ -916,6 +916,9 @@ importers:
'@deepseek-ai/dsh-goal-session': '@deepseek-ai/dsh-goal-session':
specifier: workspace:^ specifier: workspace:^
version: link:../../goal/goal-session version: link:../../goal/goal-session
'@deepseek-ai/dsh-host-api-gateway':
specifier: workspace:^
version: link:../../host/api-gateway
'@deepseek-ai/dsh-llm': '@deepseek-ai/dsh-llm':
specifier: workspace:^ specifier: workspace:^
version: link:../../llm/llm version: link:../../llm/llm
@@ -1054,6 +1057,12 @@ importers:
'@deepseek-ai/dsh-tools': '@deepseek-ai/dsh-tools':
specifier: workspace:^ specifier: workspace:^
version: link:../../core/tools 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': '@deepseek-ai/dsh-user-approval':
specifier: workspace:^ specifier: workspace:^
version: link:../../ui/user-approval version: link:../../ui/user-approval
@@ -2790,6 +2799,12 @@ importers:
'@deepseek-ai/dsh-system-prompt': '@deepseek-ai/dsh-system-prompt':
specifier: workspace:^ specifier: workspace:^
version: link:../system-prompt 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: cordis:
specifier: ^4.0.0-rc.7 specifier: ^4.0.0-rc.7
version: link:../../../vendor/cordis version: link:../../../vendor/cordis
@@ -2854,6 +2869,12 @@ importers:
'@deepseek-ai/dsh-scope': '@deepseek-ai/dsh-scope':
specifier: workspace:^ specifier: workspace:^
version: link:../scope 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: cordis:
specifier: ^4.0.0-rc.7 specifier: ^4.0.0-rc.7
version: link:../../../vendor/cordis version: link:../../../vendor/cordis
@@ -3706,6 +3727,31 @@ importers:
specifier: ^4.0.0-rc.7 specifier: ^4.0.0-rc.7
version: link:../../../vendor/cordis 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: packages/host/apiproxy:
dependencies: dependencies:
'@deepseek-ai/dsh-agent': '@deepseek-ai/dsh-agent':
@@ -6096,6 +6142,9 @@ importers:
packages/typert/generator: packages/typert/generator:
dependencies: dependencies:
'@jridgewell/gen-mapping':
specifier: ^0.3.13
version: 0.3.13
typescript: typescript:
specifier: ^6.0.3 specifier: ^6.0.3
version: 6.0.3 version: 6.0.3
@@ -6140,6 +6189,9 @@ importers:
packages/typert/registry: packages/typert/registry:
dependencies: dependencies:
'@deepseek-ai/dsh-type-meta':
specifier: workspace:^
version: link:../type-meta
zod: zod:
specifier: ^4.4.3 specifier: ^4.4.3
version: 4.4.3 version: 4.4.3
@@ -6151,6 +6203,15 @@ importers:
specifier: ^4.0.0-rc.7 specifier: ^4.0.0-rc.7
version: link:../../../vendor/cordis 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: packages/ui/app-boot:
dependencies: dependencies:
js-yaml: js-yaml:
+7
View File
@@ -59,6 +59,13 @@ describe('client bundle purity gate', () => {
expect(resolveId('@deepseek-ai/dsh-brand')).toBeNull() 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', () => { it('throws on any other @deepseek-ai leak', () => {
expect(() => resolveId('@deepseek-ai/dsh-agent')).toThrow(/purity/) expect(() => resolveId('@deepseek-ai/dsh-agent')).toThrow(/purity/)
expect(() => resolveId('@deepseek-ai/dsh-client-web')).toThrow(/purity/) expect(() => resolveId('@deepseek-ai/dsh-client-web')).toThrow(/purity/)
+2
View File
@@ -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', 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', 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', 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.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', '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', 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', 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', 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', 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', PresetOption: 'deployment menu metadata is owned by packages/ui/permission/README.md',
PresetSpec: 'deployment preset composition 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', KnobState: 'projection unit state shape is owned by packages/ui/permission/README.md',
+9 -2
View File
@@ -140,8 +140,15 @@ const SERVICE_ROLES: ServiceRole[] = [
pkg: 'typert-registry', pkg: 'typert-registry',
title: 'Runtime type registry', title: 'Runtime type registry',
mode: 'core', mode: 'core',
consumers: ['typert-loader'], consumers: ['typert-loader', 'api-gateway'],
note: 'Plugins register live zod contributions directly or through dsh-typert-loader; runtime consumers query schemas and reflection metadata at their own edges.', 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', 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/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-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/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/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': { 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.' }, 'packages/tasks/tasks-local': { kind: 'indirect', reason: 'The registry backend delegates model rendering to producer plugins and dsh-tool-tasks.' },
+9 -1
View File
@@ -40,6 +40,13 @@
"@cordisjs/plugin-logger-console": ["./vendor/logger-console/src"], "@cordisjs/plugin-logger-console": ["./vendor/logger-console/src"],
"@deepseek-ai/dsh-invariants": ["./packages/support/invariants/src/index.ts"], "@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": ["./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-typert-loader": ["./packages/typert/loader/src/index.ts"],
"@deepseek-ai/dsh-session/invariant": ["./packages/core/session/src/invariant.ts"], "@deepseek-ai/dsh-session/invariant": ["./packages/core/session/src/invariant.ts"],
"@deepseek-ai/dsh-typert-registry/types": ["./packages/typert/registry/src/types.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-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-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-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-agent/invariant": ["./packages/core/agent/src/invariant.ts"],
"@deepseek-ai/dsh-scope/invariant": ["./packages/core/scope/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"], "@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-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-web-react": ["./packages/client/web-react/src"],
"@deepseek-ai/dsh-client-connection": ["./packages/client/connection/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-hmr": ["./packages/client/hmr/src"],
"@deepseek-ai/dsh-client-modules": ["./packages/client/modules/src"], "@deepseek-ai/dsh-client-modules": ["./packages/client/modules/src"],
"@deepseek-ai/dsh-client-runtime": ["./packages/client/runtime/src"], "@deepseek-ai/dsh-client-runtime": ["./packages/client/runtime/src"],
+2
View File
@@ -51,6 +51,8 @@
{ "path": "./packages/client/modules" }, { "path": "./packages/client/modules" },
{ "path": "./packages/client/hmr" }, { "path": "./packages/client/hmr" },
{ "path": "./packages/client/connection" }, { "path": "./packages/client/connection" },
{ "path": "./packages/typert/registry" },
{ "path": "./packages/host/api-gateway" },
{ "path": "./packages/client/runtime" }, { "path": "./packages/client/runtime" },
{ "path": "./packages/client/test-runtime" }, { "path": "./packages/client/test-runtime" },
{ "path": "./packages/client/ui-layout" }, { "path": "./packages/client/ui-layout" },
+2
View File
@@ -100,7 +100,9 @@
{ "path": "./packages/llm/token-meter" }, { "path": "./packages/llm/token-meter" },
{ "path": "./packages/core/session" }, { "path": "./packages/core/session" },
{ "path": "./packages/core/scope" }, { "path": "./packages/core/scope" },
{ "path": "./packages/typert/type-meta" },
{ "path": "./packages/typert/registry" }, { "path": "./packages/typert/registry" },
{ "path": "./packages/host/api-gateway" },
{ "path": "./packages/typert/loader" }, { "path": "./packages/typert/loader" },
{ "path": "./packages/session-persistence/session-persistence" }, { "path": "./packages/session-persistence/session-persistence" },
{ "path": "./packages/session-persistence/session-checkpoint-policy" }, { "path": "./packages/session-persistence/session-checkpoint-policy" },
+4
View File
@@ -1,4 +1,5 @@
import { defineConfig } from 'tsdown' import { defineConfig } from 'tsdown'
import { typertPlugin } from './packages/typert/generator/lib/types/tsdown-plugin.js'
/** /**
* JS bundling for vendored Cordis and Harness TypeScript packages. * JS bundling for vendored Cordis and Harness TypeScript packages.
@@ -27,4 +28,7 @@ export default defineConfig({
fixedExtension: false, fixedExtension: false,
dts: false, dts: false,
clean: 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
View 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'] })],
})
+27 -3
View File
@@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url'
import tsconfigPaths from 'vite-tsconfig-paths' import tsconfigPaths from 'vite-tsconfig-paths'
import { resolvePwshPath } from './packages/bash/pwsh-local/src/resolve.ts' import { resolvePwshPath } from './packages/bash/pwsh-local/src/resolve.ts'
import { defineConfig } from 'vitest/config' import { defineConfig } from 'vitest/config'
import ts from 'typescript'
import { vitestExecArgv } from './vitest.shared.ts' import { vitestExecArgv } from './vitest.shared.ts'
import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './scripts/coverage-exempt.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 // map applies to every test file. paths must win over package exports so built
// lib/ never loads a second module-singleton copy. // lib/ never loads a second module-singleton copy.
const pathsPlugin = (): ReturnType<typeof tsconfigPaths> => tsconfigPaths({ projects: ['./tsconfig.base.json'] }) 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' const windowsUnsupportedPackages = process.platform === 'win32'
? [ ? [
@@ -88,7 +112,7 @@ const processBoundTests = [
] ]
export default defineConfig({ export default defineConfig({
plugins: [pathsPlugin()], plugins: [pathsPlugin(), standardDecoratorPlugin()],
test: { test: {
setupFiles: ['./scripts/test-invariants.ts'], setupFiles: ['./scripts/test-invariants.ts'],
// .tsx: client component specs (jsdom via per-file @vitest-environment pragma). // .tsx: client component specs (jsdom via per-file @vitest-environment pragma).
@@ -99,7 +123,7 @@ export default defineConfig({
// always fork. // always fork.
projects: [ projects: [
{ {
plugins: [pathsPlugin()], plugins: [pathsPlugin(), standardDecoratorPlugin()],
test: { test: {
name: 'thread-safe', name: 'thread-safe',
execArgv: vitestExecArgv, execArgv: vitestExecArgv,
@@ -119,7 +143,7 @@ export default defineConfig({
}, },
}, },
{ {
plugins: [pathsPlugin()], plugins: [pathsPlugin(), standardDecoratorPlugin()],
test: { test: {
name: 'process-bound', name: 'process-bound',
execArgv: vitestExecArgv, execArgv: vitestExecArgv,