-
Notifications
You must be signed in to change notification settings - Fork 0
Opencode-ify #8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Opencode-ify #8
Changes from all commits
325c7c9
e70d7fe
150f81d
742a6de
3ec3f7a
628c0f1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
|
||
| # 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 | ||
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||
|
|
||||||||||||
| The patches are in `patches/0001-workarounds-to-upstream-socket.io.patch` and fix export maps and import styles for esbuild bundling. | ||||||||||||
| 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. |
| 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 |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Add tests for hibernation recovery using The current test suite exercises Consider importing 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| 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" | ||
| ] | ||
| } |
There was a problem hiding this comment.
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/checkoutpersists 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
🤖 Prompt for AI Agents
Source: Linters/SAST tools