NTPv4 packet header encoding and decoding (RFC 5905).

An NTP packet opens with a fixed 48-byte header. This coder covers that header only — the optional extension fields and MAC that can follow it are out of scope for v0.0.1:

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|LI | VN  |Mode |    Stratum    |     Poll      |   Precision   |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                          Root Delay                          |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                       Root Dispersion                        |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                          Reference ID                        |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                                                               |
+                     Reference Timestamp (64)                 +
|                                                               |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                                                               |
+                      Origin Timestamp (64)                   +
|                                                               |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                                                               |
+                      Receive Timestamp (64)                  +
|                                                               |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                                                               |
+                     Transmit Timestamp (64)                  +
|                                                               |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

leapIndicator, version and mode share the first byte, MSB-first, and are exposed as a nested object via bitStruct. Everything else is byte-aligned.

poll and precision are signed 8-bit integers (s8be) per RFC 5905 — both are exponents of 2 (seconds), and precision in particular is routinely negative (e.g. -20 for a microsecond-precision clock). @hertzg/binstruct does export a signed 8-bit coder, so no u8be workaround is needed here.

rootDelay and rootDispersion are, per the RFC, 32-bit signed fixed-point numbers (16.16). This v0.0.1 coder surfaces them as raw u32be integers — the same "no semantic interpretation" stance @binstruct/ipv4 takes with its header checksum. Fixed-point conversion is left to the caller.

The four timestamp fields (referenceTimestamp, originTimestamp, receiveTimestamp, transmitTimestamp) are NTP era/second/fraction 64-bit values, surfaced here as raw big-endian bigints (via u64be). Converting the 32-bit seconds-since-1900 high half and 32-bit fraction low half to a JavaScript Date (or handling the 2036 rollover) is out of scope for v0.0.1 and left to the caller.

Examples

Round-trip a client request packet

import { assertEquals } from "@std/assert";
import { ntpPacket, NTP_MODE, NTP_PACKET_SIZE } from "@binstruct/ntp";

const coder = ntpPacket();
const packet = {
  leapVersionMode: { leapIndicator: 0, version: 4, mode: NTP_MODE.CLIENT },
  stratum: 0,
  poll: 4,
  precision: -20,
  rootDelay: 0,
  rootDispersion: 0,
  referenceId: 0,
  referenceTimestamp: 0n,
  originTimestamp: 0n,
  receiveTimestamp: 0n,
  transmitTimestamp: 0xe4c5c46700000000n,
};

const buffer = new Uint8Array(NTP_PACKET_SIZE);
const written = coder.encode(packet, buffer);
const [decoded, read] = coder.decode(buffer);

assertEquals(written, NTP_PACKET_SIZE);
assertEquals(read, NTP_PACKET_SIZE);
assertEquals(decoded, packet);
assertEquals(decoded.leapVersionMode.mode, NTP_MODE.CLIENT);