diff --git a/build/docusaurus.config.js b/build/docusaurus.config.js index 77ecfa2..60c5900 100644 --- a/build/docusaurus.config.js +++ b/build/docusaurus.config.js @@ -12,13 +12,17 @@ export default async function flecksDocusaurus() { /** @type {import('@docusaurus/types').Config} */ const config = { ...defaults, - title: 'flecks', - tagline: 'not static', - favicon: 'flecks.png', - url: 'https://cha0s.github.io', baseUrl: '/flecks/', + favicon: 'flecks.png', + tagline: 'not static', + title: 'flecks', + url: 'https://cha0s.github.io', + markdown: { + mermaid: true, + }, organizationName: 'cha0s', // Usually your GitHub org/user name. projectName: 'flecks', // Usually your repo name. + themes: ['@docusaurus/theme-mermaid'], themeConfig: /** @type {import('@docusaurus/preset-classic').ThemeConfig} */ ({ diff --git a/package.json b/package.json index 3a9141e..46e91a2 100644 --- a/package.json +++ b/package.json @@ -28,5 +28,8 @@ "@flecks/user": "*", "@flecks/web": "*", "lerna": "^7.4.2" + }, + "dependencies": { + "@docusaurus/theme-mermaid": "3.0.1" } } diff --git a/website/docs/sockets.mdx b/website/docs/sockets.mdx index 43da49e..b928db2 100644 --- a/website/docs/sockets.mdx +++ b/website/docs/sockets.mdx @@ -2,3 +2,351 @@ title: Sockets description: Run a websocket server and define packets. --- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# Sockets + +flecks provides real-time socket connections through [Socket.IO](https://socket.io/docs/v4/). +Packets are binary by default so they are small and fast. + +## Packets + +```javascript title="packages/example/src/index.js" +import {Flecks} from '@flecks/core/server'; + +export const hooks = { + '@flecks/socket.packets': Flecks.provide(require.context('./packets')), +} +``` + +The simplest packet can be defined like so: + +```javascript title="packages/example/src/packets/slap.js" +export default (flecks) => { + const {Packet} = flecks.fleck('@flecks/socket'); + return class Slap extends Packet {}; +}; +``` + +This transmits no packet data at all, only the packet ID. e.g., you just know you've been +slapped, since it's a slap packet. You don't know why, how hard, etc. + +This is a **long-winded way** of sending a packet: + +```javascript +const {Slap} = flecks.socket.Packets; +socket.send(new Slap()); +``` + +but let's learn about **packet hydration** to make working with packets a lot easier. + +### Packet hydration + +Instead of getting the packet class and having to manually create a new packet to send every time, +we can instead send a **dehydrated packet**: + +```javascript +socket.send(['Slap']); +``` + +Notice that **a dehydrated packet is an array**. The first element of the array is the +[gathered name](./gathering) of the packet. + +:::tip + +The second element is the `data` passed to the packet constructor. We'll see a couple more +examples of sending dehydrated packets on this page to come. + +::: + +Dehydrated sending is *just better* and should always be preferred! + +### SchemaPack + +Packets may implement a static getter method `data` to define the schema with which to serialize the +packet data. See [SchemaPack](https://github.com/phretaddin/schemapack) +to learn more. + +#### Data types + +| Type Name | Bytes | Range of Values | +|-----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------| +| bool | 1 | `true` or `false` | +| int8 | 1 | -128 to 127 | +| uint8 | 1 | 0 to 255 | +| int16 | 2 | -32,768 to 32,767 | +| uint16 | 2 | 0 to 65,535 | +| int32 | 4 | -2,147,483,648 to 2,147,483,647 | +| uint32 | 4 | 0 to 4,294,967,295 | +| float32 | 4 | 3.4E +/- 38 (7 digits) | +| float64 | 8 | 1.7E +/- 308 (15 digits) | +| string | varuint length prefix followed by bytes of each character | Any string | +| varuint | 1 byte when 0 to 127
2 bytes when 128 to 16,383
3 bytes when 16,384 to 2,097,151
4 bytes when 2,097,152 to 268,435,455
etc. | 0 to 2,147,483,647 | +| varint | 1 byte when -64 to 63
2 bytes when -8,192 to 8,191
3 bytes when -1,048,576 to 1,048,575
4 bytes when -134,217,728 to 134,217,727
etc. | -1,073,741,824 to 1,073,741,823 | +| buffer | varuint length prefix followed by bytes of buffer | Any buffer | + +### Packer + +`Packer` is a higher-order function that may be used to pack all or part of the packet data +regardless of the schema (e.g. JSON). `Packer` uses +[`msgpack-lite`](https://github.com/kawanet/msgpack-lite) to serialize the packet data. + +#### The entire packet + +```javascript title="packages/example/src/packets/anything.js" +export default (flecks) => { + const {Packer, Packet} = flecks.fleck('@flecks/socket'); + const decorator = Packer(); + return class Anything extends decorator(Packet) {}; +}; +``` + +```javascript +socket.send(['Anything', {foo: 'bar', arbitrary: 'data'}]); +``` + +#### A specific key + +```javascript title="packages/example/src/packets/anything-field.js" +export default (flecks) => { + const {Packer, Packet} = flecks.fleck('@flecks/socket'); + const decorator = Packer('document'); + return class AnythingField extends decorator(Packet) { + + // Notice that we may still define other data fields here. + static get data() { + return { + id: 'uint32', + }; + } + + }; +}; +``` + +```javascript +socket.send([ + 'AnythingField', + { + document: {whatever: 'you', want: 42}, + id: 1234567, + }, +]); +``` + +Packers ultimately pack to/from binary and are compiled down to the `buffer` data type in the +schema. + +### Bundles + +A bundle is a bunch of packets that get packed into a single packet. Before you ask, **yes**, you can +create `Bundle`s of `Bundle`s of packets. They're recursive! + +```javascript +const packets = [ + ['Action', {type: 'SET', payload: 20}], + ['Bundle', [ + ['Action', {type: 'ADD', payload: 23}], + ['Action', {type: 'ADD', payload: 26}], + ]], + // ... +]; +socket.send(['Bundle', packets]); // Nice. +``` + +Bundles ultimately pack to/from binary and are compiled down to the `buffer` data type in the +schema. + +### Acceptors + +Packets are subject to acceptors which validate and respond to packets. Your packets may implement +acceptor methods: + +#### `validate(packet, socket)` + +##### `packet` + +The packet being validated. + +##### `socket` + +The socket through which was sent the packet being validated. + +`validate` acceptor methods may throw `ValidationError`s which `@flecks/socket` provides: + +```javascript title="src/packets/some-packet.js" +export default (flecks) => { + const {Packet, ValidationError} = flecks.fleck('@flecks/socket'); + + return class SomePacket extends Packet { + + static async validate(packet, {req}) { + if (await req.checkSomething()) { + throw new ValidationError({code: 404, reason: 'no something found'}); + } + } + + }; +}; +``` + +#### `respond(packet, socket)` + +##### `packet` + +The packet being responded to. + +##### `socket` + +The socket through which was sent the packet being responded to. + +:::note + +The result from your `respond` acceptor method is serialized and transmitted and is ultimately the +result of socket.send`: + +```javascript +const result = await socket.send(['Whatever']); +// result is whatever was returned from the `respond` acceptor method. +``` + +::: + +#### Examples + +- The `@flecks/redux` client implements the following `respond` acceptor method: + + ```javascript + static async respond(packet) { + flecks.redux.dispatch(packet.data); + } + ``` + + Whenever the client receives an `Action` packet, the action will be dispatched by + the redux store. + +- `@flecks/user` implements a `validate` acceptor method for the `Logout` packet: + + ```javascript + static validate(packet, {req}) { + if (!req.user) { + throw new ValidationError({code: 400, reason: 'anonymous'}); + } + } + ``` + + If `req.user` doesn't exist, the packet fails validation. + +## Intercom + +flecks provides **intercom** as a way for your socket server nodes to communicate amongst +themselves. Intercom is provided on a server socket at `socket.req.intercom` and has two +parameters: + +### `type` + +The type of intercom call to make (e.g. `@flecks/user.users`). + +### `data` + +Arbitrary serializable data to send along with the intercom request. + +:::tip[Fun fact] + +Intercom can also be called from an implementation of `@flecks/web/server.request.socket` through +`req.intercom`. + +::: + +### Motivation + +Suppose that we are running many game simulation nodes which each have many clients connected. The +nodes are processing a large number of positions in real time. Clients connected to any +node must be able to request a position even if it is simulated on another node. A client connected +to a node may request a position `P`. A node may efficiently check if it simulates `P`. + +If the node that dispatches the request simulates the position, the response can be instant. +Otherwise, the node must use **intercom** to ask the other nodes whether they simulate `P`: + +```javascript +const responses = await socket.req.intercom('@simulation/position.request', P); +``` + +This asks the other nodes if they simulate `P`. Nodes will respond either with `undefined` or `P` +through their implementations of `@flecks/socket.intercom`: + +```javascript +export const hooks = { + '@flecks/socket.intercom': async (P, {simulation}) => { + if (simulation.has(P)) { + return simulation.positionOf(P); + } + // return undefined is implied... + }, +} +``` + +:::note + +Intercom **will** invoke the `@flecks/socket.intercom` implementation of the requesting node. + +::: + +```mermaid + sequenceDiagram + actor Client + participant Node-1 + participant Node-2 + participant Node-3 + participant Node-N + Note right of Client: Client makes a request for position(P) + Client->>Node-1: REQUEST: position(P) + alt has position(P) + Note left of Node-1: Has position: no intercom necessary. + Node-1->>Client: RESPONSE: {x, y, z} + else doesn't have position(P) + Note right of Node-1: Doesn't have position: intercom necessary. + Node-1->>Intercom: INTERCOM: @simulation/position.request(P) + par + Intercom-->>Node-1: INTERCOM: @simulation/position.request(P) + Node-1-xIntercom: RESPONSE: undefined + Note right of Node-1: Doesn't have position: undefined. + and + Intercom-->>Node-2: INTERCOM: @simulation/position.request(P) + Node-2-xIntercom: RESPONSE: {x, y, z} + Note right of Node-2: Has position: {x, y, z}. + and + Intercom-->>Node-N: INTERCOM: @simulation/position.request(P) + Node-N-xIntercom: RESPONSE: undefined + Note right of Node-N: Doesn't have position: undefined. + end + Note left of Intercom: Return all results as an array. + Intercom->>Node-1: RESPONSE: [undefined, {x, y, z}, undefined] + Note left of Node-1: Filter out and return the position from the results. + Node-1->>Client: RESPONSE: {x, y, z} + end +``` + +## Default packets + +`@flecks/socket` provides some packets by default: + +### `Refresh` + +Sent to a client, refreshes the page. + +```javascript +socket.send(['Refresh']); +``` + +### `Redirect` + +Send a string which will be assigned to `window.location.href`. + +```javascript +socket.send(['Redirect', '/']); +``` + +See [the generated hooks page](./flecks/@flecks/dox/hooks#fleckssocketpackets) for an exhaustive list of packets.