
Security News
pnpm 12’s Rust Rewrite Cuts Install Times by Up to 90%
pnpm 12 rewrites the package manager in Rust, cutting install times by up to 90% while preserving pnpm 11 workflows and lockfiles.
@dropthis/node
Advanced tools
Official Node.js SDK for dropthis — the publish layer between AI and the internet. One call in, one URL out.
![]()
Official Node.js SDK for dropthis -- the publish layer between AI and the internet. One API call in, one URL out.
npm install @dropthis/node
import { Dropthis } from "@dropthis/node";
const dropthis = new Dropthis({ apiKey: "sk_..." });
const { data, error } = await dropthis.drops.publish("<h1>Hello</h1>");
console.log(data.url); // https://abc123.dropthis.app — branded human view (always badged)
console.log(data.id); // drop_… — keep this; it's how you update or delete later
Sharing with an agent? Use
data.rawUrl. The canonicalurlalways renders a branded human view;rawUrlis the drop's exact bytes (single-file drops only —nullotherwise). See Canonical URL vs raw bytes.
Keep the
drop_…id.publish()never takes an id — every call creates a NEW drop. To change something already published, pass the id from the publish response todrops.updateContent()(the files at the URL) ordrops.updateSettings()(title, visibility, expiry, …). Lost the id? Recover it from the URL withdrops.resolve().
const { data } = await dropthis.drops.publish("<h1>Launch page</h1>");
const { data } = await dropthis.drops.publish("./report.html");
const { data } = await dropthis.drops.publish("./dist");
const { data } = await dropthis.drops.publish("./dist", {
title: "Q4 Report",
visibility: "unlisted",
noindex: true,
expiresAt: "2026-12-31T00:00:00Z",
});
const created = await dropthis.drops.publish("./dist", { title: "v1" });
const updated = await dropthis.drops.updateContent(created.data.id, "./dist-v2", {
ifRevision: created.data.revision,
});
await dropthis.drops.updateSettings("drop_abc123", { title: "New title" });
Lost the drop_… id? Resolve any public locator — a drop URL, a custom-domain URL, or a bare
vanity/shared slug — back to the drop. The target is sent to the server (POST /drops/resolve),
which owner-scopes and decomposes it; you get the full drop back, or null when nothing of yours
matches. Passing a drop_… id round-trips to an owner-scoped lookup.
Persist the drop_… id. URLs, raw_url, and slugs are locators, not identifiers — a vanity slug is renameable and the pool host rotates, so a stored URL can drift; the id never moves. Treat drop_… as an opaque case-sensitive string.
const { data } = await dropthis.drops.resolve("https://my-report.dropthis.app/");
if (data) {
console.log(data.id); // drop_… — use this for updateContent/updateSettings/delete
} else {
// none of your drops has that slug
}
drops.getContent() is the owner-side read-back (it works regardless of any viewer
password). By default it returns a JSON manifest of the current deployment's files;
pass path to download one file's exact stored bytes.
// Manifest of the current deployment
const manifest = await dropthis.drops.getContent("drop_abc123");
console.log(manifest.data.files); // [{ path, contentType, sizeBytes }, …]
// One file's bytes (and a text() helper)
const file = await dropthis.drops.getContent("drop_abc123", { path: "index.html" });
console.log(file.data.contentType); // "text/html"
console.log(file.data.text()); // "<h1>…"
// A historical (even superseded) deployment — this is also the rollback path:
// download the old version's files and republish them with updateContent().
const old = await dropthis.drops.getContent("drop_abc123", {
deploymentId: "dep_xyz789",
path: "index.html",
});
rawUrl)Every drop has two faces. The canonical data.url always serves a branded human
view — that is how the dropthis badge is guaranteed without sniffing who's asking.
For a single non-HTML file (renderMode: "file_viewer") that view is a branded
preview (image inline, text/markdown/JSON/CSV/code as escaped source, opaque binary as
a download); for a multi-file bundle with no HTML entry it's a branded index. An HTML
drop (renderMode: "user_html") renders the page itself.
When you need the underlying file's exact bytes (e.g. one agent handing an artifact
to another), use data.rawUrl — the drop's bytes at their natural path under the mount.
It's populated only for single-file (file_viewer) drops and is null for
user_html and collections (the page is itself the artifact / per-file natural paths
come from the manifest). Hand url to humans and rawUrl to agents.
const { data } = await dropthis.drops.publish("./notes.md");
console.log(data.url); // https://abc123.dropthis.app/ ← branded preview (badge)
console.log(data.rawUrl); // https://abc123.dropthis.app/notes.md ← exact bytes for agents
console.log(data.renderMode); // "file_viewer"
// An HTML site or a multi-file collection has no single raw file:
// data.rawUrl === null (use drops.getContent() to pull bytes by path)
To stream bytes through the SDK regardless of drop kind (and owner-only, so it works even
on password-protected drops), use drops.getContent() —
that's the programmatic byte-fetch path; rawUrl is the public, shareable one.
Every drop response carries a revision. Pass it back as ifRevision on
updateContent() / updateSettings() to make the update conditional: if someone else
changed the drop in between, the API answers 409 instead of clobbering, and the error
exposes the server's currentRevision so you can re-read and retry.
// 1. Read
const drop = await dropthis.drops.get("drop_abc123");
// 2. Edit locally (e.g. via getContent read-back), then
// 3. Update conditionally
const result = await dropthis.drops.updateContent(drop.data.id, "./dist-v2", {
ifRevision: drop.data.revision,
});
if (result.error?.statusCode === 409) {
console.log("Drop changed underneath us; server is at revision", result.error.currentRevision);
// re-read with drops.get(), merge, retry with the fresh revision
}
Oversized raster images (png/jpeg/webp over ~500KB) are optimized server-side before
serving — re-encoded to a smaller format, with paths left unchanged. Every transform is
disclosed in the response warnings[] (image_optimized / image_optimize_skipped),
and plan size limits are checked against the optimized bytes. It's on by default on both
publish() and updateContent(); pass optimize: false for byte-exact storage:
await dropthis.drops.publish("./dist", { optimize: false });
await dropthis.drops.updateContent(drop.data.id, "./dist-v2", { optimize: false });
The drops.publish() and drops.updateContent() methods accept:
"<h1>Hello</h1>" (auto-detected as inline content)"./report.html" (local file)"./dist" (local directory, bundled)["./dist", "./extra.css"] (multi-path bundle)new URL("https://example.com/page") (source fetch)new Uint8Array(...) (raw bytes){ kind: "content", content: "...", contentType?: "text/html", path?: "page.html" }{ kind: "source_url", sourceUrl: "https://example.com/page" } (the server fetches it; an HTML page becomes a site, any other file becomes a single-file drop with a rawUrl){ kind: "files", files: [{ path, content? | contentBase64? | bytes? | sourceUrl?, contentType? }], entry? }. Give each file its bytes inline (content/contentBase64/bytes) or a sourceUrl for the server to fetch — never both on one file. Mix them freely in one bundle.Drop settings (title, visibility, password, noindex, expiresAt, metadata) go in the second options argument, not in the input object.
A
source_urlto a non-HTML file now becomes a single-file drop. Point a top-level source URL (new URL(...)or{ kind: "source_url" }) at an image, PDF, or text file and you get a single-filefile_viewerdrop withrawUrlset — the same as publishing those bytes directly. Previously a source URL only worked for HTML pages.
All inputs are uploaded through staged presigned URLs — one signed PUT per file, up to 5 files in parallel. The SDK handles this transparently.
// Inline content with explicit MIME type
await dropthis.drops.publish({
kind: "content",
content: "<h1>Hello</h1>",
contentType: "text/html",
});
// Fetch and re-publish a remote URL
await dropthis.drops.publish({
kind: "source_url",
sourceUrl: "https://example.com/report",
});
// Multi-file bundle with explicit entry point
await dropthis.drops.publish(
{
kind: "files",
files: [
{ path: "index.html", content: "<h1>Hello</h1>" },
{ path: "style.css", content: "body { margin: 0; }" },
],
entry: "index.html",
},
{ title: "My Site" },
);
// Publish by reference: inline your HTML, let the server fetch the images.
// Each `sourceUrl` file is fetched server-side (no bytes pass through your
// process) and lands at its path in the bundle — one self-contained drop.
await dropthis.drops.publish({
kind: "files",
files: [
{
path: "index.html",
content: '<h1>Gallery</h1><img src="hero.png"><img src="logo.svg">',
},
{ path: "hero.png", sourceUrl: "https://cdn.example.com/hero.png" },
{ path: "logo.svg", sourceUrl: "https://cdn.example.com/logo.svg" },
],
entry: "index.html",
});
prepare() resolves and validates the input locally, returning the prepared request object without making any API calls. It throws PublishInputError on invalid input (e.g. missing file).
import { Dropthis, PublishInputError } from "@dropthis/node";
try {
const prepared = await dropthis.prepare("./dist");
console.log("Ready to publish:", prepared.kind);
} catch (e) {
if (e instanceof PublishInputError) {
console.error("Bad input:", e.message);
}
}
All methods return DropthisResult<T> -- either { data: T, error: null, headers } or { data: null, error, headers }. API errors never throw; check error before using data.
const result = await dropthis.drops.get("drop_abc123");
if (result.error) {
console.error(result.error.code, result.error.message);
// Also available: error.statusCode, error.requestId, error.suggestion,
// error.retryable, error.param, error.currentRevision
} else {
console.log(result.data);
}
Local input validation errors (e.g. file_not_found) are also returned as { error: { code: "file_not_found", ... } } rather than thrown -- except for prepare(), which throws PublishInputError.
error.retryable tells an agent whether retrying is worthwhile. The SDK marks its own
transient failures retryable: true only when retrying is safe: a GET timeout /
network_error, any request you sent with an idempotencyKey, or a signed-upload 5xx.
A bare (non-idempotent) POST/PATCH/DELETE timeout is left unmarked because the
server may have applied it before the connection dropped. Server-side errors carry
whatever retryable the API returns (the SDK never overrides it). publish() and
updateContent() are always safe to retry — each derives a stable idempotency key, so a
retried publish never creates a duplicate drop.
A feature your plan doesn't include (feature_not_in_plan) or a limit you've hit (quota_exceeded)
comes back as a typed, non-retryable error — don't retry it, surface the upgrade path instead.
isPlanGate(error) covers both; the narrower isFeatureNotInPlan / isQuotaExceeded are exported too.
import { isPlanGate } from "@dropthis/node";
const { error } = await dropthis.drops.publish("./dist", { password: "hunter2" });
if (isPlanGate(error)) {
// error.feature ("passwordProtect"), error.requiredPlan ("pro"), error.upgradeUrl
// — on quota_exceeded also: error.limit, error.usage, error.requested
console.log(`Needs ${error.requiredPlan}: ${error.upgradeUrl}`);
}
Pre-flight instead of failing: account.get().data.entitlements carries the capability matrix +
numeric limits, so you can check a gate or size a publish before attempting it. A 403
insufficient_scope is different — that's a scope gap on the credential, not a
plan gate; fix it by minting/re-logging with the needed scope, not by upgrading.
const dropthis = new Dropthis({
apiKey: "sk_...", // Required. Defaults to DROPTHIS_API_KEY env var.
baseUrl: "https://...", // Override API base URL.
timeoutMs: 30_000, // Request timeout in milliseconds (default: 30s).
uploadTimeoutMs: 120_000, // Timeout for signed-PUT file uploads (default: 120s).
fetch: customFetch, // Custom fetch implementation.
});
You can also pass just the API key as a string:
const dropthis = new Dropthis("sk_...");
Getting a key. Mint an sk_… key with the dropthis CLI (dropthis login, then dropthis api-keys create),
or programmatically after an OTP login (auth.verifyEmailOtp() → apiKeys.create()). The console
Credentials page lists and revokes keys, but can't mint new ones. The
credential is sent as Authorization: Bearer <key>; an at_… session token works in the same slot.
With no explicit apiKey, the client reads DROPTHIS_API_KEY from the environment.
await dropthis.drops.list({ limit: 20 });
await dropthis.drops.list({ domain: "reports.example.com" }); // only drops mounted on a custom domain
await dropthis.drops.get("drop_abc123");
await dropthis.drops.resolve("https://my-report.dropthis.app/"); // any locator → drop (or null)
await dropthis.drops.resolve("drop_abc123"); // a bare id round-trips too
await dropthis.drops.getContent("drop_abc123"); // manifest of served files
await dropthis.drops.getContent("drop_abc123", { path: "index.html" }); // one file's bytes
await dropthis.drops.updateSettings("drop_abc123", { title: "Updated" });
await dropthis.drops.delete("drop_abc123");
List results support auto-pagination:
const page = await dropthis.drops.list();
const all = await page.data.autoPagingToArray({ limit: 100 });
if (all.error) {
// a later page failed to fetch — inspect all.error.code / statusCode / requestId
} else {
for (const drop of all.data) console.log(drop.url);
}
Like every call in this SDK, autoPagingToArray() never throws for an API error — it
returns the same { data, error } result shape. On success you get { data: items, error: null };
if fetching a later page fails you get { data: null, error } carrying the underlying
code / statusCode / retryable / requestId. So you can never be handed a silently
truncated list, and you never have to wrap pagination in try/catch. (For manual control,
loop on dropthis.drops.list({ cursor }) and check each result's .error.)
To change a drop's content, use
client.drops.updateContent(dropId, newInput).drops.updateSettings()is for settings only (title, visibility, password, noindex, expiresAt, metadata).
await dropthis.deployments.list("drop_abc123");
await dropthis.deployments.get("drop_abc123", "dep_xyz789");
Low-level upload session management. Most users should use publish() instead.
await dropthis.uploads.create({
schemaVersion: 1,
files: [{ path: "index.html", contentType: "text/html", sizeBytes: 1024 }],
});
await dropthis.uploads.get("upl_abc123");
await dropthis.uploads.complete("upl_abc123"); // no body — server verifies against the manifest
await dropthis.uploads.cancel("upl_abc123");
await dropthis.auth.requestEmailOtp({ email: "you@example.com" });
await dropthis.auth.verifyEmailOtp({ email: "you@example.com", code: "123456" });
await dropthis.auth.logout(); // 204 No Content — data is null
On a failed verify the error
codeisotp_expired(no active code — request a new one) orotp_invalid(wrong digits — re-check, don't auto-resend). 401 on either.
// Delegated key — account-scoped, follows the active workspace (the default)
await dropthis.apiKeys.create({ label: "My key" });
// Delegated key restricted to specific workspaces
await dropthis.apiKeys.create({
label: "Dev only",
type: "delegated",
allowedWorkspaces: ["dev-team", "staging"],
});
// Service key pinned to one workspace — for CI/automation
await dropthis.apiKeys.create({
label: "CI deploy",
type: "service",
workspace: "prod-team",
});
// Key that can create + manage teams (not just publish) — pass a scope bundle
await dropthis.apiKeys.create({ label: "Team bot", scopes: ["team"] });
await dropthis.apiKeys.list();
await dropthis.apiKeys.delete("key_abc123"); // 204 No Content — data is null
KeyType is "delegated" | "service". A delegated key acts on behalf of the owning account and can switch workspaces server-side via workspaces.use(); a service key is pinned to one workspace and is intended for CI/automation where a stable target is needed.
Every credential carries a set of capability scopes that decide what it may do (ADR 0068). You
request them as bundles; apiKeys.create({ scopes }) mints the requested set intersected with
your own (downscope-only — you can never grant a key more than you hold).
| Bundle | Grants | Use it for |
|---|---|---|
publish (default) | publish drops, read your own context, mint downscope-only keys | a normal API key / dropthis login |
team | publish + create/rename workspaces + invite/manage members | building or running a team |
team-admin | team + delete workspaces, remove members, change roles | the irreversible team-admin tier |
service | publish minus key-minting, workspace-pinned | CI/automation |
Omit scopes for the default publish bundle. A plain key cannot do team management — call a
team op (workspaces.create(), members, invitations) with a publish-only key and the API returns
403 insufficient_scope; mint or re-login with the team bundle to fix it. Fine-grained scopes
(e.g. members:admin) may be passed individually; the created key's data.scopes echoes the
granted set.
const { data } = await dropthis.apiKeys.create({ label: "Team bot", scopes: ["team"] });
console.log(data.scopes); // the granted set (requested ∩ yours)
const { data } = await dropthis.account.get();
// data.entitlements is the full capability matrix for your plan — pre-check a
// feature gate or size a publish before attempting it:
// capabilities: { passwordProtect: false, customDomain: false, analytics: "none", ... }
// requiredPlan: { passwordProtect: "pro", customExpiry: "keep", "analytics.full": "pro", ... }
// limits: { maxSizeBytes, maxStorageBytes, defaultTtlSeconds, maxCustomHostnames,
// seatLimit, maxActiveUploadSessions }
// Enum caps carry a value — compare by value, never truthiness ("none" is truthy).
// Plan gates surface as typed errors (never retried):
// import { isFeatureNotInPlan, isQuotaExceeded } from "@dropthis/node";
// if (isFeatureNotInPlan(error)) { /* error.feature, error.requiredPlan, error.upgradeUrl */ }
// data.workspace identifies the workspace the API key is bound to:
// { id, name, slug, kind: "personal" | "team", role: "owner" | "admin" | "member" }
// A key minted in a team workspace publishes there automatically — the workspace's
// shared custom domain routes the drop without any extra option.
await dropthis.account.update({ displayName: "Jane Doe" });
await dropthis.account.delete();
Custom domains let you serve drops on your own hostname instead of the shared pool. There are two
modes: path (many drops at hostname/{slug}/) and dedicated (one drop at the hostname root).
// 1. Connect the domain — returns DNS instructions (status: "pending_dns")
const { data: domain } = await dropthis.domains.connect({
hostname: "drops.example.com",
mode: "path",
});
// 2. Create the CNAME at your DNS provider:
// drops.example.com CNAME edge.dropthis.app
const dnsRecord = domain.dns[0];
// dnsRecord.name → "drops.example.com"
// dnsRecord.value → "edge.dropthis.app"
// 3. Verify — call repeatedly until status is "live"
const { data: verified } = await dropthis.domains.verify("drops.example.com");
// Use verified.dns[0].retryAfter (seconds) as the polling interval while status !== "live"
// verified.status → "live"
// 4. Publish to the custom domain (path mode: specify a vanity slug, or omit for a random one)
const { data: drop } = await dropthis.drops.publish("<h1>Hello</h1>", {
domain: "drops.example.com",
slug: "summer-sale",
});
// drop.url → "https://drops.example.com/summer-sale/"
Other domain operations:
await dropthis.domains.list();
await dropthis.domains.get("drops.example.com");
// Repoint a dedicated domain to a different drop
await dropthis.domains.update("bio.example.com", { dropId: "drop_abc123" });
// Set a path-mode domain as the account's publish default
await dropthis.domains.update("drops.example.com", { default: true });
// Delete (remove your DNS CNAME after this to prevent re-claim by another account)
await dropthis.domains.delete("drops.example.com");
client.workspaces lists and switches the active workspace for a delegated credential.
// All workspaces the key has access to (isActive marks the current one)
const { data } = await dropthis.workspaces.list();
for (const ws of data.workspaces) {
console.log(ws.slug, ws.kind, ws.isActive);
}
// The currently active workspace (null when none is active)
const { data: active } = await dropthis.workspaces.active();
// Switch the active workspace server-side (persisted on the api_keys row)
await dropthis.workspaces.use("team-slug"); // slug or id
workspaces.use() only applies to delegated keys; service keys are pinned at creation.
Pass workspace in the publish options to target a specific workspace for that call:
const { data } = await dropthis.drops.publish("<h1>Team report</h1>", {
workspace: "team-slug",
});
console.log(data.workspace); // { id, name, slug, kind } — owning workspace echoed on every drop
Set a client-level default so every publish goes to the same workspace without repeating it:
const dropthis = new Dropthis({ apiKey: "sk_...", workspace: "team-slug" });
// All publishes now target team-slug unless you pass a different workspace in options
const { data } = await dropthis.drops.publish("./dist");
DropthisEdge honours the same workspace constructor option and publish option.
A team workspace is shared by multiple accounts. Creating and managing one needs a team-scoped
credential (see Capability scopes) — a default publish key gets
403 insufficient_scope. Drops published while a team workspace is active land there and route on its
shared custom domain automatically.
// Create a team (caller becomes its owner) and switch to it
const { data: team } = await dropthis.workspaces.create({ name: "Acme" });
await dropthis.workspaces.use(team.slug);
// Invite + manage members
await dropthis.members.invite(team.id, { email: "teammate@acme.com", role: "member" });
const { data: roster } = await dropthis.members.list(team.id);
await dropthis.members.updateRole(team.id, "acc_123", { role: "admin" }); // needs members:admin
await dropthis.members.remove(team.id, "acc_123"); // remove a member, or your own id to leave
// Rename / delete the workspace
await dropthis.workspaces.rename(team.id, { name: "Acme Inc" });
await dropthis.workspaces.delete(team.id); // owner only, needs workspaces:admin
// Invitee side — accept an invite (joins + switches active workspace)
const { data: pending } = await dropthis.invitations.list();
await dropthis.invitations.accept({ token: "inv_raw_token_from_email" });
await dropthis.invitations.acceptById({ invitationId: "inv_abc123" }); // agent path: no token, just be the invited email
role is "admin" | "member" for invites; updateRole also accepts "owner" to transfer ownership
(owner-only, enforced server-side).
Learn more at https://dropthis.app/pricing. account.get().data.entitlements reflects your active tier (capabilities + limits + the plan that unlocks each gated feature). Billing is parked — plans are granted manually for now.
Use the fs-free entry point for Cloudflare Workers and other edge runtimes. It does not import node:fs, node:path, or node:crypto.
import { DropthisEdge } from "@dropthis/node/edge";
const dropthis = new DropthisEdge({ apiKey: env.DROPTHIS_API_KEY });
const { data, error } = await dropthis.drops.publish("<h1>Hello from the edge</h1>");
DropthisEdge accepts the in-memory subset of PublishInput: inline strings, Uint8Array, URL, and the explicit { kind: "content" }, { kind: "source_url" }, and { kind: "files" } forms. Local file paths and string[] path arrays are not supported (no filesystem on the edge).
DropthisEdge exposes the drop lifecycle through drops.publish(input, options?), drops.updateContent(dropId, input, options?), drops.updateSettings, drops.get, drops.list, drops.resolve, drops.getContent, and drops.delete, plus the deployments, account, apiKeys, and domains resource accessors — the same surface as the Node client.
Key types exported from the package:
import type {
EntitlementLimits,
AccountResponse,
AccountWorkspace,
DropthisClientOptions,
DropthisResult,
DropthisErrorResponse,
DropResponse,
DropDeploymentResponse,
DropOptions,
DeploymentContentManifest,
DeploymentContentFile,
DropContentFile,
GetContentOptions,
PrepareOptions,
RequestControls,
PublishOptions,
PublishInput,
PublishFileInput,
ListPage,
KeyType,
RevokeImpact,
CreateUploadSessionRequest,
CreateUploadSessionResponse,
Workspace,
DropWorkspace,
} from "@dropthis/node";
PublishInputError (thrown only for programmer errors when building a publish input,
never for API failures) is a runtime export:
import { PublishInputError } from "@dropthis/node";
For AI coding agents (Cursor, Claude Code, Windsurf, etc.), install the dropthis-skills package:
npx skills add dropthis-dev/dropthis-skills
FAQs
Official Node.js SDK for dropthis — the publish layer between AI and the internet. One call in, one URL out.
The npm package @dropthis/node receives a total of 32 weekly downloads. As such, @dropthis/node popularity was classified as not popular.
We found that @dropthis/node demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.

Security News
pnpm 12 rewrites the package manager in Rust, cutting install times by up to 90% while preserving pnpm 11 workflows and lockfiles.

Security News
Socket CTO Ahmad Nassri joins AppSec leaders at Black Hat to discuss active malware, package manager risks, and software supply chain defense.

Research
/Security News
Thirteen malicious Packagist themes expose visitors on unpatched iPhones to a WebKit-to-kernel exploit chain that steals device data and wallet seeds.