Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
c44ec7454This file is pinned to the selected release. Download the original to open it in your design tool.
const SERVICE_UUID = '6e400001-b5a3-f393-e0a9-e50e24dcca9e'
const RX_UUID = '6e400002-b5a3-f393-e0a9-e50e24dcca9e'
const TX_UUID = '6e400003-b5a3-f393-e0a9-e50e24dcca9e'
const BROADCAST_ID = 'FFFFFFFFFFFF'
const RECORD_MAGIC = Uint8Array.of(0x53, 0x4c, 0x42, 0x31)
const FRAME_MAGIC = Uint8Array.of(0x53, 0x4c, 0x50, 0x31)
const RECORD_HEADER_BYTES = 22
const FRAME_HEADER_BYTES = 18
const FRAME_BYTES = 234
const FRAGMENT_BYTES = FRAME_BYTES - FRAME_HEADER_BYTES
const MESSAGE_BYTES = 2048
const MAX_FRAGMENTS = Math.ceil(MESSAGE_BYTES / FRAGMENT_BYTES)
const AUTH_TAG_BYTES = 16
// The CoreS3 negotiates ATT MTU 256, leaving 253 bytes for a characteristic
// value. Keep a small margin, then fall back for older hosts and legacy MTUs.
const GATT_CHUNK_BYTES = Object.freeze([244, 60, 20])
const DEVICE_STORAGE_KEY = 'stackchan.localPeer.bleDevice'
const STACKCHAN_DEVICE_NAME = 'STK'
const AUTH_DOMAIN = new TextEncoder().encode('stackchan-local-peer-auth-v1:')
const RecordKind = Object.freeze({ HELLO: 1, DATA: 2 })
const FrameKind = Object.freeze({ DISCOVER: 1, ANNOUNCE: 2, DATA: 3, ACK: 4 })
const FrameFlag = Object.freeze({ RELIABLE: 1, BROADCAST: 2 })
function concat(...parts) {
const result = new Uint8Array(parts.reduce((length, part) => length + part.byteLength, 0))
let offset = 0
for (const part of parts) {
result.set(part, offset)
offset += part.byteLength
}
return result
}
function idBytes(id) {
if (!/^[0-9a-f]{12}$/i.test(id)) throw new RangeError('peer ID must contain 12 hexadecimal characters')
return Uint8Array.from({ length: 6 }, (_, index) => Number.parseInt(id.slice(index * 2, index * 2 + 2), 16))
}
function bytesId(bytes) {
return [...bytes]
.map((value) => value.toString(16).padStart(2, '0'))
.join('')
.toUpperCase()
}
function fnv1a32(value) {
let hash = 0x811c9dc5
for (const byte of new TextEncoder().encode(value)) {
hash ^= byte
hash = Math.imul(hash, 0x01000193) >>> 0
}
return hash
}
async function authenticationKey(sharedKey, cryptoProvider = globalThis.crypto) {
return new Uint8Array(
await cryptoProvider.subtle.digest('SHA-256', concat(AUTH_DOMAIN, new TextEncoder().encode(sharedKey)))
)
}
async function authenticationTag(key, sourceId, destinationId, payload, cryptoProvider = globalThis.crypto) {
const imported = await cryptoProvider.subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
const signature = await cryptoProvider.subtle.sign(
'HMAC',
imported,
concat(idBytes(sourceId), idBytes(destinationId), payload)
)
return new Uint8Array(signature).subarray(0, AUTH_TAG_BYTES)
}
function tagsEqual(actual, expected) {
if (actual.byteLength !== expected.byteLength) return false
let difference = 0
for (let index = 0; index < actual.byteLength; index += 1) difference |= actual[index] ^ expected[index]
return difference === 0
}
export async function encodeBLELocalPeerRecord(record, key, cryptoProvider = globalThis.crypto) {
const authenticated = record.authenticated === true
const tag = authenticated
? await authenticationTag(key, record.sourceId, record.destinationId, record.payload, cryptoProvider)
: new Uint8Array(0)
const bytes = new Uint8Array(RECORD_HEADER_BYTES + record.payload.byteLength + tag.byteLength)
bytes.set(RECORD_MAGIC)
bytes[4] = 1
bytes[5] = record.kind
bytes[6] = authenticated ? 1 : 0
bytes.set(idBytes(record.sourceId), 8)
bytes.set(idBytes(record.destinationId), 14)
new DataView(bytes.buffer).setUint16(20, record.payload.byteLength)
bytes.set(record.payload, RECORD_HEADER_BYTES)
bytes.set(tag, RECORD_HEADER_BYTES + record.payload.byteLength)
return bytes
}
export class BLELocalPeerRecordDecoder {
#buffer = new Uint8Array(0)
push(value) {
this.#buffer = concat(this.#buffer, value instanceof Uint8Array ? value : new Uint8Array(value))
const records = []
for (;;) {
let start = 0
while (
start + 4 <= this.#buffer.byteLength &&
!RECORD_MAGIC.every((byte, index) => this.#buffer[start + index] === byte)
)
start += 1
if (start) this.#buffer = this.#buffer.subarray(start)
if (this.#buffer.byteLength < RECORD_HEADER_BYTES) break
const payloadLength = new DataView(
this.#buffer.buffer,
this.#buffer.byteOffset,
this.#buffer.byteLength
).getUint16(20)
const authenticated = this.#buffer[6] === 1
if (
this.#buffer[4] !== 1 ||
![RecordKind.HELLO, RecordKind.DATA].includes(this.#buffer[5]) ||
(this.#buffer[6] & ~1) !== 0 ||
payloadLength > FRAME_BYTES
) {
this.#buffer = this.#buffer.subarray(1)
continue
}
const length = RECORD_HEADER_BYTES + payloadLength + (authenticated ? AUTH_TAG_BYTES : 0)
if (this.#buffer.byteLength < length) break
records.push({
kind: this.#buffer[5],
authenticated,
sourceId: bytesId(this.#buffer.subarray(8, 14)),
destinationId: bytesId(this.#buffer.subarray(14, 20)),
payload: this.#buffer.slice(RECORD_HEADER_BYTES, RECORD_HEADER_BYTES + payloadLength),
tag: authenticated ? this.#buffer.slice(RECORD_HEADER_BYTES + payloadLength, length) : undefined,
})
this.#buffer = this.#buffer.subarray(length)
}
return records
}
reset() {
this.#buffer = new Uint8Array(0)
}
}
function encodeFrame({ kind, flags, messageId, fragmentIndex = 0, fragmentCount = 1, serviceHash, payload }) {
const bytes = new Uint8Array(FRAME_HEADER_BYTES + payload.byteLength)
bytes.set(FRAME_MAGIC)
bytes[4] = kind
bytes[5] = flags
const view = new DataView(bytes.buffer)
view.setUint32(6, messageId)
bytes[10] = fragmentIndex
bytes[11] = fragmentCount
view.setUint32(12, serviceHash)
view.setUint16(16, payload.byteLength)
bytes.set(payload, FRAME_HEADER_BYTES)
return bytes
}
function decodeFrame(bytes) {
if (bytes.byteLength < FRAME_HEADER_BYTES || bytes.byteLength > FRAME_BYTES) return undefined
if (!FRAME_MAGIC.every((value, index) => bytes[index] === value)) return undefined
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
const fragmentIndex = bytes[10]
const fragmentCount = bytes[11]
const payloadLength = view.getUint16(16)
if (fragmentCount < 1 || fragmentIndex >= fragmentCount || FRAME_HEADER_BYTES + payloadLength !== bytes.byteLength)
return undefined
return {
kind: bytes[4],
flags: bytes[5],
messageId: view.getUint32(6),
fragmentIndex,
fragmentCount,
serviceHash: view.getUint32(12),
payload: bytes.slice(FRAME_HEADER_BYTES),
}
}
function fragments(kind, flags, messageId, serviceHash, payload) {
const count = Math.max(1, Math.ceil(payload.byteLength / FRAGMENT_BYTES))
return Array.from({ length: count }, (_, index) =>
encodeFrame({
kind,
flags,
messageId,
fragmentIndex: index,
fragmentCount: count,
serviceHash,
payload: payload.subarray(index * FRAGMENT_BYTES, (index + 1) * FRAGMENT_BYTES),
})
)
}
function randomId(cryptoProvider) {
const bytes = new Uint8Array(6)
cryptoProvider.getRandomValues(bytes)
bytes[0] &= 0xfe
bytes[0] |= 0x02
return bytesId(bytes)
}
function messageId(cryptoProvider) {
const values = new Uint32Array(1)
cryptoProvider.getRandomValues(values)
return values[0] || 1
}
export class BLELocalPeerCapability {
#bluetooth
#crypto
#session
#storage
id
constructor({
bluetooth = globalThis.navigator?.bluetooth,
crypto = globalThis.crypto,
storage = globalThis.localStorage,
} = {}) {
if (!bluetooth) throw new Error('Web Bluetooth is not available')
this.#bluetooth = bluetooth
this.#crypto = crypto
this.#storage = storage
const stored = storage?.getItem('stackchan.localPeer.id')
this.id = /^[0-9a-f]{12}$/i.test(stored ?? '') ? stored.toUpperCase() : randomId(crypto)
storage?.setItem('stackchan.localPeer.id', this.id)
}
async open(options) {
if (this.#session && !this.#session.closed) throw new Error('a local peer session is already open')
if (options.transport !== undefined && options.transport !== 'ble') throw new Error('Web client supports only BLE')
const session = new BLELocalPeerSession(this.id, options, this.#bluetooth, this.#crypto, this.#storage, () => {
if (this.#session === session) this.#session = undefined
})
this.#session = session
try {
await session.connect()
return session
} catch (error) {
session.close()
throw error
}
}
}
class BLELocalPeerSession {
#id
#options
#bluetooth
#crypto
#storage
#onClose
#device
#rx
#tx
#decoder = new BLELocalPeerRecordDecoder()
#remoteId
#remoteName
#serviceHash
#key
#subscribers = new Map()
#pending = new Map()
#reassemblies = new Map()
#delivered = new Map()
#remoteSecure = false
#writeChunkIndex = 0
closed = false
constructor(id, options, bluetooth, cryptoProvider, storage, onClose) {
const serviceBytes = typeof options?.service === 'string' ? new TextEncoder().encode(options.service).byteLength : 0
if (serviceBytes < 1 || serviceBytes > 64) throw new TypeError('service must contain 1-64 UTF-8 bytes')
this.#id = id
this.#options = options
this.#bluetooth = bluetooth
this.#crypto = cryptoProvider
this.#storage = storage
this.#onClose = onClose
this.#serviceHash = fnv1a32(options.service)
}
async connect() {
this.#key = this.#options.sharedKey ? await authenticationKey(this.#options.sharedKey, this.#crypto) : undefined
let permitted = []
try {
permitted = (await this.#bluetooth.getDevices?.()) ?? []
} catch {}
const rememberedId = this.#storage?.getItem(DEVICE_STORAGE_KEY)
const permittedStackchans = permitted.filter((device) => device.name === STACKCHAN_DEVICE_NAME)
this.#device =
permitted.find((device) => device.id === rememberedId) ??
(permittedStackchans.length === 1 ? permittedStackchans[0] : undefined)
if (!this.#device) {
this.#device = await this.#bluetooth.requestDevice({
filters: [{ services: [SERVICE_UUID] }],
optionalServices: [SERVICE_UUID],
})
}
if (this.#device.id) this.#storage?.setItem(DEVICE_STORAGE_KEY, this.#device.id)
this.#device.addEventListener('gattserverdisconnected', () => this.close())
const server = await this.#device.gatt.connect()
const service = await server.getPrimaryService(SERVICE_UUID)
this.#rx = await service.getCharacteristic(RX_UUID)
this.#tx = await service.getCharacteristic(TX_UUID)
this.#tx.addEventListener('characteristicvaluechanged', (event) => {
void this.#receive(
new Uint8Array(event.target.value.buffer, event.target.value.byteOffset, event.target.value.byteLength)
)
})
await this.#tx.startNotifications()
await this.#writeRecord(RecordKind.HELLO, BROADCAST_ID, new Uint8Array(0), false)
await this.#announce()
}
async discover({ timeoutMs = 750 } = {}) {
this.#assertOpen()
await this.#sendDiscovery(FrameKind.DISCOVER)
if (timeoutMs > 0) await new Promise((resolve) => setTimeout(resolve, timeoutMs))
this.#assertOpen()
return this.#remoteId ? [{ id: this.#remoteId, name: this.#remoteName, secure: this.#remoteSecure }] : []
}
async send(peerId, type, payload) {
this.#assertOpen()
if (peerId !== this.#remoteId) {
await this.discover()
if (peerId !== this.#remoteId) throw new Error(`peer ${peerId} is unavailable`)
}
const encoded = new TextEncoder().encode(JSON.stringify({ service: this.#options.service, type, payload }))
if (encoded.byteLength > MESSAGE_BYTES) throw new RangeError('encoded message exceeds 2048 bytes')
const id = messageId(this.#crypto)
const frames = fragments(FrameKind.DATA, FrameFlag.RELIABLE, id, this.#serviceHash, encoded)
return new Promise((resolve, reject) => {
const pending = { peerId, frames, attempts: 0, resolve, reject }
this.#pending.set(id, pending)
void this.#transmit(id, pending)
})
}
async broadcast(type, payload) {
this.#assertOpen()
const encoded = new TextEncoder().encode(JSON.stringify({ service: this.#options.service, type, payload }))
if (encoded.byteLength > MESSAGE_BYTES) throw new RangeError('encoded message exceeds 2048 bytes')
const id = messageId(this.#crypto)
for (const frame of fragments(FrameKind.DATA, FrameFlag.BROADCAST, id, this.#serviceHash, encoded)) {
await this.#sendFrame(undefined, frame)
}
return { messageId: id.toString(16).padStart(8, '0') }
}
subscribe(type, handler) {
this.#assertOpen()
const handlers = this.#subscribers.get(type) ?? new Set()
handlers.add(handler)
this.#subscribers.set(type, handlers)
return () => handlers.delete(handler)
}
close() {
if (this.closed) return
this.closed = true
for (const pending of this.#pending.values()) {
clearTimeout(pending.timer)
pending.reject(new Error('local peer session is closed'))
}
this.#pending.clear()
for (const record of this.#reassemblies.values()) clearTimeout(record.timer)
this.#reassemblies.clear()
this.#delivered.clear()
this.#device?.gatt?.disconnect()
this.#onClose()
}
async #announce() {
await this.#sendDiscovery(FrameKind.ANNOUNCE)
}
async #sendDiscovery(kind) {
const payload = new TextEncoder().encode(
JSON.stringify({ service: this.#options.service, name: this.#options.displayName })
)
await this.#sendFrame(
undefined,
encodeFrame({
kind,
flags: FrameFlag.BROADCAST,
messageId: messageId(this.#crypto),
serviceHash: this.#serviceHash,
payload,
})
)
}
async #transmit(id, pending) {
if (this.closed || this.#pending.get(id) !== pending) return
pending.attempts += 1
try {
for (const frame of pending.frames) await this.#sendFrame(pending.peerId, frame)
} catch {}
pending.timer = setTimeout(() => {
if (this.#pending.get(id) !== pending) return
if (pending.attempts >= 3) {
this.#pending.delete(id)
pending.reject(new Error(`peer ${pending.peerId} did not acknowledge message`))
} else void this.#transmit(id, pending)
}, 250)
}
async #sendFrame(peerId, frame) {
await this.#writeRecord(RecordKind.DATA, peerId ?? BROADCAST_ID, frame, peerId !== undefined && !!this.#key)
}
async #writeRecord(kind, destinationId, payload, authenticated) {
const record = await encodeBLELocalPeerRecord(
{ kind, authenticated, sourceId: this.#id, destinationId, payload },
this.#key,
this.#crypto
)
for (;;) {
const chunkBytes = GATT_CHUNK_BYTES[this.#writeChunkIndex]
let offset = 0
try {
for (; offset < record.byteLength; offset += chunkBytes) {
await this.#writeChunk(record.slice(offset, offset + chunkBytes))
}
return
} catch (error) {
if (offset !== 0 || this.#writeChunkIndex === GATT_CHUNK_BYTES.length - 1) throw error
this.#writeChunkIndex += 1
}
}
}
async #writeChunk(chunk) {
if (this.#rx.writeValueWithResponse) await this.#rx.writeValueWithResponse(chunk)
else await this.#rx.writeValue(chunk)
}
async #receive(chunk) {
for (const record of this.#decoder.push(chunk)) {
if (record.kind === RecordKind.HELLO) {
if (!record.authenticated && record.destinationId === BROADCAST_ID) this.#remoteId = record.sourceId
continue
}
if (record.kind !== RecordKind.DATA || record.sourceId !== this.#remoteId) continue
const broadcast = record.destinationId === BROADCAST_ID
if (!broadcast && record.destinationId !== this.#id) continue
let secure = false
if (!broadcast && this.#key) {
if (!record.authenticated || !record.tag) continue
const expected = await authenticationTag(
this.#key,
record.sourceId,
record.destinationId,
record.payload,
this.#crypto
)
if (!tagsEqual(record.tag, expected)) continue
secure = true
this.#remoteSecure = true
} else if (record.authenticated) continue
await this.#handleFrame(record.sourceId, record.payload, secure)
}
}
async #handleFrame(peerId, bytes, secure) {
const frame = decodeFrame(bytes)
if (!frame || frame.serviceHash !== this.#serviceHash) return
if (frame.kind === FrameKind.DISCOVER || frame.kind === FrameKind.ANNOUNCE) {
try {
const envelope = JSON.parse(new TextDecoder().decode(frame.payload))
if (envelope.service !== this.#options.service) return
this.#remoteName = typeof envelope.name === 'string' ? envelope.name : undefined
if (frame.kind === FrameKind.DISCOVER) void this.#announce()
} catch {}
return
}
if (frame.kind === FrameKind.ACK) {
const pending = this.#pending.get(frame.messageId)
if (!pending || pending.peerId !== peerId || (this.#key && !secure)) return
this.#pending.delete(frame.messageId)
clearTimeout(pending.timer)
pending.resolve({ messageId: frame.messageId.toString(16).padStart(8, '0'), peerId, attempts: pending.attempts })
return
}
if (frame.kind !== FrameKind.DATA) return
if (
frame.fragmentCount > MAX_FRAGMENTS ||
(frame.flags !== FrameFlag.RELIABLE && frame.flags !== FrameFlag.BROADCAST) ||
(frame.flags === FrameFlag.RELIABLE && this.#key && !secure)
)
return
const key = `${peerId}:${frame.messageId}`
let record = this.#reassemblies.get(key)
if (!record) {
record = {
flags: frame.flags,
fragments: new Array(frame.fragmentCount),
received: 0,
secure,
timer: setTimeout(() => this.#reassemblies.delete(key), 2000),
}
this.#reassemblies.set(key, record)
}
if (record.flags !== frame.flags || record.fragments.length !== frame.fragmentCount || record.secure !== secure)
return
if (!record.fragments[frame.fragmentIndex]) {
record.fragments[frame.fragmentIndex] = frame.payload
record.received += 1
}
if (record.received !== record.fragments.length) return
clearTimeout(record.timer)
this.#reassemblies.delete(key)
const payload = concat(...record.fragments)
if (payload.byteLength > MESSAGE_BYTES) return
if (frame.flags === FrameFlag.RELIABLE) {
await this.#sendFrame(
peerId,
encodeFrame({
kind: FrameKind.ACK,
flags: 0,
messageId: frame.messageId,
serviceHash: this.#serviceHash,
payload: new Uint8Array(0),
})
)
}
const now = Date.now()
for (const [deliveredKey, deliveredAt] of this.#delivered) {
if (now - deliveredAt > 30_000) this.#delivered.delete(deliveredKey)
}
if (this.#delivered.has(key)) return
this.#delivered.set(key, now)
try {
const envelope = JSON.parse(new TextDecoder().decode(payload))
if (envelope.service !== this.#options.service || typeof envelope.type !== 'string') return
const message = {
id: frame.messageId.toString(16).padStart(8, '0'),
peer: { id: peerId, name: this.#remoteName, secure: record.secure },
type: envelope.type,
payload: envelope.payload,
}
for (const handler of this.#subscribers.get(message.type) ?? []) handler(message)
for (const handler of this.#subscribers.get('*') ?? []) handler(message)
} catch {}
}
#assertOpen() {
if (this.closed) throw new Error('local peer session is closed')
}
}
export default BLELocalPeerCapability