UNPKG

@socketsupply/socket

Version:

A Cross-Platform, Native Runtime for Desktop and Mobile Apps — Create apps using HTML, CSS, and JavaScript. Written from the ground up to be small and maintainable.

535 lines (442 loc) 16.2 kB
import { randomBytes } from '../crypto.js' import { isBufferLike } from '../util.js' import { Buffer } from '../buffer.js' import Peer, { Cache, NAT } from './index.js' /** * Hash function factory. * @return {function(object|Buffer|string): Promise<string>} */ function createHashFunction () { const encoder = new TextEncoder() let crypto = null if (!globalThis.process?.versions?.node) { if (!crypto) crypto = globalThis.crypto?.subtle return async (seed, opts = {}) => { const encoding = opts.encoding || 'hex' const bytes = opts.bytes if (typeof seed === 'object' && !isBufferLike(seed)) { seed = JSON.stringify(seed) } if (seed && typeof seed === 'string') { seed = encoder.encode(seed) } let value if (seed) { value = Buffer.from(await crypto.digest('SHA-256', seed)) if (bytes) return value return value.toString(encoding) } value = Buffer.from(randomBytes(32)) if (opts.bytes) return value return value.toString(encoding) } } return async (seed, opts = {}) => { const encoding = opts.encoding || 'hex' const bytes = opts.bytes if (typeof seed === 'object' && !isBufferLike(seed)) { seed = JSON.stringify(seed) } if (!crypto) { // eslint-disable-next-line crypto = await new Function('return import(\'crypto\')')() } let value if (seed) { value = crypto.createHash('sha256').update(seed) if (bytes) return Buffer.from(value.digest(encoding), encoding) return value.digest(encoding) } value = randomBytes(32) if (opts.bytes) return value return value.toString(encoding) } } const getMethod = (type, bytes, isSigned) => { const bits = bytes << 3 const isBigEndian = bits === 8 ? '' : 'BE' if (![8, 16, 32].includes(bits)) { throw new Error(`${bits} is invalid, expected 8, 16, or 32`) } return `${type}${isSigned ? '' : 'U'}Int${bits}${isBigEndian}` } /** * The magic bytes prefixing every packet. They are the * 2nd, 3rd, 5th, and 7th, prime numbers. * @type {number[]} */ export const MAGIC_BYTES_PREFIX = [0x03, 0x05, 0x0b, 0x11] /** * The version of the protocol. */ export const VERSION = 6 /** * The size in bytes of the prefix magic bytes. */ export const MAGIC_BYTES = 4 /** * The maximum size of the user message. */ export const MESSAGE_BYTES = 1024 /** * The cache TTL in milliseconds. */ export const CACHE_TTL = 60_000 * 60 * 6 export const PACKET_SPEC = { type: { bytes: 1, encoding: 'number' }, version: { bytes: 2, encoding: 'number', default: VERSION }, clock: { bytes: 4, encoding: 'number', default: 1 }, hops: { bytes: 4, encoding: 'number', default: 0 }, index: { bytes: 4, encoding: 'number', default: -1, signed: true }, ttl: { bytes: 4, encoding: 'number', default: CACHE_TTL }, clusterId: { bytes: 32, encoding: 'base64', default: [0b0] }, subclusterId: { bytes: 32, encoding: 'base64', default: [0b0] }, previousId: { bytes: 32, encoding: 'hex', default: [0b0] }, packetId: { bytes: 32, encoding: 'hex', default: [0b0] }, nextId: { bytes: 32, encoding: 'hex', default: [0b0] }, usr1: { bytes: 32, default: [0b0] }, usr2: { bytes: 32, default: [0b0] }, usr3: { bytes: 32, default: [0b0] }, usr4: { bytes: 32, default: [0b0] }, message: { bytes: 1024, default: [0b0] }, sig: { bytes: 64, default: [0b0] } } let FRAME_BYTES = MAGIC_BYTES for (const spec of Object.values(PACKET_SPEC)) { FRAME_BYTES += spec.bytes } /** * The size in bytes of the total packet frame and message. */ export const PACKET_BYTES = FRAME_BYTES + MESSAGE_BYTES /** * The maximum distance that a packet can be replicated. */ export const MAX_HOPS = 16 /** * @typedef constraint * @type {Object} * @property {string} type * @property {boolean} [required] * @property {function} [assert] optional validator fn returning boolean */ /** * @param {object} o * @param {{ [key: string]: constraint }} constraints */ export const validateMessage = (o, constraints) => { if (({}).toString.call(o) !== '[object Object]') throw new Error('expected object') if (({}).toString.call(constraints) !== '[object Object]') throw new Error('expected constraints') const allowedKeys = Object.keys(constraints) const actualKeys = Object.keys(o) const unknownKeys = actualKeys.filter(k => allowedKeys.indexOf(k) === -1) if (unknownKeys.length) throw new Error(`unexpected keys [${unknownKeys}]`) for (const [key, con] of Object.entries(constraints)) { const unset = !Object.prototype.hasOwnProperty.call(o, key) if (con.required && unset) { // console.warn(new Error(`${key} is required (${JSON.stringify(o, null, 2)})`), JSON.stringify(o)) throw new Error(`key '${key}' is required`) } const empty = typeof o[key] === 'undefined' if (empty) return // nothing to validate const type = ({}).toString.call(o[key]).slice(8, -1).toLowerCase() if (type !== con.type) { // console.warn(`expected .${key} to be of type ${con.type}, got ${type} in packet.. ` + JSON.stringify(o)) throw new Error(`expected '${key}' to be of type '${con.type}', got '${type}'`) } if (typeof con.assert === 'function' && !con.assert(o[key])) { // console.warn(`expected .${key} to be of type ${con.type}, got ${type} in packet.. ` + JSON.stringify(o)) throw new Error(`expected '${key}' to pass constraint assertion`) } } } /** * Used to store the size of each field */ const SIZE = 2 const isEncodedAsJSON = ({ type, index }) => ( type === PacketPing.type || type === PacketPong.type || type === PacketJoin.type || type === PacketIntro.type || type === PacketQuery.type || index === 0 ) /** * Computes a SHA-256 hash of input returning a hex encoded string. * @type {function(string|Buffer|Uint8Array): Promise<string>} */ export const sha256 = createHashFunction() /** * Decodes `buf` into a `Packet`. * @param {Buffer} buf * @return {Packet} */ export const decode = buf => { if (!Packet.isPacket(buf)) return null buf = buf.slice(MAGIC_BYTES) const o = {} let offset = 0 for (const [k, spec] of Object.entries(PACKET_SPEC)) { o[k] = spec.default try { if (spec.encoding === 'number') { const method = getMethod('read', spec.bytes, spec.signed) o[k] = buf[method](offset) offset += spec.bytes continue } const size = buf.readUInt16BE(offset) offset += SIZE let value = buf.slice(offset, offset + size) offset += size if (spec.bytes && size > spec.bytes) return null if (spec.encoding === 'hex') value = Buffer.from(value, 'hex') if (spec.encoding === 'base64') value = Buffer.from(value, 'base64') if (spec.encoding === 'utf8') value = value.toString() if (k === 'message' && isEncodedAsJSON(o)) { try { value = JSON.parse(value.toString()) } catch {} } o[k] = value } catch (err) { return null // completely bail } } return Packet.from(o) } export const getTypeFromBytes = (buf) => buf.byteLength > 4 ? buf.readUInt8(4) : 0 export class Packet { static ttl = CACHE_TTL static maxLength = MESSAGE_BYTES /** * Returns an empty `Packet` instance. * @return {Packet} */ static empty () { return new this() } /** * @param {Packet|object} packet * @return {Packet} */ static from (packet) { if (packet instanceof Packet && packet.constructor !== Packet) return packet switch (packet.type) { case PacketPing.type: return new PacketPing(packet) case PacketPong.type: return new PacketPong(packet) case PacketIntro.type: return new PacketIntro(packet) case PacketJoin.type: return new PacketJoin(packet) case PacketPublish.type: return new PacketPublish(packet) case PacketStream.type: return new PacketStream(packet) case PacketSync.type: return new PacketSync(packet) case PacketQuery.type: return new PacketQuery(packet) default: throw new Error('invalid packet type', packet.type) } } /** * @param {Packet} packet * @return {Packet} */ copy () { const PacketConstructor = this.constructor return Object.assign(new PacketConstructor({}), this) } /** * Determines if input is a packet. * @param {Buffer|Uint8Array|number[]|object|Packet} packet * @return {boolean} */ static isPacket (packet) { if (isBufferLike(packet) || Array.isArray(packet)) { const prefix = Buffer.from(packet).slice(0, MAGIC_BYTES) const magic = Buffer.from(MAGIC_BYTES_PREFIX) return magic.compare(prefix) === 0 } else if (packet && typeof packet === 'object') { // check if every key on `Packet` exists in `packet` return Object.keys(PACKET_SPEC).every(k => k in packet) } return false } /** * `Packet` class constructor. * @param {Packet|object?} options */ constructor (options = {}) { for (const [k, v] of Object.entries(PACKET_SPEC)) { this[k] = typeof options[k] === 'undefined' ? v.default : options[k] if (Array.isArray(this[k]) || ArrayBuffer.isView(this[k])) { this[k] = Buffer.from(this[k]) } } // extras that might not come over the wire this.timestamp = options.timestamp || Date.now() this.isComposed = options.isComposed || false this.isReconciled = options.isReconciled || false this.meta = options.meta || {} } /** */ static async encode (p) { p = { ...p } const buf = Buffer.alloc(PACKET_BYTES) // buf length bust be < UDP MTU (usually ~1500) if (!p.message) return buf const isBuffer = isBufferLike(p.message) const isObject = typeof p.message === 'object' if (p.index <= 0 && isObject && !isBuffer) { p.message = JSON.stringify(p.message) } else if (p.index <= 0 && typeof p !== 'string' && !isBuffer) { p.message = String(p.message) } if (p.message?.length > Packet.maxLength) throw new Error('ETOOBIG') // we only have p.nextId when we know ahead of time, if it's empty that's fine. if (p.packetId.length === 1 && p.packetId[0] === 0) { const bufs = [p.previousId, p.message, p.nextId].map(v => Buffer.from(v)) p.packetId = await sha256(Buffer.concat(bufs), { bytes: true }) } if (p.clock === 2e9) p.clock = 0 const bufs = [Buffer.from(MAGIC_BYTES_PREFIX)] // an encoded packet has a fixed number of fields each with variable length // the order of bufs will be consistent regardless of the field order in p for (const [k, spec] of Object.entries(PACKET_SPEC)) { // if p[k] is larger than specified in the spec, throw const value = p[k] || spec.default if (spec.encoding === 'number') { const buf = Buffer.alloc(spec.bytes) const value = typeof p[k] !== 'undefined' ? p[k] : spec.default const bytesRequired = (32 - Math.clz32(value) >> 3) || 1 if (bytesRequired > spec.bytes) { throw new Error(`key=${k}, value=${value} bytes=${bytesRequired}, max-bytes=${spec.bytes}, encoding=${spec.encoding}`) } const method = getMethod('write', spec.bytes, spec.signed) buf[method](value) bufs.push(buf) continue } const encoded = Buffer.from(value || spec.default, spec.encoding) if (value?.length && encoded.length > spec.bytes) { throw new Error(`${k} is invalid, ${value.length} is greater than ${spec.bytes} (encoding=${spec.encoding})`) } // create a buffer from the size of the field and the actual value of p[k] const bytes = value.length const buf = Buffer.alloc(SIZE + bytes) const offset = buf.writeUInt16BE(bytes) encoded.copy(buf, offset) bufs.push(buf) } return Buffer.concat(bufs) } static decode (buf) { try { return decode(buf) } catch (e) { console.warn('failed to decode packet', e) return null } } } export class PacketPing extends Packet { static type = 1 constructor (args) { super({ ...args, type: PacketPing.type }) validateMessage(args.message, { requesterPeerId: { required: true, type: 'string', assert: Peer.isValidPeerId }, cacheSummaryHash: { type: 'string', assert: Cache.isValidSummaryHashFormat }, probeExternalPort: { type: 'number', assert: isValidPort }, reflectionId: { type: 'string', assert: Peer.isValidReflectionId }, pingId: { type: 'string', assert: Peer.isValidPingId }, natType: { type: 'number', assert: NAT.isValid }, uptime: { type: 'number' }, cacheSize: { type: 'number' }, isConnection: { type: 'boolean' }, isReflection: { type: 'boolean' }, isProbe: { type: 'boolean' }, isDebug: { type: 'boolean' }, timestamp: { type: 'number' } }) } } export class PacketPong extends Packet { static type = 2 constructor (args) { super({ ...args, type: PacketPong.type }) validateMessage(args.message, { requesterPeerId: { required: true, type: 'string', assert: Peer.isValidPeerId }, responderPeerId: { required: true, type: 'string', assert: Peer.isValidPeerId }, cacheSummaryHash: { type: 'string', assert: Cache.isValidSummaryHashFormat }, port: { type: 'number' }, address: { type: 'string' }, uptime: { type: 'number' }, cacheSize: { type: 'number' }, natType: { type: 'number', assert: NAT.isValid }, isReflection: { type: 'boolean' }, isConnection: { type: 'boolean' }, reflectionId: { type: 'string', assert: Peer.isValidReflectionId }, pingId: { type: 'string', assert: Peer.isValidPingId }, isDebug: { type: 'boolean' }, isProbe: { type: 'boolean' }, rejected: { type: 'boolean' } }) } } export class PacketIntro extends Packet { static type = 3 constructor (args) { super({ ...args, type: PacketIntro.type }) validateMessage(args.message, { requesterPeerId: { required: true, type: 'string', assert: Peer.isValidPeerId }, responderPeerId: { required: true, type: 'string', assert: Peer.isValidPeerId }, isRendezvous: { type: 'boolean' }, natType: { required: true, type: 'number', assert: NAT.isValid }, address: { required: true, type: 'string' }, port: { required: true, type: 'number', assert: isValidPort }, timestamp: { type: 'number' } }) } } export class PacketJoin extends Packet { static type = 4 constructor (args) { super({ ...args, type: PacketJoin.type }) validateMessage(args.message, { rendezvousAddress: { type: 'string' }, rendezvousPort: { type: 'number', assert: isValidPort }, rendezvousType: { type: 'number', assert: NAT.isValid }, rendezvousPeerId: { type: 'string', assert: Peer.isValidPeerId }, rendezvousDeadline: { type: 'number' }, rendezvousRequesterPeerId: { type: 'string', assert: Peer.isValidPeerId }, requesterPeerId: { required: true, type: 'string', assert: Peer.isValidPeerId }, natType: { required: true, type: 'number', assert: NAT.isValid }, address: { required: true, type: 'string' }, key: { type: 'string' }, port: { required: true, type: 'number', assert: isValidPort }, isConnection: { type: 'boolean' } }) } } export class PacketPublish extends Packet { static type = 5 // no need to validateMessage, message is whatever you want constructor (args) { super({ ...args, type: PacketPublish.type }) } } export class PacketStream extends Packet { static type = 6 constructor (args) { super({ ...args, type: PacketStream.type }) } } export class PacketSync extends Packet { static type = 7 constructor (args) { super({ message: Buffer.from([0b0]), ...args, type: PacketSync.type }) } } export class PacketQuery extends Packet { static type = 8 constructor (args) { super({ message: {}, ...args, type: PacketQuery.type }) } } export default Packet const isValidPort = (n) => typeof n === 'number' && (n & 0xFFFF) === n && n !== 0