@ashnazg/yog
v0.3.2
Published
a websocket server/client message bus
Downloads
19
Readme
title: "@ashnazg/yog" sidebar_label: yog
it's an auth / interruption-resilient / RPC / broadcast library for browser <-> node.js server that provides a high level interface over the low level websockets protocol.
new
Client
It decorates the client pubsub implementation:
const pubsub = require('@ashnazg/pubsub'); // ~/projects/ashnazg-npm/pubsub/pubsub.js
const yog = require('@ashnazg/yog/bare-client2'); // ~/projects/ashnazg-npm/yog/bare-client2.js
const myhub = pubsub();
yog.bare(myhub);
// not returning promises from (re)connect() as this is just the bottom layer of a repeatable-reconnect system
myhub.bare.connect('ws://localhost:5000', 'foo');
myhub.on('bare:open', () => {
myhub.bare.send({t:"hello world"});
});
myhub.on('answer', msg => {
console.log("they answered with", msg);
});
Other bare-level members: reconnect(), close(), send({tuple}), state() === 'OPEN', async request({tuple})
old
server side
io layer: bare server sockets
turns socket events into a server stream of: io:open, io:close, io:error, and actual data tuples that have a "t" field (of type string or number)
RPC and register
If you use server.register({hooks...})
to make your singleton handlers for inbound events, your hooks gain:
- the ability to respond RPC style by just returning fields
- access to each other:
server.hooks[other_responder]
server.register({
showRoom({ctx, room_id}) {
return {room_contents: []};
},
player_joined({ctx}) {
const uid = ctx.account.uid;
const avatar = process(uid);
const room = server.hooks.showRoom({ctx, room_id: avatar.room_id});
room.your_uid = uid;
return room;
}
});
RPC support:
client.request('player_joined').then(resp => {
console.log('my uid', resp.your_uid);
console.log('I see in the room', resp.room_contents);
});
auth layer: server auth and security tricks
blocks all requests til auth:login packet has been accepted by replying with auth:open
auth:attack reports brute force blocking statistics auth:pending means that the client will be granted access after the delay auth:open (posted internally and externally) means the client is allowed to talk
because all auth:login field validation is passed to the caller, the auth() config can be whatever, {user,pass} for a bare system up to fancy stuff like {user,pass,token,nonce}
session: takes auth and adds token-based steps for creation/expiration/auth
const session = require('@ashnazg/yog/session-server.js');
const server = session.listen({
session_manager,
lifespan: milliseconds, //a this param is only used if session_manager is using pattern 2 below. it defaults to 24 hours.
checkPass(user, pass) { return user_id; },
getUid(username) { return user_id; } // defaults to 'username'. this is like checkPass but is only used to perform the user->id mapping for token-handling, in which we don't have a password in the request.
});
session takes a state storage interface -- there are two options you can use:
- if your provided DB supplies these two functions, I'll just use them.
const session_manager = {
generateSessionToken(user) { return new token; },
isValidSession(user, token) { return true ; }
};
- or a plain old JSON "db" and a save() method:
const session_manager = {
db: {}, // yog/session will create a tokens map on this object
save() { persist the above db however you want; }
}
Conveniently, dumbfile has exactly that interface:
const dumb = require('@ashnazg/dumbfile'); // ~/projects/ashnazg-npm/dumbfile/json.js
const session_manager = dumb.load(process.env.HOME + '/.socks-tokens.json');
Important thing to remember about user ids and the optional support for anonymous connections. (And the fact that I have to say that means this design is being too clever -- but I'm not going to slow down right now over that.)
if your checkPass(user, pass) returns zero, that means to accept the connection but not bother creating a token for it -- anonymous connections will always just pass the publically shared user/password.
auth-client
This util wraps bounce-client in an auth system that can manage session cookies and prompt the user for credentials.
All of these utils will decorate the pubsub you give them with the websocket-wrapper methods. f
- All functions provided by manager return promises.
- if successful, it resolves to an auth response.
- it resolves to {success: false, evt: {reason,code}} (not a rejection) for stopping points that are not logic errors:
- connection lost before auth handshake completed
- connection not attempted because session state was not found
- can't start connecting, no network
- server rejected creds
The library init() needs 4 to 7 parameters:
- a pubsub (which could be the engine from a project using mischievous -- that's the way I use this lib.)
- a callback:
promptForCreds({user, pass, error})=>promise({user,pass})
It's given any known creds (such as a username stored in localStorage) and must return either:- a promise for ({user,pass}) when the user submits them
- a promise for the string "cancelled" if the user backs out
- the error parameter is undefined in the first round of prompting; on later rounds, it's set to the server's response on an auth fail so the UI can display it to the user.
- a callback
showSpinner()
for telling the UI that an auth attempt is in progress. - a callback
hideSpinner()
that will be called before the auth attempt takes the next step, whether that's to show the login prompt again, or to resolving the auth attempt's promise. - a server-local api path to use in prod: if you give it
/yog
then in production, the websocket will connect towss://{current dns name}:{current port}/yog
- a dev endpoint in the form of either a port number, or a full URL like
ws://localhost:5000
(that's the default if dev is undefined.) - an optional websocket protocol name
Prod vs dev: since NODE_ENV isn't directly available in the browser, connect() determines 'is prod' by seeing if the webserver is using https.
import {init} from '@ashnazg/yog/auth-client';
const pub = require('@ashnazg/yog/pubsub.js').pubsub();
function prompt({user, pass, error}) {
if (error) console.error(error);
return q({user: "anony", pass: "mouse");
}
function spin() {
console.log("look busy! auth is in progress");
}
function fin() {
console.log("clear out the busy indicator");
}
const prod_path = '/yog';
const dev_port = 5000;
const protocol = 'sample-protocol';
const manager = init(pub, prompt, spin, fin, prod_path, dev_port, protocol);
login
This prompts the user, and for as many attempts as it takes til either the server accepts the creds, or the user hits cancel in your prompt UI, login will call connect(...)
.
Login will read/write browser state to make reconnecting easier:
- on start, pull "user" from localStorage if it's available.
- on successful auth, set "user" into localStorage and re
- if that auth response includes "token", store that token in cookie "session" so tryCookie() can find it late.
- if user cancels the prompt UI, login will clear the above two storage fields and return false
const auth_response = await manager.login();
if (auth_response === false) {
return 'user gave up';
}
console.log(auth_response.user_name); // or whatever your server responds with
tryCookie
This is for background reconnection after a network event or browser-reset; if the user/session fields from a successful login() are there, tryCookie will try to auth to the server with {user,token}
If either field is unavailable, tryCookie will invoke login()
If you want to just test in the background without risking a login() popup, pass it false: tryCookie(false)
will just return false if there's no session.
const auth_response = await manager.tryCookie(false);
if (auth_response === false) {
console.log("I can either push the user to login by calling manager.login, or maybe my app has a login button to enable at this point.");
} else {
console.log(auth_response.user_name); // or whatever your server responds with
}
connect
This is the programmatic way to directly supply {user, pass}. In the envisioned flow, you don't need to call this, as tryCookie and login do this, but it's exported to the app as I've found it useful. (For example, in one app, there's a public {user,pass} pair for readonly access to the API.)
If the server auth response includes {reason: 'token expired'}, then connect() will auto-launch login().
const auth_response = await manager.connect({user: 'anony', pass: 'mouse'});
if (auth_response === false) {
console.log("could not connect");
} else {
console.log(auth_response.user_name); // or whatever your server responds with
}
bare-client
This provides the base layer for websockets. It adds these functions to your pubsub:
send
con.send('type', {fields})
works much like a pubsub(type, {fields})
like pubsub, you can skip the 2nd parameter if event 'type' needs no other fields.
request
This has the same two params as send, but request adds a 'q' field to the network packet; the yog server uses 'q' as a RPC ID. If your server-side endpoint function returns a message body, yog will add the same 'q' value that the client picked and send that body back to the client. The goal of this is to allow the client to easily distinguish which request goes with which response.
// client side
con.request('readfile', {name: '/etc/passwd'}).then(resp => console.log("user entries:", resp);
con.request('readfile', {name: '/etc/nginx/nginx.conf'}).then(resp => console.log("nginx conf:", resp);
// server side
server.register({
readfile({name, ctx}) {
return {errors:[{code: 404, message: "I'm not going to give you "+name}]};
}
});
Since the above readfile() returned a body with no {t:'type'} field, the RPC handler will default it to the request's type.
If the server really has to send a different type back in response, add a third parameter string to your request call:
con.request('readfile', {fields}, 'response-event-type').then(resp => {});
close
con.close()
closes and deletes the socket. This does not impact your on() listeners.
reconnect
Closes any existing connection, and then reattempt.
isOpen
con.isOpen()
returns a bool
readyState
con.readyState()
allows you to look at the connection's current lifecycle step.
const bounce = require('./bounce-client.js');
if (con.readyState() === bounce.CLOSING) {
// too late to send anything or bother calling close()
}
bounce: is a client-side layer that repeats auth cookies to resume connectivity
state: never connected, connected, retrying gave_up retrying, resume
1012 is the close code for "I'm bouncing the server"
queue layer:
if a message can't be sent, it's queued until it can.
state layer:
versions the world, xfers world deltas
server side notes
io.listen(port...)
auth.listen(... auth() checker)
close codes
https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent vs http codes https://developer.mozilla.org/en-US/docs/Web/HTTP/Status
future
FYI https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/readyState
TODO: move to node-buffer on both ends, here's a browser-version:
https://github.com/feross/buffer
but find out what mobiles support.
https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/send
- https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/binaryType
- https://developer.mozilla.org/en-US/docs/Web/API/Blob
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray
OOH!
https://blog.mgechev.com/2015/02/06/parsing-binary-protocol-data-javascript-typedarrays-blobs/
responding in binary is straightforward:
https://github.com/websockets/ws#sending-binary-data