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. citeturn10search0turn4view0turn5view0
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. citeturn29view0turn28view1turn26view0turn33view3
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. citeturn10search0turn9view0turn29view0turn33view2turn31search0turn31search1
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. citeturn10search0turn5view0
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. citeturn15view7turn15view3turn30view0turn30view1turn30view2
That means your bridge has two jobs only:
- Export identity and descriptor summary into
OP_REP_DEVLISTandOP_REP_IMPORT. citeturn10search0 - Execute imported URBs by decoding
USBIP_CMD_SUBMITand calling the right WebUSB method, then encodingUSBIP_RET_SUBMIT. citeturn10search0turn25view2turn26view0turn25view0turn27view0
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
That sequence is exactly the normal USB/IP architecture, with the WebUSB adapter replacing the usual Linux “exported device” implementation. citeturn10search0turn4view1turn4view4
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. citeturn10search0turn14view0turn30view1turn38view0turn38view3
| 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 |
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. citeturn10search0turn5view0
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. citeturn15view7turn15view2turn15view3turn30view0
For all four URB packets, the common usbip_header_basic is:
command= one of0x00000001,0x00000002,0x00000003,0x00000004. citeturn18search0turn10search0seqnum= monotonically increasing per import connection on the Linux client side; replies reuse the same seqnum. citeturn10search0turn5view0devid=((busnum << 16) | devnum)in client requests; server replies set it to zero. citeturn5view0direction=0for OUT and1for IN in client requests; server replies set it to zero. citeturn5view0ep= endpoint number in client requests; server replies set it to zero. citeturn5view0
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. citeturn4view0turn33view1turn38view0turn38view1turn38view2
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. citeturn24view0turn33view1
Then map to WebUSB like this:
- If the request is standard
SET_CONFIGURATION(bRequest=0x09, recipient device), callselectConfiguration(configurationValue). WebUSB explicitly defines this method in terms of aSET_CONFIGURATIONcontrol transfer and updates its local state around it. citeturn38view1turn29view0 - If the request is standard
SET_INTERFACE(bRequest=0x0B, recipient interface), callselectAlternateInterface(interfaceNumber, alternateSetting). WebUSB defines this method in terms of aSET_INTERFACEcontrol transfer. citeturn38view1turn28view1 - If the request is
CLEAR_FEATURE(ENDPOINT_HALT)on an endpoint recipient, callclearHalt(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 dedicatedclearHalt()method. citeturn26view0turn33view3 - Otherwise:
- use
controlTransferIn(setup, length)ifdirection == IN - use
controlTransferOut(setup, payload)ifdirection == OUT
WebUSB defines those methods directly in terms of issuing a USB control transfer with the supplied setup fields, forcing the directional bit inbmRequestTypefrom the chosen method and settingwLengthfrom the API call. citeturn25view2turn14view0
- use
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. citeturn38view0turn38view1turn38view2turn14view0
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. citeturn5view0turn25view2
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. citeturn25view3turn30view1turn30view2turn38view3
So the algorithm is:
endpointNumber = pdu.base.ep- if
pdu.base.direction == IN: calldevice.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 = 0x00000003seqnum = request.seqnumdevid = 0direction = 0ep = 0status = translated Linux USB statusactual_length = bytes actually transferredstart_frame = 0number_of_packets = 0xfffffffferror_count = 0- if IN and successful, append
actual_lengthbytes of returned payload; if OUT, append no data. citeturn10search0turn26view0turn25view3
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. citeturn25view0turn27view0turn10search0turn34search0
The bridge algorithm should therefore be:
- Read
number_of_packets = n. - Read
nISO 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_lengthto 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. citeturn10search0turn34search0
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 = 0citeturn12view0turn10search0"stall"→status = -EPIPEfor bulk/interrupt/control stalls; Linux documents-EPIPEas the stalled-endpoint condition and saysclearHaltis the right cure. citeturn32search2turn33view3"babble"→status = -EOVERFLOW; Linux error docs explicitly define-EOVERFLOWas “Babble.” citeturn12view2turn31search1- short IN transfer with
USBIP_URB_SHORT_NOT_OKset → synthesizestatus = -EREMOTEIO; Linux documents that exact behavior for short packets whenURB_SHORT_NOT_OKis set. citeturn9view0turn31search0turn31search1
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. citeturn11view6turn11view7turn31search1
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. citeturn10search0turn4view4
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 laterRET_SUBMITif the promise resolves anyway. citeturn29view0turn27view0turn10search0
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. citeturn9view0turn31search2
If you must support such a device, the bridge policy is:
- detect
USBIP_URB_ZERO_PACKETon 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. citeturn9view0turn30view2turn31search2
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. citeturn10search0turn25view0turn27view0
If you only care about the wire conversion, this is the shortest correct implementation recipe.
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. citeturn15view7turn15view3turn30view0turn30view2
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. citeturn10search0turn5view0
Read exactly 48 bytes of submit header, big-endian for every integer field. Then:
- if
direction == OUTand non-control: readtransfer_buffer_lengthbytes 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. citeturn10search0turn24view0
Dispatch matrix:
ep == 0:- specialized stateful standard requests →
selectConfiguration,selectAlternateInterface,clearHalt - else generic
controlTransferIn/controlTransferOut
- specialized stateful standard requests →
ep != 0andnumber_of_packets == 0xffffffff:transferIn/transferOut
ep != 0and real packet count:isochronousTransferIn/isochronousTransferOutusing descriptor lengths aspacketLengths[]citeturn29view0turn28view1turn26view0turn25view0turn27view0
Always send the same seqnum back. Set devid=0, direction=0, ep=0. For non-ISO, use:
statusfrom the status-mapping rules aboveactual_lengthfrom returned bytes/data lengthstart_frame=0number_of_packets=0xfffffffferror_count=0- append IN payload only for IN replies. citeturn10search0turn31search1turn32search2
For ISO:
status=0unless the whole URB failed before packet-level completionactual_length = sum(per-packet actual_length)number_of_packets = nerror_count = count(nonzero packet statuses)as a reasonable Linux-compatible summary- append packed IN payload for ISO IN
- append
nISO descriptors after the payload. citeturn10search0turn25view1turn27view0
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=-ECONNRESETand suppress any laterRET_SUBMITfor that request - if your bridge cannot safely emulate cancellation for that class of transfer, document the limitation rather than silently pretending it succeeded. citeturn10search0turn4view4
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. citeturn10search0
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. citeturn38view0turn38view1turn33view1turn25view2
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. citeturn10search0
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). citeturn11view0turn11view2turn11view4turn12view0turn29view0turn28view1turn26view0turn27view0
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. citeturn38view0turn38view1turn38view2turn38view3turn22view0
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. citeturn33view1turn33view3turn31search0turn31search1
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. citeturn16search5turn16search1