diff --git a/package.json b/package.json index 99a24c0b69..cf14aa2f37 100644 --- a/package.json +++ b/package.json @@ -32,11 +32,16 @@ "rulesync:generate": "rulesync generate", "rulesync:import:cursor": "rulesync import --targets cursor", "rulesync:import:claude": "rulesync import --targets claudecode", - "rulesync:import:opencode": "rulesync import --targets opencode" + "rulesync:import:opencode": "rulesync import --targets opencode", + "prototype:scriptc": "bash ./packages/backend-scriptc/compare.sh", + "prototype:scriptc:image": "bash ./packages/backend-scriptc/image.sh", + "prototype:scriptc:coverage": "bash ./packages/backend-scriptc/coverage-inventory.sh", + "prototype:scriptc:bench": "bash ./packages/backend-scriptc/benchmark.sh" }, "workspaces": { "packages": [ "packages/*", + "!packages/backend-scriptc", "plugins/*" ] }, diff --git a/packages/backend-scriptc/.gitignore b/packages/backend-scriptc/.gitignore new file mode 100644 index 0000000000..5485e0eeff --- /dev/null +++ b/packages/backend-scriptc/.gitignore @@ -0,0 +1,9 @@ +out/ +out-image/ +out-bench/ +out-coverage/ +node_modules/ +dist/ +package-lock.json +*.scriptc +*.c diff --git a/packages/backend-scriptc/Containerfile b/packages/backend-scriptc/Containerfile new file mode 100644 index 0000000000..b434ffc869 --- /dev/null +++ b/packages/backend-scriptc/Containerfile @@ -0,0 +1,26 @@ +# PROTOTYPE — fixture size-comparison image (binary vs Node deploy bytes). +# yarn prototype:scriptc + +FROM docker.io/library/node:24-bookworm + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl gnupg \ + && curl -fsSL https://apt.llvm.org/llvm-snapshot.gpg.key \ + | gpg --dearmor -o /usr/share/keyrings/llvm.gpg \ + && echo "deb [signed-by=/usr/share/keyrings/llvm.gpg] http://apt.llvm.org/bookworm/ llvm-toolchain-bookworm-18 main" \ + > /etc/apt/sources.list.d/llvm.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends clang-18 cmake build-essential \ + && ln -sf /usr/bin/clang-18 /usr/local/bin/clang \ + && ln -sf /usr/bin/clang++-18 /usr/local/bin/clang++ \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /poc +COPY package.json tsconfig.json ./ +RUN npm install +COPY fixtures/*.ts ./ +COPY compare-inside.sh /usr/local/bin/compare-inside.sh +RUN chmod +x /usr/local/bin/compare-inside.sh + +ENV OUT_DIR=/out +CMD ["/usr/local/bin/compare-inside.sh"] diff --git a/packages/backend-scriptc/Containerfile.poc b/packages/backend-scriptc/Containerfile.poc new file mode 100644 index 0000000000..1ac711adf0 --- /dev/null +++ b/packages/backend-scriptc/Containerfile.poc @@ -0,0 +1,38 @@ +# PROTOTYPE — trimmed ScriptC Linux runtime image (not the full RHDH backend). +# yarn prototype:scriptc:image + +FROM docker.io/library/node:24-bookworm AS toolchain + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl gnupg \ + && curl -fsSL https://apt.llvm.org/llvm-snapshot.gpg.key \ + | gpg --dearmor -o /usr/share/keyrings/llvm.gpg \ + && echo "deb [signed-by=/usr/share/keyrings/llvm.gpg] http://apt.llvm.org/bookworm/ llvm-toolchain-bookworm-18 main" \ + > /etc/apt/sources.list.d/llvm.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends clang-18 cmake build-essential \ + && ln -sf /usr/bin/clang-18 /usr/local/bin/clang \ + && ln -sf /usr/bin/clang++-18 /usr/local/bin/clang++ \ + && rm -rf /var/lib/apt/lists/* + +FROM toolchain AS build +WORKDIR /poc +COPY package.json tsconfig.json ./ +RUN npm install +COPY src/main.ts ./src/main.ts +RUN mkdir -p /out \ + && ./node_modules/.bin/scriptc coverage src/main.ts --dynamic \ + && ./node_modules/.bin/scriptc build src/main.ts --dynamic --backend c -o /out/rhdh-poc \ + && ./node_modules/.bin/scriptc coverage src/main.ts --dynamic > /out/coverage.txt \ + && ls -la /out/rhdh-poc + +FROM docker.io/library/debian:bookworm-slim AS runtime +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY --from=build /out/rhdh-poc /usr/local/bin/rhdh-poc +COPY --from=build /out/coverage.txt /usr/local/share/rhdh-poc-coverage.txt +ENV PORT=7007 +EXPOSE 7007 +USER 65532:65532 +ENTRYPOINT ["/usr/local/bin/rhdh-poc"] diff --git a/packages/backend-scriptc/Containerfile.poc-node b/packages/backend-scriptc/Containerfile.poc-node new file mode 100644 index 0000000000..af84fe5461 --- /dev/null +++ b/packages/backend-scriptc/Containerfile.poc-node @@ -0,0 +1,19 @@ +# PROTOTYPE — Node twin for the trimmed PoC entry (same src/main.ts). + +FROM docker.io/library/node:24-bookworm-slim AS build +WORKDIR /poc +COPY package.json tsconfig.json ./ +RUN npm install +COPY src/main.ts ./src/main.ts +RUN ./node_modules/.bin/tsc -p tsconfig.json \ + && npm prune --omit=dev + +FROM docker.io/library/node:24-bookworm-slim AS runtime +WORKDIR /app +COPY --from=build /poc/dist/main.js ./main.js +COPY --from=build /poc/node_modules ./node_modules +COPY --from=build /poc/package.json ./package.json +ENV PORT=7007 +EXPOSE 7007 +USER 65532:65532 +ENTRYPOINT ["node", "main.js"] diff --git a/packages/backend-scriptc/Containerfile.toolchain b/packages/backend-scriptc/Containerfile.toolchain new file mode 100644 index 0000000000..e99bfa2676 --- /dev/null +++ b/packages/backend-scriptc/Containerfile.toolchain @@ -0,0 +1,22 @@ +# PROTOTYPE — clang/cmake/scriptc toolchain for coverage against the monorepo. +FROM docker.io/library/node:24-bookworm + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl gnupg \ + && curl -fsSL https://apt.llvm.org/llvm-snapshot.gpg.key \ + | gpg --dearmor -o /usr/share/keyrings/llvm.gpg \ + && echo "deb [signed-by=/usr/share/keyrings/llvm.gpg] http://apt.llvm.org/bookworm/ llvm-toolchain-bookworm-18 main" \ + > /etc/apt/sources.list.d/llvm.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends clang-18 cmake build-essential \ + && ln -sf /usr/bin/clang-18 /usr/local/bin/clang \ + && ln -sf /usr/bin/clang++-18 /usr/local/bin/clang++ \ + && rm -rf /var/lib/apt/lists/* + +# Pre-seed scriptc so coverage runs do not need a registry hit if npm cache is cold. +WORKDIR /opt/scriptc +COPY package.json ./ +RUN npm install \ + && ln -sf /opt/scriptc/node_modules/.bin/scriptc /usr/local/bin/scriptc + +WORKDIR /work diff --git a/packages/backend-scriptc/REPORT.md b/packages/backend-scriptc/REPORT.md new file mode 100644 index 0000000000..911407068e --- /dev/null +++ b/packages/backend-scriptc/REPORT.md @@ -0,0 +1,227 @@ +# ScriptC PoC findings (RHDH) + +Throwaway investigation of [vercel-labs/scriptc](https://github.com/vercel-labs/scriptc) against Red Hat Developer Hub. This is not production code and not a second Hub backend. + +**Question:** Can we compile TypeScript to a native binary, ship it in a Linux container on Windows, compare it to Node on size/memory/startup, and get anywhere near the real Hub backend (including dynamic plugins)? + +**Short answers:** + +- Trimmed Linux PoC on Windows: yes. Image size and RSS look good. +- Full `packages/backend` with dynamic plugins: no, not with the current scriptc model. +- Sensible next backend-like compile: `04-core-static-plugins` (fixed plugin set, no dynamic loader). + +--- + +## Package layout + +| Path | Role | +|---|---| +| `packages/backend-scriptc/` | Sibling package for the PoC (not under `packages/backend/`) | +| `src/main.ts` | Trimmed HTTP entry used for images and benches | +| `src/entries/02-…` … `05-…` | Coverage ladder toward backend / dynamic plugins | +| `Containerfile.poc` | Multi-stage ScriptC runtime image | +| `Containerfile.poc-node` | Node twin (same entry) | +| `Containerfile` / `compare.sh` | Fixture binary-vs-Node deploy size compare | +| `Containerfile.toolchain` | clang 18 + cmake + scriptc for coverage | +| `benchmark.sh` | README-style metrics (size, startup, RSS, latency) | +| `coverage-inventory.sh` | Host entry for the coverage ladder | +| `out-image/`, `out-bench/`, `out-coverage/` | Generated reports (wipeable) | + +Yarn: `packages/backend-scriptc` is **excluded** from workspaces (`!packages/backend-scriptc` in the root `package.json`) so Konflux/hermeto do not pull `scriptc` into the Hub image build. + +Root scripts: + +```bash +yarn prototype:scriptc # fixture size compare (binary vs Node deploy) +yarn prototype:scriptc:image # build + smoke ScriptC and Node images +yarn prototype:scriptc:bench # size + startup + RSS + /healthcheck latency +yarn prototype:scriptc:coverage # coverage ladder + READINESS / triage +``` + +Useful env vars for coverage: + +- `HEARTBEAT_SECS=10` (default 5) — progress while `scriptc coverage` runs (scriptc has no native progress UI) +- `SKIP_COMPLETED=1` — resume without wiping existing `*.coverage.txt` files + +Platform note: primary scriptc platform is macOS arm64. HTTP servers and `--dynamic` need Linux or macOS. On this Windows workstation we build and run **Linux** images with Podman. A Mac is not required for the container PoC. + +--- + +## Trimmed PoC (what actually runs) + +Shared entry: `src/main.ts` + +- Listens on `0.0.0.0:$PORT` (default 7007) +- `GET /healthcheck` and `GET /.backstage/health/v1/liveness` +- `GET /api/poc/info?echo=…` (zod-validated query) +- Uses npm `zod` → scriptc build uses `--dynamic` (embedded quickjs-ng island for that dependency) + +Images: + +| Tag | Base | Entrypoint | +|---|---|---| +| `rhdh-backend-scriptc-poc:local` | `debian:bookworm-slim` | `/usr/local/bin/rhdh-poc` | +| `rhdh-backend-scriptc-poc-node:local` | `node:24-bookworm-slim` | `node main.js` | + +Run: + +```bash +podman run --rm -p 7007:7007 rhdh-backend-scriptc-poc:local +podman run --rm -p 7007:7007 rhdh-backend-scriptc-poc-node:local +``` + +Smoke JSON (both healthy): + +```json +{"service":"rhdh-backend-scriptc","runtime":"/usr/local/bin/rhdh-poc","echo":"hi","note":"trimmed PoC — packages/backend-scriptc, not packages/backend"} +``` + +--- + +## Image size + +Source: `out-image/image-size-report.txt` (from `yarn prototype:scriptc:image`). + +| | ScriptC | Node twin | +|---|---:|---:| +| Image tag | `rhdh-backend-scriptc-poc:local` | `rhdh-backend-scriptc-poc-node:local` | +| Image size | **86 MB** (89,198,533 bytes) | **228 MB** (238,508,711 bytes) | +| Node / ScriptC | | **~2.67×** | + +Same `src/main.ts` in both images. This is not the full Hub image. + +--- + +## README-style benchmark + +scriptc’s README calls out startup, binary size, memory (RSS), and runtime. We measured those for the trimmed PoC in containers (`yarn prototype:scriptc:bench`, report in `out-bench/bench-report.txt`). + +| Metric | ScriptC | Node | Node / ScriptC | +|---|---:|---:|---:| +| Image size | 86 MB | 228 MB | 2.67× | +| Payload on disk | 1.7 MB (binary) | 3.6 MB (`/app` tree) | 2.17× | +| Startup → first healthy `/healthcheck` | 225.2 ms | 256.9 ms | 1.14× | +| RSS (`VmRSS` of PID 1) | **6.0 MB** | **62.4 MB** | **10.42×** | +| cgroup mem (`podman stats`) | 2.8 MB | 16.8 MB | 6.05× | +| Avg `/healthcheck` latency (50 reqs after warmup) | 1.9 ms | 2.0 ms | ~1× | + +Caveats: + +- Startup includes container start. It will not match the README’s process-only ~2 ms vs ~47 ms. +- Latency is a light healthcheck proxy, not scriptc’s CPU microbenchmarks. +- RSS is the clearest win on this PoC. + +How metrics were taken: + +- Image size: `podman image inspect` `.Size` +- Payload: `wc -c` on the ScriptC binary, or `du -sb /app` for Node +- Startup: time from `podman run -d` until first successful curl to `/healthcheck` +- RSS: `VmRSS` from `/proc/1/status` inside the container (ENTRYPOINT is PID 1) +- Latency: average curl `time_total` over 50 requests after 10 warmup requests + +--- + +## Coverage ladder (toward full backend) + +Command: `yarn prototype:scriptc:coverage` +Toolchain: clang 18, cmake, `scriptc@0.0.23` in `rhdh-backend-scriptc-toolchain:local` +Mode: `scriptc coverage --dynamic` +Host quirk: Yarn workspace symlinks that point at `/mnt/c/...` are dead inside Podman; the harness relinks `@internal/*`, `app`, `backend`, etc. to `/work/...` before coverage. + +### Results (wall clock in the container) + +| # | Entry | Result | Time | +|---|---|---|---:| +| 01 | `src/main.ts` (trimmed) | OK — builds with `--dynamic` | 3s | +| 02 | `createBackend()` only | OK — 100% dynamic island | 579s | +| 03 | + RHDH healthcheck plugin | OK | 544s | +| 04 | Core static plugins, **no** dynamic loader | OK | 754s | +| 05 | + `dynamicPluginsFeatureLoader` | **Blocker SC1090** | 622s | +| 99 | Full `packages/backend/src/index.ts` | **Blockers SC1090, SC2001, SC2004, SC2009, SC2020** | 1117s | + +Triage summary (`out-coverage/triage.txt`): + +| Label | Status | Static | Dynamic | SC codes | +|---|---|---:|---:|---| +| 01-main-trimmed | OK | 38 (90%) | 4 (9%) | (none) | +| 02-create-backend-empty | OK | 0 (0%) | 2 (100%) | (none) | +| 03-create-backend-health | OK | 0 (0%) | 8 (100%) | (none) | +| 04-core-static-plugins | OK | 0 (0%) | 16 (100%) | (none) | +| 05-with-dynamic-plugins | HAS_FINDINGS | 1 (8%) | 10 (83%) | SC1090 | +| 99-full-backend-index | HAS_FINDINGS | 48 (29%) | 69 (42%) | SC1090, SC2001, SC2004, SC2009, SC2020 | + +### What the rungs contain + +**01 — trimmed** (`src/main.ts`): plain `http` + zod. Mostly static; zod forces `--dynamic`. + +**02 — empty backend** (`src/entries/02-create-backend-empty.ts`): only `createBackend()` + `start()`. Tiny source file; coverage still walks `@backstage/backend-defaults` and a large npm graph (knex, sqlite drivers, redis, aws/azure/gcp clients, etc.) as shims or “lazy traps” for optional `require()`s. + +**03 — health** (`03-create-backend-health.ts`): 02 plus relative import of `packages/backend/src/modules/healthcheck.ts`. + +**04 — core static plugins** (`04-core-static-plugins.ts`): health + app, catalog, proxy, auth, guest provider, search, search-catalog, permission. No `dynamicPluginsFeatureLoader`. Coverage says it builds with `--dynamic`. + +**05 — dynamic plugins** (`05-with-dynamic-plugins.ts`): adds `dynamicPluginsFeatureLoader` + `CommonJSModuleLoader` + `PackageRoles` schema locator pattern from the real backend. Fails coverage with: + +- **SC1090** — reading `role` from a Backstage package-role typed value (optional / structural typing edge). + +**99 — full index** (`packages/backend/src/index.ts`): real Hub entry. Additional blockers include SC2001, SC2004, SC2009, SC2020 (language / stdlib lowering gaps; some noted as unreached). Still not a clean `--dynamic` build. + +### Important distinction + +“Builds with `--dynamic`” on rungs 02–04 is a **coverage** claim. We have not yet produced and smoke-tested a ScriptC binary for `createBackend()` or the static plugin set. The trimmed `main.ts` image is the only runtime-proven binary so far. + +--- + +## Dynamic plugins: why “no” + +1. **Coverage fails** on the dynamic-plugins rung (SC1090) and on the full backend index (several SC codes). +2. **Runtime model mismatch:** RHDH loads plugins from disk with CommonJS (`CommonJSModuleLoader`, filesystem scan, optional OCI install). ScriptC embeds dependency JS at **build** time; binaries do not read `node_modules` at runtime. +3. Fixing (1) alone would not give true “drop a plugin on disk and load it.” That needs a different design, for example: + - Prebundle known plugins into the binary at build time, or + - Keep a small Node/host process for dynamic loading, or + - Some other explicit external-host integration. + +`out-coverage/READINESS.txt` verdict: **NO** for a full-on PoC with dynamic plugins. + +--- + +## Tooling notes from the investigation + +- Bookworm’s default clang 14 is too old for scriptc’s LLVM IR; the toolchain image uses **clang 18**. Builds use `--backend c` for broader clang compatibility. +- `--dynamic` needs **cmake** (embeds quickjs-ng). +- Coverage with no output for minutes usually means typechecking a large import graph; the harness heartbeats every N seconds and shows stdout/stderr byte counts. +- An earlier hang after scriptc printed its report came from `tee` + process substitution + `wait`; the harness now writes to files and heartbeats in a separate loop so the process exits cleanly. +- Do not wipe `*.coverage.txt` when using `SKIP_COMPLETED=1` (the inventory script preserves them in that mode). + +--- + +## Where that leaves us + +| Goal | Status | +|---|---| +| Linux ScriptC container on Windows | Done (trimmed app) | +| Fair Node twin + image size | Done (~2.7×) | +| RSS / startup / latency bench | Done (RSS ~10×) | +| Coverage of `createBackend` / static plugins | Coverage-green for 02–04 | +| Coverage of dynamic plugins / full index | Failed (SC blockers) | +| Runtime binary for Backstage `createBackend` | Not done yet | +| Full Hub + dynamic plugins | Out of scope for current scriptc model | + +**Recommended next compile target:** build and image-twin `src/entries/04-core-static-plugins.ts` (coverage already says `--dynamic` is clean). Keep true dynamic plugins as a separate design spike. + +--- + +## Artifact index + +| Path | Contents | +|---|---| +| `out-image/image-size-report.txt` | Image size comparison | +| `out-image/image-size-report.json` | Same, machine-readable | +| `out-bench/bench-report.txt` | Size, startup, RSS, latency | +| `out-bench/bench-report.json` | Same, machine-readable | +| `out-coverage/ladder-summary.tsv` | Exit codes and seconds per rung | +| `out-coverage/triage.txt` | Per-rung static/dynamic % and SC codes | +| `out-coverage/READINESS.txt` | Dynamic-plugins readiness verdict | +| `out-coverage/*-*.coverage.txt` | Full scriptc coverage dumps | + +Generated dirs are gitignored. Re-run the yarn scripts above to refresh them. diff --git a/packages/backend-scriptc/benchmark.sh b/packages/backend-scriptc/benchmark.sh new file mode 100644 index 0000000000..27b6a2b6a8 --- /dev/null +++ b/packages/backend-scriptc/benchmark.sh @@ -0,0 +1,221 @@ +#!/usr/bin/env bash +# PROTOTYPE — compare README-style dims for trimmed PoC: size, startup, RSS, request latency. +# Usage: yarn prototype:scriptc:bench +# Optional: SKIP_BUILD=1 yarn prototype:scriptc:bench +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +OUT="$ROOT/out-bench" +SCRIPTC_IMAGE="rhdh-backend-scriptc-poc:local" +NODE_IMAGE="rhdh-backend-scriptc-poc-node:local" +WARMUP_REQUESTS="${WARMUP_REQUESTS:-10}" +LATENCY_REQUESTS="${LATENCY_REQUESTS:-50}" + +mkdir -p "$OUT" +rm -rf "$OUT"/* +echo "PROTOTYPE — wipe me" >"$OUT/README.wipe-me.txt" + +if command -v podman >/dev/null 2>&1; then + CTR=podman +elif command -v docker >/dev/null 2>&1; then + CTR=docker +else + echo "Need podman or docker." >&2 + exit 1 +fi + +if [[ "${SKIP_BUILD:-0}" != "1" ]]; then + echo "== building images ==" + "$CTR" build -t "$SCRIPTC_IMAGE" -f "$ROOT/Containerfile.poc" "$ROOT" + "$CTR" build -t "$NODE_IMAGE" -f "$ROOT/Containerfile.poc-node" "$ROOT" +fi + +image_bytes() { "$CTR" image inspect -f '{{.Size}}' "$1"; } +human_bytes() { + local b="$1" + if command -v numfmt >/dev/null 2>&1; then numfmt --to=iec --suffix=B "$b"; else echo "${b}B"; fi +} +human_ms() { awk -v s="$1" 'BEGIN { printf "%.1f ms", s * 1000 }'; } +human_mb() { awk -v b="$1" 'BEGIN { printf "%.1f MB", b / (1024*1024) }'; } + +# Parse podman/docker stats memory like "12.3MiB" / "1.2GiB" / "800KiB" → bytes +mem_to_bytes() { + local raw + raw="$(echo "$1" | tr -d ' ')" + python - "$raw" <<'PY' +import re, sys +v = sys.argv[1] +m = re.match(r"^([0-9.]+)([KMGT]i?B)$", v) +if not m: + print(0); raise SystemExit +n = float(m.group(1)); u = m.group(2) +mult = {"B":1,"KiB":1024,"KB":1000,"MiB":1024**2,"MB":1000**2,"GiB":1024**3,"GB":1000**3} +print(int(n * mult.get(u, 0))) +PY +} + +now_s() { + python - <<'PY' +import time; print(f"{time.time():.6f}") +PY +} + +wait_http() { + local url="$1" deadline_s="${2:-20}" + local start end + start="$(now_s)" + while true; do + if curl -sf "$url" >/dev/null 2>&1; then + end="$(now_s)" + awk -v a="$start" -v b="$end" 'BEGIN { printf "%.6f", b - a }' + return 0 + fi + end="$(now_s)" + awk -v a="$start" -v b="$end" -v d="$deadline_s" 'BEGIN { exit (b - a >= d) ? 0 : 1 }' && { + echo "timeout waiting for $url" >&2 + return 1 + } + sleep 0.02 + done +} + +avg_latency_s() { + local url="$1" n="$2" + local i total=0 t + for i in $(seq 1 "$n"); do + t="$(curl -sf -o /dev/null -w '%{time_total}' "$url")" + total="$(awk -v a="$total" -v b="$t" 'BEGIN { printf "%.8f", a + b }')" + done + awk -v a="$total" -v n="$n" 'BEGIN { printf "%.8f", a / n }' +} + +# Process RSS inside the container (ENTRYPOINT is PID 1 in both images). +container_rss_bytes() { + local name="$1" + "$CTR" exec "$name" sh -c 'awk "/VmRSS:/ { print \$2 * 1024; exit }" /proc/1/status' +} + +# On-disk binary / deploy payload inside the image. +container_payload_bytes() { + local name="$1" kind="$2" + if [[ "$kind" == "scriptc" ]]; then + "$CTR" exec "$name" sh -c 'wc -c < /usr/local/bin/rhdh-poc | tr -d " "' + else + "$CTR" exec "$name" sh -c 'du -sb /app | awk "{print \$1}"' + fi +} + +bench_one() { + local kind="$1" image="$2" name="$3" host_port="$4" + local url="http://127.0.0.1:${host_port}/healthcheck" + local info_url="http://127.0.0.1:${host_port}/api/poc/info?echo=bench" + + "$CTR" rm -f "$name" >/dev/null 2>&1 || true + local t0 t_start_s + t0="$(now_s)" + MSYS_NO_PATHCONV=1 "$CTR" run -d --name "$name" -p "${host_port}:7007" "$image" >/dev/null + t_start_s="$(wait_http "$url" 30)" || { + echo "$kind failed to become healthy" >&2 + "$CTR" logs "$name" || true + return 1 + } + + # Warmup then steady-state samples + local i + for i in $(seq 1 "$WARMUP_REQUESTS"); do curl -sf "$url" >/dev/null; done + sleep 0.5 + + local rss_bytes payload_bytes cgroup_mem latency_s + rss_bytes="$(container_rss_bytes "$name")" + payload_bytes="$(container_payload_bytes "$name" "$kind")" + # cgroup memory from engine stats (includes more than RSS) + local mem_raw + mem_raw="$("$CTR" stats --no-stream --format '{{.MemUsage}}' "$name" | awk '{print $1}')" + cgroup_mem="$(mem_to_bytes "$mem_raw")" + latency_s="$(avg_latency_s "$url" "$LATENCY_REQUESTS")" + curl -sf "$info_url" >"$OUT/${kind}-info.json" + + local image_b + image_b="$(image_bytes "$image")" + + # Export via nameref-like globals + eval "${kind}_image_bytes=$image_b" + eval "${kind}_payload_bytes=$payload_bytes" + eval "${kind}_startup_s=$t_start_s" + eval "${kind}_rss_bytes=$rss_bytes" + eval "${kind}_cgroup_bytes=$cgroup_mem" + eval "${kind}_latency_s=$latency_s" +} + +cleanup() { + "$CTR" rm -f rhdh-poc-scriptc-bench rhdh-poc-node-bench >/dev/null 2>&1 || true +} +trap cleanup EXIT + +echo "== bench ScriptC ==" +bench_one scriptc "$SCRIPTC_IMAGE" rhdh-poc-scriptc-bench 17007 +"$CTR" rm -f rhdh-poc-scriptc-bench >/dev/null 2>&1 || true + +echo "== bench Node twin ==" +bench_one node "$NODE_IMAGE" rhdh-poc-node-bench 17017 +"$CTR" rm -f rhdh-poc-node-bench >/dev/null 2>&1 || true + +ratio() { awk -v a="$1" -v b="$2" 'BEGIN { if (b+0==0) print "n/a"; else printf "%.2f", a/b }'; } + +cat >"$OUT/bench-report.json" </dev/null 2>&1; then + echo "scriptc CLI not found on PATH after npm install" >&2 + ls -la /poc/node_modules/.bin || true + exit 1 +fi + +bytes() { + # portable byte size + wc -c <"$1" | tr -d ' ' +} + +human() { + local b="$1" + if command -v numfmt >/dev/null 2>&1; then + numfmt --to=iec --suffix=B "$b" + else + echo "${b}B" + fi +} + +wait_http() { + local url="$1" + local i + for i in $(seq 1 30); do + if curl -sf "$url" >/dev/null; then + return 0 + fi + sleep 0.2 + done + echo "timed out waiting for $url" >&2 + return 1 +} + +smoke_kill() { + local pid="$1" + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true +} + +NODE_BIN="$(command -v node)" +NODE_BYTES="$(bytes "$NODE_BIN")" + +# --- Fixture A: static HTTP health --- +echo "== coverage: http-health.ts (static) ==" +scriptc coverage http-health.ts | tee "$OUT_DIR/coverage-http-health.txt" || true + +echo "== clang ==" +clang --version | head -2 + +echo "== build: scriptc static http-health ==" +# Prefer C backend for broader clang compatibility; LLVM needs recent clang. +scriptc build http-health.ts --backend c -o "$OUT_DIR/http-health.scriptc" +STATIC_BYTES="$(bytes "$OUT_DIR/http-health.scriptc")" + +echo "== build: node transpile http-health ==" +npx tsc http-health.ts --outDir "$OUT_DIR/node-http-health" --esModuleInterop --module nodenext --moduleResolution nodenext --target ES2022 --types node +NODE_APP_A="$(bytes "$OUT_DIR/node-http-health/http-health.js")" +NODE_DEPLOY_A=$((NODE_BYTES + NODE_APP_A)) + +# smoke both +"$OUT_DIR/http-health.scriptc" 17007 & +PID_A=$! +wait_http "http://127.0.0.1:17007/healthcheck" +smoke_kill "$PID_A" + +node "$OUT_DIR/node-http-health/http-health.js" 17017 & +PID_NA=$! +wait_http "http://127.0.0.1:17017/healthcheck" +smoke_kill "$PID_NA" + +# --- Fixture B: HTTP + zod (needs --dynamic) --- +echo "== coverage: http-with-dep.ts --dynamic ==" +scriptc coverage http-with-dep.ts --dynamic | tee "$OUT_DIR/coverage-http-with-dep.txt" || true + +echo "== build: scriptc --dynamic http-with-dep ==" +scriptc build http-with-dep.ts --dynamic --backend c -o "$OUT_DIR/http-with-dep.scriptc" +DYNAMIC_BYTES="$(bytes "$OUT_DIR/http-with-dep.scriptc")" + +echo "== build: node transpile http-with-dep + prod deps ==" +npx tsc http-with-dep.ts --outDir "$OUT_DIR/node-http-with-dep" --esModuleInterop --module nodenext --moduleResolution nodenext --target ES2022 --types node +mkdir -p "$OUT_DIR/node-http-with-dep-deploy/node_modules" +cp "$OUT_DIR/node-http-with-dep/http-with-dep.js" "$OUT_DIR/node-http-with-dep-deploy/" +# Use the exact zod version already installed in the image (avoid npm pack latest). +cp -a /poc/node_modules/zod "$OUT_DIR/node-http-with-dep-deploy/node_modules/zod" +NODE_APP_B="$(du -sb "$OUT_DIR/node-http-with-dep-deploy" | awk '{print $1}')" +NODE_DEPLOY_B=$((NODE_BYTES + NODE_APP_B)) + +"$OUT_DIR/http-with-dep.scriptc" 17008 & +PID_B=$! +wait_http "http://127.0.0.1:17008/healthcheck" +smoke_kill "$PID_B" + +( + cd "$OUT_DIR/node-http-with-dep-deploy" + node http-with-dep.js 17018 +) & +PID_NB=$! +wait_http "http://127.0.0.1:17018/healthcheck" +smoke_kill "$PID_NB" + +# --- Report --- +cat >"$REPORT" </dev/null || npm ls scriptc --depth=0 2>/dev/null | head -2 | tr '\n' ' ')", + "clangVersion": "$(clang --version | head -1)" + }, + "baselines": { + "nodeBinaryBytes": $NODE_BYTES + }, + "fixtures": { + "httpHealth": { + "mode": "scriptc-static", + "scriptcBinaryBytes": $STATIC_BYTES, + "nodeAppBytes": $NODE_APP_A, + "nodeDeployBytes": $NODE_DEPLOY_A, + "ratioNodeOverScriptc": $(awk "BEGIN {printf \"%.2f\", $NODE_DEPLOY_A / $STATIC_BYTES}") + }, + "httpWithDep": { + "mode": "scriptc-dynamic", + "scriptcBinaryBytes": $DYNAMIC_BYTES, + "nodeAppPlusDepsBytes": $NODE_APP_B, + "nodeDeployBytes": $NODE_DEPLOY_B, + "ratioNodeOverScriptc": $(awk "BEGIN {printf \"%.2f\", $NODE_DEPLOY_B / $DYNAMIC_BYTES}") + } + }, + "rhdhNote": "Full packages/backend is NOT compiled here. Next PoC step is scriptc coverage --dynamic on packages/backend/src/index.ts once blockers are inventoryable; native addons (better-sqlite3, isolated-vm) and dynamic plugin loading are hard stops without redesign." +} +EOF + +{ + echo "PROTOTYPE ScriptC vs Node size report" + echo "=====================================" + echo + echo "Node binary: $(human "$NODE_BYTES") ($NODE_BYTES bytes)" + echo + echo "Fixture A — http-health (static scriptc)" + echo " scriptc binary: $(human "$STATIC_BYTES") ($STATIC_BYTES bytes)" + echo " node app.js only: $(human "$NODE_APP_A") ($NODE_APP_A bytes)" + echo " node deploy (bin+app): $(human "$NODE_DEPLOY_A") ($NODE_DEPLOY_A bytes)" + echo " node/scriptc ratio: $(awk "BEGIN {printf \"%.2f\", $NODE_DEPLOY_A / $STATIC_BYTES}")x" + echo + echo "Fixture B — http-with-dep (scriptc --dynamic + zod)" + echo " scriptc binary: $(human "$DYNAMIC_BYTES") ($DYNAMIC_BYTES bytes)" + echo " node app+zod: $(human "$NODE_APP_B") ($NODE_APP_B bytes)" + echo " node deploy (bin+tree): $(human "$NODE_DEPLOY_B") ($NODE_DEPLOY_B bytes)" + echo " node/scriptc ratio: $(awk "BEGIN {printf \"%.2f\", $NODE_DEPLOY_B / $DYNAMIC_BYTES}")x" + echo + echo "JSON written to $REPORT" +} | tee "$TEXT" + +echo "DONE" diff --git a/packages/backend-scriptc/compare.sh b/packages/backend-scriptc/compare.sh new file mode 100644 index 0000000000..45d43939c0 --- /dev/null +++ b/packages/backend-scriptc/compare.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# PROTOTYPE — host entrypoint. Builds a Linux container and writes size reports to ./out +# Usage (from repo root): yarn prototype:scriptc +# Or: bash packages/backend/prototype-scriptc/compare.sh +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +OUT="$ROOT/out" +IMAGE="rhdh-backend-scriptc-compare:local" + +mkdir -p "$OUT" +# wipe previous comparison artifacts only +rm -rf "$OUT"/* +# keep a marker so casual readers know this dir is throwaway +echo "PROTOTYPE — wipe me" >"$OUT/README.wipe-me.txt" + +echo "== building comparison image ==" +# Prefer podman; fall back to docker +if command -v podman >/dev/null 2>&1; then + CTR=podman +elif command -v docker >/dev/null 2>&1; then + CTR=docker +else + echo "Need podman or docker (scriptc servers require Linux; this harness builds in a container)." >&2 + exit 1 +fi + +"$CTR" build -t "$IMAGE" -f "$ROOT/Containerfile" "$ROOT" + +echo "== running size comparison ==" +# Git Bash on Windows rewrites /out → a host path; keep the container path literal. +MSYS_NO_PATHCONV=1 "$CTR" run --rm \ + -v "$OUT:/out:Z" \ + "$IMAGE" + +echo +echo "Open the shareable decision demo:" +echo " $ROOT/index.html" +echo +echo "Reports:" +ls -la "$OUT" +echo +if [[ -f "$OUT/size-report.txt" ]]; then + cat "$OUT/size-report.txt" +fi diff --git a/packages/backend-scriptc/coverage-inside.sh b/packages/backend-scriptc/coverage-inside.sh new file mode 100644 index 0000000000..c269376373 --- /dev/null +++ b/packages/backend-scriptc/coverage-inside.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# PROTOTYPE — runs inside the toolchain container (repo mounted at /work). +set -euo pipefail + +OUT=/work/packages/backend-scriptc/out-coverage +HEARTBEAT_SECS="${HEARTBEAT_SECS:-5}" +# If 1, skip rungs that already have a non-empty .coverage.txt +SKIP_COMPLETED="${SKIP_COMPLETED:-0}" +mkdir -p "$OUT" + +relink() { + local link="$1" target="$2" + rm -f "$link" + ln -s "$target" "$link" + echo "relink $link -> $target" +} + +cd /work +mkdir -p node_modules/@internal node_modules/@red-hat-developer-hub +relink node_modules/@internal/plugin-dynamic-plugins-info-backend /work/plugins/dynamic-plugins-info-backend +relink node_modules/@internal/plugin-licensed-users-info-backend /work/plugins/licensed-users-info-backend +relink node_modules/@internal/plugin-scalprum-backend /work/plugins/scalprum-backend +relink node_modules/@red-hat-developer-hub/plugin-utils /work/packages/plugin-utils +relink node_modules/app /work/packages/app +relink node_modules/app-next /work/packages/app-next +relink node_modules/backend /work/packages/backend +relink node_modules/theme-wrapper /work/packages/theme-wrapper + +# Avoid process-substitution + wait hangs: write to files, heartbeat polls them. +run_cov() { + local idx="$1" total="$2" label="$3" file="$4" + local base out_txt out_err start end code hb_pid + base="$(echo "$label" | tr '/ ' '__')" + out_txt="$OUT/${base}.coverage.txt" + out_err="$OUT/${base}.coverage.err" + + echo + echo "======== [$idx/$total] coverage: $label ========" + echo "file: $file" + echo "note: scriptc has no progress flag; heartbeat every ${HEARTBEAT_SECS}s" + echo + + start=$(date +%s) + : >"$out_txt" + : >"$out_err" + + ( + while true; do + sleep "$HEARTBEAT_SECS" + end=$(date +%s) + out_bytes=$(wc -c <"$out_txt" | tr -d ' ') + err_bytes=$(wc -c <"$out_err" | tr -d ' ') + hint="" + if [[ "$out_bytes" -gt 0 ]]; then + hint=" | last: $(tail -n 1 "$out_txt" | tr -d '\r' | cut -c1-100)" + elif [[ "$err_bytes" -gt 0 ]]; then + hint=" | last err: $(tail -n 1 "$out_err" | tr -d '\r' | cut -c1-100)" + else + hint=" | (still typechecking — no scriptc output yet)" + fi + echo "[$idx/$total $label] still running… $((end - start))s elapsed (stdout=${out_bytes}B stderr=${err_bytes}B)${hint}" + done + ) & + hb_pid=$! + + set +e + if command -v stdbuf >/dev/null 2>&1; then + stdbuf -oL -eL scriptc coverage "$file" --dynamic >"$out_txt" 2>"$out_err" + else + scriptc coverage "$file" --dynamic >"$out_txt" 2>"$out_err" + fi + code=$? + set -e + + kill "$hb_pid" 2>/dev/null || true + wait "$hb_pid" 2>/dev/null || true + end=$(date +%s) + + # Mirror report to console after completion (avoids tee hang). + echo + echo "[$idx/$total $label] finished exit=$code elapsed=$((end - start))s" + echo "--- report head ---" + head -n 80 "$out_txt" || true + if [[ -s "$out_err" ]]; then + echo "--- stderr head ---" + head -n 40 "$out_err" || true + fi + local lines + lines=$(wc -l <"$out_txt" | tr -d ' ') + if [[ "$lines" -gt 80 ]]; then + echo "... (full report in ${base}.coverage.txt, $lines lines)" + fi + printf '%s\t%s\t%s\n' "$label" "$code" "$((end - start))" >>"$OUT/ladder-summary.tsv" +} + +# Preserve prior timings when skipping completed rungs. +if [[ "$SKIP_COMPLETED" == "1" && -f "$OUT/ladder-summary.tsv" ]]; then + cp "$OUT/ladder-summary.tsv" "$OUT/ladder-summary.prev.tsv" +else + rm -f "$OUT/ladder-summary.prev.tsv" +fi +printf 'label\texit\tseconds\n' >"$OUT/ladder-summary.tsv" + +prev_secs() { + local label="$1" + if [[ -f "$OUT/ladder-summary.prev.tsv" ]]; then + awk -F'\t' -v l="$label" '$1==l {print $3; exit}' "$OUT/ladder-summary.prev.tsv" + fi +} + +# Monkey-patch skip path to use preserved timings +# (run_cov reads ladder-summary.tsv for prev — point it at .prev instead) +run_cov_skip_aware() { + local idx="$1" total="$2" label="$3" file="$4" + local base out_txt + base="$(echo "$label" | tr '/ ' '__')" + out_txt="$OUT/${base}.coverage.txt" + if [[ "$SKIP_COMPLETED" == "1" && -s "$out_txt" ]] && grep -qE 'builds with --dynamic|fully static|not analyzable|blockers:' "$out_txt"; then + echo + echo "======== [$idx/$total] coverage: $label ========" + echo "skip: existing report at $out_txt" + printf '%s\t%s\t%s\n' "$label" "skipped" "$(prev_secs "$label" || echo '?')" >>"$OUT/ladder-summary.tsv" + return 0 + fi + run_cov "$idx" "$total" "$label" "$file" +} + +TOTAL=6 +run_cov_skip_aware 1 "$TOTAL" "01-main-trimmed" packages/backend-scriptc/src/main.ts +run_cov_skip_aware 2 "$TOTAL" "02-create-backend-empty" packages/backend-scriptc/src/entries/02-create-backend-empty.ts +run_cov_skip_aware 3 "$TOTAL" "03-create-backend-health" packages/backend-scriptc/src/entries/03-create-backend-health.ts +run_cov_skip_aware 4 "$TOTAL" "04-core-static-plugins" packages/backend-scriptc/src/entries/04-core-static-plugins.ts +run_cov_skip_aware 5 "$TOTAL" "05-with-dynamic-plugins" packages/backend-scriptc/src/entries/05-with-dynamic-plugins.ts +run_cov_skip_aware 6 "$TOTAL" "99-full-backend-index" packages/backend/src/index.ts + +echo +echo "======== triage ========" +python3 /work/packages/backend-scriptc/triage-coverage.py "$OUT" diff --git a/packages/backend-scriptc/coverage-inventory.sh b/packages/backend-scriptc/coverage-inventory.sh new file mode 100644 index 0000000000..32f9ace701 --- /dev/null +++ b/packages/backend-scriptc/coverage-inventory.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# PROTOTYPE — inventory scriptc coverage toward a full backend / dynamic-plugins PoC. +# Usage: yarn prototype:scriptc:coverage +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +REPO="$(cd "$ROOT/../.." && pwd)" +OUT="$ROOT/out-coverage" +IMAGE="rhdh-backend-scriptc-toolchain:local" + +mkdir -p "$OUT" +if [[ "${SKIP_COMPLETED:-0}" == "1" ]]; then + # Keep existing *.coverage.txt so rungs can be resumed. + rm -f "$OUT"/READINESS.txt "$OUT"/triage.txt 2>/dev/null || true +else + rm -f "$OUT"/*.coverage.txt "$OUT"/*.coverage.err "$OUT"/READINESS.txt "$OUT"/triage.txt "$OUT"/ladder-summary.tsv 2>/dev/null || true +fi +echo "PROTOTYPE — wipe me" >"$OUT/README.wipe-me.txt" + +if command -v podman >/dev/null 2>&1; then + CTR=podman +elif command -v docker >/dev/null 2>&1; then + CTR=docker +else + echo "Need podman or docker." >&2 + exit 1 +fi + +echo "== building toolchain image ==" +"$CTR" build -t "$IMAGE" -f "$ROOT/Containerfile.toolchain" "$ROOT" + +echo "== running coverage ladder + full backend index ==" +echo "Progress: heartbeat every ${HEARTBEAT_SECS:-5}s; SKIP_COMPLETED=${SKIP_COMPLETED:-0}" +# -t allocates a TTY so heartbeats flush live on Windows/Podman. +MSYS_NO_PATHCONV=1 "$CTR" run --rm -t \ + -e "HEARTBEAT_SECS=${HEARTBEAT_SECS:-5}" \ + -e "SKIP_COMPLETED=${SKIP_COMPLETED:-0}" \ + -v "$REPO:/work:Z" \ + -w /work \ + "$IMAGE" \ + bash /work/packages/backend-scriptc/coverage-inside.sh + +echo +echo "Artifacts in $OUT:" +ls -la "$OUT" +echo +if [[ -f "$OUT/READINESS.txt" ]]; then + cat "$OUT/READINESS.txt" +fi diff --git a/packages/backend-scriptc/fixtures/http-health.ts b/packages/backend-scriptc/fixtures/http-health.ts new file mode 100644 index 0000000000..a60a38d245 --- /dev/null +++ b/packages/backend-scriptc/fixtures/http-health.ts @@ -0,0 +1,43 @@ +/** + * PROTOTYPE fixture — static-friendly health HTTP server. + * Mirrors the thinnest RHDH backend surface: listen, route, JSON, env. + */ +import * as http from 'http'; + +function resolvePort(): number { + if (process.argv.length > 2) { + return Number(process.argv[2]); + } + const fromEnv = process.env.PORT; + if (typeof fromEnv === 'string' && fromEnv.length > 0) { + return Number(fromEnv); + } + return 7007; +} + +const port = resolvePort(); + +const server = http.createServer((req, res) => { + const url = req.url ?? '/'; + if (url === '/healthcheck' || url === '/.backstage/health/v1/liveness') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ status: 'ok' })); + return; + } + if (url === '/meta') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end( + JSON.stringify({ + service: 'prototype-scriptc-http-health', + runtime: process.argv.length > 1 ? process.argv[1] : 'unknown', + }), + ); + return; + } + res.writeHead(404, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'not found' })); +}); + +server.listen(port, '127.0.0.1', () => { + console.log(`listening on http://127.0.0.1:${port}`); +}); diff --git a/packages/backend-scriptc/fixtures/http-with-dep.ts b/packages/backend-scriptc/fixtures/http-with-dep.ts new file mode 100644 index 0000000000..fc826221d8 --- /dev/null +++ b/packages/backend-scriptc/fixtures/http-with-dep.ts @@ -0,0 +1,44 @@ +/** + * PROTOTYPE fixture — HTTP server that pulls one npm dependency. + * Forces --dynamic on scriptc (npm JS runs in the embedded island). + */ +import * as http from 'http'; +import { basename } from 'path'; +import { z } from 'zod'; + +const HealthSchema = z.object({ + status: z.literal('ok'), + path: z.string(), +}); + +function resolvePort(): number { + if (process.argv.length > 2) { + return Number(process.argv[2]); + } + const fromEnv = process.env.PORT; + if (typeof fromEnv === 'string' && fromEnv.length > 0) { + return Number(fromEnv); + } + return 7008; +} + +const port = resolvePort(); + +const server = http.createServer((req, res) => { + const url = req.url ?? '/'; + if (url === '/healthcheck') { + const body = HealthSchema.parse({ + status: 'ok', + path: basename(url), + }); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); + return; + } + res.writeHead(404, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'not found' })); +}); + +server.listen(port, '127.0.0.1', () => { + console.log(`listening on http://127.0.0.1:${port}`); +}); diff --git a/packages/backend-scriptc/image.sh b/packages/backend-scriptc/image.sh new file mode 100644 index 0000000000..042b9e7bd1 --- /dev/null +++ b/packages/backend-scriptc/image.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# PROTOTYPE — build trimmed ScriptC + Node twin Linux images and compare sizes. +# Usage: yarn prototype:scriptc:image +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +OUT="$ROOT/out-image" +SCRIPTC_IMAGE="rhdh-backend-scriptc-poc:local" +NODE_IMAGE="rhdh-backend-scriptc-poc-node:local" + +mkdir -p "$OUT" +rm -rf "$OUT"/* +echo "PROTOTYPE — wipe me" >"$OUT/README.wipe-me.txt" + +if command -v podman >/dev/null 2>&1; then + CTR=podman +elif command -v docker >/dev/null 2>&1; then + CTR=docker +else + echo "Need podman or docker." >&2 + exit 1 +fi + +echo "== building ScriptC runtime image ==" +"$CTR" build -t "$SCRIPTC_IMAGE" -f "$ROOT/Containerfile.poc" "$ROOT" + +echo "== building Node twin image ==" +"$CTR" build -t "$NODE_IMAGE" -f "$ROOT/Containerfile.poc-node" "$ROOT" + +image_bytes() { + # Virtual size in bytes from the engine (uncompressed layers as reported). + "$CTR" image inspect -f '{{.Size}}' "$1" +} + +human() { + local b="$1" + if command -v numfmt >/dev/null 2>&1; then + numfmt --to=iec --suffix=B "$b" + else + echo "${b}B" + fi +} + +SCRIPTC_BYTES="$(image_bytes "$SCRIPTC_IMAGE")" +NODE_BYTES="$(image_bytes "$NODE_IMAGE")" + +echo "== smoke ScriptC image ==" +MSYS_NO_PATHCONV=1 "$CTR" run -d --name rhdh-poc-scriptc-smoke -p 17007:7007 "$SCRIPTC_IMAGE" >/dev/null +cleanup() { + "$CTR" rm -f rhdh-poc-scriptc-smoke rhdh-poc-node-smoke >/dev/null 2>&1 || true +} +trap cleanup EXIT + +ok=0 +for i in $(seq 1 40); do + if curl -sf "http://127.0.0.1:17007/healthcheck" >/dev/null; then + ok=1 + break + fi + sleep 0.25 +done +if [[ "$ok" != 1 ]]; then + echo "ScriptC image healthcheck failed" >&2 + "$CTR" logs rhdh-poc-scriptc-smoke || true + exit 1 +fi +curl -sf "http://127.0.0.1:17007/api/poc/info?echo=hi" | tee "$OUT/scriptc-info.json" +echo + +echo "== smoke Node twin ==" +MSYS_NO_PATHCONV=1 "$CTR" run -d --name rhdh-poc-node-smoke -p 17017:7007 "$NODE_IMAGE" >/dev/null +ok=0 +for i in $(seq 1 40); do + if curl -sf "http://127.0.0.1:17017/healthcheck" >/dev/null; then + ok=1 + break + fi + sleep 0.25 +done +if [[ "$ok" != 1 ]]; then + echo "Node image healthcheck failed" >&2 + "$CTR" logs rhdh-poc-node-smoke || true + exit 1 +fi +curl -sf "http://127.0.0.1:17017/api/poc/info?echo=hi" | tee "$OUT/node-info.json" +echo + +RATIO="$(awk "BEGIN {printf \"%.2f\", $NODE_BYTES / $SCRIPTC_BYTES}")" + +cat >"$OUT/image-size-report.json" < + + + + + PROTOTYPE — ScriptC PoC for RHDH + + + +
Prototype · throwaway · not production
+

Enable ScriptC as an RHDH PoC?

+

+ Question this demo answers: + what must be true before we can compare a Node deploy with a ScriptC + binary for RHDH, + and what size comparison is even meaningful before the full backend + compiles. +

+ +
+
+

Current PoC state

+
+
+

+
+ +
+

Free play

+
+

+ Each click updates the state panel. Nothing is persisted. +

+
+
+ +
+

Guided walkthroughs

+
+
+
+ +

+ Run the real size comparison (Linux container via Podman/Docker): + yarn prototype:scriptc + then paste numbers from + packages/backend-scriptc/out/size-report.txt using + Load measured sizes. +

+ + + + diff --git a/packages/backend-scriptc/package.json b/packages/backend-scriptc/package.json new file mode 100644 index 0000000000..e9cfa41503 --- /dev/null +++ b/packages/backend-scriptc/package.json @@ -0,0 +1,19 @@ +{ + "name": "@internal/backend-scriptc", + "version": "0.0.0", + "private": true, + "description": "PROTOTYPE — trimmed ScriptC Linux PoC (not the production RHDH backend)", + "type": "module", + "scripts": { + "compare": "bash ./compare.sh", + "image": "bash ./image.sh" + }, + "dependencies": { + "zod": "3.25.76" + }, + "devDependencies": { + "@types/node": "24.10.1", + "scriptc": "0.0.23", + "typescript": "5.9.3" + } +} diff --git a/packages/backend-scriptc/src/entries/02-create-backend-empty.ts b/packages/backend-scriptc/src/entries/02-create-backend-empty.ts new file mode 100644 index 0000000000..36191d6f67 --- /dev/null +++ b/packages/backend-scriptc/src/entries/02-create-backend-empty.ts @@ -0,0 +1,7 @@ +/** + * PROTOTYPE ladder rung — createBackend() only (no plugins, no dynamic loader). + */ +import { createBackend } from '@backstage/backend-defaults'; + +const backend = createBackend(); +backend.start(); diff --git a/packages/backend-scriptc/src/entries/03-create-backend-health.ts b/packages/backend-scriptc/src/entries/03-create-backend-health.ts new file mode 100644 index 0000000000..7a7e394d62 --- /dev/null +++ b/packages/backend-scriptc/src/entries/03-create-backend-health.ts @@ -0,0 +1,9 @@ +/** + * PROTOTYPE ladder rung — createBackend + RHDH healthcheck plugin (no dynamic plugins). + */ +import { createBackend } from '@backstage/backend-defaults'; +import { healthCheckPlugin } from '../../../backend/src/modules/healthcheck'; + +const backend = createBackend(); +backend.add(healthCheckPlugin); +backend.start(); diff --git a/packages/backend-scriptc/src/entries/04-core-static-plugins.ts b/packages/backend-scriptc/src/entries/04-core-static-plugins.ts new file mode 100644 index 0000000000..a93028c4e6 --- /dev/null +++ b/packages/backend-scriptc/src/entries/04-core-static-plugins.ts @@ -0,0 +1,17 @@ +/** + * PROTOTYPE ladder rung — core static plugins, still no dynamicPluginsFeatureLoader. + */ +import { createBackend } from '@backstage/backend-defaults'; +import { healthCheckPlugin } from '../../../backend/src/modules/healthcheck'; + +const backend = createBackend(); +backend.add(healthCheckPlugin); +backend.add(import('@backstage/plugin-app-backend')); +backend.add(import('@backstage/plugin-catalog-backend')); +backend.add(import('@backstage/plugin-proxy-backend')); +backend.add(import('@backstage/plugin-auth-backend')); +backend.add(import('@backstage/plugin-auth-backend-module-guest-provider')); +backend.add(import('@backstage/plugin-search-backend')); +backend.add(import('@backstage/plugin-search-backend-module-catalog')); +backend.add(import('@backstage/plugin-permission-backend')); +backend.start(); diff --git a/packages/backend-scriptc/src/entries/05-with-dynamic-plugins.ts b/packages/backend-scriptc/src/entries/05-with-dynamic-plugins.ts new file mode 100644 index 0000000000..dd928a2471 --- /dev/null +++ b/packages/backend-scriptc/src/entries/05-with-dynamic-plugins.ts @@ -0,0 +1,35 @@ +/** + * PROTOTYPE ladder rung — adds dynamicPluginsFeatureLoader (the RHDH hard stop candidate). + */ +import { createBackend } from '@backstage/backend-defaults'; +import { + CommonJSModuleLoader, + dynamicPluginsFeatureLoader, +} from '@backstage/backend-dynamic-feature-service'; +import { PackageRoles } from '@backstage/cli-node'; +import * as path from 'path'; +import { healthCheckPlugin } from '../../../backend/src/modules/healthcheck'; + +const backend = createBackend(); + +backend.add( + dynamicPluginsFeatureLoader({ + schemaLocator(pluginPackage) { + const platform = PackageRoles.getRoleInfo( + pluginPackage.manifest.backstage.role, + ).platform; + return path.join( + platform === 'node' ? 'dist' : 'dist-scalprum', + 'configSchema.json', + ); + }, + moduleLoader: logger => + new CommonJSModuleLoader({ + logger, + }), + }), +); + +backend.add(healthCheckPlugin); +backend.add(import('@backstage/plugin-catalog-backend')); +backend.start(); diff --git a/packages/backend-scriptc/src/main.ts b/packages/backend-scriptc/src/main.ts new file mode 100644 index 0000000000..f5b27a18d3 --- /dev/null +++ b/packages/backend-scriptc/src/main.ts @@ -0,0 +1,88 @@ +/** + * PROTOTYPE — trimmed RHDH-shaped entry for a Linux ScriptC container. + * Not the full backend: no dynamic plugins, OTel, SQLite, scaffolder, etc. + */ +import * as http from 'http'; +import { z } from 'zod'; + +const InfoQuerySchema = z.object({ + echo: z.string().optional(), +}); + +function resolvePort(): number { + if (process.argv.length > 2) { + return Number(process.argv[2]); + } + const fromEnv = process.env.PORT; + if (typeof fromEnv === 'string' && fromEnv.length > 0) { + return Number(fromEnv); + } + return 7007; +} + +function sendJson(res: http.ServerResponse, status: number, body: unknown): void { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +function parseQuery(url: string): Record { + const qIndex = url.indexOf('?'); + if (qIndex < 0) { + return {}; + } + const out: Record = {}; + const raw = url.slice(qIndex + 1); + for (const part of raw.split('&')) { + if (part.length === 0) { + continue; + } + const eq = part.indexOf('='); + if (eq < 0) { + out[decodeURIComponent(part)] = ''; + } else { + out[decodeURIComponent(part.slice(0, eq))] = decodeURIComponent( + part.slice(eq + 1), + ); + } + } + return out; +} + +function pathOnly(url: string): string { + const q = url.indexOf('?'); + return q < 0 ? url : url.slice(0, q); +} + +const port = resolvePort(); +const host = '0.0.0.0'; + +const server = http.createServer((req, res) => { + const url = req.url ?? '/'; + const path = pathOnly(url); + + if (path === '/healthcheck' || path === '/.backstage/health/v1/liveness') { + sendJson(res, 200, { status: 'ok' }); + return; + } + + if (path === '/api/poc/info') { + const parsed = InfoQuerySchema.safeParse(parseQuery(url)); + if (!parsed.success) { + sendJson(res, 400, { error: 'invalid query' }); + return; + } + sendJson(res, 200, { + service: 'rhdh-backend-scriptc', + runtime: process.argv.length > 1 ? process.argv[1] : 'unknown', + echo: parsed.data.echo ?? null, + note: 'trimmed PoC — packages/backend-scriptc, not packages/backend', + }); + return; + } + + sendJson(res, 404, { error: 'not found' }); +}); + +server.listen(port, host, () => { + console.log(`listening on http://${host}:${port}`); +}); diff --git a/packages/backend-scriptc/triage-coverage.py b/packages/backend-scriptc/triage-coverage.py new file mode 100644 index 0000000000..e3410fc301 --- /dev/null +++ b/packages/backend-scriptc/triage-coverage.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""PROTOTYPE — summarize scriptc coverage ladder into triage + READINESS.""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + + +def main() -> int: + out = Path(sys.argv[1] if len(sys.argv) > 1 else "out-coverage") + triage_rows: list[tuple] = [] + + for p in sorted(out.glob("*.coverage.txt")): + text = p.read_text(errors="replace") + label = p.name.replace(".coverage.txt", "") + if "not analyzable" in text: + errs = sorted(set(re.findall(r"error (SC\d+):", text))) + triage_rows.append( + (label, "NOT_ANALYZABLE", errs, text.splitlines()[:8]) + ) + continue + static = re.search(r"compile statically\s+(\d+)\s+\((\d+)%\)", text) + dynamic = re.search(r"compile dynamically\s+(\d+)\s+\((\d+)%\)", text) + codes = sorted(set(re.findall(r"\b(SC\d+)\b", text))) + st = static.groups() if static else ("?", "?") + dy = dynamic.groups() if dynamic else ("0", "0") + builds = ("builds with --dynamic" in text) or ("fully static" in text) + # remaining blockers section means not clean + has_blockers = bool(re.search(r"^\s*blockers:\s*$", text, flags=re.M)) + kind = "OK" if builds and not has_blockers else "HAS_FINDINGS" + triage_rows.append((label, kind, st, dy, codes[:40])) + + lines: list[str] = [ + "PROTOTYPE scriptc coverage triage", + "=================================", + "", + ] + for row in triage_rows: + label, kind = row[0], row[1] + lines.append(f"## {label}") + if kind == "NOT_ANALYZABLE": + lines.append("status: NOT_ANALYZABLE (type errors gate coverage)") + lines.append(f"errorCodes: {row[2]}") + lines.append("head:") + for h in row[3]: + lines.append(f" {h}") + else: + st, dy, codes = row[2], row[3], row[4] + lines.append(f"status: {kind}") + lines.append(f"static: {st[0]} ({st[1]}%) dynamic: {dy[0]} ({dy[1]}%)") + lines.append( + f"SC codes ({len(codes)}): {', '.join(codes) if codes else '(none listed)'}" + ) + lines.append("") + + def find(suffix: str): + return next((t for t in triage_rows if t[0].endswith(suffix)), None) + + full = find("99-full-backend-index") + dyn = find("05-with-dynamic-plugins") + core = find("04-core-static-plugins") + empty = find("02-create-backend-empty") + health = find("03-create-backend-health") + + def status_of(row): + return "missing" if row is None else row[1] + + ready: list[str] = [ + "PROTOTYPE readiness — full backend + dynamic plugins", + "==================================================", + "", + "Question: Are we ready for a full-on PoC with dynamic plugins?", + "", + f"02 createBackend empty: {status_of(empty)}", + f"03 createBackend + health: {status_of(health)}", + f"04 core static plugins: {status_of(core)}", + f"05 with dynamic plugins: {status_of(dyn)}", + f"99 full packages/backend: {status_of(full)}", + "", + ] + + reasons: list[str] = [] + for name, row in [ + ("createBackend empty", empty), + ("core static plugins", core), + ("dynamic-plugins rung", dyn), + ("full backend index", full), + ]: + if row is None: + reasons.append(f"{name}: no report") + elif row[1] == "NOT_ANALYZABLE": + reasons.append( + f"{name}: blocked on TypeScript/resolution before coverage numbers" + ) + elif row[1] != "OK": + reasons.append( + f"{name}: coverage has findings/blockers — not a clean --dynamic build" + ) + else: + reasons.append( + f"{name}: coverage claims buildable with --dynamic (not runtime-proven)" + ) + + reasons.append( + "dynamic plugins require runtime CommonJS module loading from disk; " + "scriptc embeds deps at build time and does not load node_modules at runtime — " + "true dynamicPluginsFeatureLoader behavior needs a redesign " + "(prebundle plugins at build time or an external host), not just coverage green" + ) + + if ( + full + and full[1] == "OK" + and dyn + and dyn[1] == "OK" + and core + and core[1] == "OK" + ): + verdict = ( + "NOT YET — coverage may look buildable, but runtime dynamic loading " + "is still an architectural gap" + ) + else: + verdict = "NO" + + ready.append(f"Verdict: {verdict}") + ready.append("") + ready.append("Reasons:") + for r in reasons: + ready.append(f"- {r}") + ready.append("") + ready.append( + "Next compile target if pursuing backend-like PoC without true dynamic plugins:" + ) + ready.append("- Get 04-core-static-plugins analyzable + buildable") + ready.append("- Ship that as the backend-like image twin") + ready.append("- Treat dynamic plugins as a separate design spike") + + (out / "triage.txt").write_text("\n".join(lines) + "\n", encoding="utf-8") + (out / "READINESS.txt").write_text("\n".join(ready) + "\n", encoding="utf-8") + print("\n".join(ready)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/backend-scriptc/tsconfig.json b/packages/backend-scriptc/tsconfig.json new file mode 100644 index 0000000000..0af76da32a --- /dev/null +++ b/packages/backend-scriptc/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node"], + "outDir": "dist", + "rootDir": "src" + }, + "include": ["./src/main.ts"] +}