Search/Maintain a Two-Way Protocol SDK in a TypeScript Monorepo Without Breaking Its Public API

Maintain a Two-Way Protocol SDK in a TypeScript Monorepo Without Breaking Its Public API

Use when changing a typed SDK where client and server both send requests, published as several packages. Keeps public exports deliberate, adds operations on both sides with capability checks, records breaking changes, and runs examples as tests.

To use this AI rule
## Check Which Layer a Change Hits and Whether It Is Public API This guide governs work inside a multi-package TypeScript workspace that publishes a protocol SDK: a shared internal core, a client package, a server package, a standalone schema package, and thin framework or runtime adapter packages. Assume strict type checking, ES modules, and workspace-wide scripts driven by one package manager. Two properties dominate every decision. First, the published surface is a contract: anything reachable from a package entry point is public API and cannot be changed casually. Second, the protocol is bidirectional — both peers may originate requests — so a change on one side usually carries a mirror-image obligation on the other. Before editing, identify which layer the change belongs to and whether it touches the published surface. If it does, treat it as an API change and apply the surface and migration rules below, not only the style rules. ## Run One Test While Iterating and the Full Checks Before Finishing The workspace exposes aggregate scripts covering the whole repository: install dependencies, build all packages, run linting and formatting checks, auto-fix lint and formatting, type-check all packages, run the full test suite, and a combined check script that pairs type checking with linting. Narrow the loop while iterating. Use the package-manager filter flag to run a single package's test script, pass a file path to run one test file, and pass a name filter to run a single test case. A watch-mode script exists for tight iteration. Reserve full-workspace runs for the moment before declaring work complete, and run both the combined check and the test suite then. Type checking alone does not prove runtime behavior — never claim a change works without executing the tests that exercise it. ## Follow the Repository's Naming and Formatting Rules - Strict type checking throughout; explicit return types on exported functions. - PascalCase for classes and types; camelCase for functions and variables. - Lowercase hyphenated file names; test files carry a test suffix in their extension. - ES module imports. Follow the project's module resolution setting for extensions — with bundler-style resolution, relative imports carry no file extension. Group imports logically. - Two-space indentation, required semicolons, single quotes preferred. - Tests live under each package's dedicated test directory so the runner's include pattern picks them up; use descriptive test names. - Documentation comments on public APIs; inline comments reserved for genuinely non-obvious logic. When a rule is ambiguous, match the surrounding file. Do not reformat or refactor unrelated code inside a functional change. ## Pull Doc Examples From Compiled Example Files Hand-written snippets inside documentation comments rot silently because nothing compiles them. Instead, pull example code from companion example modules that are compiled and type-checked alongside the rest of the source. A companion file sits beside its subject module under a matching name. Named regions inside the companion file are referenced from the documentation fence via a source attribute pointing at the file and region. Region names follow a predictable pattern: the exported symbol name plus a variant, or a class name plus method name plus a variant. Omitting the region reference includes the whole file, which works for any file type. Run the snippet sync script after editing examples so the current content is copied into documentation comments and markdown pages, and commit the synced output together with the change. ## Export Public Symbols by Name and Keep the Package Root Runtime-Neutral Separate an internal barrel — which exports everything, is marked private, and is consumed only by sibling packages inside the workspace — from a curated public export module exposing types, error classes, constants, guards, and shared base classes. Package entry points re-export the curated module plus their own explicitly named exports. Rules that are not negotiable: - Use explicit named exports in entry points and in the curated module. Never use wildcard re-exports there. - Adding a symbol to a package entry point makes it public API. Do so only on purpose. - Internal helpers stay in the internal barrel and are never added to the curated module or to an entry point. - Keep each package root entry runtime-neutral so browser and edge-runtime bundlers can consume it. Any export whose module graph transitively reaches platform-specific built-ins such as process spawning or raw sockets must live behind a named subpath export and be covered by a test asserting the root barrel stays free of them. - Keep validation-library types out of published surfaces that promise to remain free of them; publish raw runtime schemas from the dedicated schema package instead, and let sibling packages resolve that package as a real external dependency rather than carrying private copies of the schemas. ## Build on the Schema Layer and Keep Transports Pluggable Organize the SDK into three layers. 1. Types and schema layer — protocol message types, validation schemas, and constants aligned to the specification. This is the single source of truth; every other layer derives from it. 2. Protocol layer — an abstract base class handling message routing, request and response correlation, capability negotiation, and transport lifecycle. Both peer implementations extend it, which is why bidirectional behavior is symmetric by construction. 3. High-level APIs — the client and server classes exposing typed methods, plus a higher-level server wrapper offering simplified registration of tools, resources, and prompts. Transports are a separate pluggable layer behind one interface: a streaming HTTP transport recommended for remote peers, a legacy event-stream transport retained for backwards compatibility, and a standard-input-output transport for locally spawned process integrations. Validation is pluggable in the same spirit, with a default validator plus alternatives compatible with restricted runtimes. Framework and runtime adapter packages are thin integration layers. They must never add protocol functionality; anything that changes protocol behavior belongs in the core layers. ## Trace How Requests Flow Out and In on Both Peers Outbound. A high-level method delegates to the base request method, which assigns a unique message identifier, asserts that the remote peer declared the capability required by the method, registers a pending response handler, hands the serialized request to the transport, and awaits resolution when the matching response arrives. Inbound. The transport surfaces an incoming message and base routing dispatches it as a request, a response, or a notification. Request handling looks up a handler keyed by method name, builds a context object, lets the subclass enrich that context with transport-specific detail, invokes the handler, and returns the handler result as a response over the transport. Context shape. A session identifier; a request group holding the message identifier, the method name, request metadata, an abort signal for cancellation, and helpers to send a related request or notification back to the peer; and an optional transport group carrying validated authorization details when the transport provides them. The server-side context adds logging, user-input elicitation, and model-sampling helpers. Capability enforcement runs in both directions and also at handler registration, so a peer cannot register a handler for something it never declared. ## Add a New Protocol Operation With Capability Checks on Both Sides 1. Define the request parameter schema and result schema in the types layer. 2. Add the corresponding capability flag to the client or server capability type. 3. Implement the sending method on the peer that initiates the operation. 4. Add the capability assertion inside that peer's capability check. 5. Register the receiving handler on the opposite peer. 6. Add a high-level wrapper on the simplified server API when the operation warrants one. 7. Add tests on both sides, and a runnable example when the operation is user-facing. Steps two and four are the ones most often skipped and the most expensive to skip: an operation without capability negotiation fails at runtime against peers that do not implement it, instead of failing cleanly at initialization. ## Record Breaking Changes With What, Why, and How to Migrate Record every breaking change in the migration guide, inside the subsystem section it affects. Search for related sections and extend them rather than appending new standalone sections — a guide made of parallel one-off entries becomes unreadable exactly when someone needs it. Changes scoped to a single release note file go there instead. Every entry states three things: what changed, why it changed, and how to migrate. Mechanical renames belong in the automated migration tool's mapping files. Link to the mapping rather than reproducing rename tables in prose; duplicated tables drift out of sync with the tool that actually performs the rename. Judgment: prefer additive change. When a break is unavoidable, keep the old path working behind a deprecation for one cycle where feasible, and shape the break so it surfaces as a clear compile-time error rather than a silent behavior change. ## Run Paired Client-Server Examples as Tests in CI Treat examples as tests, not decoration. Each example is its own workspace package containing a paired server and client; the client connects, asserts the results it expects, and exits with a failure status on mismatch. A runner executes every example across its configured transport and protocol-era combinations, and that job is part of the per-change continuous integration gate. Shared example plumbing lives in one shared package whose root export stays minimal — argument parsing, assertion helpers, sibling path resolution. Heavier optional dependencies such as a demonstration authorization provider or an in-memory event store sit behind a subpath export so unrelated examples do not eagerly load them. Examples import only this plumbing and set up transports inline, so each example reads as real usage rather than as a wrapper around hidden setup. Documentation guide pages get per-page snippet companions synced by the same snippet sync step, and the runnable ones are executed in continuous integration too.