sleepy-socket
v0.9.0
Published
A dependency-free WebSocket client for sleepy-serv
Maintainers
Readme
sleepy-socket
A WebSocket client for talking to sleepy-serv servers
Important Notes
- This package has zero dependencies, and runs in browsers as well as in
bun.sh. - This package is the client half of
sleepy-serv. It expects asleepy-servserver on the other end. - Requests are made over a single WebSocket connection, but they're modeled as REST-ful calls with methods, routes, headers, and status codes.
Installation
bun add sleepy-socketGetting Started
Here's a minimalist example on how to connect and make a request:
import SleepySocketClient from 'sleepy-socket'
const client = await SleepySocketClient.connect('localhost', 3000)
const res = await client.get('/users')
console.log(res.status) // 200
console.log(res.body) // the parsed response body
await client.close()connect() is the only supported way to create a client. It's async because it doesn't resolve until the connection is fully established: it requests a ticket over HTTP, opens the WebSocket, and waits for the server's welcome message. Once it resolves, the client is ready to make requests.
Making Requests
There's one method per HTTP verb: head(), get(), post(), put(), patch(), and delete(). They all take the same two parameters:
route: the route to call, such as/usersor/users/123opts: an optional object containingheaders,query, andbody
Each one returns a promise that resolves to the full response message, not just the body:
const res = await client.get('/users/123')
console.log(res)That gives you an object shaped like this:
{
id: '2b1f...', // uuid, matches the request that produced it
clientId: '9c4e...', // same as `client.id`
type: 'response',
status: 200,
timestamp: '2026-07-19T...',
headers: { 'content-type': 'application/json;charset=utf-8' },
body: { name: 'ada' },
}Note that a failing status does not reject the promise. A 404 NotFound or 500 InternalServerError resolves normally, with the status on res.status. Only transport-level problems reject, such as a timeout or the socket closing mid-flight. This means you check res.status rather than wrapping calls in try/catch:
const res = await client.get('/users/123')
if (res.status === 404) {
console.log('no such user')
}Request Options
The second parameter to any request method can contain these optional properties:
headers: aHeadersinstance. Passing anything else throws aTypeError.query: a plain object of query string valuesbody: the request body, which can be any JSON-serializable value
Here's an example that uses all three:
const res = await client.post('/users', {
headers: new Headers({
authorization: `Bearer ${token}`,
}),
query: { dryRun: true },
body: {
name: 'ada',
count: 3,
},
})When body is a non-null object, the client sets content-type to application/json;charset=utf-8 for you, unless you already set a content-type header yourself.
Notifications
Servers can push messages that aren't replies to anything. Those arrive as notifications, and you subscribe to them with on():
client.on('notification', message => {
console.log(message.event) // 'state_changed'
console.log(message.body) // { score: 1 }
})There's one thing worth pointing out here: 'notification' is the only event name the client emits. The server's own event name lives on the message's event property, so you branch on that inside your handler:
client.on('notification', message => {
switch (message.event) {
case 'state_changed':
return applyState(message.body)
case 'user_joined':
return addUser(message.body)
}
})If one of your handlers throws, the error is caught and logged, and the remaining handlers still receive the message.
Reconnection
The client reconnects automatically when the socket drops. It reclaims its previous session, so client.id stays the same across a reconnect and you don't need to re-establish application state.
You can tune the backoff:
const client = await SleepySocketClient.connect('localhost', 3000, {
reconnect: {
minDelay: 1_000,
maxDelay: 10_000,
factor: 2,
},
})Set reconnect to false to turn it off entirely:
const client = await SleepySocketClient.connect('localhost', 3000, {
reconnect: false,
})Note that only the literal value false disables reconnection. Any other value falls back to the defaults.
Response Queueing
Requests are sent over one socket, so responses can come back in a different order than they were sent. The queue option controls how the client hands those responses back to you.
For example, if you fire three requests that take 300ms, 100ms, and 200ms:
import SleepySocketClient, { Queue } from 'sleepy-socket'
const client = await SleepySocketClient.connect('localhost', 3000, {
queue: Queue.Fifo,
})
const results = []
await Promise.all([
client.get('/', { query: { delay: 300 } }).then(() => results.push(1)),
client.get('/', { query: { delay: 100 } }).then(() => results.push(2)),
client.get('/', { query: { delay: 200 } }).then(() => results.push(3)),
])The three queue types resolve those promises differently:
Queue.None: each promise resolves the moment its response arrives, soresultsis[2, 3, 1]. This is the default.Queue.Fifo: responses are held back until every earlier request has resolved, soresultsis[1, 2, 3], matching the order you sent them.Queue.Lifo: responses drain from the most recent request backwards, soresultsis[3, 2, 1].
Queue.None is the right choice most of the time. Queue.Fifo is useful when responses have to be applied in the order they were requested.
Mount Paths
If the server was created with a mountPath, give the client the same value:
const client = await SleepySocketClient.connect('localhost', 3000, {
mountPath: '/api/v2',
})
const res = await client.get('/users')The routes you pass to request methods stay mount-relative. The client joins the prefix on internally, so /users above is sent as /api/v2/users.
API
SleepySocketClient.connect(host, port, opts)
This static method creates a client, connects it, and resolves once the server has acknowledged the connection. It's the only supported way to construct a client.
The parameters are:
host: the hostname, without a scheme, such as'localhost'port: the port numberopts: an optional options object
The opts object can contain these optional properties:
queue: how responses are handed back, one ofQueue.None,Queue.Fifo, orQueue.Lifo. Defaults toQueue.None. An unrecognized value throws aRangeError.secure: set totrueto usehttpsandwssinstead ofhttpandws. Defaults tofalse.timeout: how long to wait, in milliseconds, both for the initial connection and for each individual request. Defaults to30_000.serverTimeout: how long the client tolerates silence from the server, in milliseconds, before it considers the connection dead and closes it. Defaults to120_000.mountPath: the server's mount path prefix. Defaults to''.reconnect: an options object for reconnection behavior, orfalseto disable it
The reconnect object can contain these optional properties:
minDelay: the starting backoff delay in milliseconds. Defaults to500.maxDelay: the maximum backoff delay in milliseconds. Defaults to30_000.factor: the exponential multiplier applied to the delay after each failed attempt. Defaults to2.random: the jitter source. Defaults toMath.random.
Request Methods
head(route, opts), get(route, opts), post(route, opts), put(route, opts), patch(route, opts), and delete(route, opts) all send a request and return a promise resolving to the response message.
They throw synchronously if the client isn't connected, and their promises reject on timeout or if the socket closes before the response arrives.
on(event, handler)
Registers a handler for an event. The only event emitted is 'notification'. Registering the same function twice is a no-op, since handlers are stored in a set.
off(event, handler)
Removes a previously registered handler. It's safe to call with a handler that was never registered.
close()
Closes the connection and rejects any in-flight requests. It returns a promise, so it's worth awaiting before your process exits.
Note that closing is permanent. There's no reopen, and calling close() a second time throws. If you're calling it in a finally block, guard it with isConnected:
try {
await doWork(client)
} finally {
if (client.isConnected) {
await client.close()
}
}Properties
All of these are read-only:
id: the server-assigned client id, which survives reconnectsisConnected: whether the client is currently connected and ready for requestssocket: the underlyingWebSocket, ornullwhile disconnectedconnectionData: whatever payload the server attached when the connection was established. This is where application data such as an auth token shows up.token: the reclaim token used internally to restore the session after a drop. This is not an application auth token; that would be onconnectionData.queueType: the configured queue typeisSecure: whether the connection useswsstimeout: the configured request timeoutserverTimeout: the configured server silence timeoutheartbeatInterval: how often the client sends heartbeats. This is dictated by the server, not configured by you.mountPath: the configured mount path
Queue
Contains the valid values for the queue option: Queue.None, Queue.Fifo, and Queue.Lifo.
MessageType
Contains the message type names used on the wire: MessageType.Welcome, MessageType.Heartbeat, MessageType.Request, MessageType.Response, and MessageType.Notification. A response message's type is always MessageType.Response, and a notification's is always MessageType.Notification.
Errors
Most failures surface as thrown errors or rejected promises:
Invalid queue type: <value>: aRangeErrorthrown byconnect()whenqueueisn't a validQueuevalue. This is thrown before any network call is made.Connection failed.: the connection couldn't be establishedConnection timed out.: the connection wasn't established withintimeoutmillisecondsopts.headers must be a Headers instance: aTypeErrorthrown when a request'sheadersoption isn't aHeadersobjectSocket is closed: thrown when you call a request method while disconnected, or when you callclose()more than onceRequest timed out.: a request didn't get a response withintimeoutmillisecondsSocket closed.: the socket closed while requests were still in flight. Every pending request rejects with this.
Remember that these cover transport failures only. An error response from the server, such as a 404 NotFound, resolves normally with the status on res.status.
