cd /news/developer-tools/10-typescript-files-5-sdk-artefacts-… · home › topics › developer-tools › article
[ARTICLE · art-154092] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=↑ positive

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

Panth, who leads the software team at Oizom, built a single-build-script pipeline that compiles 10 TypeScript source files into five SDK artefacts for the open-source ticket-tracker project, producing a dependency-free client typed for Node, Deno, Bun and the browser. The 237-line sdk/build.mjs concatenates the modules, strips sibling imports and throws on any package import, then derives a flat sdk.d.ts via tsc and ESM bundles via esbuild, with a post-build check that imports each bundle and fails if the declaration file contains an import line. Files are hosted on Firebase with immutable caching for /lib/v1/, no-store for /lib/latest/, and a typescript content type for .ts files.

by read5 min views2 publishedOct 20, 2026

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, 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:

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 asvideo/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?

── more in #developer-tools 4 stories · sorted by recency
── more on @panth 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/10-typescript-files-…] indexed:0 read:5min 2026-10-20 · —