Skip to content

Instantly share code, notes, and snippets.

@progrium
Created April 16, 2026 00:10
Show Gist options
  • Select an option

  • Save progrium/c9f6034db7f62dcc01f1cf0252b84268 to your computer and use it in GitHub Desktop.

Select an option

Save progrium/c9f6034db7f62dcc01f1cf0252b84268 to your computer and use it in GitHub Desktop.

Translating WebUSB Into USB/IP Wire Packets

Executive summary

The clean way to think about this bridge is not “WebUSB emits USB/IP.” The practical model is: your bridge acts as a USB/IP server, the Linux side acts as the normal USB/IP client, and every incoming USB/IP request is executed against a real device through WebUSB, then answered with the corresponding USB/IP reply. In other words, the Linux side remains authoritative for URB submission order and sequencing; the browser side is an execution engine plus descriptor source. USB/IP itself uses a short discovery/import phase (OP_REQ_DEVLIST/OP_REP_DEVLIST, OP_REQ_IMPORT/OP_REP_IMPORT) followed by a persistent URB channel carrying USBIP_CMD_SUBMIT, USBIP_RET_SUBMIT, USBIP_CMD_UNLINK, and USBIP_RET_UNLINK. All integer fields in the USB/IP protocol are in network byte order. citeturn10search0turn4view0turn5view0

For actual I/O, the mapping is straightforward for generic control, bulk, interrupt, and most isochronous traffic: endpoint 0 control URBs become controlTransferIn() or controlTransferOut(); non-control IN/OUT URBs become transferIn() / transferOut() for bulk or interrupt endpoints; isochronous URBs become isochronousTransferIn() / isochronousTransferOut(). The main places where you should not blindly forward raw control URBs are the stateful host-side operations that WebUSB already models directly: SET_CONFIGURATION should map to selectConfiguration(), SET_INTERFACE to selectAlternateInterface(), and endpoint halt clearing to clearHalt(). That preserves the browser’s own device state model instead of trying to “fight” it with raw setup packets. citeturn29view0turn28view1turn26view0turn33view3

The hard mismatches are three specific semantics. First, USB/IP UNLINK expects per-URB cancellation, but WebUSB exposes no per-transfer cancel primitive; it only exposes broader abort behavior through reset(), close(), and some configuration/interface transitions. Second, URB_ZERO_PACKET cannot be represented exactly by a single WebUSB bulk OUT call. Third, URB_SHORT_NOT_OK is not natively expressible in WebUSB, so you must synthesize the Linux-side error yourself if an IN transfer completes short when that flag is set. Those are the places where the bridge becomes an adapter with local policy, not a pure mechanical transcoder. citeturn10search0turn9view0turn29view0turn33view2turn31search0turn31search1

The translation model

A USB/IP importer first asks for the exported-device list on a short-lived TCP connection, then opens a second connection for OP_REQ_IMPORT; if the import succeeds, that second connection stays open and carries URB traffic. seqnum is per-connection and is how each USBIP_RET_SUBMIT or USBIP_RET_UNLINK is matched back to the originating request. The protocol explicitly allows multiple submits to be in flight before replies come back. citeturn10search0turn5view0

On the browser side, WebUSB exposes a USBDevice with device-level fields (vendorId, productId, deviceClass, deviceSubclass, deviceProtocol, version fields, string fields), a configurations array, interface metadata, alternate settings, and endpoint metadata. Endpoints expose endpointNumber, direction, type, and packetSize, and the endpoint objects are derived from the USB descriptors. WebUSB intentionally does not create a normal endpoint object for the control pipe; control traffic is handled separately through dedicated control-transfer methods. citeturn15view7turn15view3turn30view0turn30view1turn30view2

That means your bridge has two jobs only:

  1. Export identity and descriptor summary into OP_REP_DEVLIST and OP_REP_IMPORT. citeturn10search0
  2. Execute imported URBs by decoding USBIP_CMD_SUBMIT and calling the right WebUSB method, then encoding USBIP_RET_SUBMIT. citeturn10search0turn25view2turn26view0turn25view0turn27view0
sequenceDiagram
    participant L as Linux usbip client
    participant B as Bridge
    participant W as WebUSB device

    L->>B: OP_REQ_DEVLIST
    B->>L: OP_REP_DEVLIST

    L->>B: OP_REQ_IMPORT(busid)
    B->>L: OP_REP_IMPORT(device summary)

    loop URB traffic
        L->>B: USBIP_CMD_SUBMIT(seqnum, ep, dir, setup/payload)
        B->>W: Matching WebUSB operation
        W-->>B: Result / error / data
        B-->>L: USBIP_RET_SUBMIT(seqnum, status, actual_length, payload)
    end

    opt cancel
        L->>B: USBIP_CMD_UNLINK(unlink_seqnum)
        B-->>L: USBIP_RET_UNLINK(status)
    end
Loading

That sequence is exactly the normal USB/IP architecture, with the WebUSB adapter replacing the usual Linux “exported device” implementation. citeturn10search0turn4view1turn4view4

Packet mapping rules

The following table is the core translation surface. It combines the WebUSB method semantics with the Linux USB/IP packet formats and Linux USB setup/endpoint constants. citeturn10search0turn14view0turn30view1turn38view0turn38view3

Incoming USB/IP request or phase Browser-side action Outgoing USB/IP reply
OP_REQ_DEVLIST Build device list entry from cached WebUSB device/config/interface metadata OP_REP_DEVLIST
OP_REQ_IMPORT(busid) Open device if needed; ensure cached metadata is ready OP_REP_IMPORT
USBIP_CMD_SUBMIT, ep=0, direction=IN Decode 8-byte setup packet; usually controlTransferIn() USBIP_RET_SUBMIT with status, actual_length, and IN payload
USBIP_CMD_SUBMIT, ep=0, direction=OUT Decode setup packet; usually controlTransferOut() USBIP_RET_SUBMIT with status, actual_length=bytesWritten, no payload
Standard control SET_CONFIGURATION selectConfiguration(configurationValue) instead of raw control USBIP_RET_SUBMIT, actual_length=0
Standard control SET_INTERFACE selectAlternateInterface(interfaceNumber, altSetting) USBIP_RET_SUBMIT, actual_length=0
Standard control CLEAR_FEATURE(ENDPOINT_HALT) on endpoint recipient clearHalt(direction, endpointNumber) USBIP_RET_SUBMIT, actual_length=0
Non-control, non-iso IN transferIn(endpointNumber, length) USBIP_RET_SUBMIT with IN payload
Non-control, non-iso OUT transferOut(endpointNumber, bytes) USBIP_RET_SUBMIT, no payload
Isochronous IN isochronousTransferIn(endpointNumber, packetLengths[]) USBIP_RET_SUBMIT with packed IN payload + ISO descriptors
Isochronous OUT isochronousTransferOut(endpointNumber, bytes, packetLengths[]) USBIP_RET_SUBMIT with ISO descriptors, no payload
USBIP_CMD_UNLINK Best-effort cancel emulation USBIP_RET_UNLINK

Device-list and import packets

OP_REP_DEVLIST and OP_REP_IMPORT must carry the fields defined by the protocol: path, busid, busnum, devnum, speed, idVendor, idProduct, bcdDevice, bDeviceClass, bDeviceSubClass, bDeviceProtocol, current bConfigurationValue, bNumConfigurations, bNumInterfaces, then one (class, subclass, protocol, pad) tuple per interface. OP_REQ_IMPORT refers back to the busid previously advertised by OP_REP_DEVLIST, so the busid you generate must be stable and consistent across those two phases. citeturn10search0turn5view0

WebUSB already exposes enough metadata to populate almost all of this. vendorId, productId, deviceClass, deviceSubclass, deviceProtocol, and device version are directly exposed on USBDevice; interfaces expose interfaceClass, interfaceSubclass, and interfaceProtocol; configurations and endpoints are descriptor-derived. The only fields WebUSB does not naturally care about are the Linux-flavored exporter identities like path, busnum, and devnum, so those become bridge-owned synthetic identifiers. citeturn15view7turn15view2turn15view3turn30view0

USB/IP header fields

For all four URB packets, the common usbip_header_basic is:

  • command = one of 0x00000001, 0x00000002, 0x00000003, 0x00000004. citeturn18search0turn10search0
  • seqnum = monotonically increasing per import connection on the Linux client side; replies reuse the same seqnum. citeturn10search0turn5view0
  • devid = ((busnum << 16) | devnum) in client requests; server replies set it to zero. citeturn5view0
  • direction = 0 for OUT and 1 for IN in client requests; server replies set it to zero. citeturn5view0
  • ep = endpoint number in client requests; server replies set it to zero. citeturn5view0

All of those integer fields are network-big-endian. The 8-byte setup array in USBIP_CMD_SUBMIT is not a separately endian-corrected structure in the protocol docs; it is just the raw USB setup bytes embedded as bytes. On the Linux side, USB setup packets are defined as bmRequestType, bRequest, wValue, wIndex, wLength, with bmRequestType made by combining direction, type, and recipient fields. The standard constants are USB_DIR_IN=0x80, USB_DIR_OUT=0x00, USB_TYPE_STANDARD=0x00<<5, USB_TYPE_CLASS=0x01<<5, USB_TYPE_VENDOR=0x02<<5, and recipients DEVICE=0, INTERFACE=1, ENDPOINT=2, OTHER=3. citeturn4view0turn33view1turn38view0turn38view1turn38view2

Transfer-by-transfer algorithms

Control URBs on endpoint zero

For ep == 0, decode setup[8] as:

  • bmRequestType = setup[0]
  • bRequest = setup[1]
  • wValue = le16(setup[2:4])
  • wIndex = le16(setup[4:6])
  • wLength = le16(setup[6:8])

Linux’s own usbmon documentation describes setup packets exactly in that order, and usbfs documents the first eight bytes of a control request as the USB setup packet. citeturn24view0turn33view1

Then map to WebUSB like this:

  • If the request is standard SET_CONFIGURATION (bRequest=0x09, recipient device), call selectConfiguration(configurationValue). WebUSB explicitly defines this method in terms of a SET_CONFIGURATION control transfer and updates its local state around it. citeturn38view1turn29view0
  • If the request is standard SET_INTERFACE (bRequest=0x0B, recipient interface), call selectAlternateInterface(interfaceNumber, alternateSetting). WebUSB defines this method in terms of a SET_INTERFACE control transfer. citeturn38view1turn28view1
  • If the request is CLEAR_FEATURE(ENDPOINT_HALT) on an endpoint recipient, call clearHalt(direction, endpointNumber), not a raw control transfer. Linux’s own USB API docs warn that the halt clear should be done through the dedicated endpoint-halt mechanism rather than by issuing the control request directly, because of data-toggle tracking. WebUSB also exposes a dedicated clearHalt() method. citeturn26view0turn33view3
  • Otherwise:
    • use controlTransferIn(setup, length) if direction == IN
    • use controlTransferOut(setup, payload) if direction == OUT
      WebUSB defines those methods directly in terms of issuing a USB control transfer with the supplied setup fields, forcing the directional bit in bmRequestType from the chosen method and setting wLength from the API call. citeturn25view2turn14view0

The translator from setup-byte bmRequestType to WebUSB’s setup dictionary is:

  • requestType =
    • "standard" if (bmRequestType & 0x60) == 0x00
    • "class" if (bmRequestType & 0x60) == 0x20
    • "vendor" if (bmRequestType & 0x60) == 0x40
  • recipient =
    • "device" if (bmRequestType & 0x1f) == 0
    • "interface" if (bmRequestType & 0x1f) == 1
    • "endpoint" if (bmRequestType & 0x1f) == 2
    • "other" if (bmRequestType & 0x1f) == 3

That mapping follows the USB Chapter 9 constants and WebUSB’s USBControlTransferParameters structure. citeturn38view0turn38view1turn38view2turn14view0

A practical rule: validate that the USB/IP header direction and setup-byte direction agree. If they do not, fail locally rather than passing contradictory information into WebUSB. That is not explicitly mandated by the protocol docs, but it is the safest bridge behavior because Linux and WebUSB are both assuming a coherent control request model. citeturn5view0turn25view2

Bulk and interrupt URBs

For non-control, non-iso URBs, WebUSB uses endpoint metadata to determine whether the endpoint is bulk or interrupt; the call surface is still simply transferIn() or transferOut(). WebUSB says these methods are only valid on "bulk" or "interrupt" endpoints, and endpoint direction is represented exactly the same way as USB descriptors: endpoint number in the low four bits, direction in the top bit of bEndpointAddress. citeturn25view3turn30view1turn30view2turn38view3

So the algorithm is:

  • endpointNumber = pdu.base.ep
  • if pdu.base.direction == IN: call device.transferIn(endpointNumber, pdu.transfer_buffer_length)
  • else: read the OUT payload bytes after the 48-byte submit header and call device.transferOut(endpointNumber, payload)

Then construct USBIP_RET_SUBMIT:

  • command = 0x00000003
  • seqnum = request.seqnum
  • devid = 0
  • direction = 0
  • ep = 0
  • status = translated Linux USB status
  • actual_length = bytes actually transferred
  • start_frame = 0
  • number_of_packets = 0xffffffff
  • error_count = 0
  • if IN and successful, append actual_length bytes of returned payload; if OUT, append no data. citeturn10search0turn26view0turn25view3

Isochronous URBs

WebUSB’s ISO API shape lines up surprisingly well with USB/IP. The browser takes packetLengths[] for both directions and returns per-packet status plus aggregate data. USB/IP carries number_of_packets, an optional packed transfer buffer, and an array of iso_packet_descriptor records. The protocol docs define number_of_packets=0xffffffff for non-ISO, so any real packet count means ISO. USB/IP also states that padding between ISO packets is not transmitted. citeturn25view0turn27view0turn10search0turn34search0

The bridge algorithm should therefore be:

  • Read number_of_packets = n.
  • Read n ISO descriptors from the submit packet.
  • Build packetLengths[i] = iso_desc[i].length.
  • For ISO IN: call isochronousTransferIn(endpointNumber, packetLengths).
  • For ISO OUT: read the packed payload bytes, then call isochronousTransferOut(endpointNumber, data, packetLengths).

When answering:

  • Set top-level actual_length to the sum of per-packet actual lengths.
  • Pack the returned payload contiguously with no inter-packet padding.
  • Return one ISO descriptor per packet, carrying:
    • the original logical packet identity,
    • the requested length,
    • the packet actual_length,
    • the packet completion status.

The important implementation detail is that the returned descriptor array must let the Linux importer reconstruct packet placement correctly after receiving the packed payload. In practice, that means preserving the original packet layout identity from the request while transmitting the payload densely packed. That recommendation is an inference from the combination of the published protocol format and the Linux client’s documented “padding not transmitted” behavior. citeturn10search0turn34search0

Status and error translation

WebUSB completion statuses are "ok", "stall", and "babble" for normal transfers, and ISO packets also report per-packet status. In Linux USB error terms:

  • "ok"status = 0 citeturn12view0turn10search0
  • "stall"status = -EPIPE for bulk/interrupt/control stalls; Linux documents -EPIPE as the stalled-endpoint condition and says clearHalt is the right cure. citeturn32search2turn33view3
  • "babble"status = -EOVERFLOW; Linux error docs explicitly define -EOVERFLOW as “Babble.” citeturn12view2turn31search1
  • short IN transfer with USBIP_URB_SHORT_NOT_OK set → synthesize status = -EREMOTEIO; Linux documents that exact behavior for short packets when URB_SHORT_NOT_OK is set. citeturn9view0turn31search0turn31search1

For opaque WebUSB failures that arrive as rejected promises or NetworkError DOMExceptions, use Linux USB transport/device-failure codes rather than inventing bridge-private meanings. A sensible mapping is: disconnect/removal-like failures → -ENODEV or -ESHUTDOWN; generic transfer/protocol failure → -EPROTO. Linux’s USB error documentation treats those as the standard failure classes. citeturn11view6turn11view7turn31search1

Known semantic gaps

UNLINK cannot be represented exactly

USB/IP has an explicit cancel path: USBIP_CMD_UNLINK names a prior submit by unlink_seqnum, and if the unlink succeeds the server replies with USBIP_RET_UNLINK(status=-ECONNRESET) and does not later send a matching USBIP_RET_SUBMIT for the cancelled URB. That is a strong per-URB cancellation guarantee. citeturn10search0turn4view4

WebUSB does not expose a comparable per-transfer cancellation primitive in its method surface. The API has transfer methods and broader lifecycle methods such as close() and reset(), and reset() explicitly aborts all operations on the device rather than a selected one. That means a pure-WebUSB bridge cannot implement exact USB/IP UNLINK semantics for an arbitrary single in-flight promise. The best you can do is best-effort emulation:

  • if the request already completed: return USBIP_RET_UNLINK(status=0)
  • if the request is still pending: mark it cancelled in bridge state, attempt an aggressive wider abort only if your policy allows it, return USBIP_RET_UNLINK(status=-ECONNRESET), and suppress any later RET_SUBMIT if the promise resolves anyway. citeturn29view0turn27view0turn10search0

URB_ZERO_PACKET is a real gap

USB/IP forwards USBIP_URB_ZERO_PACKET, but WebUSB’s bulk OUT surface only sends “this buffer” and does not let you request “append a zero-length packet if the transfer length is an exact multiple of max packet size” as part of the same transfer. Linux’s USB host-side documentation explicitly calls out that bulk protocols often depend on short packets, including zero-length packets, to mark transfer boundaries. So if the importer sends ZERO_PACKET, that is not exactly representable in one WebUSB call. citeturn9view0turn31search2

If you must support such a device, the bridge policy is:

  • detect USBIP_URB_ZERO_PACKET on OUT bulk submits,
  • if payload length is not a multiple of endpoint max packet size, ignore the flag because a short packet already terminates the transfer,
  • if it is a multiple, either:
    • declare the request unsupported for strict mode, or
    • try a second zero-length transferOut() as a compatibility hack, understanding that this is not guaranteed equivalent to a hardware-appended ZLP. citeturn9view0turn30view2turn31search2

interval, start_frame, and full host scheduling fidelity are not exposed

USB/IP carries interval and start_frame for periodic traffic and ISO, because those exist in Linux URB semantics. WebUSB does not expose host-controller scheduling knobs for these values; it only gives you the high-level transfer API. So a WebUSB bridge can preserve the fields on the wire where required, but it cannot force the browser’s USB stack to schedule periodic traffic exactly the way a native Linux HCD would. citeturn10search0turn25view0turn27view0

Minimal translator procedure

If you only care about the wire conversion, this is the shortest correct implementation recipe.

Build and cache the exported-device view

When the operator selects the device in WebUSB, cache:

  • device fields: VID, PID, class/subclass/protocol, bcdDevice-equivalent version, strings, current configuration, configuration count, interface count, speed classification if your bridge can determine it,
  • per-interface class/subclass/protocol,
  • per-endpoint number, direction, type, and packet size. citeturn15view7turn15view3turn30view0turn30view2

Use that cache to answer OP_REQ_DEVLIST and OP_REQ_IMPORT. Keep busid, busnum, and devnum stable for the life of the exported device; client requests will compute devid=((busnum<<16)|devnum) from what you advertised. citeturn10search0turn5view0

Decode USBIP_CMD_SUBMIT

Read exactly 48 bytes of submit header, big-endian for every integer field. Then:

  • if direction == OUT and non-control: read transfer_buffer_length bytes of payload
  • if number_of_packets != 0xffffffff: read that many ISO descriptors
  • if ISO OUT: the payload is packed with no inter-packet padding
  • if control: parse setup[8] and do not look for an OUT payload unless this is a control-OUT with a data stage. citeturn10search0turn24view0

Dispatch to WebUSB

Dispatch matrix:

  • ep == 0:
    • specialized stateful standard requests → selectConfiguration, selectAlternateInterface, clearHalt
    • else generic controlTransferIn / controlTransferOut
  • ep != 0 and number_of_packets == 0xffffffff:
    • transferIn / transferOut
  • ep != 0 and real packet count:
    • isochronousTransferIn / isochronousTransferOut using descriptor lengths as packetLengths[] citeturn29view0turn28view1turn26view0turn25view0turn27view0

Encode USBIP_RET_SUBMIT

Always send the same seqnum back. Set devid=0, direction=0, ep=0. For non-ISO, use:

  • status from the status-mapping rules above
  • actual_length from returned bytes/data length
  • start_frame=0
  • number_of_packets=0xffffffff
  • error_count=0
  • append IN payload only for IN replies. citeturn10search0turn31search1turn32search2

For ISO:

  • status=0 unless the whole URB failed before packet-level completion
  • actual_length = sum(per-packet actual_length)
  • number_of_packets = n
  • error_count = count(nonzero packet statuses) as a reasonable Linux-compatible summary
  • append packed IN payload for ISO IN
  • append n ISO descriptors after the payload. citeturn10search0turn25view1turn27view0

Encode USBIP_RET_UNLINK

Maintain a pending-request map keyed by seqnum. On USBIP_CMD_UNLINK:

  • if request already retired: reply status=0
  • if request is still pending and you are treating it as cancelled: reply status=-ECONNRESET and suppress any later RET_SUBMIT for that request
  • if your bridge cannot safely emulate cancellation for that class of transfer, document the limitation rather than silently pretending it succeeded. citeturn10search0turn4view4

Example wire layouts

For a normal non-ISO submit/reply pair, the fixed header shape is:

USBIP_CMD_SUBMIT
  00: u32 command              = 0x00000001  (big-endian)
  04: u32 seqnum               = ...
  08: u32 devid                = (busnum << 16) | devnum
  0c: u32 direction            = 0 or 1
  10: u32 ep                   = endpoint number
  14: u32 transfer_flags
  18: s32 transfer_buffer_length
  1c: s32 start_frame          = 0 unless ISO
  20: s32 number_of_packets    = 0xffffffff unless ISO
  24: s32 interval
  28: u8  setup[8]
  30: OUT payload if present
  ... ISO descriptors if present
USBIP_RET_SUBMIT
  00: u32 command              = 0x00000003  (big-endian)
  04: u32 seqnum               = same as request
  08: u32 devid                = 0
  0c: u32 direction            = 0
  10: u32 ep                   = 0
  14: s32 status
  18: s32 actual_length
  1c: s32 start_frame          = 0 unless ISO
  20: s32 number_of_packets    = 0xffffffff unless ISO
  24: s32 error_count
  28: u8  padding[8]           = 0
  30: IN payload if present
  ... ISO descriptors if present

That follows the Linux protocol documentation exactly. citeturn10search0

A concrete control example: a Linux-side GET_DESCRIPTOR(Device, index=0, length=18) control-IN submit will carry setup = 80 06 00 01 00 00 12 00; your bridge should decode that into a standard/device request with bRequest=GET_DESCRIPTOR, wValue=0x0100, wIndex=0, wLength=18, call controlTransferIn(...), then send a USBIP_RET_SUBMIT with actual_length=18 and append the 18 returned descriptor bytes as the reply payload. citeturn38view0turn38view1turn33view1turn25view2

Primary references

The most important source is the Linux USB/IP protocol documentation itself, because it defines the discovery/import opcodes, the URB packet headers, field sizes, endianness, and the UNLINK behavior. citeturn10search0

For the browser side, the normative WebUSB API spec is the source of truth for what operations you can actually perform: control, bulk/interrupt, isochronous, configuration selection, alternate setting selection, endpoint halt clearing, reset, interface claiming, and the exact transfer result statuses (ok, stall, babble). citeturn11view0turn11view2turn11view4turn12view0turn29view0turn28view1turn26view0turn27view0

For setup-packet and endpoint-bit definitions, use the Linux Chapter 9 header, which mirrors the USB standard constants from the entity["organization","USB Implementers Forum","standards body"] USB 2.0 ecosystem: USB_DIR_*, USB_TYPE_*, USB_RECIP_*, standard request numbers, and endpoint-address/type masks. citeturn38view0turn38view1turn38view2turn38view3turn22view0

For Linux-side status interpretation, use the Linux USB Host-Side API and USB error-code documentation, especially the parts covering control setup layout, CLEAR_HALT semantics, URB_SHORT_NOT_OK, -EREMOTEIO, -EPIPE, and -EOVERFLOW. citeturn33view1turn33view3turn31search0turn31search1

If you need tooling-level compatibility context, the usbip and usbipd man pages are useful for how the standard userspace tools attach to remote exports and expect busids/import sessions to work. citeturn16search5turn16search1

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment