diff --git a/.github/workflows/build-publish-npm.yaml b/.github/workflows/build-publish-npm.yaml index ae62bda..d56e57d 100644 --- a/.github/workflows/build-publish-npm.yaml +++ b/.github/workflows/build-publish-npm.yaml @@ -27,8 +27,10 @@ jobs: - run: make patch-upstream lib-build + - run: cp -rv README.md docs ./socket.io-serverless/ + - uses: JS-DevTools/npm-publish@v3 with: token: ${{ secrets.NPM_TOKEN }} dry-run: ${{ !(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) }} - package: ./socket.io-serverless/package.json \ No newline at end of file + package: ./socket.io-serverless/package.json diff --git a/.gitignore b/.gitignore index 38b64ba..a49e58f 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ dist-ssr .yarn *.tgz /socket.io-serverless/README.md +/socket.io-serverless/docs diff --git a/INTERNAL.md b/INTERNAL.md deleted file mode 100644 index 9c727f3..0000000 --- a/INTERNAL.md +++ /dev/null @@ -1,24 +0,0 @@ -## How does this work - - -### How does typical socket.io server work - - - - -### How does this lib work diff --git a/README.md b/README.md index ec20c53..c4c9fe2 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A custom [socket.io](https://socket.io/) build for serverless environments. Currently [Cloudflare Worker + Durable Objects](https://developers.cloudflare.com/durable-objects/). -Demo client app: [sio-serverless-demo-client](https://sio-serverless-demo-client.ihate.work) running `demo-client/` `demo-server/` code in this repo. +Demo client app: [sio-serverless-demo-client](https://sio-serverless-demo-client.ihate.work) running `demo-client/` `demo-server/` code in [source code repo](https://github.com/jokester/socket.io-serverless) ## Getting started diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..d6e4623 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,10 @@ +### how the code is developed and built + +Because socket.io does not publish original TS code in NPM, I included the `socket.io` repo ([now a monorepo too](https://github.com/socketio/socket.io/issues/3533)) as a git submodule. My monorepo therefore contains packages like `socket.io-serverless` `socket.io/packages/socket.io` ``socket.io/packages/engine.io` ` + +Some socket.io code need to be patched, including export map in `package.json`. The patches are contained in the monorepo and applied by Makefile. + +`esbuild` bundle `socket.io-serverless` code , along with Socket.io and other deps, into a non minified bundle. + +A `esbuild` [build script](https://github.com/jokester/socket.io-serverless/blob/main/socket.io-serverless/build.mjs) is used to customize the deps resolution process. Some npm packages are replaced with CF-compatible implementation (like `debug`), or simple stubbed (like `node:http` ). + diff --git a/docs/how-it-works.md b/docs/how-it-works.md new file mode 100644 index 0000000..3ae63cb --- /dev/null +++ b/docs/how-it-works.md @@ -0,0 +1,50 @@ +### how socket.io works + +Socket.io (the top level library) have 2 main components: npm packages `socket.io` and `engine.io`. + +The `socket.io` packages deals with the high level concepts: namespace / room / clustering / etc. It depends on `engine.io` which holds a `http.Server` instance and deals with the transport-aware logic. + +In Node.js the 2 components just run in the same process, communicate with a event emitter API. + +### develop for CF worker / DO + +In CF DO / worker, JS runs in a non-Node.js special serverless environment. I think [workerd](https://github.com/cloudflare/workerd/tree/main/src/workerd) . + +The biggest difference compared to Node.js / web for a JS developer is perhaps the volatile state. + +In a traditional environment like Node.js process or a browser tab, the code just run till server down or tab close. But in CF the serverless environment they will stop running and destroy the in-memory state of your JS code when it is inactive. Having a JS `setTimeout` or `setInterval` timer counts as active. A pending HTTP request counts. An active WebSocket connection may or may not count (depending on the API used to accept the connection). + +Specificlly, for DO the destruction of in-memory state is actually called [hibernation](). Developers can manually persist/revive state using provided KV store-like API. + + +Also the available standard libraries is different too. + +The code using only JS language APIs should just work. Code requiring Node.js API can have Node.js polyfills behind Node.js compatibility flags. + +Since sometime in 2024 the Node.js stdlib polyfill is based on [unenv]() , behind `nodejs_compat_v2` flag. This article has a quite complete explanation [Cloudflare Workersのnodejs\_compat\_v2で何が変わったのか](https://zenn.dev/laiso/articles/8280d026a08de0) + +Prior to this, based on my non-authoritative investigation the `nodejs_compat` flag is eventually based on `ionic-team/rollup-plugin-node-polyfills` used by `@esbuild-plugins/node-modules-polyfill`, used by `esbuild`, used by `wrangler` CLI. + +### how socket.io-serverless works + +I used 2 DO to run heavily rewired `socket.io` `engine.io` code. + +`class EngineActor extends DurableObject {...}` is the DO running `engine.io` code. It just accepts WebSocket connection, forwards bidirectional WS messages between `SocketActor` and real WS connection. + +`class SocketActor extends DurableObject {...}` is the DO running `socket.io` code. It responds to RPC calls from `EngineActor`, emit messages into objects like `Namespace`. If application code above send message to a engine.io Socket (an abstraction of different transports), the message got forwarded to `EngineActor`, and flow to the other end of WS connection. + +Therefore application logic code based on on `sio.Namespace` `sio.Client` `sio.Room` should work as with the original Socket.io, but with [limitations](https://github.com/jokester/socket.io-serverless?tab=readme-ov-file#limitations). + +Besides the 2 DOs , there will need to be a worker entrypoint, a simple HTTP handler to forward request to `EngineActor` + +While it is not impossible to prevent aforementioned hibernation (I did this in a first simpler version), I decided that a serverless version should instead exploit hibernation to save energy and protect our earth. + +The states inside `EngineActor` `SocketActor`, including connection IDs, possibly dynamically created namespaces and conn IDs within, are now persisted/revived across different life cycles. + +Most of `engine.io` `socket.io` code is already driven by message events. But there was a ping timer to drive [heartbeat check](https://socket.io/docs/v4/engine-io-protocol/#heartbeat). I had to stub the original code to use [alarm]() instead. + +Currently socket.io-serverless only creates 1 instance for each DO class. In the future if performance becomes a problem it should be able to split the load with more DOs (similar to the adapter/cluster structure used by Socket.io) + + diff --git a/refactor-me.mjs b/refactor-me.mjs deleted file mode 100644 index 6953a0f..0000000 --- a/refactor-me.mjs +++ /dev/null @@ -1,54 +0,0 @@ -import * as esbuild from 'esbuild'; -import path from 'node:path'; -import url from 'node:url'; - -const ___dirname = path.dirname(url.fileURLToPath(import.meta.url)); - -const buildForProd = process.env.NODE_ENV === 'production'; - -const built = await esbuild.build({ - entryPoints: ['main.ts'], - bundle: true, - platform: 'node', - target: 'node18', - metafile: true, - minify: buildForProd, - sourcemap: true, - // FIXME should use tsc for more consistentcy between dev/prod - // but this plugins does not work. - // plugins: [esbuildPluginTsc({force: true})], - outfile: path.join(___dirname, '../build/server-main.js'), -}); - -if (buildForProd) { - console.debug( - 'analyze of bundle', - await esbuild.analyzeMetafile(built.metafile, {color: true}) - ); -} - -function shortenBytes(n) { - const k = Math.floor(Math.log2(n) / 10) - const rank = (k > 0 ? 'KMGT'[k - 1] : '') + 'b'; - const count = (n / Math.pow(1024, k)).toFixed(2); - return count + rank; -} - -{ - console.group('build result'); - - const { - metafile: {outputs}, - ...rest - } = built; - - const formattedOutputFiles = Array.from(Object.entries(outputs)).map( - ([filename, entry]) => ({ - filename, - size: shortenBytes(entry.bytes), - }) - ); - console.table(formattedOutputFiles); - console.info('built', rest); - console.groupEnd('build result'); -} diff --git a/socket.io-serverless/package.json b/socket.io-serverless/package.json index 0d2535d..e07eb7e 100644 --- a/socket.io-serverless/package.json +++ b/socket.io-serverless/package.json @@ -1,19 +1,27 @@ { "name": "socket.io-serverless", "description": "A custom socket.io build to run in Cloudflare workers.", - "version": "0.2.0", + "version": "0.2.1", "type": "module", + "homepage": "https://github.com/jokester/socket.io-serverless", + "repository": { + "type": "git", + "url": "git+https://github.com/jokester/socket.io-serverless.git" + }, + "bugs": { + "url": "https://github.com/jokester/socket.io-serverless/issues" + }, "dependencies": {}, "files": [ "dist", "README.md", + "docs", "mocks", "src", "build.mjs", "tsconfig.json" ], "scripts": { - "prepack": "node build.mjs && cp -v ../README.md .", "build": "node build.mjs", "build:watch": "node build.mjs --watch", "lint": "eslint src",