What Changed
Until now, the only JavaScript available inside a Lit Action was what shipped with the runtime: ethers (the v5 version) and the Lit.Actions SDK. If you needed anything else, you had to inline it or bundle it into your action code before uploading to IPFS.
That limitation is gone. Lit Actions can now import ES modules at runtime from jsDelivr, a public CDN that serves npm packages as ready-to-run ESM. You write a standard import statement with a version-pinned package specifier and the runtime resolves it to jsDelivr, fetches, verifies, caches, and executes it.
import { z } from "zod@3.22.4";
import { formatDistance } from "date-fns@3.6.0";
This opens the door to thousands of npm packages: validation libraries, date/time utilities, encoding tools, math libraries, protocol implementations, and more.
How It Works
Every import goes through three stages before any bytes reach the V8 engine.
1. Resolution
The runtime resolves the import specifier to a jsDelivr URL. You can write a short npm specifier (zod@3.22.4), an explicit ESM specifier (zod@3.22.4/+esm), or a full URL (https://cdn.jsdelivr.net/npm/zod@3.22.4/+esm). When no file path is specified after the version, the runtime automatically appends /+esm to request the ESM entry point from jsDelivr. Bare package names without a version (import { z } from "zod") and relative paths (./util.js) are rejected. Every import must include a pinned version.
2. Integrity Verification
Each module URL is checked against an integrity.lock manifest that maps URLs to their expected SHA-384 hash. If the hash of the downloaded content does not match, the import fails and the action does not execute.
For modules not yet in the manifest, the system uses trust-on-first-use (TOFU) with up to four-way verification: it fetches the module twice from jsDelivr, independently computes the SHA-384 of each response, verifies both against jsDelivr’s SRI hash header when available, and if the import specifier includes an inline #sha384- hash, verifies against that as well. The module is accepted only if all checks agree. The verified hash is then pinned to the lockfile so all future fetches are verified against it.
3. Caching
Once a module is verified, its source is held in an in-memory cache. Subsequent imports of the same URL (from any action execution) are served from cache without a network request.
Import Syntax
Imports use an npm-style specifier with a pinned version. The runtime automatically resolves these to jsDelivr URLs and appends /+esm when no file path is specified.
// Import a specific named export
import { z } from "zod@3.22.4";
// Import a default export
import Ajv from "ajv@8.12.0";
// Import multiple named exports
import { encode, decode } from "cbor-x@1.5.9";
// Scoped packages
import { render } from "@preact/render-to-string@6.4.1";
// Specific file path (no /+esm auto-appended)
import { format } from "date-fns@3.6.0/esm/index.js";
// Explicit /+esm still works (backward compatible)
import { z } from "zod@3.22.4/+esm";
The specifier format is:
<package>@<version>[/<file>]
The @<version> pin is required and ensures you always get the exact same bytes. The /<file> part is optional. When omitted, /+esm is automatically appended to request the package’s ESM entry point from jsDelivr. You can also specify a path to a specific file in the package.
Inline Integrity Hash
You can append a #sha384-<hash> fragment to any import specifier to declare the expected integrity hash directly in your code. The runtime will verify the fetched content against this hash before execution.
import { z } from "zod@3.22.4#sha384-oKhMb3mCbOey4gFjFHm1YmKJF/WuNdbiLPSLHMwbkPE1mEpMJOoDQMHTcIltUJQ+";
This makes integrity verification self-contained in the action code, with no dependency on an external lockfile. When an inline hash is provided, it takes priority over any entry in integrity.lock. The /+esm suffix is still auto-appended when no file path precedes the # fragment.
The fragment is never sent over the network (per the URL specification). It is stripped before fetching and used only for local verification.
Full URLs
Full jsDelivr URLs are also accepted, with or without an inline hash:
import { z } from "https://cdn.jsdelivr.net/npm/zod@3.22.4/+esm";
import { z } from "https://cdn.jsdelivr.net/npm/zod@3.22.4/+esm#sha384-oKhMb3m...";
Always pin to an exact version (@3.22.4, not @^3.22.4 or @latest). Unpinned versions can resolve to different code over time, which will cause integrity verification to fail and makes your action’s behavior non-deterministic.
Examples
import { z } from "zod@3.22.4";
// js_params: { pkpId, userData }
async function main({ pkpId, userData }) {
const UserSchema = z.object({
email: z.string().email(),
age: z.number().int().min(0).max(150),
name: z.string().min(1).max(100),
});
const result = UserSchema.safeParse(userData);
if (!result.success) {
return { error: "Validation failed", issues: result.error.issues };
}
const wallet = new ethers.Wallet(
await Lit.Actions.getPrivateKey({ pkpId })
);
const signature = await wallet.signMessage(JSON.stringify(result.data));
return { validated: result.data, signature };
}
import { format, utcToZonedTime } from "date-fns-tz@2.0.1";
// js_params: { pkpId, timezone }
async function main({ pkpId, timezone }) {
const now = new Date();
const zonedTime = utcToZonedTime(now, timezone);
const formatted = format(zonedTime, "yyyy-MM-dd HH:mm:ss zzz", { timeZone: timezone });
const wallet = new ethers.Wallet(
await Lit.Actions.getPrivateKey({ pkpId })
);
const signature = await wallet.signMessage(formatted);
return { timestamp: formatted, timezone, signature };
}
Encode Data as CBOR Before Signing
import { encode } from "cbor-x@1.5.9";
// js_params: { pkpId, payload }
async function main({ pkpId, payload }) {
const encoded = encode(payload);
const hex = Array.from(new Uint8Array(encoded))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
const wallet = new ethers.Wallet(
await Lit.Actions.getPrivateKey({ pkpId })
);
const signature = await wallet.signMessage(hex);
return { cbor: hex, signature };
}
JSON Schema Validation with AJV
import Ajv from "ajv@8.12.0";
// js_params: { pkpId, data, schema }
async function main({ pkpId, data, schema }) {
const ajv = new Ajv();
const validate = ajv.compile(schema);
if (!validate(data)) {
return { error: "Schema validation failed", errors: validate.errors };
}
const wallet = new ethers.Wallet(
await Lit.Actions.getPrivateKey({ pkpId })
);
const signature = await wallet.signMessage(JSON.stringify(data));
return { valid: true, data, signature };
}
Package Compatibility
Not every npm package works. The package must meet these requirements:
| Requirement | Why |
|---|
| Ships ESM in its npm tarball | jsDelivr serves files as-is from the published package. No transpilation or CJS-to-ESM conversion happens. |
| No Node.js built-in dependencies | Lit Actions run in Deno/V8, not Node.js. Packages that import fs, path, crypto, or other Node built-ins will fail. |
| No native/binary addons | The runtime is a sandboxed V8 isolate. Native code cannot execute. |
| Pinned to an exact version | Required for integrity verification and deterministic behavior. |
Most modern packages ship ESM. Some well-known examples that work:
- zod — Schema validation
- ajv — JSON Schema validation
- date-fns / date-fns-tz — Date utilities
- cbor-x — CBOR encoding/decoding
- uuid — UUID generation
- lodash-es — Utility functions (ESM build)
- preact — Lightweight UI rendering (for server-side HTML generation)
- superstruct — Structural validation
Packages that will not work:
- axios — depends on Node.js
http module
- lodash (non-ESM) — CJS only, use
lodash-es instead
- sharp — native binary addon
- bcrypt — native binary addon
If you are unsure whether a package ships ESM, check its package.json for an "exports" or "module" field, or test the jsDelivr URL directly in a browser: https://cdn.jsdelivr.net/npm/<package>@<version>/+esm
Why jsDelivr
We evaluated several CDN options for serving npm packages as ESM. The choice came down to a set of non-negotiable requirements for running third-party code inside a cryptographic signing environment.
The Requirements
- Immutability — The same URL must return the exact same bytes forever. If content can change, integrity hashes become meaningless.
- No server-side transformation — The CDN must serve the original files from the npm tarball. Any server-side bundling or transpilation introduces a layer we cannot audit or pin.
- Version pinning — URLs must support exact version locks (
@3.22.4) so that the resolved content is deterministic.
- SRI hash support — The CDN should support Subresource Integrity headers so hashes can be computed and verified against the original source.
How jsDelivr Meets Them
jsDelivr with pinned versions serves raw files directly from npm packages at version-pinned URLs that are guaranteed immutable. Once a version is published to npm, the content behind https://cdn.jsdelivr.net/npm/zod@3.22.4/+esm never changes. jsDelivr does not perform any transformation, bundling, or minification on the source files. What the package author published to npm is exactly what gets served. Chipotle enforces this immutability guarantee by validating the SHA-384 hash of every module on every fetch, so even if a CDN were to serve altered content, the integrity check would catch it and reject the module before execution.
jsDelivr also provides built-in SRI hash support and is backed by a multi-CDN infrastructure (Cloudflare, Fastly, and others) with high availability and global edge caching.
Alternatives Considered
| CDN | Verdict | Reason |
|---|
| esm.sh | Rejected | Performs server-side CJS-to-ESM conversion. The output is generated, not the original source. We cannot guarantee that two fetches of the same URL produce the same bytes, and we cannot audit the conversion logic. |
| unpkg.com | Rejected | Serves raw npm files (good) but does not guarantee immutability of the ?module rewriting layer. The redirects it uses also complicate integrity verification. |
| Skypack | Rejected | Performs server-side optimization and conversion. Same concerns as esm.sh. |
| Self-hosted | Deferred | Eliminates third-party trust entirely but requires operating a package mirror. May be considered for enterprise deployments in the future. |
The key constraint is that the package must already ship ESM in its published npm tarball. jsDelivr does not convert CJS to ESM. Most modern packages do ship ESM, but older CJS-only packages will not work without a conversion step that happens before publishing.
Security Model
Module imports operate under the same security model as the rest of the Lit Actions runtime. Every module is verified before it reaches V8.
| Layer | Protection |
|---|
| URL allowlist | Only https://cdn.jsdelivr.net/ is accepted. All other origins are rejected at the resolution stage. |
| Version pinning | Exact versions are required. The URL is the immutable identifier. |
| SHA-384 integrity | Every module is hashed and compared against the integrity.lock manifest or an inline #sha384- hash in the import specifier. Mismatches are fatal. |
| Trust-on-first-use | New modules are double-fetched, hashes compared, and verified against jsDelivr’s SRI header before being accepted and pinned. |
| No redirects | HTTP redirects are blocked. The CDN must serve the content directly. |
| Size limits | Responses larger than 10 MB are rejected. |
| Timeouts | Fetch operations time out after 30 seconds. |
| Sandboxing | Imported code runs in the same Deno/V8 sandbox as the rest of the action. No filesystem, no subprocess, no native code. |
Imported packages run with the same permissions as your action code, including access to Lit.Actions.getPrivateKey() if a PKP is available. Only import packages you trust. The integrity system ensures the code has not been tampered with in transit, but it does not audit what the code does.
Limits
Module imports are subject to the same resource limits as the rest of your action:
- Memory — Imported modules count toward the action’s memory limit (default 128 MB).
- Timeout — Module fetch time counts toward the action’s execution timeout (default 15 minutes).
- Network — Module fetches use a separate HTTP client from the action’s
fetch() API and do not count toward the per-action fetch limit. However, they share the same execution timeout.
- Module size — Individual modules are capped at 10 MB.
Debugging Imports
Use Lit.Actions.showImportDetails() to inspect which modules were loaded and their integrity hashes. This is useful for debugging import resolution, verifying that the expected modules were fetched, and auditing the integrity of imported code.
import { z } from "zod@3.22.4";
async function main() {
const details = Lit.Actions.showImportDetails();
// details is an array of { url, hash } objects:
// [
// {
// "url": "https://cdn.jsdelivr.net/npm/zod@3.22.4/+esm",
// "hash": "sha384-oKhMb3mCbOey4gFjFHm1..."
// }
// ]
return details;
}
The import details are also written to the action’s console log, so they appear alongside other console.log output in the response logs.
Next Steps
- Examples — More action patterns using the built-in SDK
- Patterns — Advanced patterns like gating logic and action-identity signing
- Lit Actions SDK — Full API reference