diff --git a/exmaples.c b/.arch/exmaples.c
similarity index 78%
rename from exmaples.c
rename to .arch/exmaples.c
index 946c4a8..77a69c1 100644
--- a/exmaples.c
+++ b/.arch/exmaples.c
@@ -2,6 +2,9 @@
#include
#include
+#include
+#include
+#include
#define ARRAY_LEN(arr) (sizeof(arr) / sizeof((arr)[0]))
@@ -25,7 +28,10 @@ void swagger_json_handler(chttpx_request_t* req, chttpx_response_t* res)
void swagger_gui_handler(chttpx_request_t* req, chttpx_response_t* res)
{
- char* url = "http://localhost:80/api/v1";
+ const char* port_env = getenv("CHTTPX_PORT");
+ const char* port = port_env && port_env[0] != '\0' ? port_env : "8080";
+ char url[128];
+ snprintf(url, sizeof(url), "http://localhost:%s/api/v1", port);
char swagger_html[8192];
snprintf(swagger_html, sizeof(swagger_html),
@@ -153,12 +159,51 @@ void file_upload(chttpx_request_t* req, chttpx_response_t* res)
return;
}
+void chat_on_open(chttpx_wsocket_t* ws, void* userdata)
+{
+ (void)userdata;
+ const char* room = cHTTPX_WSocketParam(ws, "room_id");
+ char welcome[256];
+ snprintf(welcome, sizeof(welcome), "Welcome to room %s!", room ? room : "?");
+ cHTTPX_WSocketSend(ws, welcome);
+}
+
+void chat_on_message(chttpx_wsocket_t* ws, const unsigned char* data, size_t len, int opcode, void* userdata)
+{
+ (void)opcode;
+ (void)userdata;
+
+ char msg[4096];
+ if (len >= sizeof(msg))
+ len = sizeof(msg) - 1;
+ memcpy(msg, data, len);
+ msg[len] = '\0';
+
+ /* Only clients in the same room (same URL path) receive the message. */
+ cHTTPX_WSocketBroadcastPeers(ws, msg);
+}
+
+void chat_on_close(chttpx_wsocket_t* ws, void* userdata)
+{
+ (void)ws;
+ (void)userdata;
+}
+
+static chttpx_wsocket_callbacks_t chat_callbacks = {
+ .on_open = chat_on_open,
+ .on_message = chat_on_message,
+ .on_close = chat_on_close,
+};
+
int main()
{
chttpx_serv_t serv = {0};
- size_t max_clients = 2;
- if (cHTTPX_Init(&serv, 80, &max_clients) != 0)
+ const char* port_env = getenv("CHTTPX_PORT");
+ uint16_t port = port_env ? (uint16_t)atoi(port_env) : 8080;
+
+ size_t max_clients = 1024;
+ if (cHTTPX_Init(&serv, port, &max_clients) != 0)
{
printf("Failed to start server\n");
return 1;
@@ -180,6 +225,8 @@ int main()
cHTTPX_RegisterRoute(&v1, "PATCH", "/file", file_upload);
cHTTPX_RegisterRoute(&v1, "GET", "/doc.api/swagger/json", swagger_json_handler);
cHTTPX_RegisterRoute(&v1, "GET", "/doc.api/swagger/gui", swagger_gui_handler);
+ /* Client connects: ws://host/api/v1/ws/chat/lobby (room_id = "lobby") */
+ cHTTPX_WSocketRegisterRoute(&v1, "/ws/chat/{room_id}", &chat_callbacks);
/* At the very end, to start listening to incoming requests from users. */
cHTTPX_Listen();
diff --git a/prog.png b/.arch/prog.png
similarity index 100%
rename from prog.png
rename to .arch/prog.png
diff --git a/swagger.json b/.arch/swagger.json
similarity index 100%
rename from swagger.json
rename to .arch/swagger.json
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..ebab7d1
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,18 @@
+.git
+.github
+.out
+.build
+*.tar.gz
+libchttpx-dev
+libchttpx.so
+*.dll
+*.a
+logs/
+exmaples.c
+swagger.json
+DEBIAN/
+scripts/
+tools/
+*.md
+LICENSE
+ChangeLog
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 2825d54..7d3a617 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -19,6 +19,8 @@ Use a **conventional commit** title — it becomes the release version on merge:
- [ ] PR title follows the table above
- [ ] Code builds on Linux (`make lin` and `make libchttpx.so`)
+- [ ] Tests pass (`make test`)
+- [ ] Example server builds separately (`make example`) if needed
- [ ] Code builds on Windows (`make win`) if applicable
- [ ] Source is formatted (`make lin-format` or `clang-format`)
- [ ] README updated if public API or usage changed
@@ -26,3 +28,6 @@ Use a **conventional commit** title — it becomes the release version on merge:
## Related issues
+
+
+
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7fc3c63..0c558b1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,9 +2,8 @@ name: CI
on:
push:
- branches: [main, master]
+ branches: [develop, main, master]
pull_request:
- branches: [main, master]
permissions:
contents: read
@@ -25,6 +24,9 @@ jobs:
- name: Build binary
run: make lin
+ - name: Run tests
+ run: make test
+
- name: Build shared library
run: make libchttpx.so
@@ -50,6 +52,9 @@ jobs:
- name: Build
run: mingw32-make win SHELL=cmd
+ - name: Run tests
+ run: mingw32-make win-test-run SHELL=cmd
+
static-analysis:
name: Static analysis (cppcheck)
runs-on: ubuntu-latest
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
new file mode 100644
index 0000000..4c5afa5
--- /dev/null
+++ b/.github/workflows/docker.yml
@@ -0,0 +1,54 @@
+name: Docker
+
+on:
+ push:
+ branches: [main, master]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: docker-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ publish:
+ name: Build and push to Docker Hub
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Extract metadata
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: noneandundefined/libchttpx
+ tags: |
+ type=raw,value=latest,enable={{is_default_branch}}
+ type=ref,event=branch
+ type=sha,prefix=
+
+ - name: Build and push
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+ platforms: linux/amd64,linux/arm64
diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml
index 9d50588..edcc349 100644
--- a/.github/workflows/labeler.yml
+++ b/.github/workflows/labeler.yml
@@ -16,6 +16,6 @@ jobs:
uses: actions/checkout@v4
- name: Apply labels
- uses: actions/labeler@v5
+ uses: actions/labeler@v7
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml
new file mode 100644
index 0000000..b5e8db5
--- /dev/null
+++ b/.github/workflows/pr-preview.yml
@@ -0,0 +1,128 @@
+name: PR Preview Build
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+permissions:
+ contents: read
+ pull-requests: write
+
+jobs:
+ preview-linux:
+ name: Preview build (Linux)
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Install dependencies
+ run: |
+ sudo apt update
+ sudo apt install -y gcc make libcjson-dev
+
+ - name: Build preview archive
+ run: make lin-lib
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: libchttpx-dev-pr-${{ github.event.pull_request.number }}
+ path: libchttpx-dev.tar.gz
+ retention-days: 14
+
+ - name: Update PR with install instructions
+ uses: actions/github-script@v7
+ env:
+ RUN_ID: ${{ github.run_id }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ HEAD_REF: ${{ github.head_ref }}
+ HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
+ with:
+ script: |
+ const markerStart = '';
+ const markerEnd = '';
+ const runId = process.env.RUN_ID;
+ const prNumber = process.env.PR_NUMBER;
+ const headRef = process.env.HEAD_REF;
+ const headRepo = process.env.HEAD_REPO;
+ const { owner, repo } = context.repo;
+ const fullRepo = `${owner}/${repo}`;
+ const runUrl = `https://github.com/${fullRepo}/actions/runs/${runId}`;
+ const scriptUrl = `https://raw.githubusercontent.com/${headRepo}/${headRef}/scripts/install-preview.sh`;
+ const oneLiner = `curl -sL ${scriptUrl} | sudo bash -s -- ${runId} ${prNumber} ${fullRepo}`;
+
+ const section = [
+ markerStart,
+ '## libchttpx preview build (Linux)',
+ '',
+ '> Auto-generated by CI on every push. Artifact is kept for 14 days.',
+ '',
+ '| | |',
+ '|---|---|',
+ `| **PR** | #${prNumber} |`,
+ `| **Workflow run** | [${runId}](${runUrl}) |`,
+ `| **Artifact** | \`libchttpx-dev-pr-${prNumber}\` |`,
+ '',
+ '### One-liner install (Linux)',
+ '',
+ 'Requires [GitHub CLI](https://cli.github.com/) — run `gh auth login` once.',
+ '',
+ '```bash',
+ oneLiner,
+ '```',
+ '',
+ '### Manual install',
+ '',
+ '```bash',
+ `gh run download ${runId} -R ${fullRepo} -n libchttpx-dev-pr-${prNumber}`,
+ 'tar -xzf libchttpx-dev.tar.gz && cd libchttpx-dev',
+ 'sudo mkdir -p /usr/local/include/libchttpx /usr/local/lib/pkgconfig',
+ 'sudo cp -r include/* /usr/local/include/libchttpx/',
+ 'sudo cp libchttpx.so /usr/local/lib/',
+ 'sudo cp libchttpx.pc /usr/local/lib/pkgconfig/',
+ 'sudo ldconfig',
+ '```',
+ '',
+ '### Compile your app',
+ '',
+ '```bash',
+ 'gcc main.c $(pkg-config --cflags --libs libchttpx) -lcjson',
+ '```',
+ markerEnd,
+ ].join('\n');
+
+ const isFork = headRepo !== fullRepo;
+ const { data: pr } = await github.rest.pulls.get({
+ owner,
+ repo,
+ pull_number: Number(prNumber),
+ });
+
+ if (isFork) {
+ await github.rest.issues.createComment({
+ owner,
+ repo,
+ issue_number: Number(prNumber),
+ body: section.replace(markerStart, '').replace(markerEnd, '').trim()
+ + '\n\n> Posted as comment because this PR is from a fork.',
+ });
+ return;
+ }
+
+ let body = pr.body ?? '';
+ const startIdx = body.indexOf(markerStart);
+ const endIdx = body.indexOf(markerEnd);
+
+ if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
+ body = body.slice(0, startIdx) + section + body.slice(endIdx + markerEnd.length);
+ } else {
+ body = body.trimEnd() + '\n\n' + section;
+ }
+
+ await github.rest.pulls.update({
+ owner,
+ repo,
+ pull_number: Number(prNumber),
+ body,
+ });
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..6ac8213
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,50 @@
+# syntax=docker/dockerfile:1
+
+# Dev base image: prebuilt libchttpx + toolchain for compiling C/C++ apps.
+#
+# Usage in other projects:
+# FROM noneandundefined/libchttpx:latest
+# WORKDIR /app
+# COPY . .
+# RUN gcc main.c $(pkg-config --cflags --libs libchttpx) -lcjson -o app
+
+FROM debian:bookworm-slim AS builder
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends \
+ build-essential \
+ libcjson-dev \
+ pkg-config \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /src
+
+COPY Makefile libchttpx.pc ./
+COPY include/ include/
+COPY src/ src/
+COPY tests/ tests/
+
+RUN make test \
+ && make lib-install DESTDIR=/out PREFIX=/usr/local
+
+FROM debian:bookworm-slim
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends \
+ build-essential \
+ libcjson-dev \
+ pkg-config \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY --from=builder /out/usr/local/ /usr/local/
+
+RUN ldconfig \
+ && pkg-config --exists libchttpx \
+ && test -f /usr/local/lib/libchttpx.so \
+ && test -d /usr/local/include/libchttpx
+
+LABEL org.opencontainers.image.source="https://github.com/netcorelink/libchttpx" \
+ org.opencontainers.image.description="A powerful, cross-platform HTTP server library in C/C++ for building full-featured web servers." \
+ org.opencontainers.image.licenses="MIT"
+
+WORKDIR /app
diff --git a/Makefile b/Makefile
index 99506b9..3133370 100644
--- a/Makefile
+++ b/Makefile
@@ -1,10 +1,11 @@
-TARGET=chttpx-server
+TARGET = libchttpx-tests
+EXAMPLE_TARGET = chttpx-server
RELEASE_DIR = libchttpx-dev
TAR = $(RELEASE_DIR).tar.gz
CC = gcc
-CFLAGS = -Wall -Wextra -O2 -Iinclude
+CFLAGS = -Wall -Wextra -Werror=implicit-function-declaration -O2 -Iinclude
TARGET_DLL = libchttpx.dll
CLANG_FORMAT = clang-format
@@ -17,40 +18,65 @@ PKGDIR ?= /pkg/usr/local
WIN_LIB_DIR = tools
-LIN_LDFLAGS = -lcjson
+LIN_LDFLAGS = -lcjson -lpthread
WIN_LDFLAGS = -lws2_32
-LIN_SRCS = $(filter-out ./lib/cjson/cJSON.c, $(shell find . -name '*.c'))
-WIN_SRCS = $(wildcard *.c) $(wildcard */*.c) $(wildcard */*/*.c)
+LIB_SRCS = $(wildcard src/*.c)
+LIB_OBJS = $(patsubst %.c,$(OBJDIR)/%.o,$(LIB_SRCS))
-LIN_OBJS = $(patsubst %.c,$(OBJDIR)/%.o,$(LIN_SRCS))
-WIN_OBJS = $(patsubst %.c,$(OBJDIR)/%.o,$(WIN_SRCS))
+TEST_SRCS = $(wildcard tests/test_*.c)
+TEST_OBJS = $(patsubst tests/%.c,$(OBJDIR)/tests/%.o,$(TEST_SRCS))
+
+EXAMPLE_SRC = .arch/exmaples.c
+EXAMPLE_OBJ = $(OBJDIR)/exmaples.o
+
+WIN_LIB_SRCS = $(wildcard src/*.c) lib/cjson/cJSON.c
# LINux build
# -
-lin: $(BINDIR)/$(TARGET)
+lin: $(BINDIR)/$(TARGET) libchttpx.so
-$(BINDIR)/$(TARGET): $(LIN_OBJS)
+$(BINDIR)/$(TARGET): $(LIB_OBJS) $(TEST_OBJS)
@mkdir -p $(BINDIR)
- $(CC) $(CFLAGS) -o $@ $(LIN_OBJS) $(LIN_LDFLAGS)
+ $(CC) $(CFLAGS) -o $@ $(LIB_OBJS) $(TEST_OBJS) $(LIN_LDFLAGS)
$(OBJDIR)/%.o: %.c
@mkdir -p $(dir $@)
$(CC) $(CFLAGS) -fPIC -c $< -o $@
+$(OBJDIR)/tests/%.o: tests/%.c
+ @mkdir -p $(dir $@)
+ $(CC) $(CFLAGS) -fPIC -c $< -o $@
+
+example: $(BINDIR)/$(EXAMPLE_TARGET)
+
+$(BINDIR)/$(EXAMPLE_TARGET): $(LIB_OBJS) $(EXAMPLE_OBJ)
+ @mkdir -p $(BINDIR)
+ $(CC) $(CFLAGS) -o $@ $(LIB_OBJS) $(EXAMPLE_OBJ) $(LIN_LDFLAGS)
+
# WINdows build
# -
+$(OBJDIR)/exmaples.o: $(EXAMPLE_SRC)
+ @mkdir -p $(dir $@)
+ $(CC) $(CFLAGS) -fPIC -c $< -o $@
+
win:
if not exist $(BINDIR) mkdir $(BINDIR)
- $(CC) $(CFLAGS) -D_WIN32 -mconsole $(WIN_SRCS) -o $(BINDIR)/$(TARGET).exe $(WIN_LDFLAGS)
+ $(CC) $(CFLAGS) -D_WIN32 -mconsole $(WIN_LIB_SRCS) $(EXAMPLE_SRC) -o $(BINDIR)/$(EXAMPLE_TARGET).exe $(WIN_LDFLAGS)
+
+win-test: $(BINDIR)/$(TARGET).exe
+
+$(BINDIR)/$(TARGET).exe:
+ if not exist $(BINDIR) mkdir $(BINDIR)
+ $(CC) $(CFLAGS) -D_WIN32 -mconsole $(WIN_LIB_SRCS) $(TEST_SRCS) -o $@ $(WIN_LDFLAGS)
# LINux shared library
# -
-libchttpx.so: $(LIN_OBJS)
- $(CC) -shared -fPIC -o libchttpx.so $(LIN_OBJS)
+libchttpx.so: $(LIB_OBJS)
+ $(CC) -shared -fPIC -o libchttpx.so $(LIB_OBJS) $(LIN_LDFLAGS)
# LINux lib install
# -
@@ -67,9 +93,9 @@ lib-install: libchttpx.so
# WINdows lib compile
# -
-win-lib: $(WIN_OBJS)
+win-lib:
@echo "Building Windows DLL..."
- $(CC) -shared -o $(TARGET_DLL) $(WIN_OBJS) -Wl,--out-implib,libchttpx.a -lws2_32
+ $(CC) -shared -o $(TARGET_DLL) $(WIN_LIB_SRCS) -Wl,--out-implib,libchttpx.a -lws2_32
@echo "Copying files to $(WIN_LIB_DIR)..."
@mkdir -p $(WIN_LIB_DIR)
@@ -106,13 +132,21 @@ lin-lib: clean libchttpx.so
# LINux run
# -
-lin-run: lin
+test: lin
$(BINDIR)/$(TARGET)
+lin-run: test
+
+lin-example: example
+ $(BINDIR)/$(EXAMPLE_TARGET)
+
# WINdows run
# -
win-run: win
+ $(BINDIR)\$(EXAMPLE_TARGET).exe
+
+win-test-run: win-test
$(BINDIR)\$(TARGET).exe
# LINux format
@@ -120,16 +154,16 @@ win-run: win
lin-format:
@echo ">> Formatting clang source files"
- $(CLANG_FORMAT) -i $(LIN_SRCS)
+ $(CLANG_FORMAT) -i $(LIB_SRCS) $(TEST_SRCS)
# WINdows format
# -
win-format:
@echo ">> Formatting clang source files"
- $(CLANG_FORMAT) -i $(WIN_SRCS)
+ $(CLANG_FORMAT) -i $(WIN_LIB_SRCS) $(TEST_SRCS) $(EXAMPLE_SRC)
-run: run-lin
+run: lin-run
clean:
rm -rf $(OBJDIR) $(BINDIR) *.a *.dll
diff --git a/README.md b/README.md
index 31750bb..45e6fdd 100644
--- a/README.md
+++ b/README.md
@@ -1,229 +1,383 @@
-
+
netcorelink/libchttpx
- A powerful, cross-platform HTTP server library in C/C++ for building full-featured web servers
+ Cross-platform HTTP/WebSocket server library in C/C++
---
-`netcorelink/libchttpx` a powerful, cross-platform HTTP server library in C/C++ for building full-featured web servers.
+`libchttpx` — библиотека для HTTP-сервера на C/C++: маршруты, middleware, CORS, JSON-парсинг, WebSocket, загрузка файлов.
-## Linux
+**Зависимости:** `libcjson` (Linux: `libcjson-dev`, Windows: входит в zip-релиз).
+
+---
+
+## Установка
+
+### Linux — последняя версия
```bash
curl -s https://raw.githubusercontent.com/netcorelink/libchttpx/main/scripts/install.sh | sudo sh
```
-## Windows
+### Linux — конкретная версия
+
+```bash
+curl -s https://raw.githubusercontent.com/netcorelink/libchttpx/main/scripts/install.sh | sudo sh -s -- --version=1.5.5
+```
+
+Также работает: `-v=1.5.5`, `-v 1.5.5`, `--version v1.5.5`.
+
+Локально:
+
+```bash
+sudo bash scripts/install.sh --version=1.5.5
+sudo bash scripts/install.sh --help
+```
+
+Файлы ставятся в `/usr/local`:
+
+| Путь | Содержимое |
+|------|------------|
+| `/usr/local/include/libchttpx/` | заголовки |
+| `/usr/local/lib/libchttpx.so` | shared library |
+| `/usr/local/lib/pkgconfig/libchttpx.pc` | pkg-config |
+
+Проверка:
+
+```bash
+pkg-config --modversion libchttpx
+ldconfig -p | grep libchttpx
+```
+
+### Windows — последняя версия
```powershell
iwr https://raw.githubusercontent.com/netcorelink/libchttpx/main/scripts/install.ps1 -UseBasicParsing | iex
```
----
+### Windows — конкретная версия
-Documentation
+```powershell
+iwr https://raw.githubusercontent.com/netcorelink/libchttpx/main/scripts/install.ps1 -OutFile install.ps1
+powershell -ExecutionPolicy Bypass -File install.ps1 --version=1.5.5
+```
-### Initial http server
+Через pipe `| iex` аргументы не передаются — используйте переменную окружения:
-```c
-#include
+```powershell
+$env:LIBCHTTPX_VERSION = '1.5.5'
+iwr https://raw.githubusercontent.com/netcorelink/libchttpx/main/scripts/install.ps1 -UseBasicParsing | iex
+```
-int main()
-{
- chttpx_serv_t serv = {0};
+### Ручная установка из GitHub Releases
- if (cHTTPX_Init(&serv, 80) != 0) {
- printf("Failed to start server\n");
- return 1;
- }
+Список версий: https://github.com/netcorelink/libchttpx/releases
- // cores
- // middlewares
- // routes
+```bash
+VERSION=v1.5.5
+curl -L "https://github.com/netcorelink/libchttpx/releases/download/${VERSION}/libchttpx-dev.tar.gz" -o libchttpx-dev.tar.gz
+tar -xzf libchttpx-dev.tar.gz
+cd libchttpx-dev
+sudo cp -r include/* /usr/local/include/libchttpx/
+sudo cp libchttpx.so /usr/local/lib/
+sudo cp libchttpx.pc /usr/local/lib/pkgconfig/
+sudo ldconfig
+```
- /* At the very end, to start listening to incoming requests from users. */
- cHTTPX_Listen();
-}
+### Docker (base image для сборки приложений)
+
+```dockerfile
+FROM noneandundefined/libchttpx:latest
+
+WORKDIR /app
+COPY . .
+RUN gcc main.c $(pkg-config --cflags --libs libchttpx) -lcjson -o app
```
-### Server timeouts settings
+Образ содержит toolchain + установленную `libchttpx`. Тег `latest` обновляется при push в `main`.
-```c
-/* Timeouts */
-serv.read_timeout_sec = 300;
-serv.write_timeout_sec = 300;
-serv.idle_timeout_sec = 90;
+> Для фиксации версии в production используйте tarball из [Releases](https://github.com/netcorelink/libchttpx/releases) или соберите образ из git-тега (`git checkout v1.5.5 && docker build -t libchttpx:1.5.5 .`).
+
+### Сборка из исходников
+
+```bash
+git clone https://github.com/netcorelink/libchttpx.git
+cd libchttpx
+sudo apt install -y gcc make libcjson-dev # Debian/Ubuntu
+
+make libchttpx.so
+sudo make lib-install PREFIX=/usr/local
+sudo ldconfig
+```
+
+Архив для релиза:
+
+```bash
+make lin-lib # → libchttpx-dev.tar.gz
```
-### CORS Settings
+---
-`origins` – Array of allowed origin strings (e.g. "https://example.com"). Each origin must match exactly the value of the "Origin" header.
+## Компиляция вашего приложения
+
+```bash
+gcc main.c $(pkg-config --cflags --libs libchttpx) -lcjson -o server
+```
-`origins_count` – Number of elements in the origins array.
+Windows (после `install.ps1`):
-`methods` – Comma-separated list of allowed HTTP methods. If NULL, defaults to: "GET, POST, PUT, DELETE, OPTIONS".
+```powershell
+gcc server.c -I"%ProgramFiles%\libchttpx\include" -L"%ProgramFiles%\libchttpx" -lchttpx -lws2_32 -o server.exe
+```
-`headers` – Comma-separated list of allowed request headers. If NULL, defaults to: "Content-Type".
+Подключение:
```c
-/* Cors */
-const char *allowed_origins[] = {
- "https://exmaple.ru",
- "http://localhost:8080",
-};
-
-cHTTPX_Cors(allowed_origins, cHTTPX_ARRAY_LEN(allowed_origins), NULL, "Accept-Language, Auth");
+#include
```
-### Middlewares
+---
-Example: Middleware for checking the authenticated user.
+## Быстрый старт
```c
-chttpx_middleware_result_t auth_middleware(chttpx_request_t *req, chttpx_response_t *res) {
- const char *token = cHTTPX_Header(req, "Auth-Token");
+#include
+#include
- if (!token) {
- *res = cHTTPX_ResJson(cHTTPX_StatusUnauthorized, "{\"error\": \"unauthorized\"}");
- return out;
- }
+void home(chttpx_request_t* req, chttpx_response_t* res)
+{
+ (void)req;
+ *res = cHTTPX_ResHtml(cHTTPX_StatusOK, "Hello
");
+}
+
+int main(void)
+{
+ chttpx_serv_t serv = {0};
- return next;
+ size_t max_clients = 1024;
+ if (cHTTPX_Init(&serv, 8080, &max_clients) != 0)
+ return 1;
+
+ chttpx_router_t api = cHTTPX_RoutePathPrefix("/api/v1");
+ cHTTPX_RegisterRoute(&api, "GET", "/", home);
+
+ cHTTPX_Listen();
+ cHTTPX_Shutdown();
+ return 0;
}
```
-Middlewares are connected `before cHTTPX_Route`.
+---
+
+## Документация
+
+### Инициализация и лимиты
```c
-cHTTPX_MiddlewareUse(auth_middleware);
+chttpx_serv_t serv = {0};
+size_t max_clients = 1024;
+
+cHTTPX_Init(&serv, 8080, &max_clients); /* NULL вместо &max_clients → 255 по умолчанию */
+
+serv.read_timeout_sec = 300;
+serv.write_timeout_sec = 300;
+serv.idle_timeout_sec = 90;
```
-### Routes
+### CORS
-`method` – HTTP method string, e.g., "GET", "POST".
+```c
+const char* allowed_origins[] = {
+ "https://example.com",
+ "http://localhost:8080",
+};
-`path` – URL path to match, e.g., "/users".
+cHTTPX_Cors(allowed_origins, 2, NULL, "Content-Type, Authorization");
+```
-`handler` – Function pointer to handle the request. The handler should return httpx_response_t. This allows the server to call the appropriate function when a matching request is received.
+| Параметр | Описание |
+|----------|----------|
+| `origins` | Разрешённые Origin (точное совпадение) |
+| `origins_count` | Количество элементов |
+| `methods` | `NULL` → `GET, POST, PUT, DELETE, OPTIONS` |
+| `headers` | `NULL` → `Content-Type` |
+
+### Middleware
```c
-cHTTPX_Route("GET", "/", home_index);
-cHTTPX_Route("GET", "/users/{uuid}/{org}", get_user); // ?org=netcorelink
-cHTTPX_Route("POST", "/users", create_user);
+chttpx_middleware_result_t auth_middleware(chttpx_request_t* req, chttpx_response_t* res)
+{
+ const char* token = cHTTPX_HeaderGet(req, "Authorization");
+ if (!token)
+ {
+ *res = cHTTPX_ResJson(cHTTPX_StatusUnauthorized, "{\"error\":\"unauthorized\"}");
+ return out;
+ }
+ return next;
+}
+
+cHTTPX_MiddlewareRecovery();
+cHTTPX_MiddlewareLogging();
+cHTTPX_MiddlewareUse(auth_middleware);
```
-### Handlers
+Встроенные: `cHTTPX_MiddlewareRecovery()`, `cHTTPX_MiddlewareLogging()`, `cHTTPX_MiddlewareRateLimiter(max, window_sec)`.
-HTML page return.
+### Маршруты
+
+Маршруты группируются префиксом:
```c
-void home_index(chttpx_request_t *req, chttpx_response_t *res) {
- *res = cHTTPX_ResHtml(cHTTPX_StatusOK, "This is home page!
");
-}
+chttpx_router_t v1 = cHTTPX_RoutePathPrefix("/api/v1");
+
+cHTTPX_RegisterRoute(&v1, "GET", "/", home_index);
+cHTTPX_RegisterRoute(&v1, "GET", "/users/{uuid}", get_user);
+cHTTPX_RegisterRoute(&v1, "POST", "/users", create_user);
```
-### Http Response
+Параметры пути:
-Return Json response
+```c
+const char* uuid = cHTTPX_Param(req, "uuid");
+```
+
+Query-параметры:
```c
-return cHTTPX_ResJson(cHTTPX_StatusOK, "{\"message\": {\"uuid\": \"%s\", \"page\": \"%s\"}}", uuid, page);
+const char* page = cHTTPX_Query(req, "page");
```
-Return Html response
-Return Media response
+### Handlers и ответы
-
+```c
+void home_index(chttpx_request_t* req, chttpx_response_t* res)
+{
+ (void)req;
+ *res = cHTTPX_ResHtml(cHTTPX_StatusOK, "Home
");
+}
-### Parsing JSON fields
+*res = cHTTPX_ResJson(cHTTPX_StatusOK, "{\"ok\":true}");
+*res = cHTTPX_ResText(cHTTPX_StatusOK, "plain text");
+*res = cHTTPX_ResFile(cHTTPX_StatusOK, "/path/to/file.png");
+```
+
+### JSON parsing
```c
typedef struct {
- char *uuid;
- char *password;
- int is_admin;
+ char* uuid;
+ char* password;
+ int is_admin;
} user_t;
-chttpx_response_t create_user(chttpx_request_t *req) {
- user_t user = {0};
+void create_user(chttpx_request_t* req, chttpx_response_t* res)
+{
+ user_t user = {0};
- chttpx_validation_t fields[] = {
- chttpx_validation_str("uuid", &user.uuid, true, 0, 36, VALIDATOR_NONE),
- chttpx_validation_str("password", &user.password, true, 6, 16, VALIDATOR_NONE),
- chttpx_validation_bool("is_admin", &user.is_admin, false),
- };
+ chttpx_validation_t fields[] = {
+ chttpx_validation_string("uuid", &user.uuid, true, 0, 36, VALIDATOR_NONE),
+ chttpx_validation_string("password", &user.password, true, 6, 16, VALIDATOR_NONE),
+ chttpx_validation_boolean("is_admin", &user.is_admin, false),
+ };
- if (!cHTTPX_Parse(req, fields, cHTTPX_ARRAY_LEN(fields), "en")) {
- *res = cHTTPX_ResJson(cHTTPX_StatusBadRequest, "{\"error\": \"%s\"}", req->error_msg);
- goto cleanup;
- }
+ if (!cHTTPX_Parse(req, fields, ARRAY_LEN(fields)))
+ {
+ *res = cHTTPX_ResJson(cHTTPX_StatusBadRequest, "{\"error\":\"%s\"}", req->error_msg);
+ return;
+ }
- /* ... */
+ *res = cHTTPX_ResJson(cHTTPX_StatusCreated, "{\"uuid\":\"%s\"}", user.uuid);
}
```
-> When working with cHTTPX_Parse, you need to refer to `req->error_msg`.
+> Ошибки парсинга — в `req->error_msg`. Для i18n-сообщений: `cHTTPX_Validate(req, fields, ARRAY_LEN(fields), "en")`.
-### Validations fields
+### Заголовки и IP клиента
-Validates an array of `cHTTPX_FieldValidation` structures.
+```c
+const char* origin = cHTTPX_HeaderGet(req, "Origin");
+const char* ip = cHTTPX_ClientIP(req);
+```
-This function ensures that `required` fields are present, `string lengths` are within `limits`,
-and basic validation for integers and boolean fields is performed.
+### WebSocket
-> When working with cHTTPX_Validate, you need to refer to `req->error_msg`.
+Event-driven API: один poll-поток на все соединения.
```c
-if (!cHTTPX_Validate(req, fields, cHTTPX_ARRAY_LEN(fields), "en")) {
- *res = cHTTPX_ResJson(cHTTPX_StatusBadRequest, "{\"error\": \"%s\"}", req->error_msg);
- goto cleanup;
+void chat_on_open(chttpx_wsocket_t* ws, void* userdata)
+{
+ (void)userdata;
+ const char* room = cHTTPX_WSocketParam(ws, "room_id");
+ cHTTPX_WSocketSend(ws, room ? room : "welcome");
}
-```
-Example response by validation:
+void chat_on_message(chttpx_wsocket_t* ws, const unsigned char* data, size_t len, int opcode, void* userdata)
+{
+ (void)opcode;
+ (void)userdata;
-- Field password is required.
-- Field password min length is 6.
-- Field password max length is 16.
+ char msg[4096];
+ if (len >= sizeof(msg))
+ len = sizeof(msg) - 1;
+ memcpy(msg, data, len);
+ msg[len] = '\0';
-### Get Headers
+ /* Только клиенты в той же комнате (тот же URL) */
+ cHTTPX_WSocketBroadcastPeers(ws, msg);
+}
-```c
-const char *origin = cHTTPX_Header(req, "Origin");
-```
+void chat_on_close(chttpx_wsocket_t* ws, void* userdata)
+{
+ (void)ws;
+ (void)userdata;
+}
-cHTTPX_Header - Get a request header by name.
+static chttpx_wsocket_callbacks_t chat_callbacks = {
+ .on_open = chat_on_open,
+ .on_message = chat_on_message,
+ .on_close = chat_on_close,
+};
-`Parameters`:
+chttpx_router_t v1 = cHTTPX_RoutePathPrefix("/api/v1");
+cHTTPX_WSocketRegisterRoute(&v1, "/ws/chat/{room_id}", &chat_callbacks);
+```
-- req – Pointer to the HTTP request.
-- name – Header name (case-insensitive).
+Клиент подключается к комнате через URL:
-### Get Params
+```
+ws://localhost:8080/api/v1/ws/chat/lobby
+```
-> The path must contain the /{uuid} construct.
+| Функция | Назначение |
+|---------|------------|
+| `cHTTPX_WSocketSend(ws, text)` | Отправить одному клиенту |
+| `cHTTPX_WSocketBroadcastPeers(ws, text)` | Всем в той же комнате |
+| `cHTTPX_WSocketBroadcast(path, text)` | По полному пути |
+| `cHTTPX_WSocketBroadcastRoom("room_id", "42", text)` | По параметру маршрута |
+| `cHTTPX_WSocketParam(ws, "room_id")` | Параметр из URL |
+
+### Завершение работы
```c
-const char *uuid = cHTTPX_Param(req, "uuid");
+cHTTPX_Listen(); /* блокирует, принимает соединения */
+cHTTPX_Shutdown(); /* освобождает ресурсы, закрывает WebSocket */
```
-cHTTPX_Param - Get a route parameter value by its name.
-
-`Parameters`:
+---
-- req – Pointer to the HTTP request.
-- name – Name of the route parameter (e.g., "uuid").
+## Preview-сборки для PR
-### Get Query params
+При pull request CI публикует артефакт `libchttpx-dev-pr-N` (14 дней). Инструкция появляется в описании PR.
-```c
-const char *sizeParam = cHTTPX_Query(req, "size");
+```bash
+gh run download RUN_ID -R netcorelink/libchttpx -n libchttpx-dev-pr-42
+tar -xzf libchttpx-dev.tar.gz && cd libchttpx-dev
+# установка как в разделе «Ручная установка»
```
-cHTTPX_Query - Get a query parameter value by name.
+---
-`Parameters`:
+## Лицензия
-- req – Pointer to the HTTP request.
-- name – Name of the query parameter.
+MIT — см. исходники репозитория.
diff --git a/include/crosspltm.h b/include/crosspltm.h
index 2e995f6..41fab23 100644
--- a/include/crosspltm.h
+++ b/include/crosspltm.h
@@ -6,14 +6,36 @@ extern "C"
{
#endif
-#include
-
#if defined(_WIN32) || defined(_WIN64)
#define CHTTPX_PLATFORM_WINDOWS
#else
#define CHTTPX_PLATFORM_POSIX
#endif
+#include
+
+/** Portable memmem — libc version needs _GNU_SOURCE; undeclared use truncates ptr on amd64. */
+static inline void* chttpx_memmem(const void* haystack, size_t haystacklen, const void* needle, size_t needlelen)
+{
+ if (needlelen == 0)
+ return (void*)haystack;
+ if (needlelen > haystacklen)
+ return NULL;
+
+ const unsigned char* h = haystack;
+ const unsigned char* n = needle;
+
+ for (size_t i = 0; i <= haystacklen - needlelen; i++)
+ {
+ if (h[i] == n[0] && memcmp(h + i, n, needlelen) == 0)
+ return (void*)(h + i);
+ }
+
+ return NULL;
+}
+
+#define memmem(haystack, haystacklen, needle, needlelen) chttpx_memmem(haystack, haystacklen, needle, needlelen)
+
#ifdef CHTTPX_PLATFORM_WINDOWS
#define strdup _strdup
#else
@@ -66,29 +88,6 @@ typedef int chttpx_socket_t;
#define strcasecmp _stricmp
#endif
-#ifdef CHTTPX_PLATFORM_WINDOWS
- static void* memmem_win(const void* haystack, size_t haystacklen, const void* needle, size_t needlelen)
- {
- if (!needlelen)
- return (void*)haystack;
- if (needlelen > haystacklen)
- return NULL;
-
- const unsigned char* h = haystack;
- const unsigned char* n = needle;
-
- for (size_t i = 0; i <= haystacklen - needlelen; i++)
- {
- if (h[i] == n[0] && memcmp(h + i, n, needlelen) == 0)
- return (void*)(h + i);
- }
-
- return NULL;
- }
-
-#define memmem(haystack, haystacklen, needle, needlelen) memmem_win(haystack, haystacklen, needle, needlelen)
-#endif
-
#ifdef __cplusplus
}
#endif
diff --git a/include/libchttpx.h b/include/libchttpx.h
index 6fe7bdf..45a0991 100644
--- a/include/libchttpx.h
+++ b/include/libchttpx.h
@@ -37,6 +37,8 @@ extern "C"
#include "i18n.h"
+#include "websocket.h"
+
#ifdef __cplusplus
}
#endif
diff --git a/include/params.h b/include/params.h
index 4419efc..4288661 100644
--- a/include/params.h
+++ b/include/params.h
@@ -24,6 +24,12 @@ extern "C"
*/
const char* cHTTPX_Param(chttpx_request_t* req, const char* name);
+ /**
+ * Match a URL path against a route template (supports {param} segments).
+ * @return 1 if matched, 0 otherwise.
+ */
+ int cHTTPX_MatchPath(const char* template, const char* path, chttpx_param_t* params, int* param_count);
+
#ifdef __cplusplus
extern
}
diff --git a/include/serv.h b/include/serv.h
index c05ad39..5ef15b2 100644
--- a/include/serv.h
+++ b/include/serv.h
@@ -19,10 +19,17 @@ extern "C"
#include
#include
+#include
-#define MAX_PATH 4096
+#define CHTTPX_MAX_PATH 4096
#define MAX_CLIENTS_DEFAULT 255
+ typedef struct chttpx_wsocket chttpx_wsocket_t;
+ typedef void (*chttpx_wsocket_on_open_t)(chttpx_wsocket_t* ws, void* userdata);
+ typedef void (*chttpx_wsocket_on_message_t)(chttpx_wsocket_t* ws, const unsigned char* data, size_t len,
+ int opcode, void* userdata);
+ typedef void (*chttpx_wsocket_on_close_t)(chttpx_wsocket_t* ws, void* userdata);
+
/* Base struct route for library */
typedef struct
{
@@ -31,6 +38,15 @@ extern "C"
chttpx_handler_t handler;
} chttpx_route_t;
+ typedef struct
+ {
+ char* path;
+ chttpx_wsocket_on_open_t on_open;
+ chttpx_wsocket_on_message_t on_message;
+ chttpx_wsocket_on_close_t on_close;
+ void* userdata;
+ } chttpx_wsocket_route_entry_t;
+
typedef struct
{
uint16_t port;
@@ -50,6 +66,11 @@ extern "C"
size_t routes_count;
size_t routes_capacity;
+ /* WebSocket routes */
+ chttpx_wsocket_route_entry_t* ws_routes;
+ size_t ws_routes_count;
+ size_t ws_routes_capacity;
+
/* Middlewares */
chttpx_middleware_stack_t middleware;
diff --git a/include/websocket.h b/include/websocket.h
index acecf98..bb1b52e 100644
--- a/include/websocket.h
+++ b/include/websocket.h
@@ -5,9 +5,20 @@
* under the terms of the MIT license. See `libchttpx.c` for details.
*/
+#ifndef WEBSOCKET_H
+#define WEBSOCKET_H
+
+#ifdef __cplusplus
+extern "C"
+{
+#endif
+
+#include "crosspltm.h"
+#include "request.h"
#include "serv.h"
-#include
+#include
+#include
#define CHTTPX_WSOCKET_OPCODE_CONTINUATION 0x0
#define CHTTPX_WSOCKET_OPCODE_TEXT 0x1
@@ -16,38 +27,63 @@
#define CHTTPX_WSOCKET_OPCODE_PING 0x9
#define CHTTPX_WSOCKET_OPCODE_PONG 0xA
-typedef struct
-{
- /* FIN - final fragment
- * 1 eq. this is the last frame of the message
- * 0 eq. the message is divided into several parts
+ struct chttpx_wsocket
+ {
+ chttpx_socket_t socket;
+ int connected;
+ /** Full request path, e.g. /api/v1/ws/chat/lobby-42 */
+ char path[CHTTPX_MAX_PATH];
+ chttpx_param_t params[MAX_PARAMS];
+ size_t params_count;
+ void* userdata;
+ };
+
+ typedef struct chttpx_wsocket chttpx_wsocket_t;
+
+ typedef void (*chttpx_wsocket_on_open_t)(chttpx_wsocket_t* ws, void* userdata);
+ typedef void (*chttpx_wsocket_on_message_t)(chttpx_wsocket_t* ws, const unsigned char* data, size_t len,
+ int opcode, void* userdata);
+ typedef void (*chttpx_wsocket_on_close_t)(chttpx_wsocket_t* ws, void* userdata);
+
+ typedef struct
+ {
+ chttpx_wsocket_on_open_t on_open;
+ chttpx_wsocket_on_message_t on_message;
+ chttpx_wsocket_on_close_t on_close;
+ void* userdata;
+ } chttpx_wsocket_callbacks_t;
+
+ /**
+ * Register a WebSocket route with event callbacks (non-blocking, shared poll thread).
*/
- int fin;
- /* Check define CHTTPX_WSOCKET_OPCODE */
- int opcode;
- int masked;
- /* Length data in payload */
- uint64_t payload_len;
- /* Mask for XOR payload */
- unsigned char mask[4];
- /* Data in socket */
- unsigned char* payload;
-} wsocket_frame_t;
-
-typedef struct
-{
- int socket;
- int connected;
-} chttpx_wsocket_t;
+ void cHTTPX_WSocketRegisterRoute(chttpx_router_t* r, const char* path, const chttpx_wsocket_callbacks_t* callbacks);
+
+ /** Send a text frame to one client. */
+ int cHTTPX_WSocketSend(chttpx_wsocket_t* ws, const char* text);
+
+ /** Send a binary frame to one client. */
+ int cHTTPX_WSocketSendBinary(chttpx_wsocket_t* ws, const unsigned char* data, size_t len);
+
+ /** Route param captured at connect time (e.g. room_id from /ws/chat/{room_id}). */
+ const char* cHTTPX_WSocketParam(chttpx_wsocket_t* ws, const char* name);
+
+ /** Broadcast text to all clients on the exact same path (same room URL). */
+ int cHTTPX_WSocketBroadcast(const char* path, const char* text);
-typedef void (*chttpx_wsocket_handler_t)(chttpx_wsocket_t* wsocket, const unsigned char* data, size_t len);
+ /** Broadcast text to everyone in the sender's room (same path as ws). */
+ int cHTTPX_WSocketBroadcastPeers(chttpx_wsocket_t* ws, const char* text);
-typedef void (*chttpx_wsocket_route_t)(chttpx_wsocket_t* wsocket);
+ /** Broadcast text to all clients whose route param matches (e.g. room_id = "42"). */
+ int cHTTPX_WSocketBroadcastRoom(const char* param_name, const char* param_value, const char* text);
-void cHTTPX_WSocketRegisterRoute(chttpx_router_t* r, const char* path, chttpx_wsocket_route_t handler);
+ /** Stop the WebSocket engine and close all connections. */
+ void cHTTPX_WSocketShutdown(void);
-int cHTTPX_WSocketUpgrade(int client_socket, const char* sec_wsocket_key);
+ /** Internal: handle upgrade request. Returns 1 if WebSocket took ownership of the socket. */
+ int cHTTPX_WSocketTryHandle(chttpx_request_t* req);
-int cHTTPX_WSocketSend(chttpx_wsocket_t* wsocket, const unsigned char* data, size_t len);
+#ifdef __cplusplus
+}
+#endif
-int cHTTPX_WSocketRecv(chttpx_wsocket_t* wsocket, unsigned char* buffer, size_t len);
\ No newline at end of file
+#endif
diff --git a/scripts/install-preview.sh b/scripts/install-preview.sh
new file mode 100644
index 0000000..27b7530
--- /dev/null
+++ b/scripts/install-preview.sh
@@ -0,0 +1,86 @@
+#!/bin/bash
+# Install libchttpx from a PR preview build (GitHub Actions artifact).
+#
+# Usage:
+# curl -sL https://raw.githubusercontent.com/OWNER/REPO/BRANCH/scripts/install-preview.sh | sudo bash -s -- RUN_ID PR_NUMBER [REPO]
+#
+# Example:
+# curl -sL https://raw.githubusercontent.com/netcorelink/libchttpx/feat/ws/scripts/install-preview.sh | sudo bash -s -- 1234567890 42
+#
+# Requires: gh (GitHub CLI) authenticated — run `gh auth login` once.
+
+set -e
+
+RUN_ID="${1:?Usage: install-preview.sh RUN_ID PR_NUMBER [REPO]}"
+PR_NUMBER="${2:?Usage: install-preview.sh RUN_ID PR_NUMBER [REPO]}"
+REPO="${3:-netcorelink/libchttpx}"
+
+PREFIX="${PREFIX:-/usr/local}"
+ARTIFACT_NAME="libchttpx-dev-pr-${PR_NUMBER}"
+
+if [ "$(id -u)" -ne 0 ]; then
+ echo "Warning: it's recommended to run with sudo to install into $PREFIX"
+fi
+
+if ! command -v gh >/dev/null 2>&1; then
+ echo "Error: GitHub CLI (gh) is required."
+ echo "Install: https://cli.github.com/"
+ echo "Then authenticate: gh auth login"
+ exit 1
+fi
+
+if ! gh auth status >/dev/null 2>&1; then
+ echo "Error: gh is not authenticated. Run: gh auth login"
+ exit 1
+fi
+
+if pkg-config --exists cjson; then
+ echo "cjson already installed."
+else
+ echo "cjson not found. Installing..."
+
+ if command -v apt >/dev/null 2>&1; then
+ sudo apt install -y libcjson-dev
+ elif command -v pacman >/dev/null 2>&1; then
+ sudo pacman -Sy --noconfirm cjson
+ elif command -v dnf >/dev/null 2>&1; then
+ sudo dnf install -y cjson-devel
+ elif command -v zypper >/dev/null 2>&1; then
+ sudo zypper install -y cjson-devel
+ else
+ echo "Unsupported package manager. Install cjson manually."
+ exit 1
+ fi
+fi
+
+TMPDIR=$(mktemp -d)
+trap 'rm -rf "$TMPDIR"' EXIT
+
+echo "Downloading preview build (run $RUN_ID, PR #$PR_NUMBER)..."
+cd "$TMPDIR"
+gh run download "$RUN_ID" -R "$REPO" -n "$ARTIFACT_NAME"
+
+echo "Extracting..."
+tar -xzf libchttpx-dev.tar.gz
+cd libchttpx-dev
+
+echo "Installing headers..."
+mkdir -p "$PREFIX/include/libchttpx"
+cp -r include/* "$PREFIX/include/libchttpx"
+
+echo "Installing shared library..."
+mkdir -p "$PREFIX/lib"
+cp libchttpx.so "$PREFIX/lib/"
+
+echo "Installing pkg-config file..."
+mkdir -p "$PREFIX/lib/pkgconfig"
+cp libchttpx.pc "$PREFIX/lib/pkgconfig/"
+
+if command -v ldconfig >/dev/null 2>&1; then
+ echo "Updating library cache..."
+ ldconfig
+fi
+
+echo "Preview libchttpx from PR #$PR_NUMBER installed successfully!"
+echo "Use it via:"
+echo " gcc main.c \$(pkg-config --cflags --libs libchttpx) -lcjson"
diff --git a/scripts/install.ps1 b/scripts/install.ps1
index f45594f..a6fbc6c 100644
--- a/scripts/install.ps1
+++ b/scripts/install.ps1
@@ -1,13 +1,94 @@
+# Install libchttpx from GitHub Releases (Windows).
+#
+# Latest:
+# iwr https://raw.githubusercontent.com/netcorelink/libchttpx/main/scripts/install.ps1 -UseBasicParsing | iex
+#
+# Specific version:
+# iwr .../install.ps1 -UseBasicParsing | iex --% --version=1.5.5
+# powershell -ExecutionPolicy Bypass -File install.ps1 --version=1.5.5
+# powershell -ExecutionPolicy Bypass -File install.ps1 -v=1.5.5
+# powershell -ExecutionPolicy Bypass -File install.ps1 -Version 1.5.5
+# $env:LIBCHTTPX_VERSION='1.5.5'; iwr .../install.ps1 -UseBasicParsing | iex
+
+param(
+ [Alias('v')]
+ [string]$Version
+)
+
+function Show-InstallUsage {
+ Write-Host @"
+Usage: install.ps1 [options]
+
+Options:
+ -Version, -v VER Release tag or version (e.g. 1.5.5 or v1.5.5)
+ --version=VER, -v=VER Same as above
+ --version VER Same as above
+ -h, --help Show this help
+
+Environment:
+ LIBCHTTPX_VERSION Version when piping to iex (e.g. 1.5.5)
+
+Examples:
+ iwr .../install.ps1 -UseBasicParsing | iex
+ powershell -File install.ps1 --version=1.5.5
+ `$env:LIBCHTTPX_VERSION='1.5.5'; iwr .../install.ps1 -UseBasicParsing | iex
+"@
+}
+
+function Normalize-VersionTag([string]$Ver) {
+ if ($Ver -match '^v') { return $Ver }
+ return "v$Ver"
+}
+
+$i = 0
+while ($i -lt $args.Count) {
+ $arg = $args[$i]
+ switch -Regex ($arg) {
+ '^(-h|--help)$' {
+ Show-InstallUsage
+ exit 0
+ }
+ '^(--version|-v)=(.+)$' {
+ $Version = $Matches[2]
+ }
+ '^(--version|-v)$' {
+ $i++
+ if ($i -ge $args.Count) {
+ Write-Error "Error: $arg requires a value"
+ exit 1
+ }
+ $Version = $args[$i]
+ }
+ default {
+ Write-Error "Error: unknown option: $arg"
+ Show-InstallUsage
+ exit 1
+ }
+ }
+ $i++
+}
+
+if (-not $Version -and $env:LIBCHTTPX_VERSION) {
+ $Version = $env:LIBCHTTPX_VERSION
+}
+
$tmp = "$env:TEMP\libchttpx.zip"
$installDir = "$env:ProgramFiles\libchttpx"
-$url = "https://github.com/netcorelink/libchttpx/releases/latest/download/libchttpx-win64.zip"
-Write-Host "Installing libchttpx to $installDir"
+if ($Version) {
+ $tag = Normalize-VersionTag $Version
+ $url = "https://github.com/netcorelink/libchttpx/releases/download/$tag/libchttpx-win64.zip"
+ Write-Host "Installing libchttpx $tag to $installDir"
+}
+else {
+ $url = "https://github.com/netcorelink/libchttpx/releases/latest/download/libchttpx-win64.zip"
+ Write-Host "Installing libchttpx (latest release) to $installDir"
+}
Invoke-WebRequest $url -OutFile $tmp
Expand-Archive $tmp $installDir -Force
+Remove-Item $tmp -Force -ErrorAction SilentlyContinue
-# Set environment variables
[Environment]::SetEnvironmentVariable(
"CPATH",
"$installDir\include;$installDir\lib\cjson",
diff --git a/scripts/install.sh b/scripts/install.sh
index f435536..c067907 100644
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -1,24 +1,94 @@
#!/bin/bash
-# Usage: curl -s https://raw.githubusercontent.com/netcorelink/libchttpx/main/scripts/install.sh | sudo sh
+# Install libchttpx from GitHub Releases.
+#
+# Latest:
+# curl -s https://raw.githubusercontent.com/netcorelink/libchttpx/main/scripts/install.sh | sudo sh
+#
+# Specific version:
+# curl -s https://raw.githubusercontent.com/netcorelink/libchttpx/main/scripts/install.sh | sudo sh -s -- --version=1.5.5
+# curl -s https://raw.githubusercontent.com/netcorelink/libchttpx/main/scripts/install.sh | sudo sh -s -- -v=1.5.5
+# curl -s https://raw.githubusercontent.com/netcorelink/libchttpx/main/scripts/install.sh | sudo sh -s -- --version v1.5.5
set -e
PREFIX="/usr/local"
+VERSION=""
RELEASE_URL="https://github.com/netcorelink/libchttpx/releases/latest/download/libchttpx-dev.tar.gz"
-# root
+usage() {
+ cat <<'EOF'
+Usage: install.sh [options]
+
+Options:
+ --version=VER, -v=VER Release tag or version (e.g. 1.5.5 or v1.5.5)
+ --version VER, -v VER Same as above
+ --prefix=PATH Install prefix (default: /usr/local)
+ -h, --help Show this help
+
+Examples:
+ curl -s .../install.sh | sudo sh
+ curl -s .../install.sh | sudo sh -s -- --version=1.5.5
+ curl -s .../install.sh | sudo sh -s -- -v=v1.5.5
+EOF
+}
+
+normalize_version_tag() {
+ case "$1" in
+ v*) echo "$1" ;;
+ *) echo "v$1" ;;
+ esac
+}
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --version=*)
+ VERSION="${1#*=}"
+ ;;
+ -v=*)
+ VERSION="${1#*=}"
+ ;;
+ --version|-v)
+ if [ -z "${2:-}" ]; then
+ echo "Error: $1 requires a value" >&2
+ exit 1
+ fi
+ VERSION="$2"
+ shift
+ ;;
+ --prefix=*)
+ PREFIX="${1#*=}"
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "Error: unknown option: $1" >&2
+ usage >&2
+ exit 1
+ ;;
+ esac
+ shift
+done
+
+if [ -n "$VERSION" ]; then
+ TAG="$(normalize_version_tag "$VERSION")"
+ RELEASE_URL="https://github.com/netcorelink/libchttpx/releases/download/${TAG}/libchttpx-dev.tar.gz"
+ echo "Installing libchttpx ${TAG}..."
+else
+ echo "Installing libchttpx (latest release)..."
+fi
+
if [ "$(id -u)" -ne 0 ]; then
- echo "Warning: it's recommended to run with sudo to install info $PREFIX"
+ echo "Warning: it's recommended to run with sudo to install into $PREFIX"
fi
-# cjson
if pkg-config --exists cjson; then
echo "cjson already installed."
else
- echo "cjson not found. Installing...."
+ echo "cjson not found. Installing..."
if command -v apt >/dev/null 2>&1; then
- # sudo apt update
sudo apt install -y libcjson-dev
elif command -v pacman >/dev/null 2>&1; then
sudo pacman -Sy --noconfirm cjson
@@ -35,30 +105,26 @@ fi
echo "cjson installed successfully!"
TMPDIR=$(mktemp -d)
+trap 'rm -rf "$TMPDIR"' EXIT
-echo "Bulding in $TMPDIR"
+echo "Building in $TMPDIR"
cd "$TMPDIR"
echo "Downloading libchttpx release..."
-curl -L "$RELEASE_URL" -o libchttpx.tar.gz
+curl -fsSL "$RELEASE_URL" -o libchttpx.tar.gz
echo "Extracting..."
tar -xzf libchttpx.tar.gz
cd libchttpx-*
-mkdir -p .build .out
-
-# Install headers
-echo "Installing headers...."
+echo "Installing headers..."
mkdir -p "$PREFIX/include/libchttpx"
cp -r include/* "$PREFIX/include/libchttpx"
-# Install library
-echo "Installing shared library...."
+echo "Installing shared library..."
mkdir -p "$PREFIX/lib"
cp libchttpx.so "$PREFIX/lib/"
-# Install pkg-config file
echo "Installing pkg-config file..."
mkdir -p "$PREFIX/lib/pkgconfig"
cp libchttpx.pc "$PREFIX/lib/pkgconfig/"
@@ -70,7 +136,4 @@ fi
echo "libchttpx installed successfully!"
echo "Use it via:"
-echo " gcc main.c \$(pkg-config --cflags --libs libchttpx)"
-
-cd /
-rm -rf "$TMPDIR"
\ No newline at end of file
+echo " gcc main.c \$(pkg-config --cflags --libs libchttpx) -lcjson"
diff --git a/src/body.c b/src/body.c
index e2b0a6d..e5a05a9 100644
--- a/src/body.c
+++ b/src/body.c
@@ -44,7 +44,8 @@ void _parse_req_body(chttpx_request_t* req, chttpx_socket_t client_fd, char* buf
req->content_length = 0;
}
- int is_json_or_text = req->content_type && (strstr(req->content_type, "application/json") || strstr(req->content_type, "text/"));
+ int is_json_or_text = req->content_type[0] != '\0' &&
+ (strstr(req->content_type, "application/json") || strstr(req->content_type, "text/"));
const char* body_start = memmem(buffer, buffer_len, "\r\n\r\n", 4);
if (!body_start || req->content_length == 0)
diff --git a/src/media.c b/src/media.c
index e22da0a..7d90650 100644
--- a/src/media.c
+++ b/src/media.c
@@ -88,10 +88,10 @@ static const content_type_map_t content_type_map[] = {{cHTTPX_CTYPE_HTML, ".html
/* Parse media in request */
void _parse_media(chttpx_request_t* req, char* buffer, size_t buffer_len)
{
- if (!req->content_type)
+ if (req->content_type[0] == '\0')
return;
- if (req->content_length > 0 && (!req->content_type || !strstr(req->content_type, cHTTPX_CTYPE_JSON)))
+ if (req->content_length > 0 && !strstr(req->content_type, cHTTPX_CTYPE_JSON))
{
char tmp_filename[512];
if (_save_body_to_temp_file(req, buffer, buffer_len, tmp_filename, sizeof(tmp_filename)) != 0)
@@ -112,7 +112,7 @@ static int _save_body_to_temp_file(chttpx_request_t* req, char* initial_buffer,
{
const char* ext = content_type_to_ext(req->content_type);
- snprintf(tmp_filename, tmp_filename_size, "/tmp/upload_%ld_%d%s", time(NULL), rand(), ext);
+ snprintf(tmp_filename, tmp_filename_size, "/tmp/upload_%lld_%d%s", (long long)time(NULL), rand(), ext);
FILE* f = fopen(tmp_filename, "wb");
if (!f)
return 1;
diff --git a/src/params.c b/src/params.c
index da068c4..1db54a8 100644
--- a/src/params.c
+++ b/src/params.c
@@ -24,6 +24,58 @@
#include "crosspltm.h"
+#include
+
+int cHTTPX_MatchPath(const char* template, const char* path, chttpx_param_t* params, int* param_count)
+{
+ int count = 0;
+ const char* t = template;
+ const char* p = path;
+
+ while (*t && *p)
+ {
+ if (*t == '{')
+ {
+ const char* t_end = strchr(t, '}');
+ if (!t_end)
+ return 0;
+
+ if (count >= MAX_PARAMS)
+ return 0;
+
+ size_t name_len = (size_t)(t_end - t - 1);
+ if (name_len >= MAX_PARAM_NAME)
+ name_len = MAX_PARAM_NAME - 1;
+ strncpy(params[count].name, t + 1, name_len);
+ params[count].name[name_len] = 0;
+
+ const char* slash = strchr(p, '/');
+ size_t val_len = slash ? (size_t)(slash - p) : strlen(p);
+ if (val_len >= MAX_PARAM_VALUE)
+ val_len = MAX_PARAM_VALUE - 1;
+ strncpy(params[count].value, p, val_len);
+ params[count].value[val_len] = 0;
+
+ count++;
+ t = t_end + 1;
+ p += val_len;
+ }
+ else
+ {
+ if (*t != *p)
+ return 0;
+ t++;
+ p++;
+ }
+ }
+
+ if (*t || *p)
+ return 0;
+
+ *param_count = count;
+ return 1;
+}
+
/**
* Get a route parameter value by its name.
* @param req Pointer to the current HTTP request structure.
diff --git a/src/response.c b/src/response.c
index c60b53b..343531e 100644
--- a/src/response.c
+++ b/src/response.c
@@ -30,68 +30,15 @@
#include "headers.h"
#include "cookies.h"
#include "queries.h"
+#include "params.h"
#include "crosspltm.h"
+#include "websocket.h"
#include
#include
chttpx_response_t cHTTPX_ResJson(uint16_t status, const char* fmt, ...);
-static int match_route(const char* template, const char* path, chttpx_param_t* params, int* param_count)
-{
- int count = 0;
- const char* t = template;
- const char* p = path;
-
- while (*t && *p)
- {
- if (*t == '{')
- {
- const char* t_end = strchr(t, '}');
- if (!t_end)
- return 0;
-
- if (count >= MAX_PARAMS)
- return 0;
-
- size_t name_len = t_end - t - 1;
- strncpy(params[count].name, t + 1, name_len);
- params[count].name[name_len] = 0;
-
- const char* slash = strchr(p, '/');
-
- size_t val_len = slash ? (size_t)(slash - p) : strlen(p);
-
- if (val_len >= MAX_PARAM_VALUE)
- val_len = MAX_PARAM_VALUE - 1;
- strncpy(params[count].value, p, val_len);
- params[count].value[val_len] = 0;
-
- count++;
- t = t_end + 1;
- p += val_len;
- }
- else
- {
- if (*t != *p)
- return 0;
- t++;
- p++;
- }
- }
-
- if (*t || *p)
- return 0;
- *param_count = count;
-
- return 1;
-}
-
-/**
- * Find a registered route by HTTP method and path.
- * @param req Pointer to the current HTTP request structure.
- * @return Pointer to the matching chttpx_route_t if found, NULL otherwise.
- */
static chttpx_route_t* find_route(chttpx_request_t* req)
{
if (!serv)
@@ -106,7 +53,7 @@ static chttpx_route_t* find_route(chttpx_request_t* req)
continue;
int count = 0;
- if (match_route(serv->routes[i].path, req->path, req->params, &count))
+ if (cHTTPX_MatchPath(serv->routes[i].path, req->path, req->params, &count))
{
req->params_count = count;
return &serv->routes[i];
@@ -310,7 +257,7 @@ static chttpx_request_t* parse_req_buffer(chttpx_socket_t client_fd, char* buffe
buffer[received] = '\0';
- char method[16], path[MAX_PATH];
+ char method[16], path[CHTTPX_MAX_PATH];
if (sscanf(buffer, "%15s %4095s", method, path) != 2)
{
@@ -403,6 +350,23 @@ void* chttpx_handle(void* arg)
/* ALLOWED OPTIONS METHOD */
is_method_options(req);
+ int ws_result = cHTTPX_WSocketTryHandle(req);
+ int keep_socket = 0;
+ if (ws_result == 1)
+ {
+ keep_socket = 1;
+ goto cleanup;
+ }
+
+ if (ws_result == -1)
+ {
+ chttpx_response_t res =
+ cHTTPX_ResJson(cHTTPX_StatusBadRequest, "{\"error\": \"websocket upgrade failed\"}");
+
+ send_response(req, res);
+ goto cleanup;
+ }
+
chttpx_route_t* r = find_route(req);
chttpx_response_t res = {0};
@@ -462,7 +426,8 @@ void* chttpx_handle(void* arg)
free(req->query);
free(req);
- chttpx_close(client_sock);
+ if (!keep_socket)
+ chttpx_close(client_sock);
return NULL;
}
diff --git a/src/serv.c b/src/serv.c
index 534c32e..69ffbb0 100644
--- a/src/serv.c
+++ b/src/serv.c
@@ -25,6 +25,7 @@
#include "utils.h"
#include "crosspltm.h"
#include "middlewares.h"
+#include "websocket.h"
/* Extern server struct data */
chttpx_serv_t* serv = NULL;
@@ -102,6 +103,10 @@ int cHTTPX_Init(chttpx_serv_t* serv_p, uint16_t port, void* max_clients)
serv->routes_count = 0;
serv->routes_capacity = 0;
+ serv->ws_routes = NULL;
+ serv->ws_routes_count = 0;
+ serv->ws_routes_capacity = 0;
+
printf("HTTP server started on port %d...\n", port);
return 0;
@@ -159,7 +164,7 @@ void cHTTPX_RegisterRoute(chttpx_router_t* r, const char* method, const char* pa
if (!r || !r->serv || !method || !path || !handler)
return;
- char fpath[MAX_PATH];
+ char fpath[CHTTPX_MAX_PATH];
if (snprintf(fpath, sizeof(fpath), "%s%s", r->prefix, path) >= (int)sizeof(fpath))
return;
@@ -197,7 +202,11 @@ void cHTTPX_Listen()
continue;
chttpx_socket_t client_fd = accept(serv->server_fd, NULL, NULL);
+#ifdef _WIN32
+ if (client_fd == INVALID_SOCKET)
+#else
if (client_fd < 0)
+#endif
continue;
/* Inc. max clients */
@@ -239,6 +248,16 @@ void cHTTPX_Shutdown()
serv->routes = NULL;
serv->routes_count = 0;
serv->routes_capacity = 0;
+
+ for (size_t i = 0; i < serv->ws_routes_count; i++)
+ free(serv->ws_routes[i].path);
+
+ free(serv->ws_routes);
+ serv->ws_routes = NULL;
+ serv->ws_routes_count = 0;
+ serv->ws_routes_capacity = 0;
+
+ cHTTPX_WSocketShutdown();
#ifdef _WIN32
chttpx_close(serv->server_fd);
#else
diff --git a/src/websocket.c b/src/websocket.c
index 55bbc5e..31ea394 100644
--- a/src/websocket.c
+++ b/src/websocket.c
@@ -1,58 +1,811 @@
/*
* Copyright (c) 2026 netcorelink
*
- * Permission is hereby granted, free of charge, to any person obtaining a copy
- * of this software and associated documentation files (the "Software"), to
- * deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
- * sell copies of the Software, and to permit persons to whom the Software is
- * furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in
- * all copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
- * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
- * IN THE SOFTWARE.
+ * WebSocket engine: one poll thread handles all connections (scalable for chat).
*/
#include "websocket.h"
-int cHTTPX_WSocketUpgrade(int client_socket, const char* sec_wsocket_key)
+#include "headers.h"
+#include "params.h"
+#include "utils.h"
+
+#ifndef CHTTPX_PLATFORM_WINDOWS
+#include
+#include
+#include
+#include
+#include
+#else
+#include
+#endif
+
+#include
+#include
+#include
+
+#define CHTTPX_WSOCKET_GUID "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
+#define CHTTPX_WSOCKET_MAX_PAYLOAD (64 * 1024)
+#define CHTTPX_WSOCKET_READ_BUF (CHTTPX_WSOCKET_MAX_PAYLOAD + 16)
+
+typedef struct
+{
+ chttpx_wsocket_t public_ws;
+ chttpx_wsocket_on_open_t on_open;
+ chttpx_wsocket_on_message_t on_message;
+ chttpx_wsocket_on_close_t on_close;
+ void* route_userdata;
+
+ unsigned char read_buf[CHTTPX_WSOCKET_READ_BUF];
+ size_t read_len;
+ size_t frame_offset;
+} ws_connection_t;
+
+typedef struct
+{
+ ws_connection_t* items;
+ size_t count;
+ size_t capacity;
+ thread_t poll_thread;
+ int engine_running;
+ int shutdown_requested;
+#if defined(_WIN32) || defined(_WIN64)
+ CRITICAL_SECTION lock;
+#else
+ pthread_mutex_t lock;
+#endif
+} ws_engine_t;
+
+static ws_engine_t ws_engine = {0};
+
+static void ws_lock(void)
+{
+#if defined(_WIN32) || defined(_WIN64)
+ EnterCriticalSection(&ws_engine.lock);
+#else
+ pthread_mutex_lock(&ws_engine.lock);
+#endif
+}
+
+static void ws_unlock(void)
+{
+#if defined(_WIN32) || defined(_WIN64)
+ LeaveCriticalSection(&ws_engine.lock);
+#else
+ pthread_mutex_unlock(&ws_engine.lock);
+#endif
+}
+
+/* --- SHA-1 + Base64 (handshake) --- */
+
+typedef struct
+{
+ uint32_t state[5];
+ uint32_t count[2];
+ unsigned char buffer[64];
+} sha1_ctx_t;
+
+static void sha1_transform(uint32_t state[5], const unsigned char buffer[64])
+{
+ uint32_t w[80];
+ uint32_t a, b, c, d, e, temp;
+ int i;
+
+ for (i = 0; i < 16; i++)
+ w[i] = ((uint32_t)buffer[i * 4] << 24) | ((uint32_t)buffer[i * 4 + 1] << 16) |
+ ((uint32_t)buffer[i * 4 + 2] << 8) | (uint32_t)buffer[i * 4 + 3];
+ for (i = 16; i < 80; i++)
+ w[i] = ((w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]) << 1) |
+ ((w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]) >> 31);
+
+ a = state[0];
+ b = state[1];
+ c = state[2];
+ d = state[3];
+ e = state[4];
+
+ for (i = 0; i < 80; i++)
+ {
+ if (i < 20)
+ temp = ((b & c) | ((~b) & d)) + 0x5A827999;
+ else if (i < 40)
+ temp = (b ^ c ^ d) + 0x6ED9EBA1;
+ else if (i < 60)
+ temp = ((b & c) | (b & d) | (c & d)) + 0x8F1BBCDC;
+ else
+ temp = (b ^ c ^ d) + 0xCA62C1D6;
+ temp += ((a << 5) | (a >> 27)) + e + w[i];
+ e = d;
+ d = c;
+ c = ((b << 30) | (b >> 2));
+ b = a;
+ a = temp;
+ }
+
+ state[0] += a;
+ state[1] += b;
+ state[2] += c;
+ state[3] += d;
+ state[4] += e;
+}
+
+static void sha1_init(sha1_ctx_t* ctx)
+{
+ ctx->state[0] = 0x67452301;
+ ctx->state[1] = 0xEFCDAB89;
+ ctx->state[2] = 0x98BADCFE;
+ ctx->state[3] = 0x10325476;
+ ctx->state[4] = 0xC3D2E1F0;
+ ctx->count[0] = ctx->count[1] = 0;
+}
+
+static void sha1_update(sha1_ctx_t* ctx, const unsigned char* data, size_t len)
{
- char accept_key[128] = {0};
- char buffer[256];
+ size_t i = 0;
+ size_t j = (ctx->count[0] >> 3) & 63;
+
+ ctx->count[0] += (uint32_t)(len << 3);
+ if (ctx->count[0] < (uint32_t)(len << 3))
+ ctx->count[1]++;
+ ctx->count[1] += (uint32_t)(len >> 29);
- char key_concat[128];
- snprintf(key_concat, sizeof(key_concat), "%s258EAFA5-E914-47DA-95CA-C5AB0DC85B11", sec_wsocket_key);
+ if (j + len > 63)
+ {
+ memcpy(&ctx->buffer[j], data, 64 - j);
+ sha1_transform(ctx->state, ctx->buffer);
+ i = 64 - j;
+ while (i + 63 < len)
+ {
+ sha1_transform(ctx->state, &data[i]);
+ i += 64;
+ }
+ j = 0;
+ }
+ memcpy(&ctx->buffer[j], &data[i], len - i);
+}
+
+static void sha1_final(sha1_ctx_t* ctx, unsigned char digest[20])
+{
+ unsigned char finalcount[8];
+ int i;
- // unsigned char hash[20];
- // sha1((unsigned char*)key_concat, strlen(key_concat), hash);
+ for (i = 0; i < 8; i++)
+ finalcount[i] = (unsigned char)((ctx->count[(i >= 4) ? 1 : 0] >> ((3 - (i & 3)) * 8)) & 255);
- // base64_encode(hash, 20, accept_key);
+ sha1_update(ctx, (unsigned char*)"\200", 1);
+ while ((ctx->count[0] & 504) != 448)
+ sha1_update(ctx, (unsigned char*)"\0", 1);
+ sha1_update(ctx, finalcount, 8);
+ for (i = 0; i < 20; i++)
+ digest[i] = (unsigned char)((ctx->state[i >> 2] >> ((3 - (i & 3)) * 8)) & 255);
+}
+
+static void base64_encode(const unsigned char* input, size_t input_len, char* output)
+{
+ static const char table[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+ size_t i = 0, j = 0;
+
+ while (i < input_len)
+ {
+ uint32_t a = i < input_len ? input[i++] : 0;
+ uint32_t b = i < input_len ? input[i++] : 0;
+ uint32_t c = i < input_len ? input[i++] : 0;
+ uint32_t triple = (a << 16) | (b << 8) | c;
+ output[j++] = table[(triple >> 18) & 0x3F];
+ output[j++] = table[(triple >> 12) & 0x3F];
+ output[j++] = table[(triple >> 6) & 0x3F];
+ output[j++] = table[triple & 0x3F];
+ }
+ output[j] = '\0';
+ if (input_len % 3 == 1)
+ {
+ output[j - 2] = '=';
+ output[j - 1] = '=';
+ }
+ else if (input_len % 3 == 2)
+ output[j - 1] = '=';
+}
+
+static void ws_compute_accept_key(const char* sec_key, char* accept_out, size_t accept_size)
+{
+ char combined[256];
+ unsigned char hash[20];
+ sha1_ctx_t ctx;
+
+ snprintf(combined, sizeof(combined), "%s%s", sec_key, CHTTPX_WSOCKET_GUID);
+ sha1_init(&ctx);
+ sha1_update(&ctx, (unsigned char*)combined, strlen(combined));
+ sha1_final(&ctx, hash);
+ if (accept_size >= 29)
+ base64_encode(hash, 20, accept_out);
+}
+
+static int ws_set_nonblocking(chttpx_socket_t fd)
+{
+#ifdef CHTTPX_PLATFORM_WINDOWS
+ u_long mode = 1;
+ return ioctlsocket(fd, FIONBIO, &mode);
+#else
+ int flags = fcntl(fd, F_GETFL, 0);
+ if (flags < 0)
+ return -1;
+ return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
+#endif
+}
+
+static ssize_t ws_send_all(chttpx_socket_t fd, const unsigned char* buf, size_t len)
+{
+ size_t total = 0;
+ while (total < len)
+ {
+ ssize_t n = send(fd, (const char*)buf + total, (int)(len - total), 0);
+ if (n <= 0)
+ return n;
+ total += (size_t)n;
+ }
+ return (ssize_t)total;
+}
+
+static int ws_send_frame(chttpx_socket_t fd, int opcode, const unsigned char* data, size_t len, int fin)
+{
+ unsigned char header[10];
+ size_t header_len = 2;
+
+ header[0] = (unsigned char)((fin ? 0x80 : 0) | (opcode & 0x0F));
+ if (len <= 125)
+ header[1] = (unsigned char)len;
+ else if (len <= 65535)
+ {
+ header[1] = 126;
+ header[2] = (unsigned char)((len >> 8) & 0xFF);
+ header[3] = (unsigned char)(len & 0xFF);
+ header_len = 4;
+ }
+ else
+ return -1;
+
+ if (ws_send_all(fd, header, header_len) < 0)
+ return -1;
+ if (len > 0 && data && ws_send_all(fd, data, len) < 0)
+ return -1;
+ return (int)len;
+}
+
+int cHTTPX_WSocketSend(chttpx_wsocket_t* ws, const char* text)
+{
+ if (!ws || !ws->connected || !text)
+ return -1;
+ return ws_send_frame(ws->socket, CHTTPX_WSOCKET_OPCODE_TEXT, (const unsigned char*)text, strlen(text), 1);
+}
+
+int cHTTPX_WSocketSendBinary(chttpx_wsocket_t* ws, const unsigned char* data, size_t len)
+{
+ if (!ws || !ws->connected)
+ return -1;
+ return ws_send_frame(ws->socket, CHTTPX_WSOCKET_OPCODE_BINARY, data, len, 1);
+}
+
+static int ws_do_upgrade(chttpx_socket_t client_socket, const char* sec_websocket_key)
+{
+ char accept_key[64] = {0};
+ char buffer[512];
+
+ ws_compute_accept_key(sec_websocket_key, accept_key, sizeof(accept_key));
int n = snprintf(buffer, sizeof(buffer),
"HTTP/1.1 101 Switching Protocols\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
"Sec-WebSocket-Accept: %s\r\n\r\n",
accept_key);
- send(client_socket, buffer, n, 0);
+ if (n <= 0 || ws_send_all(client_socket, (unsigned char*)buffer, (size_t)n) < 0)
+ return -1;
+ return 0;
+}
+
+/* --- Routes --- */
+
+static void ws_register_route(const char* path, const chttpx_wsocket_callbacks_t* callbacks)
+{
+ if (!serv || !path || !callbacks)
+ return;
+
+ if (serv->ws_routes_count == serv->ws_routes_capacity)
+ {
+ size_t new_cap = serv->ws_routes_capacity == 0 ? 4 : serv->ws_routes_capacity * 2;
+ chttpx_wsocket_route_entry_t* routes =
+ realloc(serv->ws_routes, sizeof(chttpx_wsocket_route_entry_t) * new_cap);
+ if (!routes)
+ return;
+ serv->ws_routes = routes;
+ serv->ws_routes_capacity = new_cap;
+ }
+
+ serv->ws_routes[serv->ws_routes_count].path = strdup(path);
+ serv->ws_routes[serv->ws_routes_count].on_open = callbacks->on_open;
+ serv->ws_routes[serv->ws_routes_count].on_message = callbacks->on_message;
+ serv->ws_routes[serv->ws_routes_count].on_close = callbacks->on_close;
+ serv->ws_routes[serv->ws_routes_count].userdata = callbacks->userdata;
+ serv->ws_routes_count++;
+}
+
+void cHTTPX_WSocketRegisterRoute(chttpx_router_t* r, const char* path, const chttpx_wsocket_callbacks_t* callbacks)
+{
+ if (!r || !path || !callbacks)
+ return;
+
+ char fpath[CHTTPX_MAX_PATH];
+ if (snprintf(fpath, sizeof(fpath), "%s%s", r->prefix ? r->prefix : "", path) >= (int)sizeof(fpath))
+ return;
+
+ ws_register_route(fpath, callbacks);
+}
+
+static chttpx_wsocket_route_entry_t* find_ws_route(chttpx_request_t* req)
+{
+ if (!serv || !req || !req->path)
+ return NULL;
+
+ for (size_t i = 0; i < serv->ws_routes_count; i++)
+ {
+ int count = 0;
+ if (cHTTPX_MatchPath(serv->ws_routes[i].path, req->path, req->params, &count))
+ {
+ req->params_count = count;
+ return &serv->ws_routes[i];
+ }
+ }
+ return NULL;
+}
+
+static int is_websocket_upgrade(chttpx_request_t* req)
+{
+ if (!req || !req->method || strcmp(req->method, "GET") != 0)
+ return 0;
+
+ const char* upgrade = cHTTPX_HeaderGet(req, "Upgrade");
+ const char* connection = cHTTPX_HeaderGet(req, "Connection");
+ const char* ws_key = cHTTPX_HeaderGet(req, "Sec-WebSocket-Key");
+ const char* ws_version = cHTTPX_HeaderGet(req, "Sec-WebSocket-Version");
+
+ if (!upgrade || !connection || !ws_key || !ws_version)
+ return 0;
+ if (strcasecmp(upgrade, "websocket") != 0)
+ return 0;
+ if (!strstr(connection, "pgrade") && !strstr(connection, "pGRADE"))
+ return 0;
+ if (strcmp(ws_version, "13") != 0)
+ return 0;
+ return 1;
+}
+
+/* --- Connection pool --- */
+
+static void ws_connection_close(ws_connection_t* conn)
+{
+ if (!conn->public_ws.connected)
+ return;
+
+ ws_send_frame(conn->public_ws.socket, CHTTPX_WSOCKET_OPCODE_CLOSE, NULL, 0, 1);
+ conn->public_ws.connected = 0;
+
+ if (conn->on_close)
+ conn->on_close(&conn->public_ws, conn->route_userdata);
+ chttpx_close(conn->public_ws.socket);
+ conn->public_ws.socket = (chttpx_socket_t)-1;
+}
+
+static void ws_remove_connection(size_t index)
+{
+ ws_connection_close(&ws_engine.items[index]);
+ ws_engine.items[index] = ws_engine.items[ws_engine.count - 1];
+ ws_engine.count--;
+}
+
+static int ws_add_connection(chttpx_socket_t fd, chttpx_wsocket_route_entry_t* route, chttpx_request_t* req)
+{
+ if (ws_engine.count == ws_engine.capacity)
+ {
+ size_t new_cap = ws_engine.capacity == 0 ? 64 : ws_engine.capacity * 2;
+ ws_connection_t* items = realloc(ws_engine.items, sizeof(ws_connection_t) * new_cap);
+ if (!items)
+ return -1;
+ ws_engine.items = items;
+ ws_engine.capacity = new_cap;
+ }
+
+ size_t slot = ws_engine.count;
+ ws_connection_t* conn = &ws_engine.items[slot];
+ memset(conn, 0, sizeof(*conn));
+ conn->public_ws.socket = fd;
+ conn->public_ws.connected = 1;
+ if (req && req->path)
+ strncpy(conn->public_ws.path, req->path, sizeof(conn->public_ws.path) - 1);
+ if (req && req->params_count > 0)
+ {
+ conn->public_ws.params_count = req->params_count;
+ memcpy(conn->public_ws.params, req->params, sizeof(chttpx_param_t) * req->params_count);
+ }
+ conn->public_ws.userdata = route->userdata;
+ conn->on_open = route->on_open;
+ conn->on_message = route->on_message;
+ conn->on_close = route->on_close;
+ conn->route_userdata = route->userdata;
+
+ ws_set_nonblocking(fd);
+ ws_engine.count++;
+
+ return (int)slot;
+}
+
+const char* cHTTPX_WSocketParam(chttpx_wsocket_t* ws, const char* name)
+{
+ if (!ws || !name || ws->params_count == 0)
+ return NULL;
+
+ for (size_t i = 0; i < ws->params_count; i++)
+ {
+ if (strcmp(ws->params[i].name, name) == 0)
+ return ws->params[i].value;
+ }
+ return NULL;
+}
+
+static int ws_broadcast_match(int (*match)(ws_connection_t* conn, const void* ctx), const void* ctx, const char* text)
+{
+ int sent = 0;
+ ws_lock();
+ for (size_t i = 0; i < ws_engine.count; i++)
+ {
+ ws_connection_t* conn = &ws_engine.items[i];
+ if (!conn->public_ws.connected)
+ continue;
+ if (!match(conn, ctx))
+ continue;
+ if (cHTTPX_WSocketSend(&conn->public_ws, text) >= 0)
+ sent++;
+ }
+ ws_unlock();
+ return sent;
+}
+
+static int ws_match_path(ws_connection_t* conn, const void* ctx)
+{
+ const char* path = (const char*)ctx;
+ return path && strcmp(conn->public_ws.path, path) == 0;
+}
+
+static int ws_match_peers(ws_connection_t* conn, const void* ctx)
+{
+ const chttpx_wsocket_t* ws = (const chttpx_wsocket_t*)ctx;
+ return ws && strcmp(conn->public_ws.path, ws->path) == 0;
+}
+
+typedef struct
+{
+ const char* param_name;
+ const char* param_value;
+} ws_room_match_t;
+
+static int ws_match_room(ws_connection_t* conn, const void* ctx)
+{
+ const ws_room_match_t* room = (const ws_room_match_t*)ctx;
+ if (!room || !room->param_name || !room->param_value)
+ return 0;
+
+ for (size_t i = 0; i < conn->public_ws.params_count; i++)
+ {
+ if (strcmp(conn->public_ws.params[i].name, room->param_name) == 0 &&
+ strcmp(conn->public_ws.params[i].value, room->param_value) == 0)
+ return 1;
+ }
return 0;
}
-// static wsocket_read_frame(chttpx_socket_t client_fd, wsocket_frame_t* out)
-// {
-// unsigned char hdr[2];
+int cHTTPX_WSocketBroadcast(const char* path, const char* text)
+{
+ if (!path || !text)
+ return -1;
+ return ws_broadcast_match(ws_match_path, path, text);
+}
+
+int cHTTPX_WSocketBroadcastPeers(chttpx_wsocket_t* ws, const char* text)
+{
+ if (!ws || !text)
+ return -1;
+ return ws_broadcast_match(ws_match_peers, ws, text);
+}
+
+int cHTTPX_WSocketBroadcastRoom(const char* param_name, const char* param_value, const char* text)
+{
+ if (!param_name || !param_value || !text)
+ return -1;
+
+ ws_room_match_t room = {param_name, param_value};
+ return ws_broadcast_match(ws_match_room, &room, text);
+}
+
+/* --- Frame parsing (incremental, non-blocking) --- */
+
+static int ws_try_process_one_frame(ws_connection_t* conn)
+{
+ size_t off = conn->frame_offset;
+ size_t avail = conn->read_len - off;
+
+ if (avail < 2)
+ return 0;
+
+ unsigned char* b = conn->read_buf + off;
+ int opcode = b[0] & 0x0F;
+ int masked = (b[1] & 0x80) != 0;
+ uint64_t plen = b[1] & 0x7F;
+ size_t hsize = 2;
+
+ if (plen == 126)
+ {
+ if (avail < 4)
+ return 0;
+ plen = ((uint64_t)b[2] << 8) | b[3];
+ hsize = 4;
+ }
+ else if (plen == 127)
+ return -1;
+
+ if (plen > CHTTPX_WSOCKET_MAX_PAYLOAD)
+ return -1;
+
+ size_t total = hsize + (masked ? 4 : 0) + (size_t)plen;
+ if (avail < total)
+ return 0;
+
+ unsigned char* payload = b + hsize + (masked ? 4 : 0);
+ unsigned char mask[4];
-// if (recv(client_fd, hdr, 2, MSG_WAITALL) <= 0) return -1;
+ if (masked)
+ {
+ memcpy(mask, b + hsize, 4);
+ for (uint64_t i = 0; i < plen; i++)
+ payload[i] ^= mask[i % 4];
+ }
-// int opcode = hdr[0] & 0x0F;
-// int masked = hdr[1] & 0x80;
-// int len = hdr[1] & 0x7F;
-// }
\ No newline at end of file
+ if (opcode == CHTTPX_WSOCKET_OPCODE_CLOSE)
+ {
+ conn->public_ws.connected = 0;
+ }
+ else if (opcode == CHTTPX_WSOCKET_OPCODE_PING)
+ {
+ ws_send_frame(conn->public_ws.socket, CHTTPX_WSOCKET_OPCODE_PONG, payload, (size_t)plen, 1);
+ }
+ else if (opcode == CHTTPX_WSOCKET_OPCODE_TEXT || opcode == CHTTPX_WSOCKET_OPCODE_BINARY)
+ {
+ if (conn->on_message)
+ conn->on_message(&conn->public_ws, payload, (size_t)plen, opcode, conn->route_userdata);
+ }
+
+ conn->frame_offset += total;
+ if (conn->frame_offset >= conn->read_len)
+ {
+ conn->read_len = 0;
+ conn->frame_offset = 0;
+ }
+ else if (conn->frame_offset > 0)
+ {
+ memmove(conn->read_buf, conn->read_buf + conn->frame_offset, conn->read_len - conn->frame_offset);
+ conn->read_len -= conn->frame_offset;
+ conn->frame_offset = 0;
+ }
+
+ return 1;
+}
+
+static void ws_read_and_parse(ws_connection_t* conn)
+{
+ while (conn->public_ws.connected)
+ {
+ if (conn->read_len >= CHTTPX_WSOCKET_READ_BUF)
+ {
+ conn->public_ws.connected = 0;
+ return;
+ }
+
+ ssize_t n = recv(conn->public_ws.socket, (char*)conn->read_buf + conn->read_len,
+ (int)(CHTTPX_WSOCKET_READ_BUF - conn->read_len), 0);
+ if (n < 0)
+ {
+#ifdef CHTTPX_PLATFORM_WINDOWS
+ if (WSAGetLastError() == WSAEWOULDBLOCK)
+ return;
+#else
+ if (errno == EWOULDBLOCK || errno == EAGAIN)
+ return;
+#endif
+ conn->public_ws.connected = 0;
+ return;
+ }
+ if (n == 0)
+ {
+ conn->public_ws.connected = 0;
+ return;
+ }
+
+ conn->read_len += (size_t)n;
+
+ int processed;
+ do
+ {
+ processed = ws_try_process_one_frame(conn);
+ if (processed < 0)
+ {
+ conn->public_ws.connected = 0;
+ return;
+ }
+ } while (processed == 1 && conn->public_ws.connected);
+ return;
+ }
+}
+
+/* --- Poll thread (select: works on Linux and Windows/MinGW) --- */
+
+static void ws_poll_process_readable(ws_connection_t* conn)
+{
+ ws_read_and_parse(conn);
+}
+
+static void* ws_poll_loop(void* arg)
+{
+ (void)arg;
+
+ while (ws_engine.engine_running && !ws_engine.shutdown_requested)
+ {
+ ws_lock();
+ size_t n = ws_engine.count;
+
+ chttpx_socket_t* fds = NULL;
+ if (n > 0)
+ {
+ fds = malloc(sizeof(chttpx_socket_t) * n);
+ if (fds)
+ {
+ for (size_t i = 0; i < n; i++)
+ fds[i] = ws_engine.items[i].public_ws.socket;
+ }
+ else
+ n = 0;
+ }
+ ws_unlock();
+
+ if (n == 0)
+ {
+ free(fds);
+#if defined(_WIN32) || defined(_WIN64)
+ Sleep(10);
+#else
+ usleep(10000);
+#endif
+ continue;
+ }
+
+ fd_set readfds;
+ FD_ZERO(&readfds);
+ chttpx_socket_t maxfd = 0;
+ for (size_t i = 0; i < n; i++)
+ {
+ FD_SET(fds[i], &readfds);
+ if (fds[i] > maxfd)
+ maxfd = fds[i];
+ }
+
+ struct timeval tv = {.tv_sec = 0, .tv_usec = 50000};
+ int ready = select((int)(maxfd + 1), &readfds, NULL, NULL, &tv);
+
+ if (ready <= 0)
+ {
+ free(fds);
+ continue;
+ }
+
+ ws_lock();
+ for (size_t i = 0; i < ws_engine.count;)
+ {
+ ws_connection_t* conn = &ws_engine.items[i];
+ if (!conn->public_ws.connected)
+ {
+ ws_remove_connection(i);
+ continue;
+ }
+ if (FD_ISSET(conn->public_ws.socket, &readfds))
+ {
+ ws_unlock();
+ ws_poll_process_readable(conn);
+ ws_lock();
+ if (i >= ws_engine.count)
+ break;
+ conn = &ws_engine.items[i];
+ }
+ if (!conn->public_ws.connected)
+ {
+ ws_remove_connection(i);
+ continue;
+ }
+ i++;
+ }
+ ws_unlock();
+ free(fds);
+ }
+
+ return NULL;
+}
+
+static void ws_engine_start(void)
+{
+ if (ws_engine.engine_running)
+ return;
+
+#if defined(_WIN32) || defined(_WIN64)
+ InitializeCriticalSection(&ws_engine.lock);
+#else
+ pthread_mutex_init(&ws_engine.lock, NULL);
+#endif
+
+ ws_engine.engine_running = 1;
+ ws_engine.shutdown_requested = 0;
+ _thread_create(&ws_engine.poll_thread, ws_poll_loop, NULL);
+}
+
+void cHTTPX_WSocketShutdown(void)
+{
+ if (!ws_engine.engine_running)
+ return;
+
+ ws_engine.shutdown_requested = 1;
+ _thread_join(ws_engine.poll_thread);
+
+ ws_lock();
+ while (ws_engine.count > 0)
+ ws_remove_connection(0);
+ free(ws_engine.items);
+ ws_engine.items = NULL;
+ ws_engine.count = 0;
+ ws_engine.capacity = 0;
+ ws_unlock();
+
+#if defined(_WIN32) || defined(_WIN64)
+ DeleteCriticalSection(&ws_engine.lock);
+#else
+ pthread_mutex_destroy(&ws_engine.lock);
+#endif
+
+ ws_engine.engine_running = 0;
+}
+
+int cHTTPX_WSocketTryHandle(chttpx_request_t* req)
+{
+ if (!is_websocket_upgrade(req))
+ return 0;
+
+ chttpx_wsocket_route_entry_t* route = find_ws_route(req);
+ if (!route)
+ return 0;
+
+ const char* ws_key = cHTTPX_HeaderGet(req, "Sec-WebSocket-Key");
+ if (ws_do_upgrade(req->client_fd, ws_key) != 0)
+ return -1;
+
+ ws_engine_start();
+
+ ws_lock();
+ int slot = ws_add_connection(req->client_fd, route, req);
+ ws_unlock();
+ if (slot < 0)
+ {
+ chttpx_close(req->client_fd);
+ return -1;
+ }
+
+ ws_connection_t* conn = &ws_engine.items[(size_t)slot];
+ if (conn->on_open)
+ conn->on_open(&conn->public_ws, conn->route_userdata);
+
+ return 1;
+}
diff --git a/tests/test_framework.h b/tests/test_framework.h
new file mode 100644
index 0000000..89bf5ab
--- /dev/null
+++ b/tests/test_framework.h
@@ -0,0 +1,64 @@
+#ifndef TEST_FRAMEWORK_H
+#define TEST_FRAMEWORK_H
+
+#include
+#include
+
+extern int g_tests_run;
+extern int g_tests_failed;
+
+#define TEST(name) static void name(void)
+
+#define RUN_TEST(name) \
+ do \
+ { \
+ int _failed_before = g_tests_failed; \
+ printf(" %s ... ", #name); \
+ fflush(stdout); \
+ g_tests_run++; \
+ name(); \
+ if (g_tests_failed == _failed_before) \
+ printf("ok\n"); \
+ } while (0)
+
+#define ASSERT(cond) \
+ do \
+ { \
+ if (!(cond)) \
+ { \
+ printf("FAIL\n assertion: %s (%s:%d)\n", #cond, \
+ __FILE__, __LINE__); \
+ g_tests_failed++; \
+ return; \
+ } \
+ } while (0)
+
+#define ASSERT_STREQ(expected, actual) \
+ do \
+ { \
+ const char* _exp = (expected); \
+ const char* _act = (actual); \
+ if (!_exp || !_act || strcmp(_exp, _act) != 0) \
+ { \
+ printf("FAIL\n expected '%s', got '%s' (%s:%d)\n", _exp, _act, \
+ __FILE__, __LINE__); \
+ g_tests_failed++; \
+ return; \
+ } \
+ } while (0)
+
+#define ASSERT_EQ(expected, actual) \
+ do \
+ { \
+ long long _exp = (long long)(expected); \
+ long long _act = (long long)(actual); \
+ if (_exp != _act) \
+ { \
+ printf("FAIL\n expected %lld, got %lld (%s:%d)\n", _exp, _act, \
+ __FILE__, __LINE__); \
+ g_tests_failed++; \
+ return; \
+ } \
+ } while (0)
+
+#endif
diff --git a/tests/test_params.c b/tests/test_params.c
new file mode 100644
index 0000000..a8ba239
--- /dev/null
+++ b/tests/test_params.c
@@ -0,0 +1,60 @@
+#include "test_framework.h"
+
+#include "libchttpx.h"
+
+TEST(test_match_static_path)
+{
+ chttpx_param_t params[MAX_PARAMS] = {0};
+ int count = -1;
+
+ ASSERT(cHTTPX_MatchPath("/api/v1/", "/api/v1/", params, &count));
+ ASSERT_EQ(0, count);
+}
+
+TEST(test_match_single_param)
+{
+ chttpx_param_t params[MAX_PARAMS] = {0};
+ int count = 0;
+
+ ASSERT(cHTTPX_MatchPath("/users/{uuid}", "/users/abc-123", params, &count));
+ ASSERT_EQ(1, count);
+ ASSERT_STREQ("uuid", params[0].name);
+ ASSERT_STREQ("abc-123", params[0].value);
+}
+
+TEST(test_match_ws_room_path)
+{
+ chttpx_param_t params[MAX_PARAMS] = {0};
+ int count = 0;
+
+ ASSERT(cHTTPX_MatchPath("/api/v1/ws/chat/{room_id}", "/api/v1/ws/chat/lobby", params, &count));
+ ASSERT_EQ(1, count);
+ ASSERT_STREQ("room_id", params[0].name);
+ ASSERT_STREQ("lobby", params[0].value);
+}
+
+TEST(test_match_rejects_extra_segments)
+{
+ chttpx_param_t params[MAX_PARAMS] = {0};
+ int count = 0;
+
+ ASSERT(!cHTTPX_MatchPath("/users/{uuid}", "/users/abc/extra", params, &count));
+}
+
+TEST(test_match_rejects_missing_segments)
+{
+ chttpx_param_t params[MAX_PARAMS] = {0};
+ int count = 0;
+
+ ASSERT(!cHTTPX_MatchPath("/users/{uuid}/profile", "/users/abc", params, &count));
+}
+
+void run_params_tests(void)
+{
+ printf("params\n");
+ RUN_TEST(test_match_static_path);
+ RUN_TEST(test_match_single_param);
+ RUN_TEST(test_match_ws_room_path);
+ RUN_TEST(test_match_rejects_extra_segments);
+ RUN_TEST(test_match_rejects_missing_segments);
+}
diff --git a/tests/test_response.c b/tests/test_response.c
new file mode 100644
index 0000000..628ab2c
--- /dev/null
+++ b/tests/test_response.c
@@ -0,0 +1,48 @@
+#include "test_framework.h"
+
+#include "libchttpx.h"
+
+#include
+#include
+
+TEST(test_res_json_body)
+{
+ chttpx_response_t res = cHTTPX_ResJson(cHTTPX_StatusOK, "{\"msg\":\"hi\"}");
+
+ ASSERT_EQ(cHTTPX_StatusOK, res.status);
+ ASSERT(res.body != NULL);
+ ASSERT_STREQ("application/json", res.content_type);
+ ASSERT_STREQ("{\"msg\":\"hi\"}", (const char*)res.body);
+ ASSERT_EQ(12, (long long)res.body_size);
+
+ free(res.body);
+}
+
+TEST(test_res_html_body)
+{
+ chttpx_response_t res = cHTTPX_ResHtml(cHTTPX_StatusOK, "%s
", "test");
+
+ ASSERT_EQ(cHTTPX_StatusOK, res.status);
+ ASSERT(res.body != NULL);
+ ASSERT_STREQ("text/html", res.content_type);
+ ASSERT_STREQ("test
", (const char*)res.body);
+
+ free(res.body);
+}
+
+TEST(test_res_json_not_found)
+{
+ chttpx_response_t res = cHTTPX_ResJson(cHTTPX_StatusNotFound, "{\"error\":\"missing\"}");
+
+ ASSERT_EQ(cHTTPX_StatusNotFound, res.status);
+ ASSERT(res.body != NULL);
+ free(res.body);
+}
+
+void run_response_tests(void)
+{
+ printf("response\n");
+ RUN_TEST(test_res_json_body);
+ RUN_TEST(test_res_html_body);
+ RUN_TEST(test_res_json_not_found);
+}
diff --git a/tests/test_runner.c b/tests/test_runner.c
new file mode 100644
index 0000000..10701e1
--- /dev/null
+++ b/tests/test_runner.c
@@ -0,0 +1,21 @@
+#include
+
+int g_tests_run = 0;
+int g_tests_failed = 0;
+
+void run_params_tests(void);
+void run_response_tests(void);
+void run_server_tests(void);
+void run_websocket_tests(void);
+
+int main(void)
+{
+ run_params_tests();
+ run_response_tests();
+ run_server_tests();
+ run_websocket_tests();
+
+ printf("\n%d tests, %d failed\n", g_tests_run, g_tests_failed);
+
+ return g_tests_failed == 0 ? 0 : 1;
+}
diff --git a/tests/test_server.c b/tests/test_server.c
new file mode 100644
index 0000000..ff490f4
--- /dev/null
+++ b/tests/test_server.c
@@ -0,0 +1,64 @@
+#include "test_framework.h"
+
+#include "libchttpx.h"
+
+#include
+
+static void ping_handler(chttpx_request_t* req, chttpx_response_t* res)
+{
+ (void)req;
+ *res = cHTTPX_ResJson(cHTTPX_StatusOK, "{\"pong\":true}");
+}
+
+TEST(test_init_and_shutdown)
+{
+ chttpx_serv_t serv = {0};
+
+ ASSERT_EQ(0, cHTTPX_Init(&serv, 18080, NULL));
+ ASSERT_EQ(18080, serv.port);
+ ASSERT(serv.routes == NULL);
+ ASSERT_EQ(0, serv.routes_count);
+
+ cHTTPX_Shutdown();
+}
+
+TEST(test_register_http_routes)
+{
+ chttpx_serv_t serv = {0};
+
+ ASSERT_EQ(0, cHTTPX_Init(&serv, 18081, NULL));
+
+ chttpx_router_t api = cHTTPX_RoutePathPrefix("/api/v1");
+ cHTTPX_RegisterRoute(&api, "GET", "/ping", ping_handler);
+ cHTTPX_RegisterRoute(&api, "POST", "/users", ping_handler);
+
+ ASSERT_EQ(2, (long long)serv.routes_count);
+ ASSERT_STREQ("GET", serv.routes[0].method);
+ ASSERT_STREQ("/api/v1/ping", serv.routes[0].path);
+ ASSERT_STREQ("POST", serv.routes[1].method);
+ ASSERT_STREQ("/api/v1/users", serv.routes[1].path);
+
+ cHTTPX_Shutdown();
+}
+
+TEST(test_middleware_registration)
+{
+ chttpx_serv_t serv = {0};
+
+ ASSERT_EQ(0, cHTTPX_Init(&serv, 18082, NULL));
+
+ cHTTPX_MiddlewareRecovery();
+ cHTTPX_MiddlewareRateLimiter(5, 1);
+
+ ASSERT(serv.middleware.middleware_count >= 2);
+
+ cHTTPX_Shutdown();
+}
+
+void run_server_tests(void)
+{
+ printf("server\n");
+ RUN_TEST(test_init_and_shutdown);
+ RUN_TEST(test_register_http_routes);
+ RUN_TEST(test_middleware_registration);
+}
diff --git a/tests/test_websocket.c b/tests/test_websocket.c
new file mode 100644
index 0000000..bf56452
--- /dev/null
+++ b/tests/test_websocket.c
@@ -0,0 +1,72 @@
+#include "test_framework.h"
+
+#include "libchttpx.h"
+
+static void ws_open(chttpx_wsocket_t* ws, void* userdata)
+{
+ (void)ws;
+ (void)userdata;
+}
+
+static void ws_message(chttpx_wsocket_t* ws, const unsigned char* data, size_t len, int opcode, void* userdata)
+{
+ (void)ws;
+ (void)data;
+ (void)len;
+ (void)opcode;
+ (void)userdata;
+}
+
+static void ws_close(chttpx_wsocket_t* ws, void* userdata)
+{
+ (void)ws;
+ (void)userdata;
+}
+
+TEST(test_register_websocket_route)
+{
+ chttpx_serv_t serv = {0};
+ static chttpx_wsocket_callbacks_t callbacks = {
+ .on_open = ws_open,
+ .on_message = ws_message,
+ .on_close = ws_close,
+ };
+
+ ASSERT_EQ(0, cHTTPX_Init(&serv, 18083, NULL));
+
+ chttpx_router_t api = cHTTPX_RoutePathPrefix("/api/v1");
+ cHTTPX_WSocketRegisterRoute(&api, "/ws/chat/{room_id}", &callbacks);
+
+ ASSERT_EQ(1, (long long)serv.ws_routes_count);
+ ASSERT(serv.ws_routes[0].path != NULL);
+ ASSERT_STREQ("/api/v1/ws/chat/{room_id}", serv.ws_routes[0].path);
+ ASSERT(serv.ws_routes[0].on_open == ws_open);
+ ASSERT(serv.ws_routes[0].on_message == ws_message);
+ ASSERT(serv.ws_routes[0].on_close == ws_close);
+
+ cHTTPX_Shutdown();
+}
+
+TEST(test_websocket_shutdown_without_connections)
+{
+ chttpx_serv_t serv = {0};
+ static chttpx_wsocket_callbacks_t callbacks = {
+ .on_open = ws_open,
+ .on_message = ws_message,
+ .on_close = ws_close,
+ };
+
+ ASSERT_EQ(0, cHTTPX_Init(&serv, 18084, NULL));
+
+ chttpx_router_t api = cHTTPX_RoutePathPrefix("/api/v1");
+ cHTTPX_WSocketRegisterRoute(&api, "/ws/chat/{room_id}", &callbacks);
+
+ cHTTPX_Shutdown();
+}
+
+void run_websocket_tests(void)
+{
+ printf("websocket\n");
+ RUN_TEST(test_register_websocket_route);
+ RUN_TEST(test_websocket_shutdown_without_connections);
+}