@masters-union/union-stack
v1.1.0
Published
UnionStack — file upload SDK. Direct-to-R2 multipart uploads with a small client surface.
Maintainers
Readme
Union Stack
File upload SDK — direct-to-bucket multipart uploads with a small client surface.
| Environment | Import from |
| --- | --- |
| Browser (vanilla / any framework) | @masters-union/union-stack |
| React | @masters-union/union-stack/react |
| Node.js (server-to-server) | @masters-union/union-stack/node |
Each entry is independent — importing /node pulls in none of the React, picker,
or DOM code, and vice versa.
API domain
By default the SDK calls the standard UnionStack API host. If your key has an
API domain set in the dashboard, pass that hostname to init:
const client = UnionStack.init({
apiKey: process.env.UNION_STACK_API_KEY,
apiDomain: 'uploads.example.com',
});You need this only when some of your users cannot reach the standard host at all.
Corporate and campus DNS filters, ad-blocking resolvers, and
newly-registered-domain blocklists return NXDOMAIN for it, which the browser
reports as ERR_NAME_NOT_RESOLVED before a request is ever sent. Nothing reaches
the API, so there is no server-side record of the failure. The symptom is uploads
that fail for one specific group of users while working for everyone else, with a
NETWORK error reading "Couldn't reach the upload service".
Safe to get wrong: the standard hosts stay in place behind whatever you set, so a malformed value, or a domain whose DNS or certificate breaks later, falls back instead of failing. Bad values are logged and ignored, never thrown.
Pass a bare hostname, not a URL. It has to be set at init and cannot be
delivered through the API, since fetching it would require resolving the host
that is failing. Changing the setting in the dashboard does nothing until your
integration is updated and redeployed.
Server-side uploads (Node.js)
Run uploads from your backend — no browser, no picker. Import from the /node
subpath and upload a path, Buffer, Uint8Array, ArrayBuffer, Blob/File,
or a Node Readable stream.
One-time setup: create a key with its allowed origins left empty. A key with no origins is server-only — it accepts your backend's requests (which carry no
Originheader) and rejects browser use. Don't reuse a key that lists browser origins here; it will reject server calls withORIGIN_REQUIRED. Keep the key in an environment variable.
const { UnionStack } = require('@masters-union/union-stack/node');
const client = UnionStack.init({ apiKey: process.env.UNION_STACK_API_KEY });
// Filename + content type are inferred from the path; large files stream from disk.
const file = await client.upload('./invoice.pdf');
console.log(file.url);
// → https://files.unionstack.in/f/V1StGXR8_Z5jdHi6B-myTExample: re-host remote files into UnionStack
A migration script that fetches files by URL and uploads the bytes — note there's
no Origin header to spoof anymore; a server-only key (empty allowed origins)
accepts backend calls directly.
const { UnionStack } = require('@masters-union/union-stack/node');
const client = UnionStack.init({ apiKey: process.env.UNION_STACK_API_KEY });
const MIME_TO_EXT = {
'application/pdf': 'pdf',
'image/jpeg': 'jpg',
'image/png': 'png',
'image/avif': 'avif',
'image/webp': 'webp',
'video/mp4': 'mp4',
'text/csv': 'csv',
'application/xml': 'xml',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
// ... expand this based on your analysis first based on the files present in the database
};
function resolveExtension(mime, rowId) {
if (MIME_TO_EXT[mime]) return MIME_TO_EXT[mime];
console.error(`unknown mime: ${mime} for row ${rowId}`);
fs.appendFileSync('./unknown-mimes.log', JSON.stringify({ rowId, mime }) + '\n')
throw new Error(`couldn't resolve file type`);
}
async function withRetry(fn, retries = 3) {
let lastErr;
for(let i = 0; i < retries; i++) {
try {
return await fn();
} catch(error) {
lastErr = error;
}
}
throw lastErr;
}
async function getFinalUrl(row) {
try {
const response = await withRetry(() => fetch(row.url, {
method: 'GET'
}));
if(!response.ok) {
throw new Error("Failed to fetch the resource");
}
const mime = response.headers
.get('content-type')
?.split(';')[0]
?.trim() || 'application/octet-stream';
const ext = resolveExtension(mime, row.id);
// if (contentEncoding === 'br') {
// // decompression logic, before enabling this block, check for a single filetype to figure out if brotliCompression was ever done in the first place on files, on FileStack from my experience, there isn't any brotliCompression even if the tag is br.
// finalStrema = nodeStream.pipe(createBrotliDecompress());
// }
// const filepath = `./temp/file_${row.id}.${ext}`;
// await pipeline(
// nodeStream,
// fs.createWriteStream(filepath)
// );
// const buffer = fs.readFileSync(filepath);
const contentLength = response.headers.get('content-length');
const buffer = Buffer.from(await response.arrayBuffer());
if(contentLength && buffer.length !== Number(contentLength)) {
throw new Error(`size mismatch: exp=> ${contentLength}, got=> ${buffer.length}`);
}
const blob = new Blob([buffer], {
fileName: `file_${row.id}.${ext}`,
type: mime
});
console.log(blob);
const uploadResponse = await withRetry(() => client.upload(blob, {
filename: `file_${row.id}.${ext}`,
mimeType: mime
}));
if(uploadResponse.status !== 'Stored') {
throw new Error("Failed to store uploaded file to UnionStack");
}
return uploadResponse.url;
} catch (error) {
throw new Error(error.message);
}
}