10 TypeScript files, 5 SDK artefacts: typed in Node and Deno
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. What gets hosted /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. Concatenate first 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-.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 is emitted by 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 is the exact file that was type-checked and bundled. Deno and Bun import it by URL and type it natively. It isn't a second copy. sdk.js and sdk.min.js come from esbuild, and both start with /// , which Deno and Bun read. The tarball carries a 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. The header that turned .ts into video 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. Proving the types arrive "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: Node: npm i the real tarball into a project of its own, then tsc --strict. Deno: deno check a script that imports sdk.ts, another that imports sdk.js, and a third that gets only the 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 deploy won't ship a stale bundle 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. What it costs 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?
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to