@rawback/ccapi-js
v1.1.3
Published
Canon Camera Control API (CCAPI) client for TypeScript — Web-standard fetch/streams, zero runtime dependencies.
Maintainers
Readme
@rawback/ccapi-js
A Canon Camera Control API (CCAPI) client for TypeScript, built on the
Web standard fetch and Streams APIs with zero runtime dependencies. It
runs unchanged in browsers, Node 26+, Deno, and Bun.
CCAPI is a plain HTTP(S) REST API: the camera is the HTTP server, your
application is the client. Endpoints follow
http://[IP]:[Port]/ccapi/[Version]/... and use application/json bodies.
This is the TypeScript port of the Swift reference client (RawbackCCAPI) and is
driven by the same source of truth — the CCAPI Markdown reference under
docs/api/ in
rawback-app/cccapi-swift.
The repository is named
cccapi-js(three c's); the published package is@rawback/ccapi-js(two). Both are correct — mind the difference when installing or filing issues.
Install
pnpm add @rawback/ccapi-jsQuick start
import { CCAPIClient } from '@rawback/ccapi-js';
// Older bodies serve HTTP on :8080; newer ones HTTPS on :443.
const client = new CCAPIClient({ host: '192.168.1.2', port: 8080, useTLS: false });
// 1. CCAPI is discovery-driven — connect() learns which version serves each endpoint.
const { apiVersion, device, storage } = await client.connect();
console.log(device.productName, apiVersion);
// 2. Drive the typed, namespaced endpoints — no version argument; it's auto-resolved.
const battery = await client.status.getBattery();
const beep = await client.settings.getBeep();
await client.settings.setBeep('disable');Discovery-driven — endpoints resolve their own version. The supported endpoints (and the version each lives at) vary by camera model and firmware, so call
connect()(orlistSupportedAPIs(), or passsupportedAPIsat construction) once; after that each endpoint method resolves its own version automatically — you never pass one. AVF-capable bodies don't supportGET /ccapi;connect()bootstraps vialistSupportedAPIsForDev('ver100')there.
API surface
The endpoints are grouped into namespaces on CCAPIClient, mirroring the
reference sections:
| Namespace | Docs § | Covers |
|---|---|---|
| top-level (connect, discoverVersion, listSupportedAPIs, supports, resetAuth, getDeviceInformation, getStorage, getLens) | 4.2 / 4.3 / 4.4.1 / 4.4.6 | Discovery, fixed info, connection, capability gating |
| client.status | 4.4 | Storage/directory destination, battery, temperature, power zoom |
| client.settings | 4.5 | Identity, date/time, format, beep, power, file naming, … |
| client.network | 4.5.11–4.5.34 | Network/Wi-Fi/SSL/CORS connection settings |
| client.customization | 4.6 | Exposure / ISO level increments |
| client.contents | 4.7 | Browse storages/directories, list/stream/get/delete contents, and modify them (rotate, protect, archive, rating, GPS, XMP description) |
| client.shooting | 4.8–4.10 | Shutter/rec release, AF, focus drive, zoom, flicker detection, and the full shooting-settings surface (aperture, shutter, ISO, WB, drive mode, picture style, movie format, focus bracketing, …), including typed accessors for the object-valued image quality, WB shift and power-zoom-adapter settings |
| client.liveview | 4.11 | Start/stop live view, single frames, the multipart stream(), the streamScroll() JPEG stream, the streamScrollDetail() frames + incidental info, RTP (getRTPStatus / setRTP), angle information, AF frame position, click WB |
| client.opticalViewfinder | 4.12 | AF area selection mode and AF frame/area information |
| client.event | 4.13 | getPolling (hold: true long-polls in the style the body's version takes) / getPollingRaw / clearPolling and the long-lived streamMonitoring stream |
Listing and streaming contents
Ver.1.4.0 bodies put a folder (DCIM, or XFVC/CRM for movie reels)
between the storage and the directory. Take it from the directory listing
rather than assuming it:
import { parseContentLocator, parseDirectoryLocator } from '@rawback/ccapi-js';
const { paths } = await client.contents.listDirectories('card1');
// ver140: '/ccapi/ver140/contents/card1/DCIM/100CANON' → { storage, folder: 'DCIM', directory }
// ver130: '/ccapi/ver130/contents/card1/100CANON' → { storage, directory }
const { storage, folder, directory } = parseDirectoryLocator(paths[0]);
// One page (max 100 locators).
const page = await client.contents.listContents(storage, directory, { folder, type: 'jpeg' });
// Every listed locator parses back into what the other contents calls take.
const locator = parseContentLocator(page.paths[0]);
// Stream the full listing as page-sized arrays (Web async iteration).
for await (const locators of client.contents.streamContents('card1', '100CANON')) {
console.log(locators);
}
// Download as a Web ReadableStream (no in-memory buffering).
const stream = await client.contents.streamContentData({
storage: 'card1', directory: '100CANON', file: 'IMG_0001.JPG',
});
// e.g. in Node: import { Writable } from 'node:stream';
// await stream.pipeTo(Writable.toWeb(createWriteStream('IMG_0001.JPG')));
// Or get the bytes (optionally a byte range → HTTP 206).
const { data, contentType } = await client.contents.getContentData(locator, {
range: { start: 0, end: 1023 },
});Shooting settings
Most settings are { value, ability } with a string value — pass one of the
ability entries back to the setter. A few are objects or ranges, and have
typed accessors of their own:
const av = await client.shooting.getAperture(); // { value: 'f5.6', ability: [...] }
await client.shooting.setAperture(av.ability![0]);
// Object-valued (doc 4.9.18 / 4.9.28 / 4.9.76).
const quality = await client.shooting.getStillImageQualitySetting();
await client.shooting.setStillImageQuality({ raw: 'craw', jpeg: 'large_fine' });
const shift = await client.shooting.getWhiteBalanceShiftSetting(); // { value: { ba, mg }, ability: { ba: { min, max, step }, … } }
await client.shooting.setWhiteBalanceShift({ ba: 2, mg: 0 });
await client.shooting.setPowerZoomSetting({ speed: 'slow', level: 7 });
// Picture styles: send back the full parameter set read by GET, with your edit.
const mono = await client.shooting.getPictureStyleDetail('monochrome');
await client.shooting.setPictureStyleDetail('monochrome', { ...mono.value, filter: 'red' });
// Range-valued integers (colour temperature, focus bracketing, sound levels, …).
const kelvin = await client.shooting.getRangeSetting('colortemperature'); // { value, ability: { min, max, step } }Authentication
Digest auth (realm "CameraControlApi", algorithm "MD5", qop "auth") is
required only when a password is configured in the camera's CCAPI menu. Pass
credentials and the client answers the 401 challenge transparently and
pre-authenticates subsequent requests:
const client = new CCAPIClient({
host, port, credentials: { username: 'admin', password: 'secret' },
});CCAPI mandates MD5 for Digest, which the Web Crypto API intentionally does not implement, so a small self-contained MD5 is bundled. It is used only for the Digest handshake.
Errors
Every failure is a CCAPIError whose kind mirrors the documented HTTP status
codes (badRequest, unauthorized, forbidden, notActivated, conflict,
pairingRequired, rangeNotSatisfiable, serverError, notReachable, …). The
camera's { "message": "..." } body, when present, is attached as
cameraMessage. error.isRecoverable is true for pairingRequired /
notReachable. Despite its name, pairingRequired is HTTP 503 — the camera is
temporarily unavailable (busy, shooting, wrong mode, Live View not started, …);
cameraMessage says why.
import { CCAPIError } from '@rawback/ccapi-js';
try {
await client.contents.deleteContent(locator);
} catch (error) {
if (error instanceof CCAPIError && error.kind === 'conflict') {
console.warn('Protected:', error.cameraMessage); // "File protected"
}
}Capability gating
Cameras advertise different endpoints by model and firmware. supports()
answers whether a suffix is advertised (always false before connect()), so
you can gate optional UI without call-and-catch:
if (client.supports('shooting/liveview')) {
// show the live view tab
}Calling an endpoint the camera doesn't advertise throws CCAPIError with
kind: 'notActivated'; calling one before discovery throws kind: 'invalidRequest'.
Streams and long-lived requests
Live view and event monitoring are open-ended transfers, so they are exposed as Web streams rather than promises. End them by cancellation, not by timeout:
// Multipart live view frames (or `streamScroll()` for the chunked JPEG stream).
for await (const frame of client.liveview.stream()) {
render(frame);
}
// Long-lived camera event stream — events can be minutes apart.
for await (const event of client.event.streamMonitoring()) {
console.log(event);
}
// Or long-poll: the camera holds the request until something changes.
const changed = await client.event.getPolling({ hold: true });The timeoutMs client option (default 12 000 ms, 0 disables it) is a total
deadline for ordinary JSON requests, and bounds the wait for the camera to
answer everything else:
- Binary downloads (
getContentData, Live View frames, the SDP, the root certificate) switch to an idle timeout once the headers arrive: it restarts with every chunk received, so a large but steady download is never cut off, while a camera that stops sending mid-body fails withnotReachable.getContentDatatakes a per-calltimeoutMs. - Streams (
stream(),streamScroll(),streamScrollDetail(),streamMonitoring(),streamContents(),streamContentData()) have no timeout once the camera answers — a quiet stream is normal — so end them with anAbortSignalor by breaking out of the loop. event.getPollingraises the deadline while the camera holds a long poll.
Streams are read with an explicit getReader() loop, so they work in Safari too.
Recovering from a lock or reboot
The camera allows one client at a time. A 403 means access is not allowed —
usually another device holding that slot, or a CORS-origin mismatch — as opposed
to 401, which means Digest is enabled and authentication failed. After a camera
reboot or a 403 lock, call resetAuth() to discard the cached Digest challenge
and nonce-count, then retry connect():
client.resetAuth();
await client.connect();TLS and self-signed certificates (Node)
Newer cameras serve HTTPS with a self-signed certificate. Browsers handle
trust through their own UI; in Node you can inject a custom fetch backed by a
dispatcher that trusts the camera host:
import { Agent, fetch as undiciFetch } from 'undici';
const dispatcher = new Agent({ connect: { rejectUnauthorized: false } });
const client = new CCAPIClient({
host, port: 443,
fetch: ((input, init) => undiciFetch(input, { ...init, dispatcher })) as typeof fetch,
});Scope
- Camera discovery (SSDP / Bonjour
_ccapi._tcp) requires raw UDP sockets, which are not a Web standard and not available in browsers, so it is out of scope here. Supply the camera's host/port (from your own discovery, the camera screen, or your router) and the HTTP discovery endpoints (listSupportedAPIs) take it from there. - Endpoint coverage is complete. The reference is generated from the full 925-page CCAPI PDF and chapters 4.2–4.13 are implemented — discovery, camera info and status, settings, customization, contents, shooting control and settings, recording information, live view, optical viewfinder and events. See the reference for the per-section index.
Example apps
An interactive Vite + React + Tailwind playground lives in
examples/web/. Pull the repo, point it at your camera, and click
through every namespace (status, settings, customization, contents with thumbnails,
network/CORS, discovery) with a live request log. It consumes this package via
file:../.., so it also doubles as a manual smoke test of the public API.
cd examples/web
pnpm install
pnpm run dev # http://localhost:5173 — see examples/web/README.md for proxy/CORS setupA terminal debugger built with Ink lives in examples/tui/.
It runs under Bun and logs every request with pino:
cd examples/tui
bun install
bun run start # see examples/tui/README.md for keys and loggingDevelopment
Requires Node 26+ and pnpm 10.25.0 (pinned in packageManager).
pnpm install
pnpm run typecheck # tsc --noEmit
pnpm test # vitest (parses the shared JSON fixtures under tests/)
pnpm run test:watch # vitest in watch mode
pnpm run build # tsdown → dist/ (ESM + CJS + .d.ts + .d.cts)Those four are also the whole CI gate (.github/workflows/ci.yml). There is no
linter or formatter in this repo — tsc --noEmit is the only mechanical guard,
so style conventions are enforced by review.
All API content belongs to Canon Inc.; this client is a derived implementation of the published reference.
