Dev.to Security 🔐 Cybersecurity 👁 0 📖 5 min read

Protecting a TypeScript library you publish: ship typings, hide the implementation

If you publish a TypeScript library — especially a paid SDK — you want two things that look like they conflict. Consumers should get excellent typings: autocomplete, jump-to-definition on the public API, no any leaking i

If you publish a TypeScript library — especially a paid SDK — you want two things that look like they conflict. Consumers should get excellent typings: autocomplete, jump-to-definition on the public API, no any leaking into their project. They should not get a readable copy of your implementation. Those goals do not conflict if you treat .d.ts as the product surface and the runtime JavaScript as a different artifact.

The typings describe what can be called. The published JavaScript is how it runs. You can ship the first in the clear and make the second much harder to casually copy. You cannot make the runtime secret — consumers have to execute it — and you should not use this technique to hide malware or to dodge a package registry's rules. You can, however, stop shipping your src/ tree by accident, and you can stop a one-line if (licensed) from being the entire paid gate.

What to put in the package

A clean package.json for a library that ships typings and hidden-enough JavaScript looks like this:

{
  "name": "@acme/sdk",
  "version": "2.4.0",
  "main": "./dist/index.js",
  "module": "./dist/index.mjs",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.js"
    }
  },
  "files": [
    "dist/index.js",
    "dist/index.mjs",
    "dist/index.d.ts",
    "README.md",
    "LICENSE"
  ],
  "sideEffects": false
}

The important parts:

  • types (and the types condition in exports) points at declaration files only.
  • main / module (and import / require) point at compiled JavaScript, not at TypeScript source.
  • files is an allowlist. If you omit it, you are one forgotten .gitignore or npm config away from publishing src/, tests, internal docs, and .env.example.
  • There is no *.map in files. There is no src/ in files.

Confirm what you are about to publish before you do it:

npm pack --dry-run
# or
npm pack && tar -tf acme-sdk-2.4.0.tgz

If src/engine.ts or dist/index.js.map is in that list, stop.

Declaration maps and source maps leak the original

declaration: true is what you want. declarationMap: true is not — not for a published tarball. A .d.ts.map tells the consumer's editor how to jump from a declaration to the original .ts file. If that original file is also in the package (or is reachable via a relative path that exists after install), "Go to Definition" becomes "open the implementation."

Source maps for the JavaScript have the same problem in a slightly different place. A dist/index.js.map with sourcesContent, or a //# sourceMappingURL comment that points at a file you also published, is an undo button for minification and obfuscation.

Use a publish-time tsconfig that emits typings and does not emit maps:

{
  "compilerOptions": {
    "declaration": true,
    "declarationMap": false,
    "sourceMap": false,
    "inlineSources": false,
    "inlineSourceMap": false
  }
}

If you need maps for your own debugging of the SDK, keep them in a private artifact store, not in the npm tarball. After the build, a last check:

find dist -name "*.map" -o -name "*.d.ts.map"
grep -R "sourceMappingURL" dist || true

Both should be empty for the files you publish.

Types describe the API, not the algorithm

A .d.ts file contains signatures, exported types, and maybe JSDoc. It does not (in normal emit) contain function bodies. That is the split you want:

// dist/index.d.ts — this is supposed to be public
export interface QuoteInput {
  seats: number;
  plan: "free" | "pro";
  coupon?: string;
}
export function quote(input: QuoteInput): Promise<Quote>;
export function verifyLicense(token: string): Promise<boolean>;

Consumers get a precise contract. They do not get quote's internals from the typings. Be careful not to undo that by:

  • publishing src/ "for reference,"
  • inlining implementation notes into JSDoc that you would not put in a README,
  • exporting internal types that name hidden states, magic constants, or unpublished plans,
  • bundling a "debug build" next to the production file.

The typings will reveal the shape of the API. That is their job. The algorithm, the heuristics, and the license check belong in the runtime file.

Consumers can still hook what you export

Anyone who installs the package can import it, wrap it, and inspect values at the edges:

import { quote } from "@acme/sdk";

const original = quote;
export async function quoteWrapped(input: QuoteInput) {
  const result = await original(input);
  console.log("quote", input, result);
  return result;
}

They can also read the published JavaScript. Minification already helps against a casual glance. Ordinary obfuscation (renaming, string encoding, light control-flow changes) makes wholesale copy-paste less pleasant. For a paid SDK, the internals that actually hurt to lose — a solver, a ranking function, a non-trivial license or entitlement check — are a fit for VM-style protection: selected functions compiled to a custom bytecode and run by a small interpreter, so the original control flow is not sitting in dist/index.js waiting to be pretty-printed.

In particular, do not let a paid gate collapse to a one-line if. A check like if (payload.plan === "pro") return true will be found and patched, typings or not. Keep license and auth decisions as far from a single obvious branch as you can on the client, and still have a server confirm anything that grants access to your backend.

VEILVM is a browser-based JSVMP workspace you can point at one of those internal functions before you add a CI step. Interpreted code is slower, so protect the core of the SDK, not every helper, and run your package tests against the protected build.

Be honest with registries and with users

Package managers and hosted registries have policies about malware, unexpected network behavior, and (in some places) heavy obfuscation that conceals what a package does. Do not use protection to hide malicious or surprising behavior. Do not ship an SDK that phones home in a way the typings and README do not describe.

A reasonable paid library is explicit: the public API is documented and typed; the runtime is minified or protected because it is commercial code; license checks exist and are also enforced on the server if the SDK talks to one. That is a product decision, not a dodge.

Open-source packages should usually ship readable JavaScript. Protection is for the case where the implementation is the thing you are selling.

Checklist

  • [ ] npm pack does not contain src/, tests, or *.map / *.d.ts.map.
  • [ ] types / exports.types point at .d.ts only; main / module point at compiled JS.
  • [ ] declarationMap, sourceMap, and inlineSources are off for the published emit.
  • [ ] License or entitlement checks are not a single obvious if, and the server still enforces anything that matters.
  • [ ] Tests run against the minified or protected artifact you actually publish.
  • [ ] The README does not pretend the runtime is secret, and does not conceal network behavior.

If you want to try VM protection on an internal SDK function, VEILVM runs in the browser with no sign-up: 3 free runs a day, up to 5 KB per file (resets at 00:00 UTC). A free account raises the input limit to 30 KB. Pro is $9/month or $79/year and adds an API so the protection step can run in CI. Standard requests are processed in memory and are not written to logs. Start at https://vm.ldvmp.com.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.