Network Protocol Architecture Guide
This document provides an in-depth explanation of the major artifacts produced by @hyperfrontend/network-protocol. Each section covers the purpose, behavior, requirements, and usage examples for the core components of the library.
Quick Reference: How Do I...
| Task | Solution | Module |
|---|---|---|
| Create a secure channel? | createChannel(label, { send, receive, protocolProvider, session, onDrop? }) | channel/ |
| Send a sealed message? | channel.send(origin, target, data) | channel/ |
| Key the session? | Post await channel.hello(); feed the peer's hello to channel.acceptHello(frame) | protocol/ |
| Tell a hello from a sealed frame? | channel.isHello(frame); hellos go to acceptHello, everything else to receive | protocol/ |
| Authenticate the counterpart? | Use v4 with a generated shared key of at least 16 characters | protocol/ |
| Find out why a frame was dropped? | Pass onDrop; read getProtocolErrorCode(drop.cause) | security/ |
| Route messages by topic? | Create topics with TopicStore, configure a Router function | routing/ |
| Manage multiple channels? | Use ChannelStore for CRUD operations | channel/ |
| Stop/resume message processing? | Call channel.stop() and channel.resume() | channel/ |
| Monitor queue depth? | Read channel.outbound.queue.size and channel.inbound.queue.size | queue/ |
| Validate message structure? | Use auto-generated JSON Schema in Data.schema | data/ |
| Start a new session? | Create a new channel; a live session is never rekeyed | protocol/ |
| Use in browser vs Node.js? | Import from /browser/v3, /browser/v4, /node/v3, or /node/v4 | Platform Differences |
Factory Function Reference
The library uses factory functions to inject platform-specific dependencies while producing platform-agnostic artifacts.
| Factory | Injects | Produces | Location |
|---|---|---|---|
createProtocol | The platform's SessionCrypto (composed at the entry) | ProtocolProvider | browser/v3, browser/v4, node/v3, node/v4 |
createV3ProtocolFactory(crypto) | SessionCrypto | createProtocol(logger) | lib/protocol/session |
createV4ProtocolFactory(crypto) | SessionCrypto | createProtocol(logger, sharedKey) | lib/protocol/session |
createSessionProtocolProvider(crypto, definition, logger) | SessionCrypto, { id, version, sharedKey? }, Logger | ProtocolProvider | lib/protocol/session |
createSessionProtocol(input) | Primitives, definition, session, send, receive, Logger | Protocol | lib/protocol/session |
createChannelFactory(createSender, createReceiver) | CreateSender, CreateReceiver | ChannelCreater | lib/channel |
createChannelStoreFactory(createChannel) | ChannelCreater | () => ChannelStore | lib/channel |
createSender(label, sendPacket, logger, seal, onDrop?) | Transport send, the session's sealer | Sender | lib/sender |
createReceiver(label, receivePacket, logger, open, onDrop?) | Delivery callback, the session's opener | Receiver | lib/receiver |
createSealQueue(label, seal, logger, onSuccess, onFail) | The session's sealer | Queue<UnencryptedPacket> | lib/queue |
createOpenQueue(label, open, logger, onSuccess, onFail) | The session's opener | Queue<WirePacket> | lib/queue |
createQueue(processMessage, autoStart?) | Message handler | Queue<T> | lib/queue |
createDataFactory(createHash) | createHash (platform-specific) | DataCreater | lib/data |
createProtocolProviderStore() | None | ProtocolProviderStore | lib/protocol |
createTopicStore() | None | TopicStore | lib/topic |
SessionCrypto is the set of primitives a session protocol is composed from: getRandomValues, createKeyAgreement, stretchPassword, expandKey, seal, and open from @hyperfrontend/cryptography, plus utf8Encode and utf8Decode from @hyperfrontend/string-utils. Each platform entry passes its platform's implementations; the shared logic in lib/ never touches a crypto API directly.
Composition Tree
This diagram shows how factory functions compose to create the full protocol stack:
Design Principles
-
Platform primitives are injected, never imported by the core. The shared session protocol takes a
SessionCrypto; the platform entries compose it.// ✅ lib/ receives its primitives export function createV3ProtocolFactory(crypto: SessionCrypto): (logger: Logger) => ProtocolProvider // ❌ lib/ reaching for a platform API import { subtle } from 'node:crypto' -
One session, one protocol instance, one channel. The provider is bound to a session once and a live session is never rekeyed; a new session means a new channel.
// ✅ a fresh channel for a fresh session const next = createChannel('link', { send, receive, protocolProvider, session: nextSession, }) // ❌ feeding a second hello to a keyed session; the outcome is 'rejected' channel.acceptHello(anotherHello) -
Replay is refused before decryption. The counter check reads ten plaintext header bytes, so a replayed or forged frame costs no key operation.
// ✅ what the opener does first if (counter <= lastAccepted) throw createProtocolError('replayed', ...) // ❌ decrypting first and deduplicating afterwards const packet = await open(frame) if (seen.has(packet.data.id)) return -
Pipelines report, they do not throw.
sendandreceivereturn before any crypto runs; a stage that rejects a packet logs it and callsonDrop, and the next packet proceeds.// ✅ observe drops where they happen createChannel('link', { ..., onDrop: (drop) => metrics.count(getProtocolErrorCode(drop.cause) ?? 'other') }) // ❌ nothing to catch here; the seal runs later, inside the queue try { channel.send(origin, target, data) } catch {} -
Public material is public. The hello carries a nonce and a public key in the clear, and nothing about the design depends on hiding them.
// ✅ post the hello by copy, as often as the transport needs target.postMessage(await channel.hello(), origin) // ❌ wrapping the hello in a second layer to hide it target.postMessage(await seal(hello), origin)
Protocol
Purpose
The Protocol is the object that keys one session and seals and opens its frames. It carries the session's hello exchange, its two directional AES-GCM keys, and its counters, along with the send/receive transport functions the channel was created with. It serves as a unified interface for secure message passing between two endpoints for the lifetime of one session.
Interface
interface Protocol<T = any> extends HelloExchange {
seal: PacketSealer<T> // (packet: UnencryptedPacket<T>) => Promise<WirePacket>
open: PacketOpener<T> // (frame: WirePacket) => Promise<UnencryptedPacket<T>>
send: SendPacketFn // (frame: Uint8Array) => void
receive: ReceivePacketFn<T> // (packet: UnencryptedPacket<T>) => void
getLogger: () => Logger
}
interface HelloExchange {
hello(): Promise<WirePacket> // this side's public material, the same bytes on every call
isHello(frame: WirePacket): boolean // true for a hello frame of this protocol's version
acceptHello(frame: WirePacket): HelloOutcome // 'accepted' | 'duplicate' | 'rejected'
}
type ProtocolProvider<T = any> = (send: SendPacketFn, receive: ReceivePacketFn<T>, session: ProtocolSession) => Protocol<T>
interface ProtocolSession {
protocol: string // the negotiated identifier, 'v3' or 'v4'
role: 'initiator' | 'responder'
localId: string // this endpoint's identity as stamped on packets
peerId: string // the peer's identity as stamped on packets
}
How It Works
-
Material: At construction each instance mints a 32-byte random nonce and an ephemeral P-256 key pair. The private key never leaves the agreement object; it is collected with the session.
-
Hello exchange:
hello()encodes the version byte, the hello type byte, the nonce, and the 65-byte uncompressed public key into a 99-byte frame. The transport posts it to the peer, as many times as it likes, until the peer answers. The peer's hello goes toacceptHello(frame):'accepted': the first hello of the right version; the peer's material is now known and the session can be keyed.'duplicate': byte-for-byte the hello already accepted; a retry, ignored.'rejected': not a hello of this version, a public key not tagged as an uncompressed point, or a different hello after one was accepted. A live session is never rekeyed.
Key schedule: Keys are derived once, on the first seal or open after both materials exist. Both sides order the material by role and compute the same two keys:
salt= initiator nonce followed by responder noncedh= ECDH(own private key, peer public key), 32 bytes; a peer public key that is not a point on the curve rejects hereikm=dh(v3), ordhfollowed by PBKDF2-SHA256(sharedKey,salt, 600,000 iterations) (v4)- initiator-to-responder key = HKDF-SHA256(
ikm,salt,hyperfrontend/network-protocol/<protocol>/<initiatorId>/<responderId>/i2r) - responder-to-initiator key = the same with
/r2i
Each key is a non-extractable AES-GCM-256 CryptoKey with a single usage: the initiator's i2r key can only encrypt and its r2i key can only decrypt, and the responder holds the mirror image. The protocol id and both identities are bound into the info, so keys belong to one negotiated session; both public keys are bound in through the agreement. Raw material (dh, the stretch, ikm) is zeroed once the keys exist. A derivation that fails rejects every later seal and open with invalid-session.
Sealing: seal(packet) takes the next counter (starting at 1), encodes the ten-byte header (version, type 0, counter as an unsigned 64-bit big-endian integer), serialises { origin, target, data } with the message as a JSON string, UTF-8 encodes it, and seals it with AES-GCM under the sending key with the header as additional authenticated data and a nonce of four zero bytes followed by the counter's eight header bytes. The result is the header followed by the ciphertext and its 16-byte tag, in a buffer the transport may transfer. A session that has numbered every frame it can (the counter passes the largest safe integer) rejects with counter-exhausted.
Opening: open(frame) checks, in order and before any key operation, that the frame is at least 27 bytes (malformed), that its version byte is this protocol's (unsupported-version), and that its counter is above the last accepted one (replayed). It then opens the ciphertext under the receiving key with the same nonce and additional data (authentication-failed when the tag does not verify), parses and validates the packet (malformed when it authenticated but carries no valid packet), and only then records the counter as accepted.
Waiting for the peer: Until the peer's hello is accepted, every seal and open waits on the keys. The channel's queues hold their frames, so product traffic sent before the session is keyed leaves as soon as it is.
Send/Receive: Transport-agnostic functions injected at creation time. For browsers, send typically wraps postMessage; for Node.js, a worker or IPC port.
Requirements
- A valid
Loggerinstance from@hyperfrontend/logging - For
v4, a shared key of at leastMIN_SHARED_KEY_LENGTH(16) characters;createProtocolthrows otherwise - A session whose
protocolmatches the provider's id; the provider throwsinvalid-sessionin the caller's frame otherwise - Send function:
(frame: Uint8Array) => void - Receive function:
(packet: UnencryptedPacket<T>) => void
Example
import { createProtocol } from '@hyperfrontend/network-protocol/browser/v4'
import { createLogger } from '@hyperfrontend/logging'
const logger = createLogger({ level: 'info' })
// Create a protocol provider bound to a generated shared key
const protocolProvider = createProtocol(logger, sharedKey)
// Instantiate for one session with transport functions
const protocol = protocolProvider(
(frame) => otherWindow.postMessage(frame, origin, [frame.buffer]), // send
(packet) => handleReceivedMessage(packet.data), // receive
{ protocol: 'v4', role: 'initiator', localId, peerId }
)
otherWindow.postMessage(await protocol.hello(), origin)
// ... once the peer's hello has been accepted:
const frame = await protocol.seal({ origin: localId, target: peerId, data })
const packet = await protocol.open(incomingFrame)
The channel does this binding for you; call the provider yourself only when you are building your own pipeline.
Channel
Purpose
A Channel is a named, bidirectional communication pipe that binds one protocol instance to one session and combines a Sender and Receiver with coordinated lifecycle controls. Channels provide a high-level abstraction for managing message flow between two endpoints.
Interface
interface Channel<T = any> extends StopResumeControl, HelloExchange {
label: string
send: SendFn<T> // (origin, target, data) => void
receive: ReceiveFn // (frame: Uint8Array) => void; hello frames go to acceptHello instead
outbound: OutboundPipeline // { queue: { size }, stop, resume }
inbound: InboundPipeline // { queue: { size }, stop, resume }
}
interface ChannelOptions<T = any> {
send: SendPacketFn // transmits each sealed frame to the peer
receive: ReceivePacketFn<T> // receives each opened packet
protocolProvider: ProtocolProvider<T>
session: ProtocolSession
onDrop?: PacketDropHandler // receives every packet either pipeline discards
}
interface StopResumeControl {
stop: () => void
resume: () => void
}
How It Works
-
Binding:
createChannel(label, options)validates the label, the callbacks, the provider, and the session, then calls the provider once. A provider that throws (a session negotiated for another protocol) throws out ofcreateChannelin the caller's frame. The instance'ssealfeeds the outbound pipeline and itsopenfeeds the inbound one; itshello,isHello, andacceptHelloare exposed on the channel unchanged. -
Outbound Flow:
channel.send(origin, target, data)validates the origin, the target, and the data envelope synchronously (and throws on a malformed packet), assembles anUnencryptedPacket, and appends it to the seal queue. The seal stage seals one packet at a time and hands each frame to yoursend. -
Inbound Flow:
channel.receive(frame)appends the frame to the open queue. The open stage opens one frame at a time and hands each packet to yourreceive. Route hello frames tochannel.acceptHelloinstead; a hello fed toreceivefails to authenticate and is dropped. -
Lifecycle Control:
stop()pauses both queues (frames accumulate but are not processed);resume()restarts processing.channel.outboundandchannel.inboundexpose the same controls per direction. -
Queue Visibility:
channel.outbound.queue.sizeandchannel.inbound.queue.sizereport how many packets are waiting, for monitoring and backpressure. -
Drops: A packet a stage rejects is logged through the protocol's logger and, when
onDropis given, reported as aPacketDropwith thedirection, thestage('seal'or'open'), thereason, thecause(aProtocolErrorfor a protocol rejection), and thepacketas the stage received it. The channel continues with the next packet.
Requirements
- A unique label (non-empty string)
- Send packet function for transport
- Receive packet callback
- A configured
ProtocolProvider - A
ProtocolSessionwith a protocol id, a role, and both identities (non-empty strings)
Example
import { createChannel } from '@hyperfrontend/network-protocol/browser/channel'
import { getProtocolErrorCode } from '@hyperfrontend/network-protocol/security'
const channel = createChannel('my-channel', {
send: (frame) => transport.send(frame),
receive: (packet) => console.log('Received:', packet.data),
protocolProvider,
session: { protocol: 'v3', role: 'initiator', localId, peerId },
onDrop: (drop) => console.warn(drop.direction, drop.stage, getProtocolErrorCode(drop.cause), drop.reason),
})
// Key the session
transport.send(await channel.hello())
transport.onMessage((frame) => (channel.isHello(frame) ? channel.acceptHello(frame) : channel.receive(frame)))
// Send a message
channel.send(localId, peerId, messageData)
// Pause processing (e.g., for backpressure)
channel.stop()
// Resume later
channel.resume()
Channel Store
For managing multiple channels, use ChannelStore:
interface ChannelStore<T = any> {
readonly create: (label: string, options: ChannelOptions<T>) => Channel<T>
readonly add: (...channels: Channel<T>[]) => void
readonly existsByName: (name: string) => boolean
readonly existsById: (id: string) => boolean
readonly getByName: (name: string) => Channel<T> | null
readonly getById: (id: string) => Channel<T> | null
readonly removeByName: (...names: string[]) => void
readonly removeById: (...ids: string[]) => void
readonly clear: () => void
readonly list: readonly ChannelEntry<T>[]
}
createChannelStore() from /browser/channel or /node/channel returns a store whose create uses that platform's createChannel; labels are unique within a store.
Packet Types
Purpose
Packets are the fundamental data units that flow through the protocol. A packet has exactly two states: in the clear on either side of the pipeline, and sealed on the wire.
Packet Hierarchy
Interface Definitions
interface PacketBase {
origin: string // Sender identifier (UUID v4)
target: string // Recipient identifier (UUID v4)
}
interface UnencryptedPacket<T = any> extends PacketBase {
data: Data<T> // Structured message with metadata
}
type WirePacket = Uint8Array // A sealed frame carrying one packet
type Packet<T = any> = UnencryptedPacket<T> | WirePacket
type PacketSealer<T = any> = (packet: UnencryptedPacket<T>) => Promise<WirePacket>
type PacketOpener<T = any> = (packet: WirePacket) => Promise<UnencryptedPacket<T>>
interface PacketDrop {
direction: 'inbound' | 'outbound'
stage: 'seal' | 'open'
reason: string
cause?: unknown // the error the stage threw, when it threw one
packet: unknown // the packet as the stage received it
}
Wire Format
Every frame starts with a version byte (3 for v3, 4 for v4) and a type byte.
| Frame | Layout | Length |
|---|---|---|
| Hello | [version][type=1][nonce 32][public key 65] | 99 bytes, plaintext |
| Data | [version][type=0][counter u64 big-endian] then AES-GCM ciphertext and 16-byte tag | at least 27 bytes; shorter is malformed |
The ten-byte data header is the additional authenticated data of the seal, and the AES-GCM nonce is four zero bytes followed by the counter's eight header bytes, so a counter is used once under a direction's key and the nonce is unique by construction. The plaintext inside a data frame is the UTF-8 JSON of { origin, target, data }, where data is the SerializedData envelope (its message as a JSON string).
How They Work
-
Outbound Transformation:
UnencryptedPacket → seal → WirePacket -
Inbound Transformation:
WirePacket → open → UnencryptedPacket -
Origin/Target Preservation: Origin and target identifiers travel inside the sealed payload, so they are authenticated along with the data and are available to the receiver for routing decisions once the frame is open.
Example
import { createUnencryptedPacket } from '@hyperfrontend/network-protocol/browser/packet'
const packet = createUnencryptedPacket(
originId, // UUID v4
targetId, // UUID v4
data // Data<T> object
)
// → { origin: '...', target: '...', data: { pid, id, sequence, message, schema, schemaHash } }
Queue
Purpose
Queues are FIFO message processing containers that ensure ordered, asynchronous handling of packets at each pipeline stage. They provide flow control through stop/resume capabilities.
Interface
interface Queue<T extends object> {
addMessage: (message: T) => void
isRunning: () => boolean
stop: () => void
resume: () => void
size: () => number
currentMessage: () => T | null
}
How It Works
-
FIFO Processing: Messages are processed in the order they're added. Each message fully completes before the next begins; that ordering is what lets the protocol assign a monotonically increasing counter to each frame it seals and keep the replay counter exact on each frame it opens.
-
Array-backed: The queue is an array with a moving head, so a pull is constant time; once 1,024 pulled slots sit at the front, the backing array is compacted.
-
Backpressure Control:
stop()halts processing while still accepting new messages. Whenresume()is called, accumulated messages are processed in order. -
Callbacks: Each specialised queue is created with success and failure callbacks;
onFail(raw, reason, cause?)receives the rejected input, the stage's message, and the error the operation threw when it threw one.
Queue Types
| Queue | Creator | Input | Output | Purpose |
|---|---|---|---|---|
| Seal queue | createSealQueue | UnencryptedPacket | WirePacket | Validate the packet, seal it, validate the frame |
| Open queue | createOpenQueue | WirePacket | UnencryptedPacket | Validate the frame, open it, validate the resulting packet |
Example
import { createQueue } from '@hyperfrontend/network-protocol/queue'
const queue = createQueue<{ id: string }>(
async (message) => {
await processMessage(message)
},
true // autoStart
)
queue.addMessage({ id: '123' }) // Immediately starts processing
queue.stop()
queue.addMessage({ id: '456' }) // Queued but not processed
queue.addMessage({ id: '789' }) // Queued but not processed
console.log(queue.size()) // 2
queue.resume() // Processes 456, then 789
Sender & Receiver
Purpose
Sender and Receiver are the outbound and inbound message processing pipelines. Each wraps one queue around the session's sealer or opener.
Sender Interface
interface Sender<T = any> {
send: SendFn<T> // (origin, target, data) => void
stop: () => void
resume: () => void
queue: OutboundQueue // { size: number }
}
type SendFn<T = any> = (origin: string, target: string, data: Data<T>) => void
type SendPacketFn = (frame: Uint8Array) => void
Receiver Interface
interface Receiver {
receive: ReceiveFn // (frame: Uint8Array) => void
stop: () => void
resume: () => void
queue: InboundQueue // { size: number }
}
type ReceiveFn = (frame: Uint8Array) => void
type ReceivePacketFn<T = any> = (packet: UnencryptedPacket<T>) => void
How They Work
Sender Pipeline:
Receiver Pipeline:
Example
import { createSender } from '@hyperfrontend/network-protocol/browser/sender'
import { createReceiver } from '@hyperfrontend/network-protocol/browser/receiver'
// Sender usage (internal to Channel)
const sender = createSender(
'host sender',
(frame) => frameWindow.postMessage(frame, origin, [frame.buffer]),
logger,
protocol.seal,
(drop) => report(drop)
)
sender.send(originId, targetId, data)
// Check queue depth for monitoring
console.log('Pending seals:', sender.queue.size)
// Receiver usage (internal to Channel)
const receiver = createReceiver(
'host receiver',
(packet) => deliver(packet.data.message),
logger,
protocol.open,
(drop) => report(drop)
)
receiver.receive(incomingFrame)
Topic
Purpose
A Topic is a named category for message routing. Topics enable pub/sub patterns where multiple channels can subscribe to the same topic, receiving copies of relevant messages.
Interface
interface Topic {
readonly name: string // Human-readable identifier
readonly id: string // UUID for internal tracking
}
interface TopicStore {
readonly create: (...names: string[]) => void
readonly add: (...topics: Topic[]) => void
readonly getByName: (name: string) => Topic | null
readonly getById: (id: string) => Topic | null
readonly existsByName: (name: string) => boolean
readonly existsById: (id: string) => boolean
readonly removeByName: (...names: string[]) => void
readonly removeById: (...ids: string[]) => void
readonly clear: () => void
readonly list: readonly Topic[]
}
How It Works
-
Topic Creation: Create topics by name; the store assigns a unique UUID automatically.
-
Uniqueness: Names must be unique within a store. Attempting to create a duplicate throws an error.
-
Lookup: Topics can be retrieved by name or ID for routing configuration.
-
Lifecycle: Topics can be removed or the entire store cleared for cleanup.
Example
import { createTopicStore } from '@hyperfrontend/network-protocol/topic'
const topics = createTopicStore()
// Create multiple topics at once
topics.create('user-events', 'system-alerts', 'data-updates')
// Lookup by name
const userEvents = topics.getByName('user-events')
// → { name: 'user-events', id: '550e8400-e29b-41d4-a716-446655440000' }
// Check existence
if (topics.existsByName('user-events')) {
// Route messages to this topic
}
// List all topics
console.log(topics.list)
// → [{ name: 'user-events', id: '...' }, { name: 'system-alerts', id: '...' }, ...]
Routing
Purpose
Routing connects topics to channels, determining which channels receive messages for a given topic. The routing system supports both dynamic (per-message) and cached (static) subscription resolution.
Interface
interface RoutingOptions {
isDynamic: boolean // Fetch subscriptions per-message or cache once
subscriptions: Subscriptions // WeakMap<Channel, Topic[]>
}
type Router = (channels: Channel[], topics: Topic[]) => RoutingOptions
interface RoutedPacket {
topicId: string
packet: unknown
}
interface RoutedWirePacket extends RoutedPacket {
packet: Uint8Array // a sealed frame
}
interface RoutedUnencryptedPacket<T = any> extends RoutedPacket {
packet: UnencryptedPacket<T>
}
How It Works
-
Router Configuration: A
Routerfunction receives available channels and topics, returning aRoutingOptionsobject that maps channels to their subscribed topics. -
WeakMap for Memory Efficiency: Subscriptions use
WeakMap<Channel, Topic[]>, allowing channels to be garbage collected when no longer referenced elsewhere. -
Dynamic vs Cached Mode:
isDynamic: true- Subscriptions are resolved for each message (useful when subscriptions change frequently)isDynamic: false- Subscriptions are cached after first resolution (optimal for stable configurations)
-
Routed Packets: Packets are wrapped with a
topicIdfor routing decisions, allowing the same packet to be sent to multiple channels subscribed to a topic.createRoutedWirePacketwraps a sealed frame;createRoutedUnencryptedPacketwraps a packet in the clear.
Example
import { createRoutedWirePacket } from '@hyperfrontend/network-protocol/routing'
// Create a routed packet
const routedPacket = createRoutedWirePacket(topic.id, sealedFrame)
// → { topicId: '550e8400-...', packet: Uint8Array[...] }
// Router configuration example
const router: Router = (channels, topics) => {
const subscriptions = new WeakMap<Channel, Topic[]>()
const byName = (name: string) => topics.filter((topic) => topic.name === name)
// Subscribe channel A to user-events and system-alerts
subscriptions.set(channelA, [...byName('user-events'), ...byName('system-alerts')])
// Subscribe channel B to data-updates only
subscriptions.set(channelB, byName('data-updates'))
return {
isDynamic: false, // Cache these subscriptions
subscriptions,
}
}
Security Suite
Purpose
The Security Suite is the pair of per-session packet operations a protocol implements, together with the session description, the hello outcome, and the error codes a protocol raises. The /security entry exports the types and the error helpers; the protocol entries provide the implementations.
Interface
interface SecuritySuite<T = any> {
seal: PacketSealer<T> // seals outgoing packets under the session's sending key
open: PacketOpener<T> // opens incoming frames under the session's receiving key
}
type SessionRole = 'initiator' | 'responder'
interface ProtocolSession {
protocol: string
role: SessionRole
localId: string
peerId: string
}
type HelloOutcome = 'accepted' | 'duplicate' | 'rejected'
interface ProtocolError extends Error {
code: ProtocolErrorCode
}
function createProtocolError(code: ProtocolErrorCode, message: string): ProtocolError
function getProtocolErrorCode(error: unknown): ProtocolErrorCode | null
Error Codes
| Code | Raised by | When |
|---|---|---|
unsupported-version | open | The frame's version byte is not this protocol's |
replayed | open | The frame's counter is not above the last accepted one |
authentication-failed | open | The frame's tag does not verify under the session's keys |
malformed | open | The frame is shorter than 27 bytes, or authenticated but carries no packet |
counter-exhausted | seal | The session has sealed every counter value it can number |
invalid-session | both, and the provider | The session cannot be keyed from the material it holds, or was negotiated for another protocol |
Every error survives the pipeline's drop report as drop.cause; getProtocolErrorCode(drop.cause) returns the code, or null for an error that is not a protocol error.
What Each Protocol Claims
| Claim | v3 | v4 |
|---|---|---|
| A script that can only listen cannot read frames | ✅ | ✅ |
| A script that can only listen cannot forge frames | ✅ | ✅ |
| A replayed frame, or a frame from another session, is rejected | ✅ | ✅ |
| The counterpart is authenticated | ❌ | ✅ |
| A key mismatch is detected | n/a | ✅ |
| The hello is hidden | ❌ | ❌ |
v3 defeats scripts that can only listen: a passive observer of message events cannot read or forge frames. Any script that can post to a peer's window with a genuine source can complete a v3 handshake as that peer, so v3 does not authenticate who the counterpart is. v4 binds the session to the pre-shared key: without the key a script can neither read frames nor produce frames the counterpart accepts, and a key mismatch is detected because no frame ever authenticates. A party that can run a hello exchange against a v4 side can test key guesses offline afterwards, which is why the key must be generated (128 bits or more), not chosen by a person. Neither protocol hides the hello; public keys and nonces are public by design.
Cost
| Cost | v3 | v4 |
|---|---|---|
| Per session | one ECDH agreement plus one HKDF per direction | the same plus one PBKDF2-SHA256 stretch (600,000 iterations) |
| Per message | one AES-GCM operation per direction | one AES-GCM operation per direction |
The stretch is the only expensive step and it runs once, when the session is keyed, so it lands on the handshake and not on traffic.
Example
import { createProtocol } from '@hyperfrontend/network-protocol/browser/v3'
import { createProtocolError, getProtocolErrorCode, ProtocolErrorCode } from '@hyperfrontend/network-protocol/security'
const protocolProvider = createProtocol(logger)
const protocol = protocolProvider(sendFn, receiveFn, session)
// Manual usage of the suite operations
const frame = await protocol.seal(unencryptedPacket)
const packet = await protocol.open(frame)
// Reading a rejection
try {
await protocol.open(frame) // the same counter again
} catch (error) {
getProtocolErrorCode(error) // => 'replayed'
}
// Raising one from your own implementation of the suite
throw createProtocolError(ProtocolErrorCode.Malformed, 'Frame carries no packet')
Data
Purpose
Data is the structured message payload within packets. It includes metadata for message tracking, sequencing, and schema validation.
Interface
interface Data<T = unknown> {
pid: string // Process identifier (UUID v4)
id: string // Unique message ID (UUID v4)
sequence: number // Sequence number within process
message: T // The actual message payload
schema: Schema // JSON Schema describing the message
schemaHash: string // SHA-256 hash of the schema
}
interface SerializedData<T = unknown> {
// Same fields, but message is JSONString<T> instead of T
message: JSONString<T>
}
How It Works
-
Process Tracking:
pidgroups related messages;sequenceorders them within a process. -
Message Identification:
idis a unique UUID for deduplication and acknowledgment. -
Schema Validation: Auto-generated JSON Schema enables runtime validation of message structure. The
schemaHashallows quick comparison without full schema analysis. -
Serialization: For wire transmission,
messageis JSON-stringified and typed asJSONString<T>to preserve type information; the seal stage does this itself, so a channel takesData<T>and the frame carriesSerializedData<T>.
Requirements
Example
import { createData, deserializeData } from '@hyperfrontend/network-protocol/browser/data'
const serialized = await createData(pid, 1, {
action: 'LOGIN',
username: 'alice',
})
// Result:
// {
// pid: '550e8400-e29b-41d4-a716-446655440000',
// id: '7f3b1e82-9e47-4b1a-a8c3-2d4e5f6a7b8c',
// sequence: 1,
// message: '{"action":"LOGIN","username":"alice"}',
// schema: { type: 'object', properties: { action: {...}, username: {...} } },
// schemaHash: 'a1b2c3d4...'
// }
const data = deserializeData(serialized) // message is the object again; this is what channel.send takes
End-to-End Flow
Complete Message Journey
Here's how a message flows from sender to receiver once both hellos have been accepted:
Practical Example
Setup, on both sides:
import { logger } from '@hyperfrontend/logging'
import { createChannel } from '@hyperfrontend/network-protocol/browser/channel'
import { createData, deserializeData } from '@hyperfrontend/network-protocol/browser/data'
import { createProtocol } from '@hyperfrontend/network-protocol/browser/v4'
const protocolProvider = createProtocol(logger, sharedKey) // the same generated key on both sides
Client A, in the main window:
const iframeWindow = document.querySelector('iframe').contentWindow
const channelA = createChannel('main-to-iframe', {
send: (frame) => iframeWindow.postMessage(frame, iframeOrigin, [frame.buffer]),
receive: (packet) => console.log('A received:', packet.data.message),
protocolProvider,
session: {
protocol: 'v4',
role: 'initiator',
localId: mainId,
peerId: iframeId,
},
})
window.addEventListener('message', ({ origin, data }) => {
if (origin !== iframeOrigin) return
if (channelA.isHello(data)) channelA.acceptHello(data)
else channelA.receive(data)
})
iframeWindow.postMessage(await channelA.hello(), iframeOrigin)
// Send a message; it is sealed as soon as B's hello has keyed the session
const data = deserializeData(
await createData(sessionPid, 1, {
action: 'SYNC_STATE',
payload: state,
})
)
channelA.send(mainId, iframeId, data)
Client B, in the iframe:
const channelB = createChannel('iframe-to-main', {
send: (frame) => window.parent.postMessage(frame, mainOrigin, [frame.buffer]),
receive: (packet) => console.log('B received:', packet.data.message),
protocolProvider,
session: {
protocol: 'v4',
role: 'responder',
localId: iframeId,
peerId: mainId,
},
})
window.addEventListener('message', ({ origin, data }) => {
if (origin !== mainOrigin) return
if (channelB.isHello(data)) channelB.acceptHello(data)
else channelB.receive(data)
})
window.parent.postMessage(await channelB.hello(), mainOrigin)
Both sides agree on the roles and the identities before the channels exist; mainId is A's localId and B's peerId, and the reverse for iframeId.
Platform Differences
| Aspect | Browser | Node.js |
|---|---|---|
| Crypto API | Web Crypto API | Node.js crypto module (webcrypto.subtle) |
| Transport | postMessage, MessageChannel | worker_threads, IPC, process.send() |
| Entry Points | @hyperfrontend/network-protocol/browser/v3, .../browser/v4 | @hyperfrontend/network-protocol/node/v3, .../node/v4 |
| Text Encoding | @hyperfrontend/string-utils/browser | @hyperfrontend/string-utils/node |
Both platforms expose identical Protocol, Channel, and other interfaces; only the injected primitives differ, and a session keyed by a browser-composed side and a Node-composed side produces identical frames.
Summary
The @hyperfrontend/network-protocol library provides a comprehensive, secure communication framework built on these principles:
- Session-Keyed Envelope: Ephemeral P-256 agreement, HKDF expansion per direction, AES-GCM with the header authenticated and the counter as the nonce
- One-Shot Sessions: The first hello keys the session; a repeat is a duplicate, anything else is rejected, and a live session is never rekeyed
- Typed Transformations: Two packet states, in the clear and on the wire, with one seal and one open between them
- Queue-Based Flow Control: Ordered processing with backpressure support and an exact replay counter
- Platform Agnostic: Same API for browser and Node.js, interoperable across them
- Observable: Drop reports with machine-readable codes, queue visibility, and structured logging
- Flexible Routing: Topic-based pub/sub with dynamic subscription support
Each artifact is designed to be composable, testable, and production-ready for applications requiring secure cross-context communication.
Links
- README.md - Package overview, installation, and quick start
- src/lib/README.md - Module index with links to each subdomain
- src/browser/README.md - Browser platform entry points
- src/node/README.md - Node.js platform entry points