From 26dccd353670e569fd78cb0f6efaed74d1d138dd Mon Sep 17 00:00:00 2001 From: Furqan-14 Date: Sat, 16 May 2026 11:01:20 +0530 Subject: [PATCH] fix: built base emsdk and other tool fixes --- .github/workflows/ci.yml | 88 +++++++++++++++++++----- CMakeLists.txt | 27 ++------ CMakePresets.json | 2 +- cmake/Sanitizers.cmake | 8 +-- cmake/pdfium.cmake | 21 +----- cmake/toolchains/wasm.cmake | 13 ++-- engine/CMakeLists.txt | 9 --- gateway/README.md | 107 +++++++++++++++++++++++++---- gateway/app/__init__.py | 3 + gateway/app/config.py | 33 +++++++++ gateway/app/main.py | 24 +++++++ gateway/app/routers/documents.py | 32 +++++++++ gateway/app/routers/edits.py | 16 +++++ gateway/app/routers/health.py | 35 ++++++++++ gateway/app/routers/render.py | 21 ++++++ gateway/app/services/engine.py | 45 ++++++++++++ gateway/pyproject.toml | 59 ++++++++++++++++ gateway/tests/conftest.py | 14 ++++ gateway/tests/test_health.py | 25 +++++++ gateway/tests/test_placeholders.py | 29 ++++++++ wasm/CMakeLists.txt | 41 +++++++++++ wasm/README.md | 96 ++++++++++++++++++++++---- wasm/emsdk.pinned | 10 +++ wasm/hello.cpp | 35 ++++++++++ wasm/hello.test.mjs | 46 +++++++++++++ wasm/package.json | 13 ++++ 26 files changed, 743 insertions(+), 109 deletions(-) create mode 100644 gateway/app/__init__.py create mode 100644 gateway/app/config.py create mode 100644 gateway/app/main.py create mode 100644 gateway/app/routers/documents.py create mode 100644 gateway/app/routers/edits.py create mode 100644 gateway/app/routers/health.py create mode 100644 gateway/app/routers/render.py create mode 100644 gateway/app/services/engine.py create mode 100644 gateway/pyproject.toml create mode 100644 gateway/tests/conftest.py create mode 100644 gateway/tests/test_health.py create mode 100644 gateway/tests/test_placeholders.py create mode 100644 wasm/CMakeLists.txt create mode 100644 wasm/emsdk.pinned create mode 100644 wasm/hello.cpp create mode 100644 wasm/hello.test.mjs create mode 100644 wasm/package.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f2e56a..266ab58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,9 +13,6 @@ concurrency: cancel-in-progress: true jobs: - # --------------------------------------------------------------------------- - # Static checks: Rule R2 boundary + formatting. Fast, gates the build matrix. - # --------------------------------------------------------------------------- lint: runs-on: ubuntu-latest steps: @@ -24,9 +21,6 @@ jobs: - name: Rule R2 — PDFium boundary check run: bash scripts/check_pdfium_boundary.sh - # Pinned so CI matches the version the tree was formatted with — clang-format - # output drifts between releases, and an unpinned runner version would cause - # spurious lint failures. - name: Install clang-format (pinned) run: pipx install clang-format==22.1.5 @@ -36,9 +30,6 @@ jobs: find engine \( -name '*.cpp' -o -name '*.cc' -o -name '*.h' -o -name '*.hpp' \) \ -print0 | xargs -0 clang-format --dry-run --Werror - # --------------------------------------------------------------------------- - # Build + test on all three platforms (Gate G0: compiles clean everywhere). - # --------------------------------------------------------------------------- build: needs: lint strategy: @@ -66,15 +57,10 @@ jobs: if: runner.os == 'Windows' uses: ilammy/msvc-dev-cmd@v1 - # GitHub-hosted runners ship vcpkg preinstalled. VCPKG_INSTALLATION_ROOT is - # a runner OS env var, so it must be promoted to GITHUB_ENV here — it is - # NOT visible via the ${{ env.* }} context in a job-level env: block. - name: Locate vcpkg shell: bash run: | echo "VCPKG_ROOT=$VCPKG_INSTALLATION_ROOT" >> "$GITHUB_ENV" - # The runner's preinstalled vcpkg may predate our pinned builtin-baseline; - # fetch so manifest resolution can find that commit's versions tree. git -C "$VCPKG_INSTALLATION_ROOT" fetch --quiet origin || true - name: Create vcpkg binary cache dir @@ -88,9 +74,6 @@ jobs: key: vcpkg-${{ matrix.os }}-${{ hashFiles('vcpkg.json') }} restore-keys: vcpkg-${{ matrix.os }}- - # vcpkg.json ships without a builtin-baseline; the local bootstrap script - # normally pins it. In CI we pin it on the fly (idempotent) so the - # CMakeLists dependency-pinning guard is satisfied. - name: Pin vcpkg dependency baseline shell: bash run: | @@ -106,3 +89,74 @@ jobs: - name: Test run: ctest --preset ${{ matrix.preset }} + + gateway: + needs: lint + runs-on: ubuntu-latest + defaults: + run: + working-directory: gateway + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + cache-dependency-path: gateway/pyproject.toml + + - name: Install gateway (editable, with dev extras) + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + + - name: Ruff — lint + run: python -m ruff check . + + - name: Ruff — format check + run: python -m ruff format --check . + + - name: Pytest + run: python -m pytest + + + wasm: + needs: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Read pinned emsdk version + id: emsdk-version + run: | + version=$(grep '^EMSDK_VERSION=' wasm/emsdk.pinned | cut -d= -f2) + if [ -z "$version" ]; then + echo "::error::EMSDK_VERSION not found in wasm/emsdk.pinned" + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Install Ninja + uses: seanmiddleditch/gha-setup-ninja@v5 + + - name: Set up Emscripten ${{ steps.emsdk-version.outputs.version }} + uses: mymindstorm/setup-emsdk@v14 + with: + version: ${{ steps.emsdk-version.outputs.version }} + actions-cache-folder: emsdk-cache-${{ steps.emsdk-version.outputs.version }} + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Configure (WASM) + + run: cmake --preset wasm + + - name: Build + run: cmake --build --preset wasm + + - name: Smoke test + run: node wasm/hello.test.mjs diff --git a/CMakeLists.txt b/CMakeLists.txt index 2116882..1ee0d0b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,10 +1,5 @@ cmake_minimum_required(VERSION 3.25) -# --------------------------------------------------------------------------- -# Dependency-pinning guard (Rule: pin all dependency versions on Day 1). -# We refuse to configure until vcpkg.json carries a 'builtin-baseline', which -# scripts/bootstrap adds via `vcpkg x-update-baseline --add-initial-baseline`. -# --------------------------------------------------------------------------- file(READ "${CMAKE_CURRENT_SOURCE_DIR}/vcpkg.json" _pdfengine_vcpkg_json) string(JSON _pdfengine_baseline ERROR_VARIABLE _pdfengine_baseline_err GET "${_pdfengine_vcpkg_json}" "builtin-baseline") @@ -24,32 +19,30 @@ project(PdfEngine HOMEPAGE_URL "https://example.invalid/pdf-engine" LANGUAGES CXX) -# --------------------------------------------------------------------------- -# Global C++ settings — C++23, no compiler extensions (engine blueprint §1.3). -# --------------------------------------------------------------------------- set(CMAKE_CXX_STANDARD 23) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) -# compile_commands.json — consumed by clang-tidy and editors. set(CMAKE_EXPORT_COMPILE_COMMANDS ON) -# Single output dirs so test binaries find imported shared libs at runtime. set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") set(CMAKE_LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib") set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib") list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") +if(EMSCRIPTEN) + message(STATUS "PdfEngine ${PROJECT_VERSION} — WASM hello-world configuration (Phase 0)") + add_subdirectory(wasm) + return() +endif() + # Only the top-level project drives testing/install defaults. if(CMAKE_SOURCE_DIR STREQUAL CMAKE_BINARY_DIR) message(FATAL_ERROR "In-source builds are not allowed. Use a preset: " "cmake --preset -debug") endif() -# --------------------------------------------------------------------------- -# Build options. -# --------------------------------------------------------------------------- option(PDFENGINE_BUILD_TESTS "Build engine unit/smoke tests" ON) option(PDFENGINE_ENABLE_SANITIZERS "Build with AddressSanitizer/UBSan" OFF) option(PDFENGINE_WARNINGS_AS_ERRORS "Treat compiler warnings as errors" OFF) @@ -59,9 +52,6 @@ include(CompilerWarnings) include(Sanitizers) include(pdfium) # defines pdfium::pdfium when PDFENGINE_WITH_PDFIUM is ON -# --------------------------------------------------------------------------- -# Third-party dependencies (resolved by vcpkg via the manifest). -# --------------------------------------------------------------------------- find_package(freetype CONFIG REQUIRED) find_package(harfbuzz CONFIG REQUIRED) find_package(spdlog CONFIG REQUIRED) @@ -72,13 +62,8 @@ if(PDFENGINE_BUILD_TESTS) include(GoogleTest) endif() -# --------------------------------------------------------------------------- -# Subprojects. -# --------------------------------------------------------------------------- add_subdirectory(engine) -# bindings/, gateway/, frontend/, wasm/ are placeholders in Phase 0 and are not -# wired into the build yet. message(STATUS "PdfEngine ${PROJECT_VERSION} configured") message(STATUS " C++ standard ......... ${CMAKE_CXX_STANDARD}") diff --git a/CMakePresets.json b/CMakePresets.json index 7781da9..48413bb 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -99,7 +99,7 @@ { "name": "wasm", - "displayName": "WASM • Emscripten (Phase 0 stub — validation only)", + "displayName": "WASM • Emscripten hello-world (Phase 0, Rule R5)", "generator": "Ninja", "binaryDir": "${sourceDir}/out/build/${presetName}", "toolchainFile": "${sourceDir}/cmake/toolchains/wasm.cmake", diff --git a/cmake/Sanitizers.cmake b/cmake/Sanitizers.cmake index 278395c..258f029 100644 --- a/cmake/Sanitizers.cmake +++ b/cmake/Sanitizers.cmake @@ -1,12 +1,6 @@ # AddressSanitizer / UndefinedBehaviorSanitizer wiring. -# Enabled per build via -DPDFENGINE_ENABLE_SANITIZERS=ON (see the *-asan presets). +# Enabled per build via -DPDFENGINE_ENABLE_SANITIZERS=ON # Usage: pdfengine_enable_sanitizers() -# -# Coverage by platform: -# MSVC -> ASan only (/fsanitize=address). UBSan/TSan unsupported. -# GCC/Clang -> ASan + UBSan. -# TSan is intentionally not wired here yet; it conflicts with ASan and is only -# relevant once the engine is multithreaded (Phase 3+). function(pdfengine_enable_sanitizers target) if(NOT PDFENGINE_ENABLE_SANITIZERS) diff --git a/cmake/pdfium.cmake b/cmake/pdfium.cmake index 440a391..b68081e 100644 --- a/cmake/pdfium.cmake +++ b/cmake/pdfium.cmake @@ -1,21 +1,5 @@ # PDFium integration. -# -# PDFium is NOT a vcpkg package — it is built from source via depot_tools/GN/Ninja -# (see third_party/pdfium/). That build installs into: -# -# third_party/pdfium/install/ -# include/ public PDFium headers (fpdfview.h, fpdf_*.h, ...) -# lib/ the static library (pdfium.lib / libpdfium.a) -# -# This module turns that install tree into an imported target: pdfium::pdfium -# -# Behaviour: -# PDFENGINE_WITH_PDFIUM = OFF (default) -# No target is created. The engine compiles with PDFium code paths -# #ifdef-ed out. This keeps Phase 0 unblocked before PDFium is built. -# PDFENGINE_WITH_PDFIUM = ON -# The install tree MUST exist; otherwise this is a hard error directing -# the developer to the build script. +# Creates imported target pdfium::pdfium from third_party/pdfium/install/. if(NOT PDFENGINE_WITH_PDFIUM) message(STATUS "PDFium: disabled (PDFENGINE_WITH_PDFIUM=OFF). " @@ -51,8 +35,7 @@ set_target_properties(pdfium::pdfium PROPERTIES IMPORTED_LOCATION "${PDFIUM_LIBRARY}" INTERFACE_INCLUDE_DIRECTORIES "${PDFIUM_INCLUDE_DIR}") -# PDFium is a C++ static lib; consumers on Linux also need the C++ runtime and -# pthreads. These are no-ops where irrelevant. + if(UNIX AND NOT APPLE) set_property(TARGET pdfium::pdfium APPEND PROPERTY INTERFACE_LINK_LIBRARIES pthread dl) diff --git a/cmake/toolchains/wasm.cmake b/cmake/toolchains/wasm.cmake index 60265a5..4f94da3 100644 --- a/cmake/toolchains/wasm.cmake +++ b/cmake/toolchains/wasm.cmake @@ -1,12 +1,9 @@ -# WebAssembly (Emscripten) toolchain hook — STUB for Phase 0. +# WebAssembly (Emscripten) toolchain hook. # -# The Phase 0 "WASM hello-world" task is validation-only and must NOT block -# Phase 1 (Rule R5). This file exists so the integration point is real; the -# full WASM build (PDFium + Skia + FreeType + HarfBuzz compiled together) is -# Phase 2 work. -# -# It chain-loads the real Emscripten toolchain from the active EMSDK, then lets -# vcpkg layer on top. Used by the `wasm` configure preset. +# Used by the `wasm` configure preset to chain-load the real Emscripten +# toolchain from the active EMSDK. Phase 0 builds only the hello-world target +# in wasm/ (Rule R5 — never blocks shipping). Phase 2 will compile the full +# engine (PDFium + Skia + FreeType + HarfBuzz) through this same toolchain. if(NOT DEFINED ENV{EMSDK}) message(FATAL_ERROR diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index 2734760..af8aa18 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -1,8 +1,5 @@ # pdfengine — the C++23 PDF SDK core. # -# Phase 0 scope: a minimal-but-real static library that compiles, links against -# its vcpkg dependencies, and exposes version/build introspection. Feature -# modules (parser, render, text, core) are filled in from Phase 1 onward. # Generate the version header from the project version. configure_file( @@ -24,10 +21,6 @@ target_include_directories(pdfengine "${CMAKE_CURRENT_SOURCE_DIR}/src" ) -# Dependencies resolved by vcpkg. -# spdlog - logging, used now (engine blueprint §13.1). -# freetype/harfbuzz - linked now to prove the vcpkg toolchain end to end; -# actually exercised by Dev 3 from Phase 1 onward. target_link_libraries(pdfengine PUBLIC spdlog::spdlog @@ -36,8 +29,6 @@ target_link_libraries(pdfengine harfbuzz::harfbuzz ) -# PDFium is optional in Phase 0 (built from source separately). When enabled, -# only this target gets the macro + link — and only src/parser/ uses it (R2). if(PDFENGINE_WITH_PDFIUM) target_link_libraries(pdfengine PRIVATE pdfium::pdfium) target_compile_definitions(pdfengine PRIVATE PDFENGINE_WITH_PDFIUM) diff --git a/gateway/README.md b/gateway/README.md index 477a6b7..edae496 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -1,24 +1,107 @@ -# Gateway (FastAPI) — placeholder +# Gateway (FastAPI) -Phase 0 task **"FastAPI service scaffolding"** (Dev 1) and all later API work -lives here. Not implemented yet — this session scaffolds the C++ build pipeline -only. +Phase 0 task **"FastAPI service scaffolding"** (Dev 1). Skeleton only — every +PDF-touching route returns **501** until the pybind11 engine module in +[`bindings/python/`](../bindings/python/) lands at Gate G0b. -Planned responsibilities (see `docs/phase0.md` and the engine blueprint §8): +What the gateway eventually does (see `docs/phase0.md` and engine blueprint §8): - Auth, metadata, storage (S3 / MinIO), async job queue. - Bridges to the C++ engine via **pybind11** (`bindings/python/`). -- Placeholder routes + a `/health` endpoint returning `200` — **no PDF logic** - until the engine wrappers exist. +- Hosts the API the React + TypeScript viewer in [`frontend/`](../frontend/) calls. -When implemented: +## Current state ``` gateway/ - pyproject.toml + pyproject.toml # pinned FastAPI/uvicorn/pydantic + ruff/pytest app/ - main.py # FastAPI app + /health - routers/ - services/ + __init__.py # exports __version__ + main.py # FastAPI app factory + config.py # pydantic-settings (PDFENGINE_* env vars) + routers/ # PEP 420 namespace pkg — no __init__.py needed + health.py # GET /health → 200 + documents.py # CRUD → 501 (placeholder) + render.py # render/text → 501 (placeholder) + edits.py # apply edits → 501 (placeholder) + services/ # PEP 420 namespace pkg + engine.py # lazy pybind11 import wrapper (engine absent in Phase 0) tests/ + conftest.py + test_health.py # /health works without the engine + test_placeholders.py # every PDF route 501s until G0b lands ``` + +The gateway depends on **no C++** today — the `engine` service module probes +for `import pdfengine` lazily and reports `engine_available: false` via +`/health` while the pybind11 module does not exist. + +## Local development + +Requires Python 3.11+ (3.12 works). Build dirs are git-ignored. + +```powershell +cd gateway +python -m venv .venv +.venv\Scripts\Activate.ps1 +pip install -e ".[dev]" +``` + +```sh +# Linux/macOS +cd gateway +python -m venv .venv +source .venv/bin/activate +pip install -e ".[dev]" +``` + +Run the service: + +```sh +uvicorn app.main:app --reload +# → http://127.0.0.1:8000/health +# → http://127.0.0.1:8000/docs (OpenAPI UI) +``` + +Lint, format, and test (the exact commands CI runs): + +```sh +ruff check . +ruff format --check . +pytest +``` + +## Configuration + +Environment variables are read by `app/config.py` with the prefix +`PDFENGINE_`: + +| Var | Default | Meaning | +|------------------------------|---------|--------------------------------------------------| +| `PDFENGINE_ENVIRONMENT` | `dev` | `dev` / `staging` / `prod` — echoed in `/health` | +| `PDFENGINE_ENGINE_AVAILABLE` | `false` | Forces the engine-availability flag for testing | + +A `.env` file in `gateway/` is auto-loaded if present (it is git-ignored +via the repo-wide `.venv/` and Python rules — add `.env` to your local +ignores if you keep secrets in it). + +## CI + +The `gateway` job in [`.github/workflows/ci.yml`](../.github/workflows/ci.yml) +runs on **ubuntu-latest only** — the gateway is pure Python with no +OS-specific surface, so a cross-platform matrix would burn runner minutes +catching nothing. The engine `build` matrix still covers Linux + macOS + +Windows; once the pybind11 wheel is built per-platform there, the gateway +job will install it and run integration tests against the real engine. + +## What is **not** here yet (and why) + +- **Real document/render/edit routes** — blocked on Gate G0b + (`PdfDocument` / `PdfPage` interface contracts) and the pybind11 module. + Stubbing them earlier would just lock us into a bad API. +- **Auth, storage, job queue** — Phase 1 / 2 work. Leaving these out of + Phase 0 keeps the surface area small enough to keep CI green while the + engine is still being scaffolded. +- **Containerization (Dockerfile, compose)** — added when there is + something non-trivial to package. A FastAPI app with a `/health` route + does not need an image yet. diff --git a/gateway/app/__init__.py b/gateway/app/__init__.py new file mode 100644 index 0000000..d64cfa0 --- /dev/null +++ b/gateway/app/__init__.py @@ -0,0 +1,3 @@ +"""PDF engine gateway — FastAPI service.""" + +__version__ = "0.0.0" diff --git a/gateway/app/config.py b/gateway/app/config.py new file mode 100644 index 0000000..6df22dc --- /dev/null +++ b/gateway/app/config.py @@ -0,0 +1,33 @@ +"""Runtime configuration loaded from environment variables. + +Anything that varies between dev / staging / prod (storage URLs, queue +endpoints, auth secrets) lands here. Phase 0 only exposes the bare minimum +needed for `/health` and the app factory. +""" + +from functools import lru_cache + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_prefix="PDFENGINE_", + env_file=".env", + extra="ignore", + ) + + environment: str = Field(default="dev", description="dev | staging | prod") + engine_available: bool = Field( + default=False, + description=( + "True once the pybind11 module in bindings/python/ is importable. " + "Phase 0 default is False — routes that need the engine return 501." + ), + ) + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + return Settings() diff --git a/gateway/app/main.py b/gateway/app/main.py new file mode 100644 index 0000000..8cb2927 --- /dev/null +++ b/gateway/app/main.py @@ -0,0 +1,24 @@ +"""FastAPI app factory.""" + +from fastapi import FastAPI + +from app import __version__ +from app.routers import documents, edits, health, render + + +def create_app() -> FastAPI: + app = FastAPI( + title="PDF Engine Gateway", + version=__version__, + description="Auth, metadata, storage, and job-queue gateway over the C++ PDF engine.", + ) + + app.include_router(health.router) + app.include_router(documents.router) + app.include_router(render.router) + app.include_router(edits.router) + + return app + + +app = create_app() diff --git a/gateway/app/routers/documents.py b/gateway/app/routers/documents.py new file mode 100644 index 0000000..c899523 --- /dev/null +++ b/gateway/app/routers/documents.py @@ -0,0 +1,32 @@ +"""Document CRUD — upload, list, fetch metadata, delete. + +Phase 0 placeholder: every route returns 501 because there is no engine +to parse PDFs and no storage backend wired up. Real implementations land +after Gate G0b (engine interface contracts) and the pybind11 module exist. +""" + +from fastapi import APIRouter, HTTPException, status + +router = APIRouter(prefix="/documents", tags=["documents"]) + +_NOT_IMPLEMENTED = "Engine bridge (bindings/python) not yet available — Phase 0 placeholder." + + +@router.post("", status_code=status.HTTP_501_NOT_IMPLEMENTED) +def upload_document() -> None: + raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail=_NOT_IMPLEMENTED) + + +@router.get("", status_code=status.HTTP_501_NOT_IMPLEMENTED) +def list_documents() -> None: + raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail=_NOT_IMPLEMENTED) + + +@router.get("/{document_id}", status_code=status.HTTP_501_NOT_IMPLEMENTED) +def get_document(document_id: str) -> None: + raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail=_NOT_IMPLEMENTED) + + +@router.delete("/{document_id}", status_code=status.HTTP_501_NOT_IMPLEMENTED) +def delete_document(document_id: str) -> None: + raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail=_NOT_IMPLEMENTED) diff --git a/gateway/app/routers/edits.py b/gateway/app/routers/edits.py new file mode 100644 index 0000000..3092472 --- /dev/null +++ b/gateway/app/routers/edits.py @@ -0,0 +1,16 @@ +"""Edit operations — proxies to `engine.apply_edits()` (pybind11). + +Phase 0 placeholder. Real implementation produces an incremental save +(append-only xref, Rule R4) and returns a new document revision id. +""" + +from fastapi import APIRouter, HTTPException, status + +router = APIRouter(prefix="/documents/{document_id}/edits", tags=["edits"]) + +_NOT_IMPLEMENTED = "Engine bridge (bindings/python) not yet available — Phase 0 placeholder." + + +@router.post("", status_code=status.HTTP_501_NOT_IMPLEMENTED) +def apply_edits(document_id: str) -> None: + raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail=_NOT_IMPLEMENTED) diff --git a/gateway/app/routers/health.py b/gateway/app/routers/health.py new file mode 100644 index 0000000..1f82590 --- /dev/null +++ b/gateway/app/routers/health.py @@ -0,0 +1,35 @@ +"""Liveness / readiness probes. + +`/health` must work with **zero** engine dependencies — it is the signal load +balancers and orchestrators use to decide whether the process is up. It +deliberately does not import or touch the pybind11 bridge. +""" + +from typing import Annotated + +from fastapi import APIRouter, Depends +from pydantic import BaseModel + +from app import __version__ +from app.config import Settings, get_settings + +router = APIRouter(tags=["health"]) + +SettingsDep = Annotated[Settings, Depends(get_settings)] + + +class HealthResponse(BaseModel): + status: str + version: str + environment: str + engine_available: bool + + +@router.get("/health", response_model=HealthResponse) +def health(settings: SettingsDep) -> HealthResponse: + return HealthResponse( + status="ok", + version=__version__, + environment=settings.environment, + engine_available=settings.engine_available, + ) diff --git a/gateway/app/routers/render.py b/gateway/app/routers/render.py new file mode 100644 index 0000000..58a431b --- /dev/null +++ b/gateway/app/routers/render.py @@ -0,0 +1,21 @@ +"""Page rendering — proxies to `engine.render_page()` (pybind11). + +Phase 0 placeholder. Real route returns a PNG/JPEG of the requested page +at the requested DPI once the engine bridge exists. +""" + +from fastapi import APIRouter, HTTPException, status + +router = APIRouter(prefix="/documents/{document_id}/pages", tags=["render"]) + +_NOT_IMPLEMENTED = "Engine bridge (bindings/python) not yet available — Phase 0 placeholder." + + +@router.get("/{page_index}/render", status_code=status.HTTP_501_NOT_IMPLEMENTED) +def render_page(document_id: str, page_index: int, dpi: int = 96) -> None: + raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail=_NOT_IMPLEMENTED) + + +@router.get("/{page_index}/text", status_code=status.HTTP_501_NOT_IMPLEMENTED) +def extract_page_text(document_id: str, page_index: int) -> None: + raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail=_NOT_IMPLEMENTED) diff --git a/gateway/app/services/engine.py b/gateway/app/services/engine.py new file mode 100644 index 0000000..95eab8b --- /dev/null +++ b/gateway/app/services/engine.py @@ -0,0 +1,45 @@ +"""Thin wrapper around the pybind11 engine module (`bindings/python/`). + +The wrapper exists so routers depend on this module, not on the pybind11 +import directly. That keeps routers testable when the engine is absent +(Phase 0) and gives us one place to translate engine error types into +HTTP responses later. + +The pybind11 module is not built yet; importing it is expected to fail +in Phase 0. `is_available()` is the public probe. +""" + +from __future__ import annotations + +from typing import Any + +_engine: Any | None = None +_import_error: Exception | None = None + + +def _try_import() -> None: + global _engine, _import_error + if _engine is not None or _import_error is not None: + return + try: + import pdfengine as _mod # type: ignore[import-not-found] + + _engine = _mod + except ImportError as exc: + _import_error = exc + + +def is_available() -> bool: + _try_import() + return _engine is not None + + +def require() -> Any: + """Return the engine module or raise — callers should prefer ``is_available`` + and return 501 themselves so the error surface is consistent.""" + _try_import() + if _engine is None: + raise RuntimeError( + "pdfengine pybind11 module is not built — see bindings/python/." + ) from _import_error + return _engine diff --git a/gateway/pyproject.toml b/gateway/pyproject.toml new file mode 100644 index 0000000..d6ad7e4 --- /dev/null +++ b/gateway/pyproject.toml @@ -0,0 +1,59 @@ +[project] +name = "pdfengine-gateway" +version = "0.0.0" +description = "FastAPI gateway over the PDF engine (auth, metadata, storage, jobs)." +requires-python = ">=3.11" +license = { text = "Proprietary" } +readme = "README.md" + +# Pinned exact versions — see docs/phase0.md "Dependency pinning". +# Bumps are deliberate, not opportunistic. +dependencies = [ + "fastapi==0.115.6", + "uvicorn[standard]==0.34.0", + "pydantic==2.10.4", + "pydantic-settings==2.7.1", +] + +[project.optional-dependencies] +dev = [ + "pytest==8.3.4", + "httpx==0.28.1", + "ruff==0.8.6", +] + +[build-system] +requires = ["hatchling>=1.27"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["app"] + +# --- Ruff ------------------------------------------------------------------- +# Single tool for lint + format. CI runs `ruff check` and `ruff format --check`. +[tool.ruff] +line-length = 100 +target-version = "py311" +extend-exclude = [".venv", "build", "dist"] + +[tool.ruff.lint] +select = [ + "E", "F", "W", # pycodestyle + pyflakes + "I", # isort + "B", # bugbear + "UP", # pyupgrade + "SIM", # simplify + "RUF", # ruff-specific +] +ignore = [ + "E501", # line length handled by formatter +] + +[tool.ruff.format] +quote-style = "double" + +# --- Pytest ------------------------------------------------------------------ +[tool.pytest.ini_options] +minversion = "8.0" +testpaths = ["tests"] +addopts = "-ra --strict-markers --strict-config" diff --git a/gateway/tests/conftest.py b/gateway/tests/conftest.py new file mode 100644 index 0000000..60cadcf --- /dev/null +++ b/gateway/tests/conftest.py @@ -0,0 +1,14 @@ +"""Shared pytest fixtures for gateway tests.""" + +from collections.abc import Iterator + +import pytest +from fastapi.testclient import TestClient + +from app.main import create_app + + +@pytest.fixture() +def client() -> Iterator[TestClient]: + with TestClient(create_app()) as test_client: + yield test_client diff --git a/gateway/tests/test_health.py b/gateway/tests/test_health.py new file mode 100644 index 0000000..1e8ebdf --- /dev/null +++ b/gateway/tests/test_health.py @@ -0,0 +1,25 @@ +"""`/health` is the load-balancer probe — it must keep working even when the +engine bridge is absent (Phase 0 default).""" + +from fastapi.testclient import TestClient + + +def test_health_returns_ok(client: TestClient) -> None: + response = client.get("/health") + assert response.status_code == 200 + + payload = response.json() + assert payload["status"] == "ok" + assert payload["engine_available"] is False + assert "version" in payload + assert "environment" in payload + + +def test_health_does_not_require_engine(client: TestClient) -> None: + """Regression guard: importing the app and hitting /health must not + transitively import the pybind11 module (which doesn't exist yet).""" + import sys + + response = client.get("/health") + assert response.status_code == 200 + assert "pdfengine" not in sys.modules diff --git a/gateway/tests/test_placeholders.py b/gateway/tests/test_placeholders.py new file mode 100644 index 0000000..bbcb1e9 --- /dev/null +++ b/gateway/tests/test_placeholders.py @@ -0,0 +1,29 @@ +"""Every PDF-touching route returns 501 until the engine bridge exists. + +When Gate G0b lands and `bindings/python/` is wired up, these tests will +fail loudly — that is the cue to replace them with real route tests. +""" + +import pytest +from fastapi.testclient import TestClient + + +@pytest.mark.parametrize( + ("method", "path"), + [ + ("POST", "/documents"), + ("GET", "/documents"), + ("GET", "/documents/abc"), + ("DELETE", "/documents/abc"), + ("GET", "/documents/abc/pages/0/render"), + ("GET", "/documents/abc/pages/0/text"), + ("POST", "/documents/abc/edits"), + ], +) +def test_placeholder_routes_return_501(client: TestClient, method: str, path: str) -> None: + response = client.request(method, path) + assert response.status_code == 501, ( + f"{method} {path} returned {response.status_code}; " + "Phase 0 placeholder routes must 501 until the engine bridge exists." + ) + assert "engine bridge" in response.json()["detail"].lower() diff --git a/wasm/CMakeLists.txt b/wasm/CMakeLists.txt new file mode 100644 index 0000000..0fd8f92 --- /dev/null +++ b/wasm/CMakeLists.txt @@ -0,0 +1,41 @@ +# Phase 0 WASM hello-world. +# +# Only built when the Emscripten toolchain is active (see the root +# CMakeLists.txt — it early-returns into this subdir when EMSCRIPTEN is set). +# Rule R5: this target must never become a Phase 1 dependency. + +if(NOT EMSCRIPTEN) + message(FATAL_ERROR + "wasm/CMakeLists.txt requires the Emscripten toolchain. " + "Use: cmake --preset wasm (after activating emsdk).") +endif() + +add_executable(hello hello.cpp) + +# Emit an ES6 module so Node 20+ and modern browsers can `import` it directly. +# Suffix .mjs is what tells emcc to emit an ES module; the matching .wasm is +# produced alongside it. +set_target_properties(hello PROPERTIES + OUTPUT_NAME "hello" + SUFFIX ".mjs" + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin" +) + +# Emscripten link flags. Keep this list short — every flag added here is a +# Phase 0 commitment the frontend dev will inherit. +target_link_options(hello PRIVATE + "-sMODULARIZE=1" + "-sEXPORT_ES6=1" + "-sENVIRONMENT=node,web" + # Functions callable from JS via ccall/cwrap. Underscore-prefix is the + # C symbol name emscripten exposes. + "-sEXPORTED_FUNCTIONS=['_add','_hello_version']" + "-sEXPORTED_RUNTIME_METHODS=['ccall','cwrap']" + # Engine blueprint §6.3: real PDFs can exceed the default heap. + "-sALLOW_MEMORY_GROWTH=1" +) + +target_compile_features(hello PRIVATE cxx_std_23) + +message(STATUS "WASM hello-world configured") +message(STATUS " Output ............... ${CMAKE_BINARY_DIR}/bin/hello.mjs (+ hello.wasm)") diff --git a/wasm/README.md b/wasm/README.md index d665e03..c8c4482 100644 --- a/wasm/README.md +++ b/wasm/README.md @@ -1,22 +1,88 @@ -# WASM build — placeholder +# WASM (Emscripten) -Phase 0 task **"WASM hello-world build (Emscripten)"** and the Phase 2 task -**"WASM rendering path"** live here. Not implemented yet. +Phase 0 task **"WASM hello-world build (Emscripten)"** lives here. The full +engine-in-WASM build is Phase 2 work — see *Phase 2* below. -**Rule R5: WASM never blocks shipping.** Server-side rendering is always the -fallback. The Phase 0 WASM task is *validation only* — prove that Emscripten can -compile a C++ function callable from JS. Do not gate Phase 1 on it. +> **Rule R5: WASM never blocks shipping.** Server-side rendering is always the +> fallback. This Phase 0 deliverable is *validation only* — proves that the +> Emscripten toolchain end-to-end compiles a C++ function callable from JS. +> Do **not** gate Phase 1 on it. -Planned pipeline (engine blueprint §6): +## What's here today -```sh -emcmake cmake --preset wasm -emmake cmake --build --preset wasm -# outputs: engine.wasm + engine.js +``` +wasm/ + emsdk.pinned # pinned Emscripten SDK version + CMakeLists.txt # builds the hello-world Emscripten target + hello.cpp # exports add() and hello_version() via EMSCRIPTEN_KEEPALIVE + hello.test.mjs # Node 20+ smoke test — loads the module, asserts return values + package.json # `npm test` shortcut for hello.test.mjs ``` -Key build flags to carry forward: `ALLOW_MEMORY_GROWTH=1`, Web Worker + -OffscreenCanvas for off-main-thread rendering. +Build output goes to `out/build/wasm/bin/hello.{mjs,wasm}`. The `.mjs` is an +ES6 module that imports cleanly into Node 20+ **and** modern browsers. -The `cmake/toolchains/wasm.cmake` hook and a `wasm` CMake preset are stubbed in -this session so the integration point exists. +## Prerequisites + +1. **Emscripten SDK** at the version pinned in [`emsdk.pinned`](emsdk.pinned). + + ```sh + git clone https://github.com/emscripten-core/emsdk + cd emsdk + ./emsdk install 3.1.74 # match emsdk.pinned + ./emsdk activate 3.1.74 + source ./emsdk_env.sh # Linux/macOS + .\emsdk_env.bat # Windows cmd.exe + .\emsdk_env.ps1 # Windows PowerShell + ``` + + After `emsdk_env` runs in your shell, `$EMSDK` is set — that's what + [`cmake/toolchains/wasm.cmake`](../cmake/toolchains/wasm.cmake) keys off. + On Windows, install emsdk on a space-free path outside OneDrive (same rule + as the PDFium build — see [`docs/phase0.md`](../docs/phase0.md#onedrive-warning)). + +2. **Node 20+** for the smoke test (CI uses Node 20). +3. **CMake 3.25+ and Ninja** — already required for the rest of the repo. + +## Build + run + +```sh +cmake --preset wasm +cmake --build --preset wasm + +node wasm/hello.test.mjs +# → [wasm-smoke] OK — add(2,3)=5, hello_version()=1, cwrap add(40,2)=42 +``` + +CI does the same three commands on Ubuntu after installing the pinned emsdk. + +### Local build dir override (Windows / OneDrive) + +The default `binaryDir` lives under the repo. On Windows the +[OneDrive + spaced-path constraint](../docs/phase0.md#onedrive-warning) +applies — drop a `CMakeUserPresets.json` next to this README's parent that +overrides `binaryDir` to e.g. `C:/Users//pdfeng-build/wasm`, and set +`HELLO_MJS=C:/Users//pdfeng-build/wasm/bin/hello.mjs` when running the +smoke test. + +## What this proves (and what it does NOT) + +| Validated by Phase 0 hello-world | Still TODO in Phase 2 | +|------------------------------------------|---------------------------------------------| +| emsdk install + version pin | PDFium cross-compiled with Emscripten | +| `cmake --preset wasm` configures cleanly | Skia / FreeType / HarfBuzz cross-compiled | +| C → JS export via `ccall`/`cwrap` | Engine cross-compiled | +| ES6-module output usable from Node + web | Web Worker + OffscreenCanvas integration | +| CI pipeline for the WASM artifact | Page-render parity with the server path | + +## Phase 2 — real engine-in-WASM + +When that work begins, **do not** delete the hello-world target — keep it as a +toolchain canary so a broken emsdk install fails fast and obviously. The real +target will live alongside it (e.g. `pdfengine_wasm`) and consume the engine +library via the standard CMake import path once the engine has Emscripten +support in its vcpkg/build story. + +Key flags to carry forward (engine blueprint §6): +`ALLOW_MEMORY_GROWTH=1`, Web Worker + `OffscreenCanvas` for off-main-thread +rendering, ES6 module output. diff --git a/wasm/emsdk.pinned b/wasm/emsdk.pinned new file mode 100644 index 0000000..78d6b0d --- /dev/null +++ b/wasm/emsdk.pinned @@ -0,0 +1,10 @@ +# Emscripten SDK pinned version — Rule: never track rolling HEAD. +# +# Bumps are deliberate (quarterly cadence, like PDFium). CI installs this exact +# version; local developers should match it. Look up versions at: +# https://github.com/emscripten-core/emsdk/blob/main/emscripten-releases-tags.json +# +# To re-pin: pick a tagged release and update EMSDK_VERSION. The setup-emsdk +# GitHub Action reads this file via its `version:` input in CI. + +EMSDK_VERSION=3.1.74 diff --git a/wasm/hello.cpp b/wasm/hello.cpp new file mode 100644 index 0000000..d914f37 --- /dev/null +++ b/wasm/hello.cpp @@ -0,0 +1,35 @@ +// Phase 0 WASM hello-world. +// +// Scope (Rule R5: WASM never blocks shipping): +// Prove the Emscripten toolchain compiles a C++ function callable from JS. +// Nothing more. The real engine-in-WASM build happens in Phase 2 and depends +// on PDFium/Skia/FreeType/HarfBuzz cross-compiled with Emscripten plus the +// frozen interface contracts from Gate G0b — all of which are out of scope +// for Phase 0. +// +// The exported C symbols here are loaded from JS via cwrap()/ccall(); see +// hello.test.mjs. + +#include +#include + +#if defined(__EMSCRIPTEN__) +#include +#define WASM_EXPORT EMSCRIPTEN_KEEPALIVE +#else +#define WASM_EXPORT +#endif + +extern "C" { + +// Smallest possible "does it run?" probe. +WASM_EXPORT int add(int a, int b) { + return a + b; +} + +// A version probe so the JS side can sanity-check the module it loaded. +WASM_EXPORT int hello_version() { + return 1; +} + +} // extern "C" diff --git a/wasm/hello.test.mjs b/wasm/hello.test.mjs new file mode 100644 index 0000000..51a44bb --- /dev/null +++ b/wasm/hello.test.mjs @@ -0,0 +1,46 @@ +// Phase 0 WASM smoke test. +// +// Loads the Emscripten-built hello module and asserts the C export is callable +// from JS. This is the test CI runs after `cmake --build --preset wasm`. +// +// Run from the repo root: +// node wasm/hello.test.mjs +// +// Or set HELLO_MJS to point elsewhere if your build directory differs. + +import { strict as assert } from "node:assert"; +import { existsSync } from "node:fs"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { dirname, resolve } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, ".."); + +const helloPath = + process.env.HELLO_MJS ?? resolve(repoRoot, "out/build/wasm/bin/hello.mjs"); + +if (!existsSync(helloPath)) { + console.error( + `[wasm-smoke] hello.mjs not found at: ${helloPath}\n` + + `Did you run \`cmake --build --preset wasm\` first?\n` + + `Override with HELLO_MJS=/path/to/hello.mjs if your build dir differs.`, + ); + process.exit(1); +} + +const { default: createModule } = await import(pathToFileURL(helloPath).href); +const Module = await createModule(); + +// add(): the core "does it run?" check. +const addResult = Module.ccall("add", "number", ["number", "number"], [2, 3]); +assert.equal(addResult, 5, `add(2, 3) returned ${addResult}, expected 5`); + +// hello_version(): guards against loading a stale module from a previous build. +const version = Module.ccall("hello_version", "number", [], []); +assert.equal(version, 1, `hello_version() returned ${version}, expected 1`); + +// cwrap-style wrapping should also work — frontend will use this pattern. +const addWrapped = Module.cwrap("add", "number", ["number", "number"]); +assert.equal(addWrapped(40, 2), 42); + +console.log("[wasm-smoke] OK — add(2,3)=5, hello_version()=1, cwrap add(40,2)=42"); diff --git a/wasm/package.json b/wasm/package.json new file mode 100644 index 0000000..b3879c5 --- /dev/null +++ b/wasm/package.json @@ -0,0 +1,13 @@ +{ + "name": "@pdfengine/wasm-smoke", + "version": "0.0.0", + "private": true, + "description": "Phase 0 WASM hello-world smoke test (Rule R5 — never blocks shipping).", + "type": "module", + "scripts": { + "test": "node hello.test.mjs" + }, + "engines": { + "node": ">=20" + } +}