2026-10-08 18:31:03 +00:00
…
2026-10-08 18:31:03 +00:00
2026-10-08 18:31:03 +00:00
2026-10-08 18:31:03 +00:00
…

@fin.cx/doclink

The doclink protocol: a standing TypedSocket from a document client — such as DocBox, a self-hosted document intake, or invoice.bot, which fetches invoices from supplier portals — to the services it delivers documents to, with ready-to-use server and client classes. Over the same link a service fetches the documents the client holds, scans and prints through the local scanners and printers the client exposes, and starts retrievals of the sources the client exposes and follows them live.

The client connects to the server, never the other way round: the client may be a local install behind a router, the server a service on the internet. The server issues a token per connection; the client keeps one link per connection standing and delivers what it holds, and nothing is lost while either side is offline.

Issue Reporting and Security

For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.

Install

pnpm add @fin.cx/doclink

Node.js only (it hashes with node:crypto). It runs on @api.global/typedsocket 9 and @api.global/typedrequest 9: both sides of a link must speak TypedSocket's major 9, whose handshake refuses any other major.

What it gives you

  • The contract — the typed requests of both directions (doclink.requests.ts), the data they carry (doclink.data.ts), the limits (doclink.constants.ts), the refusal codes (doclink.refusals.ts) and the words a person reads, in English and German (doclink.words.ts, doclink.words.de.ts).
  • DoclinkServer — the server side, ready to run inside a host that serves a TypedSocket: authentication through the host, one open link per connection, heartbeats, the fetch, check and settlement of every offered document, the devices and sources of each link, scan, print and retrieval jobs, the live view of a retrieval, events.
  • DoclinkClient — the client side of one connection: the standing socket, the hello, the outbox, the files, the devices, the sources, the jobs and the frames of a watched retrieval.
  • DoclinkDeviceScheduler — one queue per device across every connection of a client host.
  • Helpers both sides use — chunking with integrity (createDoclinkChunk, DoclinkChunkAssembler), the checks of everything that travels (checkDocument, checkDevices, checkScanSettings, checkPrintOptions, checkSources, checkRetrievalProgress, checkRetrievalFrame, …), the reconnect advice (doclinkReconnectAdviceOf, doclinkReconnectDelay).

The model: connections are many to many

  • A connection is created on the server, which issues its token once (only its SHA-256 is kept there). It belongs to one server account — a set of books, a workspace.
  • A client holds one link per connection: one TypedSocket each, authenticated by that connection's token. A client may hold connections to several servers, and several connections to one server.
  • Each connection is bound on the client's side to one entity (IDoclinkBinding: an organization and one of its entities). Only that entity's documents travel on it, and a scan the server starts is filed under it. Routing is the client's job; the server shows the binding and trusts nothing else of it.
  • A server account may hold connections of several clients — several locations scanning for one company — each with its own token, label and instance.
  • A device (scanner or printer) may be exposed to several connections. A job belongs to the connection that started it; its progress and its result travel on that connection alone. The client runs one job per device at a time, in the order they came, across all its connections.
  • A source (a supplier portal, say) is a place the client fetches documents from; it may be exposed to several connections too. The server sees its id, its name and how its last retrieval ended, nothing it takes to sign in there. A retrieval belongs to the connection that started it like a scan, runs one at a time per source, and does not outlive its link.
  • The hello names what kind of client is on the other end: client.product is docbox, invoice.bot or the name of another client (TDoclinkClientProduct), and the server hands it to its host with every offered document, so a finance app can say where a document came from.

Why one socket per connection rather than one per server carrying several tokens: the server pins exactly one identity to a socket for its whole life, so a revocation closes exactly that socket, limits and rate budgets apply per connection, and nothing on the wire has to say which connection a message is for. A client with many connections to one server opens that many sockets; each idle one costs a heartbeat every 30 s.

The protocol

Every request is a TypedRequest over the one TypedSocket of the link. A refusal is a TypedResponseError whose errorData carries code (TDoclinkErrorCode), status (the HTTP status the same refusal would have) and, where it says so, field, reason, retryAfterMs or supportedVersions.

Every count, size, sequence, index, time and duration on the wire is a whole number; the contract marks each one @asType integer, so a client generated from it (the Swift Doclink of @api.global/swiftsupport) reads it as an integer. A scan area and a scanner's extents in millimetres may be fractional. The lists resolutions and supportedVersions hold whole numbers too, but stay plain numbers in a generated contract: the annotation would replace the list.

Client → server

Method Carries Answers
doclinkHello protocolVersion, token, client (product — the kind of client —, version, instanceId, instanceLabel), binding (organization and entity), features it offers, the devices exposed to this connection, the sources exposed to it (with retrieval), its outboxId server (product, version), connection (id, label, account label), the features used, acceptance (since, document types, media types), limits, the cursor of its outbox, the documentSources it takes
doclinkHeartbeat sentAt, pendingNotifications receivedAt
doclinkNotifyDocument one document: outbox, sequence, document id, SHA-256, file name, media type, size, document type, source (scan, email, mailbox, upload, portal), metadata (type source, date, sender, recipient, amount in minor units and currency, reference, due date, language, pages), received time, scanJobId of a scan's result, retrievalJobId of a retrieval's a receipt: accepted, or settled with the acknowledgement
doclinkPublishDevices revision, the complete set of exposed devices revision, devices held
doclinkUpdateDeviceStatus status updates of exposed devices applied
doclinkReportScanProgress / doclinkReportPrintProgress a job's progress the job's state as the server holds it (cancelled tells the client to stop)
doclinkPublishSources revision, the complete set of exposed sources (id, name, last run: time, outcome, failure) revision, how many sources held
doclinkReportRetrievalProgress a retrieval's progress: state, the step (number, action, started/done/failed, detail), documentCount, failure and the client's sourceReason the retrieval's state as the server holds it (cancelled tells the client to stop)
doclinkRetrievalFrame one frame of a watched retrieval: job id, sequence, width, height, jpeg (base64), time watching (false tells the client to send no more)

Server → client

Method Carries Answers
doclinkFetchChunk document id, SHA-256, chunk index, chunk size one chunk (index, count, offset, total, the file's and the chunk's SHA-256, base64 data); not_found or changed
doclinkAcknowledge acknowledgements (outcome taken, waiting, duplicate, refused with a reason, dismissed; the server's document id) and the outbox's cursor how many
doclinkLinkStatus active, paused (with retryAfterMs) or closing (with the reason) —
doclinkReplay an outbox and a sequence to offer again after how many are scheduled
doclinkStartScan job id, device id, scan settings (source, colour mode, resolution, format, area, intent) the job's first progress (accepted or waiting)
doclinkStartPrint job id, device id, print options (copies, media size, sides, colour mode, quality, job name), the file (name, media type, size, SHA-256, chunk size and count) the job's first progress (receiving)
doclinkPrintChunk job id, one chunk of the print file chunks received, whether complete
doclinkCancelJob job id the job's state
doclinkStartRetrieval job id, source id the retrieval's first progress (accepted); refused source_unknown, source_busy, source_unavailable, client_locked, too_many_jobs
doclinkWatchRetrieval job id, watching job id, whether frames follow (not for a retrieval that ended)
doclinkCancelRetrieval job id the retrieval's state

The six retrieval methods are 1.2.0's; they travel only on a link that uses retrieval.

Connect, notify, fetch, acknowledge

client                                           server
  | -- WebSocket upgrade, TypedSocket handshake -> |
  | -- doclinkHello(token, client, binding,       |  authenticate(token) → connection
  |       features, devices, outboxId) ---------> |  one link per connection: an older one is ended
  | <-------- server, connection, features,       |
  |           acceptance, limits, cursor(n) ----- |
  |  forget the outbox up to n                     |
  | -- doclinkHeartbeat (at once, then every 30 s) -> |
  | <-- doclinkAcknowledge(missed acks) --------- |  what a lost link did not confirm
  | -- doclinkNotifyDocument(seq n+1) ----------> |  recordOffer → new
  | <---------------------- receipt: accepted --- |
  |                                               |  screen: size, acceptance, duplicate?
  | <-- doclinkFetchChunk(doc, sha, 0) ---------- |
  | -- chunk 0 (base64, chunk SHA-256) ---------> |
  | <-- doclinkFetchChunk(doc, sha, 1) ---------- |  … until the last chunk
  | -- chunk k --------------------------------->  |  the whole file's SHA-256 holds → takeFile
  |                                               |  settleOffer → cursor(n+1)
  | <-- doclinkAcknowledge(ack, cursor n+1) ----- |
  |  keep the outcome, forget up to n+1            |
  | -- doclinkHeartbeat (every 30 s) -----------> |  revalidate: revoked meanwhile?

Replay after a drop

client                                           server
  |  … the link drops while seq 5 is fetched …    |  the fetch fails: seq 5 stays unsettled
  |  outbox: 5 (sent), 6 (new)                     |  the acknowledgement of 4 was not delivered
  | -- reconnect, doclinkHello ------------------> |
  | <------------------------------ cursor(4) --- |
  | -- doclinkHeartbeat (at once) --------------> |
  | <-- doclinkAcknowledge(ack 4) --------------- |
  | -- doclinkNotifyDocument(5) ----------------> |  recordOffer → pending: fetched again
  | -- doclinkNotifyDocument(6) ----------------> |  recordOffer → new
  |    … fetch, settle, acknowledge as above …    |

A notification holds a place of the window (maxNotificationsInFlight) until it settles. The server frees the place before its acknowledgement goes out, so a client may offer the next document as soon as it sees one and is never refused for the window.

The same notification may always be sent again: an offer is keyed by its outbox and sequence, a settled one is answered with its outcome, and one with another document or file under the same key is refused sequence_conflict. A server restored from a backup may ask for an outbox again (doclinkReplay); a client offers what it still holds.

A scan the server starts

server                                           client
  | -- doclinkStartScan(job, scanner, settings) -> |  exposed to this connection? settings within
  |                                               |  its capabilities? → queue on the device
  | <---------------------- progress: accepted -- |  (`waiting` while another job runs on it)
  | <-- doclinkReportScanProgress(scanning, 1) -- |
  |                                               |  the pages are filed under the connection's
  |                                               |  entity and appended to this outbox
  | <-- doclinkReportScanProgress(done, docs) --- |
  | <-- doclinkNotifyDocument(scanJobId: job) --- |  … fetched and acknowledged as any document

A print goes the other way: doclinkStartPrint announces the file, doclinkPrintChunks carry it, the client checks the whole file's SHA-256, queues the job for the printer, and reports waiting, queued, printing, done, or failed with its reason and the device's own words.

A retrieval the server starts

server                                           client (invoice.bot)
  |  hello: sources [{ src_1, "Telekom portal",  |
  |    lastRun }]; publishSources on a change     |
  | -- doclinkStartRetrieval(job, src_1) -------> |  exposed to this connection? its secrets
  |                                               |  unlocked? no retrieval of it running?
  | <---------------------- progress: accepted -- |  (or refused: source_unknown, client_locked,
  |                                               |   source_busy, source_unavailable, too_many_jobs)
  | <-- doclinkReportRetrievalProgress(retrieving) |
  | -- doclinkWatchRetrieval(job, true) --------> |  the live view starts
  | <-- …Progress(step 1 navigate started) ------ |
  | <-- doclinkRetrievalFrame(seq 1, jpeg) ------ |  secrets masked; one in flight, ≥ 200 ms apart
  | <-- …Progress(step 2 fill_secret done) ------ |  names the field, never the value
  | <-- doclinkRetrievalFrame(seq 2, jpeg) ------ |
  | -- doclinkWatchRetrieval(job, false) -------> |  no more frames
  | <-- …Progress(step 5 download done, docs 2) - |
  | <-- doclinkNotifyDocument(portal,             |  … fetched and acknowledged as any document
  |       retrievalJobId: job) ------------------ |
  | <-- …Progress(done, documentCount 2) -------- |
  • Steps. A step has a number (1 to 1000), an action (navigate, sign_in, fill, fill_secret, click, wait, second_factor, download, deliver), a state (started, done, failed) and a short detail for a person (at most 200 characters, control characters stripped). A step says what it does, never what it typed or read.
  • The end. done with the document count; failed with not_exposed, source_unreachable, sign_in_failed, second_factor_failed, step_failed, client_locked, too_many_steps, too_many_documents, timeout (15 min) or link_lost, and the client's own words where it gave any; cancelled (doclinkCancelRetrieval).
  • One link. A retrieval does not outlive its link: when the link ends, the server fails it with link_lost and the client aborts it. Unlike a scan, it is not taken up again after a reconnect; the host starts a new one. A source runs one retrieval at a time, and a retrieval counts against the jobs a link may hold open (4).
  • The live view. Frames go only while the server watches, never otherwise. The client sends one at a time and at most one per 200 ms; a newer frame waits in place of an older one, which is dropped — a slow link loses frames and never queues them. A frame is a JPEG of at most 160 KiB (218,456 characters as base64, within the 256 KiB message) and at most 4096 pixels on each side; the client skips a larger one (too_large) and the host hands it smaller ones — a lower quality or a smaller size. The server refuses a larger one too_large, drops one out of order or sooner than 100 ms after the last, and answers watching: false for a retrieval it does not watch, which stops the client's frames.

1.1.0 peers

Everything 1.2.0 adds is optional on the wire, so 1.1.0 servers and clients keep working with it:

  • A 1.1.0 client, a 1.2.0 server. The client offers no retrieval and no sources; it ignores the hello answer's documentSources. Documents, scans and prints run as before; startRetrieval is refused feature_unavailable.
  • A 1.2.0 client, a 1.1.0 server. The old server refuses a hello that offers retrieval; the client sends its hello again without it, and tries retrieval once more on the next link after this one drops (a server upgraded meanwhile takes it). It names no documentSources, so the client offers a portal document as an upload and without retrievalJobId. DocBox, which offers no sources, says nothing a 1.1.0 server does not read.
  • The hello answer is checked. A 1.2.0 client checks the server's answer (checkHelloAnswer) and uses nothing of one outside the protocol: it drops the link as answer_invalid and connects again after the backoff. A 1.1.0 server answers within it as long as its host does: the server clamps the limits itself, and the host's connection id must match DOCLINK_ID_PATTERN and its acceptance.since be whole milliseconds. Its acceptance lists may be as long as the host makes them and its stale time as long as it likes. The names it passes on (server, connection, account) need only be strings; the client cleans them.
  • Silence is noticed on both sides. A 1.2.0 client drops a link whose server answered and asked nothing for staleAfterMs (at least two heartbeat intervals) and connects again, as a server always did with a silent client. A server of either version answers every heartbeat, so a working link is never dropped for it.

Security model

  • The token decides. A hello's token is the only credential; nothing the client says about itself (instance, binding, devices) is trusted for authorization. Tokens are dlk_ and 32 random bytes; a server keeps only their SHA-256, shows a token once, and can revoke (the open link is closed at once) and rotate it.
  • The first request is the hello, and only once. Every other request before it is refused not_registered; a second hello already_registered. A refused hello closes the connection after its answer. The client trusts the answer no more than the server trusts the hello: it checks every field against the hello it answers (checkHelloAnswer), tolerates fields it does not know, and shows the server's texts only cleaned (cleanDoclinkText).
  • The newest hello of a connection wins. A hello of another installation (instanceId) supersedes the open link, whose client stops reconnecting (superseded) — two installations with one token do not fight over it. A hello of the same installation is its own reconnect: the old link is ended as stale.
  • The client offers, the server fetches. The server reads nothing but what was notified, by document id and SHA-256; a client serves only documents of its connection. Every chunk and the whole file are checked before the host sees a byte; a damaged transfer is fetched once more, then refused.
  • No device address travels. A device is named by the client's id and nothing else; the server cannot name an address, a protocol or a driver, and every request names only devices the person exposed to that connection. Unknown fields are refused, not ignored.
  • Sources are names, secrets stay on the client. A source travels as an id, a name and how its last retrieval ended — never an address, a user name, a password or a session. The server names a source by the client's id and nothing else; it cannot say where to sign in or with what. A step names its action and the field, never a value typed or read; a failure's words are the client's, bounded, stripped of control characters.
  • The live view is the client's to give. Frames go only while the server watches a retrieval of its own connection, and stop when it ends, the server unwatches or the link drops. The client's host masks secrets before it hands a frame on — doclink cannot look into a JPEG; it sends a frame on that one link alone and keeps none but the newest one waiting. The server hands frames to its host as events and keeps none.
  • Bounded everywhere. Message size (256 KiB), chunk size (16–128 KiB), file size (50 MiB unless the server says less), notifications in flight (8), devices per connection (32), sources per connection (200), jobs open per link (4, scans, prints and retrievals together), copies (99), pages per scan (200; a feeder that runs on is stopped), steps and documents per retrieval (1000 each), job timeouts (scan 10 min, print 30 min, retrieval 15 min), frames (160 KiB, 4096 pixels a side, one at a time and at least 200 ms apart), free texts (120 or 200 characters).

Running a server

The host owns the transport: it constructs its TypedRouter, the DoclinkServer adds its handlers to it, and the host creates the TypedSocket server and SmartServe in TypedSocket's order. The adapter is everything the protocol needs answered or kept; events are everything a host may show, persist or audit.

import { TypedRouter } from '@api.global/typedrequest';
import { TypedSocket } from '@api.global/typedsocket';
import { SmartServe } from '@push.rocks/smartserve';
import { DoclinkServer, type IDoclinkServerAdapter } from '@fin.cx/doclink';

declare const adapter: IDoclinkServerAdapter;

const router = new TypedRouter();
let typedSocket: TypedSocket | undefined;
const doclinkServer = new DoclinkServer({
  typedRouter: router,
  transport: () => typedSocket,
  adapter,
  server: { product: 'example.service', version: '1.0.0' },
  features: ['documents', 'scan', 'print', 'retrieval'],
  limits: { maxFileBytes: 25 * 1024 * 1024 },
});
doclinkServer.on((event) => {
  // linkOpened, linkClosed, heartbeat, documentSettled, devicesChanged, deviceStatus, jobChanged,
  // sourcesChanged, retrievalChanged, retrievalFrame
});

typedSocket = TypedSocket.createServer(router);
const smartServe = new SmartServe({
  port: 3000,
  websocket: {
    typedRouter: typedSocket.getServerRoutingSurface(router),
    transportOwner: typedSocket.webSocketTransportOwner,
  },
});
typedSocket.attachSmartServe(smartServe);
await smartServe.start();
doclinkServer.start();

The adapter (IDoclinkServerAdapter):

Method Does
authenticate(request) resolves the token to the connection it opens (connectionId, label, account label, what it accepts, its features and limits) or refuses it (token_invalid, token_revoked, token_rotated, connection_deleted, account_closed, rate_limited)
revalidate(link) asked at every heartbeat: null to go on, a reason to end the link (revoked on another process, taken over, the account closed)
readCursor(connection, outbox) how far an outbox is settled
recordOffer(connection, document, context) keeps an offer once by outbox and sequence; answers new, pending, settled with its acknowledgement, or conflict
screenOffer(connection, document, context) before the fetch: settle it now (a duplicate by its SHA-256) or null
takeFile(connection, document, bytes, context) the checked file: take it in, keep it waiting, or refuse it
settleOffer(connection, ack) keeps a settlement and answers the cursor after it
undeliveredAcks / markAcksDelivered the acknowledgements a client has not confirmed, delivered after the first heartbeat of every link

The connection's label and accountLabel reach every client as one line of at most DOCLINK_MAX_NAME_LENGTH (120) characters: line breaks and control characters become spaces, a longer name is cut, and a name with nothing left goes as the connection id. The server logs a warning naming the connection whenever it changes a name. A connectionId must match DOCLINK_ID_PATTERN and acceptance.since must be whole milliseconds: the server passes both on as the adapter answers them, and a 1.2.0 client refuses a hello answer without them (answer_invalid). A retryAfterMs the adapter answers is sent in whole milliseconds, rounded up.

The context of an offer (IDoclinkOfferContext, since 1.2.0) is the link it came in on: its linkId, the client — whose product says what kind of client sent it — and the binding. A host may name a document's origin by it (portal documents of an invoice.bot); an adapter written for 1.1.0 ignores it. documentSettled carries the client as well.

What a host calls: startScan, startPrint, cancelJob, jobOf, devicesOf, startRetrieval, cancelRetrieval, watchRetrieval, retrievalOf, sourcesOf, linkOf, links, closeConnection (revoked, rotated, deleted, account closed), deliverAck (a person's later decision about a waiting document), requestReplay, hasLink(peer) and answers(method) for a host gate that admits the link's connections, and stop(). Everything a host is refused comes as a DoclinkRefusedError whose refusal.code is a TDoclinkRefusalCode — link_offline when the connection has no open link on this process. cancelJob of a job whose start the client has not answered yet answers the job as it is and sends the cancel with that answer (before it, the client could not match the job); startScan/startPrint then answer the job cancelled.

A retrieval, followed live:

import { DOCLINK_RETRIEVAL_REFUSALS, doclinkRefusalOf, doclinkRetrievalActionKey, doclinkRetrievalRefusalKey, doclinkRetrievalStepKey, doclinkWord, type TDoclinkRetrievalRefusal } from '@fin.cx/doclink';

declare const connectionId: string;
declare const showRefusal: (text: string) => void;
declare const showStep: (text: string) => void;
declare const showFrame: (src: string, width: number, height: number) => void;

// the sources a person may pick from: as the hello and doclinkPublishSources said them
const sources = doclinkServer.sourcesOf(connectionId) ?? [];

try {
  await doclinkServer.startRetrieval(connectionId, { jobId: 'job_42', sourceId: sources[0].sourceId });
} catch (error) {
  const code = doclinkRefusalOf(error)?.code; // source_unknown, client_locked, source_busy, too_many_jobs, …
  if (!code || !(DOCLINK_RETRIEVAL_REFUSALS as readonly string[]).includes(code)) throw error;
  showRefusal(doclinkWord(doclinkRetrievalRefusalKey(code as TDoclinkRetrievalRefusal), 'de'));
}
await doclinkServer.watchRetrieval(connectionId, 'job_42', true);

doclinkServer.on((event) => {
  if (event.type === 'retrievalChanged' && event.retrieval.step) {
    const { number, action, state } = event.retrieval.step;
    showStep(doclinkWord(doclinkRetrievalStepKey(state), 'en', { number: String(number), action: doclinkWord(doclinkRetrievalActionKey(action), 'en') }));
  }
  if (event.type === 'retrievalFrame') {
    showFrame(`data:image/jpeg;base64,${event.frame.jpeg}`, event.frame.width, event.frame.height);
  }
});
// later: watchRetrieval(connectionId, 'job_42', false) when nobody looks any more, or cancelRetrieval(connectionId, 'job_42')

Its documents arrive as offers with source: 'portal' and retrievalJobId: 'job_42', and settle like any other.

A host behind its own connection gate passes closePeer so the gate does the closing, and lets the link's methods and peers through its own registration (hasLink, answers).

Running a client

One DoclinkClient per connection, one DoclinkDeviceScheduler per host:

import { DoclinkClient, DoclinkDeviceScheduler, type IDoclinkClientAdapter, type IDoclinkDeviceAdapter } from '@fin.cx/doclink';

declare const outboxOfConnection: IDoclinkClientAdapter;
declare const devicesOfConnection: IDoclinkDeviceAdapter;

const scheduler = new DoclinkDeviceScheduler();
const client = new DoclinkClient({
  url: 'https://service.example/socket',
  token: 'dlk_…',
  client: { product: 'docbox', version: '2.2.0', instanceId: 'inst-7f3a', instanceLabel: 'Office Hamburg' },
  binding: { organizationId: 'org_1', organizationLabel: 'Example GmbH', entityId: 'ent_a', entityLabel: 'Company A' },
  adapter: outboxOfConnection,
  devices: devicesOfConnection,
  scheduler,
  onStatus: (status) => {
    // connecting, connected, paused, reconnecting, refused (with its reason), stopped
  },
});
client.start();
// after appending to the outbox:
client.notifyOutboxChanged();
// after the person changed which devices this connection sees:
await client.publishDevices();

The outbox adapter (IDoclinkClientAdapter) is one connection's: outboxId, pendingNotifications(after, limit) (unsettled, in order), pendingCount, applyAcks (every outcome, later decisions included), pruneThrough(outbox, n), reopenAfter (for a replay request) and readDocument(id, sha256) (only a document this connection notified; not_found or changed otherwise). Sequences are dense: 1, 2, 3 … per outbox; a new outbox starts the server's cursor at 0.

The device adapter (IDoclinkDeviceAdapter) answers the devices exposed to this connection (list), scans (scan files the pages under the connection's entity, appends them to this connection's outbox with scanJobId, answers them) and prints (print); a device that cannot do a job throws a DoclinkDeviceError with its reason. Both get an AbortSignal that fires when the job is cancelled, times out or the client stops.

A client of sources — invoice.bot — names itself and passes a source adapter (IDoclinkSourceAdapter):

import { DoclinkClient, DoclinkSourceError, type IDoclinkClientAdapter, type IDoclinkJobDocument, type IDoclinkSourceAdapter, type TDoclinkRetrievalAction } from '@fin.cx/doclink';

declare const outboxOfConnection: IDoclinkClientAdapter;
declare const portalsOfConnection: () => Promise<{ id: string; name: string }[]>;
declare const vault: { locked: boolean };
declare const screencast: { start(onFrame: (jpeg: Uint8Array, width: number, height: number) => void): void; stop(): void };
declare const runFlow: (
  sourceId: string,
  hooks: {
    signal: AbortSignal;
    retrievalJobId: string;
    onStep(number: number, action: TDoclinkRetrievalAction, state: 'started' | 'done' | 'failed', field?: string): void;
  },
) => Promise<IDoclinkJobDocument[] | 'sign_in_refused'>;

const sources: IDoclinkSourceAdapter = {
  // names only: never the portal's address, the user name or a secret
  list: async () => (await portalsOfConnection()).map(({ id, name }) => ({ sourceId: id, name })),
  readiness: async () => (vault.locked ? 'client_locked' : null),
  retrieve: async (sourceId, context) => {
    // frames only while the server watches, secrets masked on the page; `too_large` asks for a lower quality
    const watch = (watching: boolean) => (watching ? screencast.start((jpeg, width, height) => context.frame({ jpeg, width, height })) : screencast.stop());
    const stopListening = context.onWatch(watch);
    watch(context.watching);
    try {
      const result = await runFlow(sourceId, {
        signal: context.signal,
        // the documents go into the outbox with source 'portal' and this retrievalJobId
        retrievalJobId: context.jobId,
        // what the step does and to which field, never the value it types
        onStep: (number, action, state, field) => context.report({ step: { number, action, state, detail: field } }),
      });
      if (result === 'sign_in_refused') {
        throw new DoclinkSourceError('sign_in_failed', 'The portal did not take the password');
      }
      return result;
    } finally {
      stopListening();
      screencast.stop();
    }
  },
};

const client = new DoclinkClient({
  url: 'https://service.example/socket',
  token: 'dlk_…',
  client: { product: 'invoice.bot', version: '1.0.0', instanceId: 'bot-7f3a', instanceLabel: 'invoice.bot' },
  binding: { organizationId: 'org_1', organizationLabel: 'Example GmbH', entityId: 'ent_a', entityLabel: 'Company A' },
  adapter: outboxOfConnection,
  sources,
});
client.start();
// after a source was added, renamed, withdrawn or retrieved on the client's own account:
await client.publishSources();

retrieve signs in, reports its steps, files the documents under the connection's entity, appends them to this connection's outbox with source: 'portal' and retrievalJobId: context.jobId, and answers them; the client notifies them when it resolves (or earlier, on notifyOutboxChanged()). A failure it can name is a DoclinkSourceError with a TDoclinkRetrievalFailure and, if it likes, its own words, which the client bounds; any other error fails the retrieval as step_failed and sends nothing of its message. context.signal fires when the retrieval is cancelled, times out (15 min), its link ends or the client stops. context.watching and context.onWatch say whether frames are worth making; context.frame answers what became of one — sent, held (the newest one waiting), unwatched, too_large or malformed.

A client keeps its link standing: TypedSocket reconnects a dropped connection; when a TypedSocket client ends, a new one is opened after a jittered backoff (1 s doubling to 5 min). rate_limited and account_closed wait as long as asked (or an hour); token_invalid, token_revoked, token_rotated, connection_deleted, protocol_unsupported and superseded end the loop until a person acts.

Two reasons are the client's own, and both connect again after the backoff. answer_invalid: the server's hello answer is outside the protocol (checkHelloAnswer names the field), and nothing of it is used. stale: the server answered and asked nothing for staleAfterMs, and at least two heartbeat intervals. A half-open connection sends no close, so the client drops it itself. whenConnected() rejects a refused or stopped link with a DoclinkRefusedError whose status is the code's (DOCLINK_REFUSAL_STATUS): 401 for a token, 426 for the protocol.

Words

doclinkWord(key, 'en' | 'de', params) words an outcome (outcome.*), a refusal (refusal.*), a link reason (link.*), a job's state and failure (job.state.*, job.failure.*), a device's status (device.status.*), and a retrieval: its state and failure (retrieval.state.*, retrieval.failure.*), a step and its action (retrieval.step.* with {number} and {action}, retrieval.action.*), why it did not start (retrieval.refusal.*, every code of DOCLINK_RETRIEVAL_REFUSALS) and its live view (retrieval.live.waiting, .watching, .ended), and why a request of either side was refused (error.*). Every refusal code a client can receive has words in both languages: doclinkRefusalCodeKey(code) answers link.<code> for a reason of the link's, error.<code> for any other code of DOCLINK_REFUSAL_STATUS, and undefined for a code a newer peer sent that this version has no words for. The doclinkRetrieval…Key helpers build the retrieval keys. {service} is the server's name as the client shows it.

import { doclinkRefusalCodeKey, doclinkRefusalOf, doclinkWord } from '@fin.cx/doclink';

declare const error: unknown;
const refusal = doclinkRefusalOf(error);
if (refusal) {
  const key = doclinkRefusalCodeKey(refusal.code);
  console.log(key ? doclinkWord(key, 'de', { service: 'nevermind' }) : refusal.code);
}

This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the license.md file.

Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.

Trademarks

This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.

Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.

Company Information

Task Venture Capital GmbH Registered at District Court Bremen HRB 35230 HB, Germany

For any legal inquiries or further information, please contact us via email at hello@task.vc.

By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

S
Description
No description provided
Readme
916 KiB
Languages
TypeScript 100%