Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .github/workflows/check.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: check

on:
push:
branches: [main]
pull_request: {}
workflow_dispatch: {}

jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
Comment on lines +9 to +14

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Apply least-privilege permissions and avoid credential persistence.

The workflow currently uses the default permissions, which can grant unnecessary write access to the repository depending on organization settings. Additionally, actions/checkout persists the default GitHub token in the local git config, which could be exposed if later steps execute compromised code.

Please restrict the workflow permissions to read-only and disable credential persistence in the checkout step.

🔒 Proposed security improvements
 jobs:
   test:
     runs-on: ubuntu-latest
+    permissions:
+      contents: read
     timeout-minutes: 10
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@v4
+        with:
+          persist-credentials: false
 
       # init submodule (pinned to the socket.io@4.8.1 tag commit) and fetch the
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/test.yaml around lines 9 - 14, Update the test job
workflow to declare read-only GitHub token permissions and configure the
actions/checkout@v4 step with credential persistence disabled. Keep the existing
job and checkout behavior otherwise unchanged.

Source: Linters/SAST tools


# init submodule (pinned to the socket.io@4.8.1 tag commit) and fetch the
# tag ref so `make patch-upstream` (which runs `git reset --hard socket.io@4.8.1`)
# can resolve it.
- run: git submodule update --init
- run: git -C socket.io fetch --depth 1 origin socket.io@4.8.3

- uses: pnpm/action-setup@v4
with:
version: 9.12.0

- uses: actions/setup-node@v4
with:
node-version: "20"
cache: pnpm

- run: pnpm install

- name: Patch upstream socket.io and build library
run: make patch-upstream lib-build

- name: Format check (library)
run: pnpm --filter ./socket.io-serverless exec dprint check

- name: Typecheck library (non-blocking, pre-existing upstream errors)
continue-on-error: true
run: pnpm --filter ./socket.io-serverless run typecheck

- name: Build demo-server Worker (wrangler dry-run)
run: pnpm run --filter ./demo-server build:cf

- name: Build demo-client (vite build)
run: pnpm run --filter ./demo-client build

- name: Run integration tests
run: pnpm run --filter ./demo-server test
7 changes: 7 additions & 0 deletions .opencode/commands/build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
description: Build the socket.io-serverless library with esbuild
agent: build
---
Build the socket.io-serverless library by running `make lib-build` from the repo root. This runs `pnpm run --filter socket.io-serverless build` which triggers the esbuild script in `build.mjs`.

After building, verify the output exists at `socket.io-serverless/dist/cf.js` and check for any build errors.
11 changes: 11 additions & 0 deletions .opencode/commands/check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
description: Build + typecheck + lint the library (full CI check)
agent: build
---
Run the full verification pipeline for the socket.io-serverless library in order:

1. `make lib-build` — build with esbuild
2. `pnpm run --filter socket.io-serverless typecheck` — typecheck with tsc
3. `pnpm run --filter socket.io-serverless lint` — lint with eslint

Report the results of each step. Stop and report errors if any step fails.
7 changes: 7 additions & 0 deletions .opencode/commands/demo-node.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
description: Run the demo server locally in Node.js mode
agent: build
---
Start the demo server in Node.js mode by running `pnpm run --filter ./demo-server dev:node` from the repo root. This uses tsx to run `src/node/main.ts` directly without Cloudflare Workers.

The server should start and listen for connections. Report the port/host it's listening on.
5 changes: 5 additions & 0 deletions .opencode/commands/format.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
description: Format code with dprint
agent: build
---
Format the socket.io-serverless library source code by running `pnpm run --filter socket.io-serverless format` from the repo root. This uses dprint.
7 changes: 7 additions & 0 deletions .opencode/commands/lint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
description: Run ESLint on the socket.io-serverless library
agent: build
---
Run `pnpm run --filter socket.io-serverless lint` from the repo root to check for linting errors in `socket.io-serverless/src/`.

Report any lint errors found. If fixes are needed, run `pnpm run --filter socket.io-serverless lint:fix` to auto-fix.
10 changes: 10 additions & 0 deletions .opencode/commands/patch-upstream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
description: Initialize or patch the socket.io git submodule
agent: build
---
Initialize and patch the upstream socket.io git submodule:

1. If not already initialized: `git submodule update --init`
2. Apply patches: `make patch-upstream`
Comment on lines +7 to +8

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Fetch the pinned upstream ref before applying patches.

On a fresh checkout, git submodule update --init does not guarantee that the local socket.io@4.8.1 ref exists, but make patch-upstream resets to that ref. Add the fetch step used by CI before invoking the Make target.

Proposed documentation update
 1. If not already initialized: `git submodule update --init`
-2. Apply patches: `make patch-upstream`
+2. Fetch the pinned ref: `git -C socket.io fetch --depth 1 origin socket.io@4.8.1`
+3. Apply patches: `make patch-upstream`
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
1. If not already initialized: `git submodule update --init`
2. Apply patches: `make patch-upstream`
1. If not already initialized: `git submodule update --init`
2. Fetch the pinned ref: `git -C socket.io fetch --depth 1 origin socket.io@4.8.1`
3. Apply patches: `make patch-upstream`
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.opencode/commands/patch-upstream.md around lines 7 - 8, Update the
patch-upstream instructions to fetch the pinned socket.io@4.8.1 upstream ref
after initializing the submodule and before running make patch-upstream, using
the same fetch command or step already used by CI.


The patches are in `patches/0001-workarounds-to-upstream-socket.io.patch` and fix export maps and import styles for esbuild bundling.
7 changes: 7 additions & 0 deletions .opencode/commands/typecheck.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
description: Typecheck the socket.io-serverless library with tsc
agent: build
---
Typecheck the library by running `pnpm run --filter socket.io-serverless typecheck` from the repo root.

Report any type errors found. Do NOT modify tsconfig.json to silence errors unless explicitly asked.
118 changes: 118 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# socket.io-serverless

A custom socket.io build that runs in Cloudflare Workers + Durable Objects. This is a pnpm monorepo with the core library, demo server, and demo client.

## Project Structure

```
socket.io-serverless/ # Main library (the npm package)
src/cf/index.ts # Public API entry point
src/cf/eio/ # Engine.IO layer rewired for Durable Objects
src/cf/sio/ # Socket.IO layer rewired for Durable Objects
src/debug/ # Custom debug logger replacement
src/utils/ # Utility modules (lazy, persisted)
build.mjs # esbuild script (not wrangler) - custom resolution plugins
dist/ # Build output (esm format, non-minified)
mocks/ # Stub modules replacing Node.js APIs for CF environment
demo-server/ # Backend demo app (CF Worker + Durable Objects)
test/ # Vitest + @cloudflare/vitest-pool-workers integration tests
vitest.config.mts # Vitest config (runs DOs in workerd via Miniflare)
demo-client/ # Frontend demo app (Preact + Vite + Tailwind)
shared-config/ # Shared tsconfig, eslint, dprint, jest configs
socket.io/ # Git submodule (upstream socket.io monorepo)
patches/ # Patches applied to the socket.io submodule
docs/ # Development and architecture documentation
```

## Architecture

Two Durable Objects implement socket.io in a serverless environment:

1. **EngineActor** (`src/cf/eio/EngineActorBase.ts`) — Runs engine.io code. Accepts WebSocket connections, forwards messages bidirectionally between `SocketActor` and real WS connections. Singleton instance. Uses DO Alarms API for heartbeat instead of `setInterval`.

2. **SocketActor** (`src/cf/sio/SocketActorBase.ts`) — Runs socket.io code. Responds to RPC calls from `EngineActor`, emits into `Namespace`/`Client`/`Room` objects. Application logic lives in `onServerCreated` callback. Single instance (no cluster adapter yet).

A **Worker entrypoint** (thin HTTP handler) forwards upgrade requests to `EngineActor`. State inside DOs (connection IDs, namespaces, client IDs) is persisted/revived across hibernation via DO Storage API.

## Build & Development

### Prerequisites
- pnpm 9.12.0
- Node.js 20+
- The `socket.io` git submodule must be initialized: `git submodule update --init`
- Then patch it: `make patch-upstream` (or `cd socket.io && git fetch --depth 1 origin socket.io@4.8.3 && git reset --hard FETCH_HEAD && git reset . && git checkout -- . && git apply < ../patches/0001-workarounds-to-upstream-socket.io.patch`)

### Install
```bash
pnpm install
```

### Build the library
```bash
make lib-build # pnpm run --filter socket.io-serverless build
make lib-watch # pnpm run --filter socket.io-serverless build:watch
```

### Typecheck the library
```bash
# inside socket.io-serverless/
pnpm run typecheck # tsc --noEmit
pnpm run typecheck:watch
```

### Lint & Format the library
```bash
# inside socket.io-serverless/
pnpm run lint # eslint src
pnpm run lint:fix # eslint --fix src
pnpm run format # dprint fmt
```

### Run demos
```bash
make demo-server-dev # wrangler dev (CF Worker + DO)
make demo-client-dev # vite dev (Preact frontend)
make demo-server-bundle # wrangler deploy --dry-run
```

### Demo server also runs as plain Node.js
```bash
# inside demo-server/
pnpm run dev:node # tsx watch src/node/main.ts
```

### Run integration tests
The tests use `@cloudflare/vitest-pool-workers` to spin up the real `workerd` runtime locally via Miniflare, with the Durable Object bindings from `demo-server/wrangler.toml` honored. They exercise the full Worker → EngineActor → SocketActor pipeline, inspect DO storage via `runInDurableObject`, and flush the 30 s heartbeat `AlarmTimer` via `runDurableObjectAlarm`.

```bash
make lib-build # the Worker imports the bundled library, so build it first
pnpm run --filter ./demo-server test # vitest run
pnpm run --filter ./demo-server test:watch # vitest watch
```

Available test APIs (imported from `cloudflare:test` / `cloudflare:workers`):
- `env.engineActor` / `env.socketActor` — DO namespace bindings, exactly as in production
- `exports.default.fetch(req)` — invoke the Worker entrypoint as a real upgrade request
- `runInDurableObject(stub, cb)` — inspect or seed DO instance state and `state.storage` directly
- `runDurableObjectAlarm(stub)` — fire the EngineActor's `AlarmTimer` heartbeat immediately
- `evictDurableObject(stub)` — test hibernation recovery (hibernatable WebSockets + persisted state)

## Key Technical Details

- **Build tooling**: esbuild (not wrangler) bundles `socket.io-serverless` with custom resolution plugins in `build.mjs`. Node.js stdlib imports (`http`, `fs`, `crypto`, etc.) are rewired to mock stubs in `mocks/`. Socket.io upstream TS source is imported directly (bypassing npm export maps).
- **Formatter**: dprint (config in `shared-config/dprint.json` or per-package)
- **Linter**: ESLint flat config (`.mjs` files in `shared-config/`)
- **TypeScript**: extends `@tsconfig/strictest`, moduleResolution is `bundler`
- **Integration tests** live in `demo-server/test/` and use Vitest + `@cloudflare/vitest-pool-workers` (no jest tests). See "Run integration tests" above.
- Only **WebSocket transport** is supported; engine.io protocol v4 only
- Parent namespaces must be defined in `onServerCreated` callback (no dynamic/function-based namespace creation)
- Room memberships do NOT survive DO hibernation
- No message acknowledgements, no connection state recovery

## Code Conventions

- TypeScript strict mode (from `@tsconfig/strictest`)
- Single quotes, no semicolons (dprint formatting)
- Use `src/debug/index.ts` instead of the `debug` npm package for logging
- Imports in the library are rewired at build time by esbuild plugins; be aware that some imports resolve to mocks in the CF environment
- Package is `type: "module"` — use ESM imports
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,4 @@ demo-server-bundle:
pnpm run --filter ./demo-server build:cf

patch-upstream:
cd socket.io && git reset --hard socket.io@4.8.1 && git reset . && git checkout -- . && git apply < ../patches/0001-workarounds-to-upstream-socket.io.patch
cd socket.io && git fetch --depth 1 origin socket.io@4.8.3 && git reset --hard FETCH_HEAD && git reset . && git checkout -- . && git apply < ../patches/0001-workarounds-to-upstream-socket.io.patch
6 changes: 5 additions & 1 deletion demo-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
"version": "0.0.1",
"description": "a simple socket.io server",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"test": "vitest run",
"test:watch": "vitest",
"lint": "gts lint",
"clean": "gts clean",
"dev:cf": "DEBUG=limb:*,-socket.io:* wrangler dev",
Expand All @@ -23,6 +24,7 @@
"socket.io-serverless": "workspace:^"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.18.4",
"@cloudflare/workers-types": "^4.20241004.0",
"@tsconfig/strictest": "^2.0.5",
"@types/debug": "^4.1.12",
Expand All @@ -31,6 +33,8 @@
"base64id": "^2.0.0",
"tsx": "*",
"typescript": "*",
"vite": "^6.0.0",
"vitest": "^4.1.0",
"wrangler": "^3.80"
}
}
93 changes: 93 additions & 0 deletions demo-server/test/durable-objects.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, it, expect } from 'vitest'
import { env, runInDurableObject, runDurableObjectAlarm } from 'cloudflare:test'
import { exports } from 'cloudflare:workers'
import type { SocketActor } from '../src/cf/main'

const IncomingRequest = Request<unknown, IncomingRequestCfProperties<unknown>>

function openClientWebSocket(res: Response): WebSocket {
const ws = res.webSocket
if (!ws) throw new Error('response has no webSocket')
ws.accept()
return ws
}

function nextMessage(ws: WebSocket, timeoutMs = 2000): Promise<string> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('timeout waiting for ws message')), timeoutMs)
ws.addEventListener('message', (e: MessageEvent) => {
clearTimeout(timer)
resolve(typeof e.data === 'string' ? e.data : String(e.data))
}, { once: true })
})
}

/** The Worker generates the engine.io `sid` via `generateBase64id()`,
* ignoring any client-supplied `eio_sid` query param, so it must be
* recovered from the engine.io "open" packet that follows the upgrade.
*/
async function openSocketIoConnection(): Promise<{ ws: WebSocket; eioSid: string }> {
const req = new IncomingRequest('https://example.com/socket.io/', {
headers: { upgrade: 'websocket' },
})
const res = await exports.default.fetch(req)
if (res.status !== 101) throw new Error(`expected 101, got ${res.status}`)
const ws = openClientWebSocket(res)
const openPacket = await nextMessage(ws)
if (openPacket[0] !== '0') throw new Error(`expected engine.io open packet, got ${openPacket}`)
const eioSid = JSON.parse(openPacket.slice(1)).sid as string
return { ws, eioSid }
}

describe('durable object state', () => {
it('exposes both DO namespaces as bindings', () => {
expect(env.engineActor).toBeDefined()
expect(env.socketActor).toBeDefined()
// both bindings can resolve the singleton name; IDs will differ across namespaces
const engineId = env.engineActor.idFromName('singleton')
const socketId = env.socketActor.idFromName('singleton')
expect(typeof engineId.toString()).toBe('string')
expect(typeof socketId.toString()).toBe('string')
})

it('EngineActor rejects non-upgrade requests with 426', async () => {
const id = env.engineActor.idFromName('singleton')
const stub = env.engineActor.get(id)
const res = await stub.fetch('https://eioServer.internal/socket.io/?eio_sid=abcdefghij')
expect(res.status).toBe(426)
})

it('persists a new client into SocketActor storage after a WS connection', async () => {
const { ws, eioSid } = await openSocketIoConnection()
try {
// give the 100ms-delayed createEioSocket + onEioSocketConnection a moment
// to land in SocketActor's Persister
await new Promise((r) => setTimeout(r, 250))

const id = env.socketActor.idFromName('singleton')
const stub = env.socketActor.get(id)
const storedClients = await runInDurableObject(stub, async (_instance: SocketActor, state) => {
const entry = await state.storage.get<{ clientIds: string[] }>('_clients')
return entry?.clientIds ?? []
})
expect(storedClients).toContain(eioSid)
} finally {
ws.close()
}
})

it('runs the EngineActor alarm without throwing', async () => {
const { ws } = await openSocketIoConnection()
try {
await new Promise((r) => setTimeout(r, 250))
const id = env.engineActor.idFromName('singleton')
const stub = env.engineActor.get(id)
// runDurableObjectAlarm returns true if an alarm was scheduled and ran.
// AlarmTimer schedules a 30s alarm on each new connection; this flushes it.
const ran = await runDurableObjectAlarm(stub)
expect(typeof ran).toBe('boolean')
} finally {
ws.close()
}
})
})
Comment on lines +79 to +93

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Add tests for hibernation recovery using evictDurableObject.

The current test suite exercises runInDurableObject and runDurableObjectAlarm, but does not test hibernation recovery. As per coding guidelines, you must use Cloudflare test APIs such as runInDurableObject, runDurableObjectAlarm, and evictDurableObject to inspect storage, trigger alarms, and test hibernation recovery.

Consider importing evictDurableObject from cloudflare:test and adding a test case that verifies state is successfully restored when the Durable Object wakes up from hibernation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@demo-server/test/durable-objects.spec.ts` around lines 79 - 93, Add a
hibernation-recovery test in the durable-object test suite using
evictDurableObject, alongside the existing runInDurableObject and
runDurableObjectAlarm coverage. Establish or inspect state through the
EngineActor stub, evict the Durable Object, then invoke it again and assert the
persisted state is restored successfully; import evictDurableObject from
cloudflare:test and preserve socket cleanup.

Source: Coding guidelines

13 changes: 13 additions & 0 deletions demo-server/test/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"moduleResolution": "bundler",
"types": [
"@cloudflare/vitest-pool-workers/types"
]
},
"include": [
"./**/*.ts",
"../src/cf/main.ts"
]
}
Loading
Loading