@hussmartinez/sdk-core
v0.4.4
Published
Headless runtime shared by the @hussmartinez SDK family.
Readme
@hussmartinez/sdk-core
Headless runtime shared by the @hussmartinez SDK family: worker messaging, errors, session primitives, the tool registry, the custom-element base, file policy and the messages helper.
The package is ESM only, has no dependencies, and does nothing on import, so it's safe to load on a server (ADR 0004).
Status:
0.x, so the API may still change between minor versions. Errors, worker messaging, messages and the file policy are available; the session, tools and custom-element base come later.
Errors
Every SDK throws one subclass of SdkError, with a stable code, serialisable details and a standard cause (ADR 0008). Codes are SCREAMING_SNAKE_CASE and never change once released. User-facing text is looked up by code in the messages catalogue, never taken from message (ADR 0014).
import { isSdkError, SdkError } from "@hussmartinez/sdk-core";
type PdfErrorCode = "FILE_TOO_LARGE" | "PDF_ENCRYPTED";
export class PdfError extends SdkError<PdfErrorCode> {
override readonly name = "PdfError";
}
throw new PdfError("FILE_TOO_LARGE", "The file exceeds the size limit.", {
details: { limit: 52_428_800, size: 73_400_320 },
});- Subclasses narrow
codeto their own union and setnameexplicitly, so it survives minification. They keep the base constructor's signature(code, message, options), so errors can be rebuilt after crossing a worker boundary. detailsaccept JSON-compatible values only: strings, finite numbers, booleans,null, arrays and plain objects.isSdkError(value, code?)narrows a caught value, optionally to one code. It recognises errors from other realms and from duplicate copies of sdk-core, whereinstanceoffails, through a shared brand. Errors that merely look similar, such as Node errors with a stringcode, aren't accepted.- A second copy of sdk-core in use on the page gets a warning, once, in development, naming both versions, because
instanceof SdkErrorfails across copies (ADR 0047). Anything that evaluates sdk-core's module twice counts too, even with one install: test suites that callvi.resetModules, and some dev-server reloads, may warn "2 copies (0.4.1, 0.4.1)".
Across a worker boundary
structuredClone and postMessage drop an error's code and details. Serialise errors explicitly instead:
// In the worker
port.postMessage({ error: serializeError(error) });
// On the page
const error = deserializeError(message.error, { errorClasses: { PdfError } });
error instanceof PdfError; // trueserializeError(error, options?)turns any thrown value into plain data and never throws for the value itself. Values that aren'tSdkErrors get the codeUNKNOWN. Their original name goes indetails.originalName, or theirtypeofindetails.thrownType. Stack traces are left out unless you passincludeStack: true, so they don't cross the boundary in production.deserializeError(data, options?)rebuilds the error with its registered class, or asSdkErrorkeeping its name. It treats the data as untrusted: invalid input becomes anUNKNOWNerror, details are sanitised again, and class names are only looked up as own keys oferrorClasses.- Both follow the
causechain up tomaxDepthlevels (8 by default) and stop at cycles.
Messages
Every visible string comes from a typed English catalogue that hosts can override, in whole or in part, for example from a translator's JSON file (ADR 0014, ADR 0019).
// src/messages/en.ts
export const en = defineMessages({
...sdkCoreMessages,
"toolbar.rotate": "Rotate",
"result.saved": "Saved {size, fileSize}",
"pages.selected": plural("count", { one: "{count} page selected", other: "{count} pages selected" }),
"errors.FILE_TOO_LARGE": "This file is {actual, fileSize}. The limit is {maximum, fileSize}.",
});
const messages = createMessages(en, { locale: props.locale, overrides: props.messages });
messages.t("result.saved", { size: 1_300_000 }); // "Saved 1.3 MB"
messages.t("pages.selected", { count: 3 }); // "3 pages selected"
messages.error(error); // text for error.code, never error.message- Placeholders:
{name}takes a string or a number.{name, number},{name, fileSize}(decimal units),{name, duration}(milliseconds, shown as "3 hr, 5 min"),{name, megapixels}(pixels, shown in millions, such as "24.2"),{name, date}and{name, time}are formatted withIntlfor the locale. Unknown keys and missing or wrongly typed parameters are type errors. - Plurals: plain objects,
{ "$plural": "count", "one": "…", "other": "…" }, whose form is chosen withIntl.PluralRules. Translations may add forms, such asfewandmany. - Errors:
messages.error()looks uperrors.<CODE>with the error'sdetailsas parameters, and falls back toerrors.UNKNOWN. IncludesdkCoreMessagesfor sdk-core's own codes. - Plain text only: the output is a string, never markup, so render it as text.
- Locale: it defaults to
"en". Always pass the host'slocale, and never read it from the browser, because server and client must render the same text. - Overrides are checked at runtime too. Unknown keys are ignored, and malformed messages fall back to English, with warnings in development.
t()never throws.
File policy
Every SDK enforces limits on hostile input before or while decoding it, and hosts configure them through filePolicy (ADR 0012, ADR 0020).
export const pdfLimits = defineLimits(
{ ...coreLimits, maxPages: { code: "TOO_MANY_PAGES", unit: "count", default: 2_000 } },
{ errorClass: PdfError },
);
const policy = pdfLimits.resolve(props.filePolicy); // such as { maxFileSize: 300_000_000 }
if (checkFile(policy, file) === "warning") {
announce(messages.t("warnings.maxFileSize", { actual: file.size }));
}
policy.check("maxPages", pageCount); // throws PdfError("TOO_MANY_PAGES") above 2,000
await withProcessingTime(policy, (signal) => client.call("open", { bytes }, { signal }), { signal });| Core limit | Code | Default | Warning from |
|---|---|---|---|
| maxFileSize | FILE_TOO_LARGE | 100 MB | 50 MB |
| maxFiles | TOO_MANY_FILES | 20 | — |
| maxDecodedSize | DECODED_SIZE_TOO_LARGE | 500 MB | — |
| maxProcessingTime | PROCESSING_TIME_EXCEEDED | 120 s | — |
check()throws above the maximum, withdetails: { limit, maximum, actual, unit }, and returns"warning"from the warning threshold.- Helpers:
checkFileandcheckFilescheck before reading;createDecodedSizeBudgetstops a decompression bomb mid-stream, in the worker;withProcessingTimeruns one operation, such as a worker call, under the time limit, which then rejects withPROCESSING_TIME_EXCEEDED. It cleans up when the operation settles, adds no listeners to the caller's signal, and rejects withUNSUPPORTED_ENVIRONMENTwhereAbortSignal.anyis missing.
- Settings: each is a number or
{ maximum, warning }.Infinitydisables a limit, which removes a protection, or disables a warning. Unknown limit names throw, so a typo can't leave a default in place. - Text: violations have text under
errors.<CODE>and warnings underwarnings.<limit>insdkCoreMessages.
Workers
createWorkerClient and exposeWorker are a small, typed, versioned RPC layer for SDK workers (ADR 0011, ADR 0018). One interface describes the methods, and both sides are checked against it:
// contract.ts
export interface PdfWorker {
render: { input: { bytes: ArrayBuffer; page: number }; output: ImageBitmap; progress: number };
}// pdf.worker.ts: call exposeWorker first, and load WebAssembly lazily
exposeWorker<PdfWorker>({
async render({ bytes, page }, { signal, progress }) {
const engine = await loadEngine();
return engine.render(bytes, page, { signal, onProgress: progress });
},
});// On the page
const client = createWorkerClient<PdfWorker>({
createWorker: () => new Worker(new URL("./pdf.worker.js", import.meta.url), { type: "module" }),
errorClasses: { PdfError },
});
const bitmap = await client.call("render", { bytes, page: 1 }, { signal, onProgress });- Lifetimes: the worker starts on the first call or
start(), stops afteridleTimeoutwithout calls (30 s by default), and is recreated when needed.destroy()stops it for good. - Start-up:
exposeWorkermust run before any heavy initialisation. The client waits up tostartTimeout(10 s) for the worker to announce itself, and that should measure start-up, not WebAssembly compilation. - Cancellation: an aborted call rejects at once. An
SdkErrorreason, such as the file policy'sPROCESSING_TIME_EXCEEDED, is passed through unchanged. Any other reason gives codeABORTED, orTIMEOUTwhen the signal timed out, with the reason ascause. These aren'tAbortErrors. The handler'ssignalis aborted. If the call is still running aftercancelTimeout(1 s), the worker is terminated, and other calls on it fail withWORKER_TERMINATED. Nothing is retried automatically. - Buffers: inputs are copied unless you pass
transfer: true, which detaches them. Results are transferred, so handlers must return buffers they no longer need. Views into part of a buffer, such as WebAssembly memory, are always copied, and only their bytes. An input or result that can't be cloned, such as one containing a function, fails that call withWORKER_DATA_NOT_CLONEABLE. - Errors: handler errors arrive rebuilt with
deserializeError, soPdfErrorcodes survive. Errors that escape the handlers, and failures to start, arrive asWORKER_CRASHED,WORKER_START_FAILEDorWORKER_PROTOCOL_MISMATCH. - Crash reporting: pass
onCrashto hear about those three failures even when no call is pending. SDKs should forward it to their host'sonEventor error reporting. Expected shutdowns, such as the idle timeout,destroy()or a stuck call being stopped, don't trigger it.
Timelines
Pure helpers for a sequence of items, such as the clips of an audio or video timeline, over whole units like samples or frames, never seconds (ADR 0042):
import { locate, sequenceOffsets, snap } from "@hussmartinez/sdk-core";
const offsets = sequenceOffsets([48_000, 24_000]); // [0, 48000, 72000]
locate(offsets, 48_000); // { index: 1, within: 0 }
locate(offsets, 72_000); // undefined: the total length is past the end
snap(47_990, offsets, Math.round(8 * unitsPerPixel)); // 48000sequenceOffsets(lengths)returns every boundary, the starts and then the total, frozen. Lengths must be positive safe integers.locate(offsets, position)finds the item under a position with a binary search. A position on a boundary belongs to the item that starts there, and the total length and beyond returnundefined.snap(position, targets, tolerance)returns the nearest target within the tolerance, the earlier one on a tie, or the position itself. The tolerance is in the same units, so a UI converts its pixel tolerance with its zoom.
Invalid units throw RangeError.
Custom elements
@hussmartinez/sdk-core/element builds an SDK's <hm-<kind>-editor> element from a definition and the SDK's own mount function, so sdk-core imports no framework (ADR 0040). Importing it touches no browser API.
import { createEditorElement } from "@hussmartinez/sdk-core/element";
export const definePdfEditorElement = createEditorElement<PdfEditorProps, PdfEditorMethods>({
kind: "pdf",
attributes: { theme: "theme", locale: "locale" },
properties: ["file", "filePolicy"],
methods: ["undo", "redo"],
events: ["change", "error"],
mount: (host, props, { dispatch }) => mountPdfEditor(host, props, dispatch),
});- Registration: the returned
define(tagName?)registershm-<kind>-editor, or another name, and returns its class. On a server it throwsUNSUPPORTED_ENVIRONMENT. - Light DOM:
mountrenders into the element itself, so the host's stylesheets apply as they do in React. - Lifecycle:
- properties set before the element was upgraded are applied;
- changes cause one
updateper microtask; - the editor mounts on connect, and unmounts after a removal but not after a move.
- Methods and events: methods throw
InvalidStateErrorwhile the element is disconnected.dispatch(name, detail)fireshm-<kind>-editor-<name>.
Standalone scripts use adoptSharedStyleSheet({ name, version, cssText }) to adopt ui's styles and their own once per page, shared with other standalone scripts on the same ui minor.
API report
The public API is recorded in api/sdk-core.api.md and api/sdk-core-element.api.md by api-extractor (ADR 0015). pnpm check:api, part of verify and verify:fast, fails when the API changes without the report. After an intended change, run pnpm api:update and commit the updated report with the change.
Tests
pnpm test runs the unit tests in Node. pnpm test:browser runs the browser tests, with real workers, in Chromium. pnpm test:browser:all runs them in Chromium, Firefox and WebKit, as CI does. Install the browsers once with pnpm exec playwright install.
pnpm check:size checks the size budgets in .size-limit.js: the brotli size of the . and ./element entries, each with the shared code it loads.
