Skip to content
Merged
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
60 changes: 60 additions & 0 deletions .github/workflows/publish-codegen.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: Publish codegen

on:
workflow_dispatch:
inputs:
publish_npm:
type: boolean
default: false
description: Publish @pydantic-socketio/codegen to npm
publish_gh_release:
type: boolean
default: true
description: Create a GitHub Release
use_changelog:
type: boolean
default: true
description: Extract notes from the codegen changelog
release_tag:
type: string
required: false
description: Codegen tag (empty for latest codegen tag)

jobs:
get-tag:
runs-on: ubuntu-latest
outputs:
release_tag: ${{ steps.tag.outputs.tag }}
steps:
- name: Verify codegen tag
id: tag
uses: atomiechen/reusable-workflows/.github/actions/verify-tag@main
with:
tag: ${{ inputs.release_tag }}
tag_pattern: "codegen-v*"

test:
needs: get-tag
uses: ./.github/workflows/test-codegen.yml
with:
release_tag: ${{ needs.get-tag.outputs.release_tag }}

publish:
needs: [get-tag, test]
permissions:
contents: write
id-token: write
uses: atomiechen/reusable-workflows/.github/workflows/publish-npm-package.yml@main
with:
package_name: "@pydantic-socketio/codegen"
package_dir: packages/codegen
package_manager: npm
tag_prefix: codegen-v
tag_pattern: "codegen-v*"
release_tag: ${{ needs.get-tag.outputs.release_tag }}
changelog_file: packages/codegen/CHANGELOG.md
publish_npm: ${{ inputs.publish_npm }}
publish_gh_release: ${{ inputs.publish_gh_release }}
use_changelog: ${{ inputs.use_changelog }}
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
27 changes: 5 additions & 22 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,36 +35,19 @@ jobs:
outputs:
release_tag: ${{ steps.set_release_tag.outputs.tag }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Fetch all tags
run: |
git fetch --prune --unshallow --tags
- name: Verify and set release tag
id: set_release_tag
run: |
release_tag=${{ inputs.release_tag }}
if [ -z "$release_tag" ]; then
echo "Input tag is empty. Fetching latest tag."
release_tag=$(git describe --tags $(git rev-list --tags --max-count=1))
if [ -z "$release_tag" ]; then
echo "No latest tag available. Exiting workflow."
exit 1
fi
else
if ! git rev-parse -q --verify "refs/tags/$release_tag" >/dev/null; then
echo "Invalid tag '$release_tag'. Exiting workflow."
exit 1
fi
fi
echo "tag=$release_tag" >> $GITHUB_OUTPUT
uses: atomiechen/reusable-workflows/.github/actions/verify-tag@main
with:
tag: ${{ inputs.release_tag }}
tag_pattern: "v*"
test:
needs: get-tag
uses: ./.github/workflows/test.yml
with:
release_tag: ${{ needs.get-tag.outputs.release_tag }}
build-n-publish:
needs: test
needs: [get-tag, test]
permissions:
id-token: write # IMPORTANT: mandatory for trusted publishing and sigstore
contents: write # IMPORTANT: mandatory for making GitHub Releases
Expand Down
77 changes: 77 additions & 0 deletions .github/workflows/test-codegen.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
name: Test TypeScript codegen

on:
workflow_call:
inputs:
release_tag:
type: string
required: false
pull_request:
branches: [main]
paths:
- "src/pydantic_socketio/**"
- "packages/codegen/**"
- "pyproject.toml"
- "uv.lock"
- ".github/workflows/test-codegen.yml"
push:
branches: [main]
paths:
- "src/pydantic_socketio/**"
- "packages/codegen/**"
- "pyproject.toml"
- "uv.lock"
- ".github/workflows/test-codegen.yml"

jobs:
consumer-types:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: ["18", "20", "22", "24", "26"]
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.release_tag || '' }}
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
cache-dependency-path: packages/codegen/package-lock.json
- uses: astral-sh/setup-uv@v6
with:
enable-cache: true
- run: uv sync --locked
- run: npm ci --prefix packages/codegen
- run: npm test --prefix packages/codegen
- name: Verify npm tarball installation
run: |
npm pack --dry-run --json ./packages/codegen > "$RUNNER_TEMP/codegen-pack.json"
npm pack ./packages/codegen --pack-destination "$RUNNER_TEMP"
if tar -tzf "$RUNNER_TEMP"/pydantic-socketio-codegen-*.tgz | grep -E '^package/(test/|node_modules/|pyproject\.toml$|src/pydantic_socketio/)' >/dev/null; then
echo 'The npm tarball contains development or Python files.' >&2
exit 1
fi
npm install --prefix "$RUNNER_TEMP/codegen-install" "$RUNNER_TEMP"/pydantic-socketio-codegen-*.tgz
"$RUNNER_TEMP/codegen-install/node_modules/.bin/pydantic-socketio-codegen" \
packages/codegen/test/generated/server.json -o "$RUNNER_TEMP/socketio.generated.ts"
test -s "$RUNNER_TEMP/socketio.generated.ts"
"$RUNNER_TEMP/codegen-install/node_modules/.bin/pydantic-socketio-codegen" \
packages/codegen/test/generated/client.json -o "$RUNNER_TEMP/client.generated.ts"
test -s "$RUNNER_TEMP/client.generated.ts"
- name: Keep Python distributions free of npm files
if: matrix.node-version == '24'
run: |
uv build --sdist --wheel --out-dir "$RUNNER_TEMP/pyd-sio-dist"
if tar -tzf "$RUNNER_TEMP"/pyd-sio-dist/*.tar.gz | grep '/packages/' >/dev/null; then
echo 'The Python source distribution includes npm packages.' >&2
exit 1
fi
if unzip -Z1 "$RUNNER_TEMP"/pyd-sio-dist/*.whl | grep '/packages/' >/dev/null; then
echo 'The Python wheel includes npm packages.' >&2
exit 1
fi
uv build "$RUNNER_TEMP"/pyd-sio-dist/*.tar.gz --wheel --out-dir "$RUNNER_TEMP/from-sdist"
10 changes: 3 additions & 7 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Checkout to release tag if provided
if: ${{ inputs.release_tag }}
run: |
echo "Checking out to release tag ${{ inputs.release_tag }}"
git fetch --prune --unshallow --tags
git checkout ${{ inputs.release_tag }}
with:
ref: ${{ inputs.release_tag || '' }}
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v3
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install uv
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,13 @@ Legacy unscoped emit registrations are shown as applying to all namespaces,
with scoped overrides excluded. Lifecycle and catch-all handlers are listed as
omitted in the document because they are not concrete events.

### TypeScript codegen

The in-development [`@pydantic-socketio/codegen`](https://github.com/atomiechen/Pydantic-SocketIO/tree/main/packages/codegen)
package reads that exported JSON and generates event types for the official
`socket.io-client`. Export with `asyncapi_schema()` once, then run the codegen
CLI on the JSON; the Python runtime does not need Node.js.


### Migration: Monkey Patching Original Socket.IO

Expand Down
3 changes: 3 additions & 0 deletions packages/codegen/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
test/generated/
test/consumer-negative.ts
9 changes: 9 additions & 0 deletions packages/codegen/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Changelog

All notable changes to `@pydantic-socketio/codegen` will be recorded here.

## [Unreleased]

### Added

- Generate native Socket.IO TypeScript event types from the Pydantic-SocketIO AsyncAPI contract.
150 changes: 150 additions & 0 deletions packages/codegen/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# TypeScript types for Pydantic-SocketIO

`@pydantic-socketio/codegen` turns a Pydantic-SocketIO AsyncAPI contract into event types for the official Socket.IO TypeScript client **or server**. Event names, namespace paths, payloads, handler argument names, and ACKs come from the Python registrations. The generated file contains types; it does not replace Socket.IO.

Requires Pydantic-SocketIO with `asyncapi_schema()` and Node.js 18 or newer.

## Python server → TypeScript client

Register both incoming and outgoing events, then export the contract:

```python
# contract.py
import json
from pathlib import Path

from pydantic import BaseModel
import pydantic_socketio


class Ask(BaseModel):
text: str


class Answer(BaseModel):
accepted: bool


class Notice(BaseModel):
text: str


server = pydantic_socketio.Server(async_mode="threading")
server.register_emit("notice", Notice, namespace="/chat")


@server.on("ask", namespace="/chat")
def ask(sid: str, request: Ask) -> Answer:
server.emit("notice", Notice(text=request.text), to=sid, namespace="/chat")
return Answer(accepted=True)


schema = pydantic_socketio.asyncapi_schema(server, title="Chat API", version="1.0.0")
Path("asyncapi.json").write_text(json.dumps(schema, indent=2))
```

Run `python contract.py`. In the TypeScript project, generate and use the types:

```sh
npm install --save-dev @pydantic-socketio/codegen
npx @pydantic-socketio/codegen asyncapi.json -o src/socketio.generated.ts
```

```ts
import { io, type Socket } from "socket.io-client";
import { Chat, type Notice, type Ask, type Answer } from "./socketio.generated";

const socket: Socket<Chat.ServerToClientEvents, Chat.ClientToServerEvents> = io(Chat.path);
// /chat becomes Chat; Chat.path is "/chat".

socket.on("notice", (notice: Notice) => console.log(notice.text));
socket.emit("ask", { text: "Hello" } satisfies Ask, (answer: Answer) => {
console.log(answer.accepted);
});
```

The server's `ask` handler supplies `ClientToServerEvents`, its `Answer` return annotation supplies the ACK type, and `register_emit("notice", ...)` supplies `ServerToClientEvents`.

## Python client → TypeScript server

The same command works when Python is the client. Its outgoing registrations become `ClientToServerEvents`; its handlers become `ServerToClientEvents`:

```python
# client_contract.py
import json
from pathlib import Path

from pydantic import BaseModel
import pydantic_socketio


class Ask(BaseModel):
text: str


class Answer(BaseModel):
accepted: bool


class Notice(BaseModel):
text: str


client = pydantic_socketio.Client()
client.register_emit("ask", Ask, namespace="/chat", ack_type=Answer)


@client.on("notice", namespace="/chat")
def notice(message: Notice) -> Answer:
print(message.text)
return Answer(accepted=True)


schema = pydantic_socketio.asyncapi_schema(client, title="Chat API", version="1.0.0")
Path("asyncapi.json").write_text(json.dumps(schema, indent=2))
```

Run `python client_contract.py`, then run the same `npx @pydantic-socketio/codegen asyncapi.json -o src/socketio.generated.ts` command. On the TypeScript side, use the generated interfaces with the official server:

```ts
import { Server, type Namespace } from "socket.io";
import { Chat } from "./socketio.generated";

const server = new Server(3000);
const chat: Namespace<Chat.ClientToServerEvents, Chat.ServerToClientEvents> =
server.of(Chat.path);
chat.on("connection", (socket) => {
socket.on("ask", (request, ack) => {
console.log(request.text); // Ask.text is a string
ack?.({ accepted: true }); // Answer
socket.emit("notice", { text: "Received" }, (answer) => {
console.log(answer.accepted); // boolean
});
});
});
```

A Python client can connect to that server and call `ask` with `response_model=Answer` to validate the ACK at runtime. The generated TypeScript describes both directions; no second generator or wrapper is needed.

## Namespaces and regeneration

`/` becomes `Root`, `/chat` becomes `Chat`, and `/admin` becomes `Admin`. These are generated TypeScript namespaces containing `path`, `ClientToServerEvents`, and `ServerToClientEvents`; they are not Socket.IO server instances.

On a TypeScript client, `io(Root.path)` and `io(Chat.path)` return separate namespace sockets, which normally share one underlying connection. On a TypeScript server, use **one** `Server` and obtain namespace handles with `server.of(path)`. If the Python contract covers both `/` and `/chat`, the server can type them separately:

```ts
import { Server, type Namespace } from "socket.io";
import { Root, Chat } from "./socketio.generated";

const server = new Server<Root.ClientToServerEvents, Root.ServerToClientEvents>(3000);
server.on("connection", (socket) => { /* events in / */ });
const chat: Namespace<Chat.ClientToServerEvents, Chat.ServerToClientEvents> =
server.of(Chat.path);
chat.on("connection", (socket) => { /* events in /chat */ });
```

Unscoped outgoing registrations also appear under `Unscoped`; a registration for a specific namespace overrides that fallback there.

Named Pydantic models are exported as named TypeScript declarations such as `Ask`, `Answer`, and `Notice`. Primitive event arguments stay inline, and Python handler argument names appear in tuple labels. If two different models share a name, codegen adds event context so their types remain distinct. The JSON contract is sufficient to understand the generated file without Python source access.

After changing event names, models, namespaces, or ACK types, export `asyncapi.json` again, rerun codegen, and type-check the TypeScript project. Use `--help` for CLI syntax.
Loading
Loading