pcloud-kit
v0.3.0
Published
Unofficial pCloud SDK
Readme
pcloud-kit
Unofficial, zero-dependency, type-safe pCloud SDK for modern JavaScript runtimes.
A modern alternative to pcloud-sdk-js with full TypeScript support, and no runtime dependencies.
Why
| | pcloud-sdk-js | pcloud-kit |
| -------------- | -------------------------------------------- | --------------------------------------------- |
| Runtime deps | isomorphic-fetch, form-data, invariant | none |
| TypeScript | untyped JS | strict, exactOptionalPropertyTypes |
| Module format | CJS | ESM only, sideEffects: false |
| Progress | XHR events | stream-based byte counts |
| Errors | raw rejected JSON | typed PcloudApiError / PcloudNetworkError |
| API style | callback or promise | promise only |
| Environments | Node + browser | Node + browser (platform-neutral build) |
| EU/US failover | manual | automatic on 5xx |
Requirements
- Node
>=22.18.0— relies on globalfetch,Writable.toWeb,openAsBlob,Array.prototype.toSorted - Browser — any modern browser with
fetch,FormData, andReadableStream
Install
npm install pcloud-kit
# pnpm add pcloud-kit / yarn add pcloud-kit / bun add pcloud-kitQuick start
import { createClient } from 'pcloud-kit'
const client = createClient({ token: process.env.PCLOUD_TOKEN! })
const root = await client.listfolder(0)
for (const entry of root.contents ?? []) {
// TypeScript narrows entry to FolderMetadata | FileMetadata via isfolder
console.log(entry.isfolder ? '[dir]' : '[file]', entry.name)
}Authentication
OAuth — server-side code flow (Node)
import { buildAuthorizeUrl, getTokenFromCode } from 'pcloud-kit/oauth'
// Step 1: redirect the user to pCloud's consent screen
const url = buildAuthorizeUrl({
clientId: process.env.PCLOUD_CLIENT_ID!,
redirectUri: 'https://example.com/callback',
responseType: 'code',
})
// Step 2: in your callback handler, exchange the code for an access token
const { access_token, locationid } = await getTokenFromCode(
code,
process.env.PCLOUD_CLIENT_ID!,
process.env.PCLOUD_APP_SECRET!,
)
const client = createClient({ token: access_token })OAuth — browser popup flow
import { initOauthToken, popup } from 'pcloud-kit/oauth-browser'
// On the main page: opens a popup and wires the callback
initOauthToken({
clientId: 'YOUR_CLIENT_ID',
redirectUri: 'https://example.com/oauth-callback',
receiveToken: (token, locationid) => {
const client = createClient({ token })
},
})
// On the redirectUri page: reads the token from the URL and passes it back
popup()For environments where a redirect URI is impractical, initOauthPollToken uses the poll_token response type and simultaneously polls both EU and US servers:
import { initOauthPollToken } from 'pcloud-kit/oauth-browser'
initOauthPollToken({
clientId: 'YOUR_CLIENT_ID',
receiveToken: (token, locationid) => {
/* … */
},
onError: (err) => {
/* … */
},
})Username/password session token
If you have a session token from userinfo (the auth field), use type: 'pcloud' — this switches the auth header from access_token= to auth=:
const client = createClient({ token: sessionToken, type: 'pcloud' })type defaults to 'oauth'.
Convenience methods
These are fully typed and bound directly on the client:
| Method | Signature | Returns |
| ------------------ | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------- |
| userinfo | () | Promise<UserInfo> |
| listfolder | (folderid?: number, options?: ListFolderOptions) | Promise<FolderMetadata> |
| createfolder | (name: string, parentfolderid?: number) | Promise<FolderMetadata> |
| deletefile | (fileid: number) | Promise<FileMetadata> |
| deletefolder | (folderid: number, recursive?: boolean) | Promise<FolderMetadata> |
| movefile | (fileid: number, tofolderid: number) | Promise<FileMetadata> |
| movefolder | (folderid: number, tofolderid: number) | Promise<FolderMetadata> |
| renamefile | (fileid: number, toname: string) | Promise<FileMetadata> |
| renamefolder | (folderid: number, toname: string) | Promise<FolderMetadata> |
| getfilelink | (fileid: number) | Promise<string> |
| getthumblink | (fileid: number, options?: ThumbOptions) | Promise<string> |
| sharefolder | (folderid: number, mail: string, permissions: 'view' \| 'edit', message?: string) | Promise<ShareInfo> |
| appshare | (folderid: number, userid: number, clientid: string) | Promise<AppShareInfo> |
| login | (params?: Record<string, string>) | Promise<UserInfo> |
| register | (email: string, password: string, options?: RegisterOptions) | Promise<number> |
| upload | (source: string \| Blob \| File, folderid?: number, options?: UploadOptions) | Promise<{ metadata: FileMetadata; checksums: Checksums }> |
| download | (url: string, options?: DownloadOptions) | Promise<ReadableStream<Uint8Array>> |
| downloadfile | (fileid: number, destination: string \| WritableStream<Uint8Array>, options?: DownloadOptions) | Promise<FileLocal> |
| remoteupload | (url: string, folderid?: number, options?: RemoteUploadOptions) | Promise<{ metadata: FileMetadata }> |
| getthumbsfileids | (fileids: number[], receiveThumb: (thumb: ThumbResult) => void, options?: ThumbOptions) | Promise<ThumbResult[]> |
| getthumbslinks | (fileids: number[], options?: ThumbOptions) | Promise<ThumbResult[]> |
Uploads
// From a Blob or File (browser or Node)
const { metadata, checksums } = await client.upload(new Blob(['hello']), folderId, {
onBegin: () => console.log('starting'),
onProgress: ({ loaded, total }) => console.log(loaded, '/', total),
onFinish: () => console.log('done'),
})
// From a filesystem path (Node only)
await client.upload('/path/to/video.mp4', folderId)
// Remote URL — pCloud fetches it server-side, progress is polled automatically
await client.remoteupload('https://example.com/archive.zip', folderId, {
onProgress: ({ all }) => console.log(all.downloaded, '/', all.size),
})Chunked/resumable upload primitives (upload_create, upload_write, upload_save) are available via client.call() — there is no high-level wrapper yet.
Downloads and streaming
// Download a file to disk (Node)
const { path, bytes } = await client.downloadfile(fileId, '/tmp/out.bin')
// Download to any WritableStream (browser or Node)
await client.downloadfile(fileId, writableStream, {
onProgress: ({ loaded, total }) => {
/* … */
},
})
// Get a raw ReadableStream to pipe or consume yourself
const url = await client.getfilelink(fileId)
const stream = await client.download(url)Error handling
import { PcloudApiError, PcloudNetworkError } from 'pcloud-kit'
try {
await client.listfolder(0)
} catch (err) {
if (err instanceof PcloudApiError) {
// pCloud returned a non-zero result code.
// `err.params` echoes the method's input params with
// known secret/sensitive keys stripped, so it's safe to log.
console.error(err.result, err.method, err.params)
} else if (err instanceof PcloudNetworkError) {
// fetch itself failed (timeout, DNS, etc.). The underlying fetch URL
// may include the auth token as a query param, so `err.message` and
// `err.cause.message` are scrubbed: values of known secret keys are
// replaced with `***`. `err.cause` is a plain `{ name, message }`
// object (not the raw fetch error) to prevent incidental leaks.
console.error(err.status, err.cause)
}
}The error classes can also be imported from pcloud-kit/errors to avoid pulling in the full client.
Logging and proxies
pCloud's HTTP/JSON API requires authentication parameters to travel as query-string values: ?access_token=, ?auth=, and — for register / login — ?password=. The SDK keeps those values out of its own thrown errors (see Error handling), but anything sitting between your app and *.pcloud.com will see the full URL.
If you operate or pass through any of the following, disable query-string capture (or scrub these keys) so tokens and passwords don't end up in stored logs:
- reverse proxies / load balancers (nginx
$request, Envoy access logs, ALB/CloudFront request logs) - APM and tracing (OpenTelemetry HTTP spans, Datadog/New Relic/Sentry breadcrumbs that record outbound URLs)
- CI logs that print fetch URLs on failure
The server-side OAuth code exchange (getTokenFromCode) is exempt: client_id, code, and client_secret travel in the POST body (per RFC 6749 §3.2), so they don't appear in URLs.
Low-level client.call() — 160+ endpoints
The convenience methods cover the most common operations. For everything else, call() is constrained to the PcloudMethodName literal union:
// Typed against ~160 known pCloud method names
const stat = await client.call<{ metadata: FileMetadata }>('stat', { fileid: 12345 })
// Escape hatch for unlisted or future methods
const res = await client.callRaw('some_new_method', { param: 'value' })call() also accepts an AbortSignal:
const controller = new AbortController()
await client.call('stat', { fileid: 12345 }, { signal: controller.signal })See the pCloud HTTP API docs for the full method list and parameters.
Advanced client options
const client = createClient({
token,
type: 'oauth', // 'oauth' (default) | 'pcloud'
apiServer: 'eapi.pcloud.com', // default EU server; 'api.pcloud.com' for US
useProxy: true, // auto-detect nearest server via getapiserver on init
coalesceReads: false, // disable in-flight deduplication of identical GET reads
})
// Swap the token on an existing client (e.g. after token refresh, or
// when reusing a long-lived client across multi-tenant requests).
// Resets the in-flight read coalesce cache so reads under the old
// token can't be served to a caller that now holds a different token.
client.setToken(newToken)
// Re-detect the nearest server
const server = await client.setupProxy()The client automatically falls back from the EU to the US server (api.pcloud.com) on a 5xx or connection failure — no configuration needed.
Migrating from pcloud-sdk-js
Client creation
// Before
import pCloudSdk from 'pcloud-sdk-js'
const client = pCloudSdk.createClient(token)
// After
import { createClient } from 'pcloud-kit'
const client = createClient({ token })Listing a folder
The method name and promise shape are identical. You now get discriminated union types for free:
const folder = await client.listfolder(0, { recursive: true })
for (const entry of folder.contents ?? []) {
if (entry.isfolder) {
entry.folderid // FolderMetadata — TypeScript knows this
} else {
entry.fileid // FileMetadata — TypeScript knows this
}
}Upload progress
// Before — XHR ProgressEvent
client.upload(file, folderId, { onProgress: (event) => event.loaded / event.total })
// After — stream-based byte counts
client.upload(file, folderId, {
onProgress: ({ loaded, total }) => loaded / (total ?? Infinity),
})Error handling
// Before — raw rejected JSON
.catch((err) => {
if (err.result === 2000) { /* invalid token */ }
})
// After — typed error classes
.catch((err) => {
if (err instanceof PcloudApiError && err.result === 2000) { /* invalid token */ }
})Gaps
- Chunked/resumable uploads — no high-level wrapper; use
client.call('upload_create', …)etc. directly. - Upload progress on file paths (Node) — progress reflects bytes passing through the stream tee, not HTTP body-upload confirmation events.
Development
pnpm install
pnpm exec playwright install chromium # one-time, for browser tests
pnpm test # vitest — runs all three projects (see below)
pnpm build # tsdown (ESM + .d.ts)The project uses tsdown for bundling, vitest for testing, oxlint for linting, and oxfmt for formatting.
Test layout
pnpm test runs three Vitest projects:
| Project | Location | Environment | Mocking |
| ------------------ | ---------------------------------------- | --------------------- | ------------------ |
| unit | test/unit/**/*.test.ts | Node | fetch stub |
| scenario-node | examples/node/**/*.scenario.test.ts | Node | MSW (msw/node) |
| scenario-browser | examples/browser/**/*.scenario.test.ts | Chromium (Playwright) | MSW service worker |
Filter to one project with the matching script:
pnpm test:unit
pnpm test:scenario-node
pnpm test:browserThe unit tests stub fetch directly to assert URL shapes and error handling; the scenario tests drive the SDK end-to-end against MSW handlers shared between Node and the browser.
Examples
examples/ holds runnable scenarios that double as integration tests. They share fixtures and MSW handlers so the same flows are exercised from both Node and the browser.
examples/
shared/ fixtures, MSW handler factory, scenario runner
node/ Vitest tests + a tsx-runnable manual script
browser/ Vitest browser-mode tests + a Vite + React demo pageManual scenario — Node
Run the same flows the scenario-node tests cover from the command line:
pnpm scenario:node # MSW-mocked, no network
pnpm scenario:node:real # hits the real pCloud API (see env vars below)For --real mode, set:
| Var | Required | Purpose |
| ------------------- | -------- | -------------------------------------------------------- |
| PCLOUD_TOKEN | yes | OAuth access token (sent as ?access_token=) |
| PCLOUD_AUTH_TOKEN | optional | Session token (sent as ?auth=); skipped if unset |
| PCLOUD_CLIENT_ID | optional | OAuth app client id; required for the code-exchange step |
| PCLOUD_APP_SECRET | optional | OAuth app secret; required for the code-exchange step |
| PCLOUD_CODE | optional | Authorization code; required for the code-exchange step |
Steps that lack their required env vars are skipped — you don't have to provide an app secret just to validate listfolder.
Manual scenario — browser
pnpm scenario:browser # MSW-mocked, no network
pnpm scenario:browser:real # hits the real pCloud APIBoth commands open http://localhost:5173. A small React + Vite page with one button per flow (oauth listfolder, pcloud-mode listfolder, OAuth poll-token, error path).
In MSW mode the inputs are pre-filled with fixture tokens and every flow works against the mocked handlers.
In :real mode the worker is not started and requests go to the live API. Paste a real token into the input(s) — ?access_token= for the oauth-mode button, ?auth= for the pcloud-mode button. The OAuth poll-token and error-path buttons are disabled because they depend on MSW (the OAuth poll button uses a fake client_id and intercepts window.open; the error button overrides the worker handlers).
License
MIT
