From 169b73d6731da894fb691ee94209fe6235770910 Mon Sep 17 00:00:00 2001 From: yep Date: Fri, 17 Jul 2026 17:05:31 -0400 Subject: [PATCH 1/2] feat: added base structure ws(websocket) functionality (#13) * feat: added base structure ws(websocket) functionality * added yml workflows for build test library * fix: segmentation fault * added base tests for work library * added docker ci/cd and docker push tag --- .dockerignore | 18 + .github/pull_request_template.md | 5 + .github/workflows/ci.yml | 9 +- .github/workflows/docker.yml | 54 ++ .github/workflows/pr-preview.yml | 128 +++++ Dockerfile | 52 ++ Makefile | 65 ++- exmaples.c | 53 +- include/libchttpx.h | 2 + include/params.h | 6 + include/serv.h | 21 + include/websocket.h | 94 ++-- scripts/install-preview.sh | 86 ++++ src/params.c | 52 ++ src/response.c | 79 +-- src/serv.c | 15 + src/websocket.c | 821 +++++++++++++++++++++++++++++-- tests/test_framework.h | 64 +++ tests/test_params.c | 60 +++ tests/test_response.c | 48 ++ tests/test_runner.c | 21 + tests/test_server.c | 64 +++ tests/test_websocket.c | 72 +++ 23 files changed, 1746 insertions(+), 143 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/docker.yml create mode 100644 .github/workflows/pr-preview.yml create mode 100644 Dockerfile create mode 100644 scripts/install-preview.sh create mode 100644 tests/test_framework.h create mode 100644 tests/test_params.c create mode 100644 tests/test_response.c create mode 100644 tests/test_runner.c create mode 100644 tests/test_server.c create mode 100644 tests/test_websocket.c 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..c42c0c2 --- /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: netcorelink/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/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..042c240 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,52 @@ +# 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 \ + gcc \ + make \ + 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 \ + gcc \ + make \ + 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="libchttpx C HTTP/WebSocket library (dev base image)" \ + org.opencontainers.image.licenses="MIT" + +WORKDIR /app diff --git a/Makefile b/Makefile index 99506b9..7c9edd7 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,5 @@ -TARGET=chttpx-server +TARGET = libchttpx-tests +EXAMPLE_TARGET = chttpx-server RELEASE_DIR = libchttpx-dev TAR = $(RELEASE_DIR).tar.gz @@ -17,40 +18,60 @@ 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_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 # - 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) exmaples.c -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 +88,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 +127,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 +149,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) exmaples.c -run: run-lin +run: lin-run clean: rm -rf $(OBJDIR) $(BINDIR) *.a *.dll diff --git a/exmaples.c b/exmaples.c index 946c4a8..77a69c1 100644 --- a/exmaples.c +++ b/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/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..06a599f 100644 --- a/include/serv.h +++ b/include/serv.h @@ -19,10 +19,17 @@ extern "C" #include #include +#include #define 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..215a83e 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[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/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..83bd590 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]; @@ -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..0a9cce7 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; @@ -239,6 +244,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..15b8473 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[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); +} From 7fe64868b98350786ee04b432b3fb660261b47fc Mon Sep 17 00:00:00 2001 From: noneandundefined Date: Sat, 18 Jul 2026 02:16:56 +0500 Subject: [PATCH 2/2] fix: fixed Dockerfile build include --- Dockerfile | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 042c240..225fe8f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,8 +12,7 @@ FROM debian:bookworm-slim AS builder RUN apt-get update \ && apt-get install -y --no-install-recommends \ - gcc \ - make \ + build-essential \ libcjson-dev \ pkg-config \ && rm -rf /var/lib/apt/lists/* @@ -32,8 +31,7 @@ FROM debian:bookworm-slim RUN apt-get update \ && apt-get install -y --no-install-recommends \ - gcc \ - make \ + build-essential \ libcjson-dev \ pkg-config \ && rm -rf /var/lib/apt/lists/*