🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@labelgrid/core

Package Overview
Dependencies
Maintainers
1
Versions
3
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@labelgrid/core - npm Package Compare versions

Comparing version
0.1.0
to
0.2.0
+14
dist/timeouts.d.ts
/**
* Shared parsing for the configurable request/transfer timeouts, used by both
* the MCP server (env vars) and the CLI (flags and env vars). A value must be a
* positive integer number of milliseconds; anything else is rejected so the
* caller can fall back to the client default and warn once.
*/
export type TimeoutParse = {
/** The parsed positive-integer ms, or undefined when unset OR invalid. */
value: number | undefined;
/** True only when a value was supplied but was not a positive integer. */
invalid: boolean;
};
/** Parses a timeout string into a positive-integer millisecond value. */
export declare function parseTimeoutMs(raw: string | undefined): TimeoutParse;
/**
* Shared parsing for the configurable request/transfer timeouts, used by both
* the MCP server (env vars) and the CLI (flags and env vars). A value must be a
* positive integer number of milliseconds; anything else is rejected so the
* caller can fall back to the client default and warn once.
*/
/** Parses a timeout string into a positive-integer millisecond value. */
export function parseTimeoutMs(raw) {
if (raw === undefined || raw.trim() === '')
return { value: undefined, invalid: false };
const n = Number(raw.trim());
if (Number.isInteger(n) && n > 0)
return { value: n, invalid: false };
return { value: undefined, invalid: true };
}
+40
-0

@@ -11,2 +11,42 @@ # Changelog

## [0.2.0] - 2026-07-23
### Added
- `MAX_UPLOAD_BYTES` (4 GiB) upload ceiling and a `FILE_TOO_LARGE` structured
error. An oversized file is now rejected with an honest size-and-limit
message instead of being mislabeled `FILE_NOT_FOUND`.
- `mintUpload`, `putToPresignedUrl` and `commitUpload` — the presigned upload
flow's three steps are now individually exported and composed by
`uploadViaPresignedUrl`, so an alternate transport can reuse mint + commit and
swap only the byte-transfer step. Pure refactor; no behavior change.
- `LabelGridClient.getRaw(path, query?)` — an authenticated raw GET for file
downloads that returns the live response for streaming, or the same
normalized structured error as the JSON path, honoring the raw transfer
timeout. Consolidates the duplicated authed-download helpers that lived in the
MCP and CLI packages.
### Changed
- Presigned-URL uploads now stream the file from disk with an explicit
`Content-Length` header instead of buffering the whole file into memory, so
peak memory stays flat regardless of file size. (An explicit `Content-Length`
is required — S3-compatible presigned PUTs reject a chunked body with 411.)
- Multipart uploads no longer make a redundant in-memory copy of the file bytes
before building the form Blob.
- `UploadOptions` accepts an optional `onProgress` callback, invoked with the
running byte count as the presigned PUT streams (for a transfer-progress UI).
### Fixed
- Multipart uploads (cover art, license documents) now use the longer raw
transfer timeout instead of the 60s JSON request timeout, so a large file on
a slow uplink is no longer aborted mid-upload.
- `raw()` now composes a caller-supplied `AbortSignal` with the transfer-timeout
signal (`AbortSignal.any`) instead of letting the caller's signal silently
replace and disable the timeout.
- Presigned-upload progress reporting now forwards a source-stream error onto
the composed request body, so a file that becomes unreadable mid-transfer
fails cleanly as `UPLOAD_FAILED` instead of raising an unhandled stream error.
## [0.1.0] - 2026-07-20

@@ -13,0 +53,0 @@

@@ -26,2 +26,13 @@ /**

};
/**
* The outcome of a raw authenticated GET: either the live {@link Response} (for
* the caller to stream a file body from) or a normalized structured error.
*/
export type RawResult = {
ok: true;
res: Response;
} | {
ok: false;
error: ApiError;
};
export declare class LabelGridClient {

@@ -78,2 +89,14 @@ private readonly baseUrl;

raw(url: string, init: RequestInit): Promise<Response>;
/**
* Authenticated raw GET for file-body downloads (statement CSV/PDF): sends the
* same auth headers as the JSON path but returns the live {@link Response} on
* success so the caller streams the body to disk, never buffering a large file
* in memory. A non-2xx is read and normalized to the same structured
* {@link ApiError} as the JSON path; a network/timeout failure maps to the same
* NETWORK_ERROR/TIMEOUT. Uses the longer raw transfer timeout, since a download
* is a byte transfer, not a JSON call.
*/
getRaw(path: string, query?: Record<string, unknown>): Promise<RawResult>;
/** Maps a fetch rejection (abort/timeout vs other) to a structured error. */
private mapFetchError;
}
+84
-5

@@ -196,3 +196,7 @@ /**

}
const init = { method, headers, signal: AbortSignal.timeout(this.timeoutMs) };
// A raw body is a byte transfer (a multipart upload), not a JSON call — it
// gets the longer transfer timeout so a large file on a slow uplink is not
// aborted at the 60s JSON deadline.
const effectiveTimeoutMs = opts.rawBody !== undefined ? this.rawTimeoutMs : this.timeoutMs;
const init = { method, headers, signal: AbortSignal.timeout(effectiveTimeoutMs) };
if (opts.rawBody !== undefined) {

@@ -215,3 +219,3 @@ init.body = opts.rawBody;

code: 'TIMEOUT',
message: `The request timed out after ${Math.round(this.timeoutMs / 1000)} seconds. Try again, or narrow the request.`,
message: `The request timed out after ${Math.round(effectiveTimeoutMs / 1000)} seconds. Try again, or narrow the request.`,
status: 0,

@@ -262,3 +266,3 @@ },

code: 'TIMEOUT',
message: `The request timed out after ${Math.round(this.timeoutMs / 1000)} seconds while reading the response. Try again, or narrow the request.`,
message: `The request timed out after ${Math.round(effectiveTimeoutMs / 1000)} seconds while reading the response. Try again, or narrow the request.`,
status: 0,

@@ -382,3 +386,16 @@ },

const form = new FormData();
form.append(fieldName, new Blob([new Uint8Array(bytes)], { type: contentType(filePath) }), basename(filePath));
// Wrap the buffer's bytes in a zero-copy Uint8Array VIEW (same underlying
// memory, honoring byteOffset/byteLength on a pooled Buffer), which the Blob
// then copies once. The previous `new Blob([new Uint8Array(bytes)])` made an
// extra full copy before the Blob's own copy — that redundant copy is gone.
// Node's FormData accepts a Blob field. Streaming multipart is not practical
// with undici's FormData (it materializes the parts), so this stays a single
// in-memory buffer — acceptable for the small files (images, PDFs, lyrics)
// that use the multipart path; the large binaries go through the streaming
// presigned-URL flow instead.
// The `as BlobPart` cast is only because the DOM lib types a Buffer's
// ArrayBufferLike (which could be a SharedArrayBuffer) too narrowly for
// BlobPart; the view is a valid Blob part at runtime.
const view = new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
form.append(fieldName, new Blob([view], { type: contentType(filePath) }), basename(filePath));
for (const [key, value] of Object.entries(extra ?? {})) {

@@ -396,4 +413,66 @@ form.append(key, value);

raw(url, init) {
return this.fetchFn(url, { signal: AbortSignal.timeout(this.rawTimeoutMs), ...init });
const timeoutSignal = AbortSignal.timeout(this.rawTimeoutMs);
// Compose, never replace: spreading `...init` last would let a caller-supplied
// signal silently drop the transfer timeout. AbortSignal.any aborts when
// EITHER the caller's signal or the timeout fires, so the timeout always holds.
const signal = init.signal != null ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal;
return this.fetchFn(url, { ...init, signal });
}
/**
* Authenticated raw GET for file-body downloads (statement CSV/PDF): sends the
* same auth headers as the JSON path but returns the live {@link Response} on
* success so the caller streams the body to disk, never buffering a large file
* in memory. A non-2xx is read and normalized to the same structured
* {@link ApiError} as the JSON path; a network/timeout failure maps to the same
* NETWORK_ERROR/TIMEOUT. Uses the longer raw transfer timeout, since a download
* is a byte transfer, not a JSON call.
*/
async getRaw(path, query) {
const url = `${this.baseUrl}${path}${buildQuery(query)}`;
let res;
try {
res = await this.fetchFn(url, {
method: 'GET',
headers: this.authHeaders(),
signal: AbortSignal.timeout(this.rawTimeoutMs),
});
}
catch (err) {
return { ok: false, error: this.mapFetchError(err) };
}
if (res.ok)
return { ok: true, res };
// Read and normalize the error body exactly as the JSON path does.
let body = null;
try {
const text = await res.text();
if (text.length > 0) {
try {
body = JSON.parse(text);
}
catch {
body = text;
}
}
}
catch {
// no readable error body — normalizeError falls back to status defaults
}
return { ok: false, error: normalizeError(res, body) };
}
/** Maps a fetch rejection (abort/timeout vs other) to a structured error. */
mapFetchError(err) {
if (err instanceof DOMException && (err.name === 'TimeoutError' || err.name === 'AbortError')) {
return {
code: 'TIMEOUT',
message: `The request timed out after ${Math.round(this.rawTimeoutMs / 1000)} seconds. Try again, or narrow the request.`,
status: 0,
};
}
return {
code: 'NETWORK_ERROR',
message: err instanceof Error ? err.message : 'Network request failed.',
status: 0,
};
}
}

@@ -16,5 +16,23 @@ /**

* never finalized. Business rules (format checks, transcoding) stay server-side.
*
* The step-2 PUT streams the file from disk rather than buffering it whole: the
* request body is a read stream and the object's byte size is sent as an
* explicit Content-Length. (An S3-compatible presigned PUT rejects a chunked
* Transfer-Encoding body with 411 MissingContentLength, and fetch defaults a
* stream body to chunked, so the header is mandatory.) This keeps peak memory
* flat regardless of file size.
*/
import type { ApiResult, LabelGridClient } from './http.js';
import type { ApiError, ApiResult, LabelGridClient } from './http.js';
/** Hard ceiling on a single uploaded file, in bytes (4 GiB). */
export declare const MAX_UPLOAD_BYTES: number;
/**
* Composes a byte-counting Transform onto `src` for progress reporting and —
* critically — forwards a SOURCE error onto the composed body. `pipe()` does NOT
* propagate a source-stream error to its destination, so without this a file
* that becomes unreadable mid-transfer (deleted, an I/O fault) would emit an
* unhandled 'error' on the read stream — crashing the process — instead of
* destroying the composed body so `fetch` rejects into the UPLOAD_FAILED catch.
*/
export declare function attachProgressCounter(src: NodeJS.ReadableStream, onProgress: (bytesSoFar: number) => void): NodeJS.ReadableStream;
/**
* The structural subset of {@link LabelGridClient} the presigned-upload flow

@@ -32,3 +50,34 @@ * needs. Declared as a Pick so any object with these methods (including a test

filePath: string;
/** Byte ceiling override (defaults to {@link MAX_UPLOAD_BYTES}); for tests. */
maxBytes?: number;
/** Called with the running byte count as the upload streams (for a progress UI). */
onProgress?: (bytesSoFar: number) => void;
};
/**
* THE SEAM: the presigned flow is three independently-callable steps —
* {@link mintUpload} (get a signed URL + key), {@link putToPresignedUrl} (send
* the bytes), {@link commitUpload} (record the object) — and
* {@link uploadViaPresignedUrl} just composes them. An alternate transport that
* does the PUT out of process (e.g. a browser or a worker doing the byte
* transfer directly) can reuse mint + commit and swap only the middle step,
* without reimplementing the URL-minting or commit contracts.
*/
/** The result of minting a presigned URL: the signed URL + object key, or an error. */
export type MintResult = {
uploadUrl: string;
key: string;
} | {
error: ApiError;
};
/** Step 1: mint the presigned URL + object key for `filename`. */
export declare function mintUpload(client: Pick<UploadHttp, 'post'>, uploadUrlPath: string, filename: string): Promise<MintResult>;
/**
* Step 2: stream the file's bytes to the presigned URL — NO auth header (the URL
* is signed). Re-stats the file so Content-Length is its size at upload time,
* enforcing the byte ceiling and the FILE_NOT_FOUND (TOCTOU) contract there.
* Returns null on success, or a structured error.
*/
export declare function putToPresignedUrl(client: Pick<UploadHttp, 'raw'>, uploadUrl: string, filePath: string, maxBytes?: number, onProgress?: (bytesSoFar: number) => void): Promise<ApiError | null>;
/** Step 3: commit the uploaded object key (idempotent — a retried commit will not duplicate). */
export declare function commitUpload(client: Pick<UploadHttp, 'put'>, commitPath: string, key: string): Promise<ApiResult<unknown>>;
export declare function uploadViaPresignedUrl(client: UploadHttp, opts: UploadOptions): Promise<ApiResult<unknown>>;
+123
-51

@@ -16,65 +16,114 @@ /**

* never finalized. Business rules (format checks, transcoding) stay server-side.
*
* The step-2 PUT streams the file from disk rather than buffering it whole: the
* request body is a read stream and the object's byte size is sent as an
* explicit Content-Length. (An S3-compatible presigned PUT rejects a chunked
* Transfer-Encoding body with 411 MissingContentLength, and fetch defaults a
* stream body to chunked, so the header is mandatory.) This keeps peak memory
* flat regardless of file size.
*/
import { statSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { createReadStream, statSync } from 'node:fs';
import { basename } from 'node:path';
import { Transform } from 'node:stream';
import { log } from '../log.js';
import { contentType } from './content-types.js';
/** True only for an existing regular file. */
function isReadableFile(p) {
/** Hard ceiling on a single uploaded file, in bytes (4 GiB). */
export const MAX_UPLOAD_BYTES = 4 * 1024 * 1024 * 1024;
/** Stats a path, returning its size only for an existing regular file. */
function statReadableFile(p) {
try {
return statSync(p).isFile();
const st = statSync(p);
return st.isFile() ? { size: st.size } : null;
}
catch {
return false;
return null;
}
}
export async function uploadViaPresignedUrl(client, opts) {
// Fail fast and locally: never touch the network for a file we cannot read.
if (!isReadableFile(opts.filePath)) {
const error = {
code: 'FILE_NOT_FOUND',
message: `No readable file at ${opts.filePath}.`,
status: 0,
};
return { error };
}
// Step 1: mint the presigned URL.
const minted = await client.post(opts.uploadUrlPath, {
filename: basename(opts.filePath),
/**
* Composes a byte-counting Transform onto `src` for progress reporting and —
* critically — forwards a SOURCE error onto the composed body. `pipe()` does NOT
* propagate a source-stream error to its destination, so without this a file
* that becomes unreadable mid-transfer (deleted, an I/O fault) would emit an
* unhandled 'error' on the read stream — crashing the process — instead of
* destroying the composed body so `fetch` rejects into the UPLOAD_FAILED catch.
*/
export function attachProgressCounter(src, onProgress) {
let transferred = 0;
const counter = new Transform({
transform(chunk, _enc, cb) {
transferred += chunk.length;
onProgress(transferred);
cb(null, chunk);
},
});
src.pipe(counter);
src.on('error', (e) => counter.destroy(e instanceof Error ? e : new Error(String(e))));
return counter;
}
function fileTooLargeError(size, limit) {
return {
code: 'FILE_TOO_LARGE',
message: `The file is ${size} bytes, over the ${limit}-byte upload limit.`,
status: 0,
};
}
/** Step 1: mint the presigned URL + object key for `filename`. */
export async function mintUpload(client, uploadUrlPath, filename) {
const minted = await client.post(uploadUrlPath, { filename });
if ('error' in minted)
return minted;
return { error: minted.error };
const uploadUrl = minted.data?.upload_url;
const key = minted.data?.key;
if (typeof uploadUrl !== 'string' || typeof key !== 'string') {
const error = {
code: 'UPLOAD_URL_INVALID',
message: 'The upload-url response did not contain a usable upload_url and key.',
status: 0,
return {
error: {
code: 'UPLOAD_URL_INVALID',
message: 'The upload-url response did not contain a usable upload_url and key.',
status: 0,
},
};
return { error };
}
// Step 2: PUT the bytes directly to storage — NO auth header (the URL is signed).
// The file passed isReadableFile above, but it can vanish before this read
// (a TOCTOU race); a structured FILE_NOT_FOUND is the contract, not a throw.
let bytes;
try {
bytes = await readFile(opts.filePath);
}
catch {
const error = {
return { uploadUrl, key };
}
/**
* Step 2: stream the file's bytes to the presigned URL — NO auth header (the URL
* is signed). Re-stats the file so Content-Length is its size at upload time,
* enforcing the byte ceiling and the FILE_NOT_FOUND (TOCTOU) contract there.
* Returns null on success, or a structured error.
*/
export async function putToPresignedUrl(client, uploadUrl, filePath, maxBytes = MAX_UPLOAD_BYTES, onProgress) {
// The file may vanish between an earlier stat and this transfer (a TOCTOU
// race); a structured FILE_NOT_FOUND is the contract, not a throw.
const stat = statReadableFile(filePath);
if (stat === null) {
return {
code: 'FILE_NOT_FOUND',
message: `The file at ${opts.filePath} could not be read.`,
message: `The file at ${filePath} could not be read.`,
status: 0,
};
return { error };
}
if (stat.size > maxBytes)
return fileTooLargeError(stat.size, maxBytes);
let putRes;
try {
putRes = await client.raw(uploadUrl, {
// A stream body must declare Content-Length (a chunked PUT is rejected 411 by
// S3-compatible storage) and requires duplex: 'half' for undici's fetch.
let body = createReadStream(filePath);
if (onProgress !== undefined) {
// Count bytes as they pass through, inside the Transform (never a 'data'
// listener, which would flip the stream to flowing mode and race the
// reader). attachProgressCounter also forwards a source-stream error so a
// mid-transfer read failure becomes a clean UPLOAD_FAILED, not a crash.
body = attachProgressCounter(body, onProgress);
}
const putInit = {
method: 'PUT',
headers: { 'Content-Type': contentType(opts.filePath) },
body: new Uint8Array(bytes),
});
headers: {
'Content-Type': contentType(filePath),
'Content-Length': String(stat.size),
},
body,
duplex: 'half',
};
putRes = await client.raw(uploadUrl, putInit);
}

@@ -87,12 +136,6 @@ catch (err) {

});
const error = {
code: 'UPLOAD_FAILED',
message: 'Uploading the file to storage failed.',
status: 0,
};
return { error };
return { code: 'UPLOAD_FAILED', message: 'Uploading the file to storage failed.', status: 0 };
}
if (!putRes.ok) {
// Abort BEFORE the commit — a half-uploaded object is never finalized.
const error = {
return {
code: 'UPLOAD_FAILED',

@@ -102,6 +145,35 @@ message: `Uploading the file to storage failed with status ${putRes.status}.`,

};
return { error };
}
// Step 3: commit the object key (idempotent — a retried commit will not duplicate).
return client.put(opts.commitPath, { s3_key: key }, { idempotency: true });
return null;
}
/** Step 3: commit the uploaded object key (idempotent — a retried commit will not duplicate). */
export function commitUpload(client, commitPath, key) {
return client.put(commitPath, { s3_key: key }, { idempotency: true });
}
export async function uploadViaPresignedUrl(client, opts) {
const limit = opts.maxBytes ?? MAX_UPLOAD_BYTES;
// Fail fast and locally: never touch the network for a file we cannot read,
// and reject an oversized file with an honest size error (not FILE_NOT_FOUND).
const initialStat = statReadableFile(opts.filePath);
if (initialStat === null) {
return {
error: {
code: 'FILE_NOT_FOUND',
message: `No readable file at ${opts.filePath}.`,
status: 0,
},
};
}
if (initialStat.size > limit) {
return { error: fileTooLargeError(initialStat.size, limit) };
}
// Compose the seam: mint → PUT the bytes → commit. A failed PUT aborts before
// the commit, so a half-uploaded object is never finalized.
const minted = await mintUpload(client, opts.uploadUrlPath, basename(opts.filePath));
if ('error' in minted)
return { error: minted.error };
const putError = await putToPresignedUrl(client, minted.uploadUrl, opts.filePath, limit, opts.onProgress);
if (putError !== null)
return { error: putError };
return commitUpload(client, opts.commitPath, minted.key);
}

@@ -14,1 +14,2 @@ /**

export * from './log.js';
export * from './timeouts.js';

@@ -14,1 +14,2 @@ /**

export * from './log.js';
export * from './timeouts.js';
{
"name": "@labelgrid/core",
"version": "0.1.0",
"version": "0.2.0",
"description": "Shared LabelGrid public-API client: HTTP transport, uploads, content types, the catalog-entity registry, and redacting logging",
"type": "module",
"keywords": ["labelgrid", "music-distribution", "api-client"],
"keywords": [
"labelgrid",
"music-distribution",
"api-client"
],
"main": "dist/index.js",

@@ -15,3 +19,8 @@ "types": "dist/index.d.ts",

},
"files": ["dist", "README.md", "CHANGELOG.md", "LICENSE"],
"files": [
"dist",
"README.md",
"CHANGELOG.md",
"LICENSE"
],
"scripts": {

@@ -18,0 +27,0 @@ "build": "tsc",