@pinooxhq/pinion-client
v1.2.0
Published
Client for Pinion — resumable uploads with fetch or Axios, retry, resume, progress ETA, and unwrap presets
Readme
@pinooxhq/pinion-client
Browser client for the Pinion resumable upload protocol.
Works with or without Axios — fetch by default, Axios when you already use it.
Quick start
npm install @pinooxhq/pinion-clientimport { uploadFile } from '@pinooxhq/pinion-client';
await uploadFile(file, {
baseURL: '/api/pinion',
unwrapPreset: 'pinoox',
onProgress: ({ percent }) => console.log(percent + '%'),
});That is the whole idea: init → upload parts → complete — handled for you.
Table of contents
- Quick start
- Features
- Install
- Ways to use
- Transport
- Framework patterns
- Advanced
- HTTP rules
- API reference
- Publish to npm
- Troubleshooting
- License
Features
| Feature | Description |
|---------|-------------|
| Zero deps | Native fetch — no Axios required |
| Axios optional | Pass Axios for onUploadProgress per chunk |
| Resume | fingerprint + localStorage session cache |
| Parallel parts | Upload multiple chunks at once (default 2) |
| Retry | Auto-retry failed parts with backoff |
| Checksums | SHA-256 chunk_hash per part |
| Progress | percent, bytes, speed, eta |
| Unwrap presets | pinoox, laravel, flat, raw |
| Batch upload | uploadMany() for multiple files |
| TypeScript | Full .d.ts included |
| Cancel | AbortSignal or client.cancel() |
Install
# fetch only (recommended)
npm install @pinooxhq/pinion-client# with Axios (optional peer dependency)
npm install @pinooxhq/pinion-client axiosyarn add @pinooxhq/pinion-client
pnpm add @pinooxhq/pinion-clientRequires Node.js 18+ or a modern browser with fetch.
Ways to use
Pick the style that fits your project — from one-liner to full control.
1 — One function (fastest)
Best for a single upload button. No setup object needed.
import { uploadFile } from '@pinooxhq/pinion-client';
const result = await uploadFile(file, {
baseURL: '/api/pinion',
unwrapPreset: 'pinoox',
parallel: 2,
onProgress: ({ percent }) => bar.style.width = percent + '%',
});Skip Pinion for small files and fall back to a normal POST:
const skipped = await uploadFile(file, {
baseURL: '/api/pinion',
auto: true,
threshold: 8 * 1024 * 1024,
});
if (skipped === null) {
// file is below threshold — use a regular upload
}With Axios (legacy signature):
import axios from 'axios';
import { uploadFile } from '@pinooxhq/pinion-client';
await uploadFile(axios, file, { baseURL: '/api/pinion', unwrapPreset: 'pinoox' });2 — Fluent API (reusable)
Create one uploader, use it for many files.
import { pinion } from '@pinooxhq/pinion-client';
const uploader = pinion({
baseURL: '/app/pinion',
unwrapPreset: 'pinoox',
headers: { Authorization: 'Bearer …' },
});
if (!uploader.for(file).needsPinion()) {
return fallbackUpload(file);
}
await uploader.for(file).upload({ parallel: 2, retry: 2 });3 — Full client (batch + hooks)
Maximum control — progress hooks, batch uploads, cancel.
import { createPinionFetch } from '@pinooxhq/pinion-client';
const client = createPinionFetch({
baseURL: '/api/pinion',
unwrapPreset: 'pinoox',
destination: 'uploads/media',
extensions: ['mp4', 'zip', 'pdf'],
});
await client.upload(file, {
onProgress: ({ percent, bytesUploaded, bytesTotal, speed, eta }) => {
console.log(`${percent}% · ${speed} B/s · ETA ${eta}s`);
},
onChunkStart: (i) => console.log('part', i),
onError: (err, i) => console.warn(err.code, i),
});
await client.uploadMany([file1, file2], {
fileParallel: 1,
onFileStart: (f, i) => console.log('file', i, f.name),
onFileComplete: (f, result, i) => console.log('done', i),
});
client.cancel();4 — Low-level steps (manual)
Call each HTTP step yourself — useful for custom UI or non-file sources.
import { createPinionFetch, sha256Hex } from '@pinooxhq/pinion-client';
const client = createPinionFetch({ baseURL: '/api/pinion', unwrapPreset: 'pinoox' });
const session = await client.api.init({
filename: file.name,
size: file.size,
fingerprint: client.buildFingerprint(file),
});
const blob = file.slice(0, session.chunk_size);
const form = new FormData();
form.append('upload_id', session.id);
form.append('index', '0');
form.append('chunk_hash', await sha256Hex(blob));
form.append('chunk', blob);
await client.api.uploadPart(form);
await client.api.complete(session.id);5 — With Axios (optional)
Use Axios when you need per-chunk onUploadProgress or already have an Axios instance.
import axios from 'axios';
import { pinion, createPinionAxios } from '@pinooxhq/pinion-client';
// shortcut
const uploader = pinion(axios, { baseURL: '/api/pinion', unwrapPreset: 'pinoox' });
await uploader.for(file).upload({
onProgress: ({ percent }) => console.log(percent),
onUploadProgress: (event, index) => console.log('part', index, event.loaded),
});
// or get both axios instance + client
const { axios: http, client } = createPinionAxios(axios, {
baseURL: '/api/pinion',
unwrapPreset: 'pinoox',
axiosConfig: { timeout: 120000 },
});6 — Custom transport
Plug in your own HTTP layer by implementing get, postJson, postForm.
import { createPinionClient, createFetchTransport } from '@pinooxhq/pinion-client';
const transport = createFetchTransport({
baseURL: '/api/pinion',
fetch: myFetch,
unwrap: (res) => res.data,
headers: { 'X-App': 'portal' },
});
const client = createPinionClient({ transport });Transport
| Mode | How | Notes |
|------|-----|-------|
| fetch (default) | pinion({ baseURL }) · createPinionFetch() · uploadFile(file, …) | Zero extra deps |
| Axios | pinion(axios, …) · createPinionClient(axiosInstance, …) | onUploadProgress per chunk |
| Custom | createPinionClient({ transport }) | Full control |
Check active mode: client.transport.kind → 'fetch' or 'axios'.
Framework patterns
Vue 3
import { ref } from 'vue';
import { pinion } from '@pinooxhq/pinion-client';
const percent = ref(0);
const uploader = pinion({ baseURL: '/app/pinion', unwrapPreset: 'pinoox' });
async function onPick(file) {
percent.value = 0;
await uploader.for(file).upload({
parallel: 2,
onProgress: (p) => { percent.value = p.percent; },
});
}React
import { useState, useMemo } from 'react';
import { createPinionFetch } from '@pinooxhq/pinion-client';
export function usePinion(baseURL = '/api/pinion') {
const client = useMemo(
() => createPinionFetch({ baseURL, unwrapPreset: 'pinoox' }),
[baseURL],
);
const [progress, setProgress] = useState(0);
const upload = (file) => client.for(file).upload({
onProgress: (p) => setProgress(p.percent),
});
return { upload, progress, cancel: () => client.cancel() };
}Advanced
Unwrap presets
Map your backend JSON envelope without custom code.
| Preset | Returns |
|--------|---------|
| pinoox | res.data.data |
| laravel | same envelope |
| flat | res.data |
| raw | full response object |
import { createPinionClient, unwrapPresets } from '@pinooxhq/pinion-client';
createPinionClient({ baseURL: '/api/pinion', unwrapPreset: 'pinoox' });
createPinionClient({
baseURL: '/api/pinion',
unwrap: (res) => res.data.result,
});
unwrapPresets.pinoox(response);Cancel upload
const controller = new AbortController();
uploader.for(file).upload({ signal: controller.signal });
controller.abort();
// or
uploader.cancel();HTTP rules
| Step | Content-Type | Body |
|------|--------------|------|
| init, complete, abort | application/json | plain object |
| upload | (auto) | FormData — never set multipart/form-data manually |
API reference
Factories
| Function | Description |
|----------|-------------|
| uploadFile(file, options?) | One-shot upload (fetch) |
| uploadFile(axios, file, options?) | One-shot upload (Axios) |
| pinion(options?) | Fluent client (fetch) |
| pinion(axios, options?) | Fluent client (Axios) |
| createPinionFetch(options?) | Explicit fetch client |
| createPinionAxios(axios, options?) | { axios, client } |
| createPinionClient(options?) | Full client (fetch) |
| createPinionClient(axios, options?) | Full client (Axios) |
Client options
| Option | Default | Description |
|--------|---------|-------------|
| baseURL | /api/pinion | Pinion HTTP root |
| fetch | globalThis.fetch | Custom fetch (Node polyfill, etc.) |
| transport | auto | Custom PinionTransport |
| unwrapPreset | — | pinoox | laravel | flat | raw |
| unwrap | auto | Custom response mapper |
| storageKey | pinion_sessions | Resume cache key |
| storage | localStorage / memory | Custom adapter |
| headers | {} | Default request headers |
| destination | — | Default server folder |
| extensions | — | Allowed file extensions |
| threshold | 8 MB | shouldUsePinion limit |
Upload options
| Option | Default | Description |
|--------|---------|-------------|
| parallel | 2 | Concurrent parts |
| retry | 2 | Retries per part |
| retryDelayMs | 800 | Backoff base |
| chunkSize | server | Requested part size |
| fileHash | — | Whole-file hash on complete |
| meta | {} | Custom init metadata |
| signal | — | AbortSignal |
| onProgress | — | { percent, bytesUploaded, bytesTotal, chunkIndex, speed, eta } |
| onUploadProgress | — | Raw Axios event per part (Axios only) |
Client methods
| Method | Description |
|--------|-------------|
| client.for(file) | Fluent handle |
| client.upload(file) | Full upload flow |
| client.resume(file) | Same as upload |
| client.uploadMany(files) | Batch upload |
| client.cancel() | Abort active upload |
| client.api.* | Low-level HTTP steps |
| client.transport | Active transport (kind: fetch | axios) |
| client.getStoredSession(fp) | Read resume cache |
Transport helpers
| Export | Description |
|--------|-------------|
| createFetchTransport() | Build fetch transport |
| createAxiosTransport() | Build Axios transport |
| isAxiosInstance() | Detect Axios instance |
| isPinionTransport() | Detect custom transport |
Publish to npm
Prerequisites
| Item | Notes |
|------|-------|
| npmjs.com account | Email verified |
| @pinooxhq org | Required for scoped package |
| Node.js 18+ | node -v |
Steps
cd packages/pinion/client
npm pack # verify tarball
npm login
npm version minor -m "chore: release v%s"
npm publish --access publicVerify:
mkdir /tmp/pinion-test && cd /tmp/pinion-test
npm init -y
npm install @pinooxhq/pinion-clientimport { pinion, PROTOCOL } from '@pinooxhq/pinion-client';
console.log(PROTOCOL); // pinionCI (optional)
name: Publish npm
on:
release:
types: [published]
jobs:
publish:
runs-on: ubuntu-latest
defaults:
run:
working-directory: client
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: https://registry.npmjs.org
- run: npm ci
- run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}| Layer | Registry | Package |
|-------|----------|---------|
| Server (PHP) | Packagist | pinoox/pinion |
| Browser (JS) | npm | @pinooxhq/pinion-client |
Troubleshooting
| Error | Fix |
|-------|-----|
| PINION_NO_FETCH | Pass options.fetch or use Axios |
| 402 Payment Required | Add --access public on publish |
| 403 Forbidden | Check org membership or token |
| 403 cannot publish over | Bump version: npm version patch |
| Multipart fails | Do not set Content-Type on FormData |
| PINION_INIT_FAILED | Set unwrapPreset or custom unwrap |
| Progress stuck | Check missing_indexes via client.api.status(id) |
License
MIT — Pinoox
PHP protocol: pinoox/pinion
