Edge runtimes
The Node SDK's native client and app kit run unchanged on edge runtimes: Cloudflare Workers, Vercel Edge, Bun, and Deno. They import nothing from node:* and are built on web globals (fetch, ReadableStream, TextEncoder, btoa, crypto.subtle).
So you can provision tenants, act on objects, mint signed URLs, and verify webhooks straight from a Worker.
The one exception is the S3 Client, whose SigV4 signer uses node:crypto. It is Node-only by design and is not exported from the edge entry. On the edge you use the native data plane instead, which is the recommended surface anyway.
Use the /edge entry
Import from the dedicated edge entry, @kelphect/sdk/edge:
import {
LockwellKit,
NativeClient,
AdminClient,
verifyWebhook,
WEBHOOK_SIGNATURE_HEADER_NAME,
sha256ChecksumBase64,
} from "@kelphect/sdk/edge";The /edge subpath re-exports only the node:*-free modules (native.js, admin.js, kit.js, and helpers).
Import from /edge, not the default barrel. The default barrel (@kelphect/sdk) also re-exports the Node-only S3 Client, so a bundler that follows the barrel drags node:crypto into the edge bundle and forces a nodejs_compat flag or polyfill. Importing from /edge bundles to zero node-only code with no compatibility flag.
Why this is reliable The SDK's own test/edge.test.js statically walks the /edge import graph and **fails the
build** if any node: specifier ever becomes reachable from it, and dynamically runs a request with node:crypto poisoned at the module loader. The guarantee is enforced, not aspirational. :::
A Cloudflare Workers / Hono example
This is the shape of the runnable examples/hono-edge app. Secrets live only in the edge env bindings (c.env) and never reach the browser. The browser only ever receives a short-lived signed URL.
import { Hono } from "hono";
import { cors } from "hono/cors";
// Edge-safe imports only. The S3 Client is not even reachable through /edge.
import { LockwellKit, verifyWebhook, WEBHOOK_SIGNATURE_HEADER_NAME } from "@kelphect/sdk/edge";
const app = new Hono();
app.use("/api/*", cors());
// env bindings are per-request on Workers, so build the kit from c.env per call.
function kitFor(env) {
return new LockwellKit({
admin: { endpoint: env.LOCKWELL_ADMIN_ENDPOINT, token: env.LOCKWELL_ADMIN_TOKEN },
native: { endpoint: env.LOCKWELL_PUBLIC_ENDPOINT },
});
}
// Mint a browser-direct signed upload URL (no credential leaves the server).
app.post("/api/signed-upload", async (c) => {
const { tenantId, creds } = resolveTenant(c); // server-side; never from a forgeable path
const { key, contentType } = await c.req.json();
const kit = kitFor(c.env);
const native = kit.clientForTenant(tenantId, creds);
const up = await kit.signedUploadUrl(native, creds.bucket, key, {
ttlSeconds: 300,
contentType,
});
// Return ONLY the URL + how to use it.
return c.json({ url: up.url, method: up.method, headers: up.headers });
});
// Stream an upload straight through the Worker to the native PUT (no buffering).
app.put("/api/objects/:key{.+}", async (c) => {
const { tenantId, creds } = resolveTenant(c);
const native = kitFor(c.env).clientForTenant(tenantId, creds);
const res = await native.putObject(creds.bucket, c.req.param("key"), c.req.raw.body, {
contentType: c.req.header("content-type") || "application/octet-stream",
});
return c.json({ etag: res.etag, versionId: res.versionId });
});
// Receive + verify a webhook with the constant-time WebCrypto HMAC.
app.post("/api/webhooks/lockwell", async (c) => {
const secret = c.env.WEBHOOK_SHARED_SECRET;
if (!secret) return c.json({ error: "no verification secret configured" }, 501);
const sig = c.req.header(WEBHOOK_SIGNATURE_HEADER_NAME) || "";
const raw = new Uint8Array(await c.req.raw.clone().arrayBuffer()); // exact bytes
if (!(await verifyWebhook(raw, sig, secret))) return c.json({ error: "invalid signature" }, 401);
const event = await c.req.json();
return c.json({ verified: true, recordCount: (event.Records || []).length });
});
export default app; // Hono's `app` IS a { fetch(request, env, ctx) } Workers handlerc.req.raw.body is a web ReadableStream. The native client streams it straight to Lockwell with no whole-object buffering. The native bearer token is minted and refreshed by the SDK and never leaves the server.
Per-runtime notes
Cloudflare Workers. export default app. Set the two endpoint URLs as wrangler.toml [vars] and the admin token and shared secrets via wrangler secret put. No compatibility_flags = ["nodejs_compat"] is needed.
Vercel Edge. Re-export app.fetch from an Edge Function or route handler with export const runtime = 'edge', passing process.env as the env arg: app.fetch(request, process.env).
Bun. Bun.serve({ port, fetch: (req) => app.fetch(req, process.env) }).
Deno. Deno.serve({ port }, (req) => app.fetch(req, Deno.env.toObject())). Import-map the @kelphect/sdk and hono specifiers in deno.json.
Edge-safe checksums
A native idempotent PUT needs a body-integrity checksum (see idempotency keys). On the edge, sha256ChecksumBase64 computes the SHA-256 with crypto.subtle, no node:crypto:
import { sha256ChecksumBase64 } from "@kelphect/sdk/edge";
const body = "invoice-payload";
await native.putObject("billing", "invoices/2026-001.json", body, {
idempotencyKey: "invoice-2026-001",
checksums: { sha256: await sha256ChecksumBase64(body) },
});CRC32C and CRC64NVME checksums can be precomputed and passed directly.
What runs where
| Surface | Edge-safe? | Notes |
|---|---|---|
NativeClient (data plane) | Yes | Pure web globals; the recommended edge surface. |
LockwellKit (app kit) | Yes | clientForTenant, signed URLs, verifyWebhook are all node-free. |
AdminClient (control plane) | Yes | Uses global fetch; typically a server-side/admin context. |
verifyWebhook | Yes | WebCrypto HMAC + constant-time compare. |
S3 Client (SigV4) | No | Node-only. Its signer uses node:crypto. Not exported from /edge. |
Next steps
- Data operations. The native API the edge client exposes.
- Signed URLs. The browser-direct upload pattern shown above.
- Webhooks. Receiving and verifying deliveries.