Readonlyerrors$Canonical fatal / non-fatal error channel.
All SerialError instances produced by the session (connect / read / write / close) are multiplexed here. This is the main channel, not a supplementary one.
Every emission is the exact same instance that is also surfaced to
the relevant call-site subscriber (for example connect$().subscribe
receives the same SerialError that errors$ emits for that
failure), so a single subscription is enough to observe the full
error history without double-normalisation.
Fatal failures (connect / read / close) additionally drive state$
to 'error' and tear down the live pump + port; non-fatal failures
(currently only send$ write failures) are multiplexed here without
mutating state$, on the assumption that a real connection loss is
detected by the read pump on the next tick.
Readonlylines$Decoded text split into complete lines using \n, \r\n, and
lone interior \r (see implementation). Intended for logs,
newline-framed command responses, and parsers—not for mirroring raw
terminal output where \r must be preserved for progress/redraw. For
rendering terminal text, prefer terminalText$.
A trailing fragment without a line terminator is buffered until a later
chunk completes a line, or discarded on disconnect. The incomplete tail is
bounded by SerialSessionOptions.lineBuffer maxChars (default
1,048,576); when exceeded, leading characters are discarded and a
non-fatal SerialErrorCode.LINE_BUFFER_OVERFLOW is emitted on
errors$. Pass { maxChars: 0 } for unlimited growth. It is not
subscription-lazy: the same framing runs whenever the read pump is active,
independent of subscribers.
Readonlyreceive$Incoming data from the serial port as UTF-8 decoded text.
The stream is driven by the read pump started by connect$ and is
decoded internally with a streaming TextDecoder, so multi-byte
characters split across chunks are joined correctly. It is not
subscription-lazy: emissions happen regardless of whether a consumer
is currently subscribed, so late subscribers see only new data.
Emits raw decoder chunks (not line-aligned): carriage returns and
other control characters from the peer are preserved. Use this for
terminal-like mirrors, progress output that relies on \r, or raw
inspection. Do not drive those UIs from lines$, which may
split on interior \r and break redraw semantics.
For newline-framed protocols, logs, or line-by-line parsing, prefer
lines$ or derive custom framing from receive$.
Readonlystate$Canonical lifecycle source for the session.
Reactive session lifecycle state as a discriminated union. Replays the
current state on subscribe. Switch on state.status and use the
per-variant fields (portInfo when SerialSessionStatus.Connected,
error when SerialSessionStatus.Error) instead of correlating
separate streams.
ReadonlyterminalTerminal-display oriented cumulative text derived from receive$.
This stream collapses carriage-return redraws (\r) and keeps normal
newline behavior (\n, \r\n) so apps can bind terminal-like output
directly without wrapping createTerminalBuffer in every consumer.
By default, retains at most 10,000 completed lines and 1,048,576
characters; configure via SerialSessionOptions.terminalBuffer.
Equivalent behavior:
createTerminalBuffer(receive$).text$
Open a serial port and start the internal read pump.
Returns an Observable that completes when the port is fully opened and
the read pump is running. Subscribing to receive$ before calling
connect$ is safe: emissions simply start after the pump is active.
Unsubscribing during 'connecting' abandons that attempt. A port the
abandoned attempt already opened is closed, and a later connect$()
does not share its requestPort or open.
An Observable that completes on successful connection.
Close the active serial port and stop the internal read pump.
Safe to call when already disconnected or while a disconnect is already
in progress. When called during 'connecting', cancels the in-flight
connect$() and returns the session to 'idle' without reaching
'connected'. If open() has already started, this Observable completes
only after that attempt releases the port. state$ may emit 'idle'
before that completion. If open() has not started, including while
requestPort() is still pending, it completes without waiting.
requestPort() cannot be aborted, so an open port picker stays up and a
port chosen afterward is not opened.
An Observable that completes when an opened port is fully
closed. During 'connecting', before open() starts, it completes
once the attempt is cancelled.
Permanently tear down the session and complete all observables.
dispose$ is cold. Calling it only returns an Observable. The port is
not released, and state$ does not move to 'disposed', until
that Observable is subscribed. Subscribe with subscribe(), or wait
with an API that subscribes, such as firstValueFrom.
Unlike disconnect$, which returns the session to 'idle' for
reuse, a subscribed dispose$ closes any active connection, releases
internal resources, emits 'disposed' on state$, and completes
every session stream. After disposal, connect$ and send$
fail with SerialErrorCode.SESSION_DISPOSED; create a new
session instead of reusing this instance.
Safe to subscribe more than once; later subscriptions complete immediately.
An Observable that completes when disposal has finished. Nothing runs until it is subscribed.
Enqueue data for ordered transmission.
Writes are serialized internally through a FIFO send queue so that
concurrent send$ calls are delivered to the port in call order,
regardless of how quickly each subscriber runs. The bytes are fixed when
the Observable is subscribed, not when send$() is called. String
payloads are UTF-8 encoded via a shared TextEncoder at that moment.
Uint8Array payloads are copied then and those bytes are written
unchanged. Mutating the caller's buffer after subscribe does not change
a queued write. Each subscription snapshots independently. Write
failures are normalized into
SerialError with SerialErrorCode.WRITE_FAILED and
multiplexed on SerialSession.errors$ in addition to being
surfaced to the subscriber, so a single subscription is enough to
observe every I/O error. Calling send$ while the session is not in
'connected' state fails fast with
SerialErrorCode.PORT_NOT_OPEN.
The returned Observable completes once the enqueued payload has been flushed to the underlying writer.
Unsubscribing before that write starts skips the payload. Unsubscribing
after writer.write() has started stops next / error / complete
for that subscriber only. The in-flight write is still awaited and is
not aborted. takeUntil and timeout follow the same rule because
they unsubscribe.
disconnect$, dispose$, and a connect$ that is about to become
'connected' drop sends that have not started. Those subscribers error
with SerialErrorCode.PORT_NOT_OPEN, and that failure is not
emitted on SerialSession.errors$. The in-flight write is not
aborted. The next send$ waits until it settles, so the writes do not
overlap.
Text (UTF-8 encoded at subscribe) or raw bytes copied at subscribe.
An Observable that completes when the payload is written.
Public API for interacting with the Web Serial API through a minimal, session-oriented surface.
This interface is the swappable public contract for dependency injection and test fakes. Prefer typing application code against
SerialSessionand calling createSerialSession only at composition boundaries. Any object that structurally matches this shape (including a hand-written fake) is assignable toSerialSession; a separateSerialSessionLike-style alias is intentionally not exported.The session is intentionally slim so that apps (Angular, Vue, React, etc.) can drive their UI from
state$(canonical lifecycle state) +errors$(error event channel) +receive$+terminalText$+lines$and never have to rebuild BehaviorSubjects, manage a read loop, or serialize writes themselves.All imperative Web Serial work (open / read loop / write / close) is encapsulated by the implementation. Only Observables are exposed.
Example
See