# 10 TypeScript files, 5 SDK artefacts: typed in Node and Deno

> Source: <https://dev.to/panthpatel/10-typescript-files-5-sdk-artefacts-typed-in-node-and-deno-2j6d>
> Published: 2026-10-20 22:43:32+00:00

Ten TypeScript files go in, five artefacts come out, and the client is typed in Node, Deno, Bun and the browser. One build script does it, and the SDK has no dependencies.

The constraint: an orchestrator should be able to import the client straight from a URL and get types, and nothing I host may be a second copy of the code that drifts from the first.

I'm Panth, and I lead the software team at Oizom. This is from [ticket-tracker](https://github.com/Panth977/ticket-tracker), my open-source board, where AI agents work tickets alongside people. Writing a client by hand for every orchestrator is the thing the app exists to avoid, so it ships one: `@tm/sdk`, hosted next to the API it talks to.

```
/lib/v1/sdk.js              ESM bundle, no dependencies   (browsers, Deno, Bun)
/lib/v1/sdk.min.js          the same, minified
/lib/v1/sdk.ts              the whole SDK as ONE TypeScript file
/lib/v1/sdk.d.ts            one flat declaration file, no imports
/lib/v1/tm-sdk-1.6.0.tgz    and tm-sdk.tgz, for npm install
/lib/v1/openapi.json        OpenAPI 3.1 for /v1
/lib/v1/sdk.json            version, byte sizes, SHA-256 of every file
/lib/latest/…               the same files, never cached
```

The `v1` is the API's wire version, not the package's. A breaking change to the wire format would get `/lib/v2/`, and `/lib/v1/` keeps working. A new SDK build for the same wire version replaces the files in place under a new tarball name.

`sdk/build.mjs` is 237 lines. The order of the steps is the whole idea:

```
sdk/src/*.ts  ──concat, imports stripped──▶  dist/sdk.ts
                                               │
                    tsc --emitDeclarationOnly  │        esbuild
                                               ▼            ▼
                                         dist/sdk.d.ts   dist/sdk.js + sdk.min.js
                                                            │  npm pack
                                                            ▼
                                                  dist/tm-sdk-<version>.tgz
```

Step one glues the 10 modules in `sdk/src/` into one file, in a fixed order, and drops every `import … from './x.js'` line. Anything that imports a package rather than a sibling is a bug, so the script throws instead of dropping it:

``` js
const from = stmt.match(/from\s+'([^']+)'/);
if (from && !from[1].startsWith('.'))
  throw new Error(`${file}: the SDK must have no dependencies, but it imports '${from[1]}'`);
```

Everything else is derived from that one file. That's what keeps the promise cheap.

`sdk.d.ts`` tsc` from a file that has no imports. Inside the repo the SDK's types come from the same shared zod schemas the server validates with, so a wrong field is a compile error rather than a 400. But a URL import can't resolve `zod` or the shared package. Emitting from the concatenated file means the declaration file can't come out mentioning them. It is also a full strict type-check on the way.`sdk.ts`` sdk.js` and `sdk.min.js``/// <reference types="./sdk.d.ts" />`, which Deno and Bun read.` package.json` with `types` first in its `exports` map, so tsc and editors resolve the declaration file. Node can't import over https, so this is the supported path there.
At the end, the build imports each bundle, checks the exports and the version stamp, creates a client with a fake `fetch`, and asks the MCP tool factory for a tool. It also fails if `sdk.d.ts` has a single `import` line. A broken build never reaches the deploy.

Hosting is Firebase, and the headers come from `firebase.json`. Three rules matter.

| Path | Header | Why | 
|---|---|---|
| `/lib/v1/**` | `Cache-Control: public, max-age=31536000, immutable` | A URL import in a hot loop must not cost a round trip | 
| `/lib/latest/**` | `Cache-Control: no-store` | A moving name is only useful if it moves | 
| `/lib/**/*.ts` | `Content-Type: application/typescript` | Otherwise `.ts` goes out as`video/mp2t` | 

The last one is real. Left alone, Hosting serves a `.ts` file as an MPEG transport stream, and Deno refuses it. `.d.ts` also ends in `.ts`, so one rule covers both. Next to it, `/lib/v1/sdk*.js` gets `X-TypeScript-Types: /lib/v1/sdk.d.ts`, which is what makes `import … from 'https://…/sdk.js'` typed in Deno.

Header rules match the request path, not the file. `/lib/index.html` doesn't match a request for `/lib/`, so the install page's rule is spelled three ways. This looks right in review and is wrong in production, which is why the headers are asserted rather than argued: a check starts the real Hosting emulator on the real config, fetches all eighteen files and asserts the content type, cache rule, CORS header and `X-TypeScript-Types` on each.

"It compiles" proves little if the types never loaded, because untyped code compiles too. `sdk/fixtures/check.mjs` runs one check per runtime against a local stand-in that sends the same headers as `firebase.json`:

`npm i` the real tarball into a project of its own, then `tsc --strict`.` deno check` a script that imports `X-TypeScript-Types` header.
Every fixture ends in `@ts-expect-error` lines. They fail if the mistake compiles, and they also fail if the types never resolved, since an expected error that never happens is itself an error. So "no types" can't pass as "types fine". And there is a negative control: the same bundle with both type hints removed must fail `deno check`. It does. If it ever passed, the checks above it would prove nothing, and the script says so.

The SDK is built by `pnpm sdk:build`, not by the deploy. So the deploy checks what it's about to upload: every artefact exists, carries the version `sdk/package.json` claims, starts with the triple-slash reference, has a flat declaration file, and is newer than every file under `sdk/src`. If not, it stops and prints the command to run.

The failure it guards against is quiet: yesterday's bundle going out next to today's API. Nothing would error. A field would just be missing from the types until someone hit it.

The rule is "derive, don't copy", and the price is mostly the checks. The build script is 237 lines, and the fixture and header checks add their own. In return there is one place to change the client, and the four runtimes see the same code with the same types.

How do you ship a typed client to users who import it over a URL rather than from npm?
