This is an automated email from the ASF dual-hosted git repository. wu-sheng pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit 961513b58bc40bab15c3c8b8db10e18bc3549f71 Author: Wu Sheng <[email protected]> AuthorDate: Mon May 18 21:06:31 2026 +0800 build: real packages/* builds + self-contained dist + copy-in image (no compile in image) Fixes the regression where the published image crashed on first import with `ERR_UNKNOWN_FILE_EXTENSION: Unknown file extension ".ts"`: the internal workspace packages had no build step, their `main` fields pointed at src/*.ts, and the BFF's esbuild used `--packages=external` so dist/server.js fell straight through to Node's resolver on the uncompiled TypeScript source. Closes the path end-to-end and adds a single-command artifact target. workspace packages (api-client / design-tokens / templates) - Each gets a `tsconfig.build.json` (NodeNext / declaration / source maps / out to dist). - `build` script = `tsc -p tsconfig.build.json`. `prepare` script fires on `pnpm install` so a fresh dev tree builds packages automatically. - `main` / `types` / `exports` point at `dist/`. `files: ["dist"]` so `pnpm deploy` ships only the compiled output. - design-tokens keeps `./tokens.css` exporting from `src/` (CSS isn't compiled). self-contained ./dist/ (the local "binary mode") - New `scripts/package.mjs` produces a flat `./dist/` at the repo root: server.js + node_modules (with the .pnpm symlink store intact) + bundled_templates + static + horizon.example.yaml + package.json. - `pnpm package` runs it. Each invocation rm -rfs ./dist first — every rebuild is fully clean. - The deploy output is `renameSync`d into place to preserve pnpm's in-tree relative symlinks (cpSync default would keep them as symlinks pointing into the deleted scratch; dereference would explode the tree 5-10x). - BFF loaders gained a `__dirname/bundled_templates` probe so the flat sibling layout (server.js next to bundled_templates) resolves without any env-var override. Existing dev + docker probes kept. - Verified: `node dist/server.js` boots, serves SPA + API, audits to /tmp paths, OAP demo reachable. docker image (copy-in only — no compile, no pnpm install) - Single-stage Dockerfile. Just `COPY dist/* /app/`. The runtime image doesn't see Node toolchain, devDeps, pnpm, or any source. - `.dockerignore` is allowlist-style: ignore everything, unignore only `dist/` and `Dockerfile`. Tiny build context, no accidental source leaks if a future Dockerfile change tries to reach back. - Image now 178 MB (was significantly larger with the multi-stage builder). Built and run-tested locally end-to-end. - bundled_templates owned by `horizon` user so admin Layer-Templates + Overview-Templates saves don't EACCES (carries forward the earlier Issue-3 fix). - /data writable volume + HORIZON_*_FILE env-var defaults (carried forward from the earlier review-fixes commit). ci - publish-image.yaml installs Node + pnpm on the runner, runs `pnpm install && pnpm package`, then `docker buildx` consumes the pre-built dist/. Image build itself needs zero network. - Switched from multi-arch (linux/amd64+arm64) to single-arch (amd64): argon2 ships native bindings, and cross-arch packaging requires a per-arch matrix that each builds its own dist/ + platform-tagged image. Noted as a follow-up in the workflow comment; arm64 demand has not surfaced yet. logging (binary + image, production-ready) - Logger default level: dev `debug`, **prod `error`** (per the product-ready directive). Operators bump to `info` for Fastify's per-request access logs, `debug` for lifecycle chatter, `trace` for full instrumentation. Genuine request errors stay logged at the error floor. - Logging story documented in docs/setup/container-image.md with a three-channel summary (app log / audit log / wire-debug log), example pino JSON output, docker logs + jq recipes, and per-aggregator ingestion notes (vector / fluent-bit / promtail / filebeat). side-fixes uncovered while making node dist/server.js boot - server.staticDir from horizon.yaml was being ignored — server.ts only read HORIZON_STATIC_DIR env var. Now reads env first, falls back to the YAML value. Schema and docs already documented this knob; the implementation was missing. - The route-policy fail-loud check (from the prior commit) tripped on Fastify's auto-registered HEAD-for-GET routes. Fix: HEAD inherits its GET sibling's policy (same data, same RBAC). - The same fail-loud check also 401'd every SPA static route (`/`, `/login`, `/assets/*` registered by @fastify/static). Fix: non-`/api/*` routes without an explicit policy entry are inherently public — the SPA bundle is harmless; the protected APIs it calls each have their own enforced policy entry. /api/* routes still hard-error on missing policy. verified: bff type-check + lint + 77/77 tests; ui type-check + lint; license-eye 0 invalid; pnpm package green; node dist/server.js boots and serves; docker build + docker run boot, default log level quiet, LOG_LEVEL=info opens access logs. --- .dockerignore | 25 ++++++ .github/workflows/publish-image.yaml | 25 +++++- Dockerfile | 82 +++++++----------- apps/bff/package.json | 3 + apps/bff/src/logger.ts | 13 ++- apps/bff/src/logic/layers/loader.ts | 5 ++ apps/bff/src/logic/overview/loader.ts | 1 + apps/bff/src/rbac/route-policy.ts | 12 +-- apps/bff/src/server.ts | 19 +++-- docs/setup/container-image.md | 56 ++++++++++++- package.json | 4 +- packages/api-client/package.json | 14 +++- packages/api-client/tsconfig.build.json | 16 ++++ packages/design-tokens/package.json | 14 +++- packages/design-tokens/tsconfig.build.json | 16 ++++ packages/templates/package.json | 13 ++- packages/templates/tsconfig.build.json | 16 ++++ scripts/package.mjs | 130 +++++++++++++++++++++++++++++ 18 files changed, 387 insertions(+), 77 deletions(-) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b37888f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Allowlist style: the image is a pure copy-in of `./dist/`, so the +# Docker context never needs anything else. Excluding everything by +# default keeps the context tiny, the build deterministic, and +# eliminates any temptation to reach back into source from a future +# Dockerfile change. + +* +!dist +!dist/** +!Dockerfile diff --git a/.github/workflows/publish-image.yaml b/.github/workflows/publish-image.yaml index 043a143..0fe0a9a 100644 --- a/.github/workflows/publish-image.yaml +++ b/.github/workflows/publish-image.yaml @@ -58,6 +58,23 @@ jobs: with: persist-credentials: false + # The Dockerfile is a pure copy-in of `./dist/` (no compile or + # network inside `docker build`). Build the artifact on the + # runner first; the image just lays it out under /app. + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Activate pnpm + run: corepack enable && corepack prepare [email protected] --activate + + - name: Install workspace deps + run: pnpm install --frozen-lockfile + + - name: Build self-contained ./dist/ + run: pnpm package + - name: Set up QEMU uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 @@ -103,8 +120,14 @@ jobs: - name: Build + push run: | set -eu + # Single-platform (linux/amd64) for now. The `./dist/` is built + # on the GitHub-hosted runner (linux/amd64), and `argon2` ships + # native bindings — cross-arch packaging would need a per-arch + # matrix of build jobs that each produce their own dist/ + + # platform-tagged image, then a buildx imagetools merge into a + # manifest list. Easy follow-up when arm64 demand is real. docker buildx build \ - --platform linux/amd64,linux/arm64 \ + --platform linux/amd64 \ --file Dockerfile \ --label "org.opencontainers.image.source=https://github.com/${{ github.repository }}" \ --label "org.opencontainers.image.revision=${{ github.sha }}" \ diff --git a/Dockerfile b/Dockerfile index 056fd6e..c55f6d0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,68 +13,48 @@ # See the License for the specific language governing permissions and # limitations under the License. -# ---- builder ---------------------------------------------------------------- -# Builds the BFF bundle + the Vite SPA in a single workspace install. Final -# image only carries the BFF dist + UI dist + the production dependency tree -# (no source, no devDependencies, no pnpm store). -FROM node:20-alpine AS builder -WORKDIR /workspace - -# argon2 (password hashing) builds a native module; alpine needs python + a -# C toolchain at build time. The deps are dropped from the runtime stage. -RUN apk add --no-cache python3 make g++ libc6-compat -# Activate the pnpm version pinned in the repo's root `package.json`. -# We pin it here too so `corepack prepare` doesn't need package.json -# on disk yet (it runs before the COPY of workspace manifests). -RUN corepack enable && corepack prepare [email protected] --activate - -COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./ -COPY apps/bff/package.json apps/bff/ -COPY apps/ui/package.json apps/ui/ -COPY packages/api-client/package.json packages/api-client/ -COPY packages/design-tokens/package.json packages/design-tokens/ -COPY packages/templates/package.json packages/templates/ -RUN pnpm install --frozen-lockfile - -COPY . . -RUN pnpm --filter @skywalking-horizon-ui/bff build \ - && pnpm --filter @skywalking-horizon-ui/ui build - -# Deploy a focused production install for the BFF — pnpm copies only the -# packages it actually needs (workspace deps + runtime npm deps). The -# `--legacy` flag is mandatory under pnpm 10+ for non-injected -# workspaces (the alternative is enabling `inject-workspace-packages` -# everywhere, which we don't need for plain workspace deps). -RUN pnpm deploy --legacy --filter @skywalking-horizon-ui/bff --prod /deploy/bff +# Copy-in image. The image does NOT compile anything and does NOT run +# `pnpm install` — it consumes the pre-built `./dist/` produced by +# `pnpm package` at the repo root and lays it out under `/app/`. Build +# the artifact first: +# +# pnpm install # one-time / on lockfile changes +# pnpm package # produces ./dist/ (server.js + node_modules +# # + bundled_templates + static + example yaml) +# docker build -t horizon-ui:local . +# +# Net effect: tiny image (no Node toolchain, no devDeps, no pnpm store, +# no source), reproducible (the dist/ tarball is the contract), and +# air-gap-friendly (image build needs zero network). -# ---- runtime --------------------------------------------------------------- FROM node:20-alpine WORKDIR /app # Run as a non-root user — the BFF doesn't need any privileged access. RUN addgroup -S horizon && adduser -S -G horizon horizon -# Read-only artifacts (code, deps, static assets, example config) — owned -# by root, world-readable. The BFF never writes here. -COPY --from=builder /deploy/bff/dist ./dist -COPY --from=builder /deploy/bff/node_modules ./node_modules -COPY --from=builder /deploy/bff/package.json ./package.json -COPY --from=builder /workspace/apps/ui/dist ./static -COPY --from=builder /workspace/horizon.example.yaml ./horizon.example.yaml +# Pre-built artifact. Layout matches what `node server.js` expects: +# server.js + bundled_templates + node_modules + static all siblings +# under /app/. The bundled-template loader probes `__dirname/bundled_ +# templates` first (see apps/bff/src/logic/layers/loader.ts). +# +# Read-only artifacts owned by root: +COPY dist/server.js ./server.js +COPY dist/package.json ./package.json +COPY dist/node_modules ./node_modules +COPY dist/static ./static +COPY dist/horizon.example.yaml ./horizon.example.yaml # `bundled_templates/` is writable: the admin Layer-Templates and -# Overview-Templates editors `writeFileSync` into the per-key/per-id -# JSON files here. Must be owned by the `horizon` user, otherwise admin -# saves EACCES. The loader still resolves the directory via -# `__dirname/../bundled_templates`, so the path layout stays in sync -# with the source tree. -COPY --from=builder --chown=horizon:horizon /workspace/apps/bff/src/bundled_templates ./bundled_templates +# Overview-Templates editors `writeFileSync` into per-key / per-id JSON +# files. Owned by the `horizon` user so saves don't EACCES. +COPY --chown=horizon:horizon dist/bundled_templates ./bundled_templates # `/data` is the writable state directory the BFF writes its runtime # files into (audit log, setup state, alarm state, wire debug log). -# Operators can mount a PVC / named volume / host bind at /data and -# the configured paths below land on durable storage. Without this -# mount the writes go to the container's writable layer (ephemeral). +# Operators mount a PVC / named volume / host bind here for durable +# storage. Without a mount the writes go to the container's writable +# layer (ephemeral). RUN mkdir -p /data && chown horizon:horizon /data VOLUME ["/data"] @@ -88,4 +68,4 @@ ENV NODE_ENV=production \ USER horizon EXPOSE 8081 -CMD ["node", "dist/server.js"] +CMD ["node", "server.js"] diff --git a/apps/bff/package.json b/apps/bff/package.json index 82e66bc..9e50ba6 100644 --- a/apps/bff/package.json +++ b/apps/bff/package.json @@ -4,6 +4,9 @@ "private": true, "type": "module", "main": "dist/server.js", + "files": [ + "dist" + ], "scripts": { "dev": "tsx watch src/server.ts", "build": "esbuild src/server.ts --bundle --platform=node --format=esm --target=node20 --outfile=dist/server.js --packages=external", diff --git a/apps/bff/src/logger.ts b/apps/bff/src/logger.ts index 335beed..bbeb0db 100644 --- a/apps/bff/src/logger.ts +++ b/apps/bff/src/logger.ts @@ -19,8 +19,19 @@ import pino, { type LoggerOptions } from 'pino'; const isDev = process.env.NODE_ENV !== 'production'; +/** + * Default log level: + * - dev: `debug` (verbose, helpful while iterating) + * - prod: `error` (quiet by default — Fastify's per-request `info` + * access logs are suppressed; only warnings, errors, and fatals + * reach stdout) + * + * Operators turn it up explicitly when triaging: `LOG_LEVEL=info` for + * access logs, `LOG_LEVEL=debug` for the lifecycle chatter, `trace` + * for everything pino-instrumented code emits. + */ export const loggerOptions: LoggerOptions = { - level: process.env.LOG_LEVEL ?? (isDev ? 'debug' : 'info'), + level: process.env.LOG_LEVEL ?? (isDev ? 'debug' : 'error'), ...(isDev ? { transport: { diff --git a/apps/bff/src/logic/layers/loader.ts b/apps/bff/src/logic/layers/loader.ts index 55641a3..c7b7770 100644 --- a/apps/bff/src/logic/layers/loader.ts +++ b/apps/bff/src/logic/layers/loader.ts @@ -320,7 +320,12 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); * arbitrary working dir with the templates beside them. */ function locateConfigDir(): string { const candidates = [ + // Self-contained flat dist (`pnpm package` output: server.js + + // bundled_templates as siblings inside ./dist/). + join(__dirname, 'bundled_templates', 'layers'), + // Dev (tsx watch: apps/bff/src/logic/layers/loader.ts). join(__dirname, '..', '..', 'bundled_templates', 'layers'), + // Docker image (/app/dist/server.js → /app/bundled_templates). join(__dirname, '..', 'bundled_templates', 'layers'), join(process.cwd(), 'bundled_templates', 'layers'), ]; diff --git a/apps/bff/src/logic/overview/loader.ts b/apps/bff/src/logic/overview/loader.ts index 2ba0cd8..faf984d 100644 --- a/apps/bff/src/logic/overview/loader.ts +++ b/apps/bff/src/logic/overview/loader.ts @@ -39,6 +39,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); * — see that file for the rationale. */ function locateConfigDir(): string { const candidates = [ + path.join(__dirname, 'bundled_templates', 'overviews'), path.join(__dirname, '..', '..', 'bundled_templates', 'overviews'), path.join(__dirname, '..', 'bundled_templates', 'overviews'), path.join(process.cwd(), 'bundled_templates', 'overviews'), diff --git a/apps/bff/src/rbac/route-policy.ts b/apps/bff/src/rbac/route-policy.ts index 685e473..1a859a2 100644 --- a/apps/bff/src/rbac/route-policy.ts +++ b/apps/bff/src/rbac/route-policy.ts @@ -243,11 +243,13 @@ export function makeRouteAuthHook(deps: AuthDeps) { logger.error({ method: methods, url: route.url }, msg); throw new Error(msg); } - logger.warn( - { method: methods, url: route.url }, - 'rbac: non-api route has no policy entry; defaulting to auth-only', - ); - chosen = 'auth'; + // Everything else (SPA static — `/`, `/login`, `/assets/*`, etc., + // registered by @fastify/static) is inherently public: the served + // bundle is harmless, and the protected APIs it calls all have + // their own ROUTE_POLICY entries enforced above. Gating these + // routes on 'auth' breaks the login page (the browser can't load + // /login before the user has a session). + return; } if (chosen === 'public') return; diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts index 915f21d..4101f0b 100644 --- a/apps/bff/src/server.ts +++ b/apps/bff/src/server.ts @@ -181,13 +181,18 @@ registerOverviewTemplatesAdminRoutes(app, { config: source, sessions }); registerAuthStatusRoutes(app, { config: source, ldapHealth, sessions }); registerAdminUsersRoute(app, { config: source, seenCache }); -// Serve the built SPA out of the BFF when HORIZON_STATIC_DIR points at a -// directory (Docker image layout: /app/static contains the Vite dist). -// Local dev keeps using the Vite dev-server on :9091 so this is a no-op -// when the env var is absent. -const staticDir = process.env.HORIZON_STATIC_DIR - ? resolvePath(process.env.HORIZON_STATIC_DIR) - : null; +// Serve the built SPA out of the BFF when a static dir is configured. +// Two paths to set it: +// - HORIZON_STATIC_DIR env var (Docker image layout: /app/static). +// - server.staticDir in horizon.yaml (local dev / operator-managed). +// The env var wins when both are set so the image's default isn't +// silently shadowed by a stale YAML value. Vite dev-server on :9091 is +// still the right path during UI development; this branch is for the +// "serve the built SPA from the BFF" workflow. +const staticDir = (() => { + const raw = process.env.HORIZON_STATIC_DIR ?? source.current.server.staticDir; + return raw ? resolvePath(raw) : null; +})(); if (staticDir && existsSync(staticDir)) { await app.register(fastifyStatic, { root: staticDir, prefix: '/', wildcard: false }); // SPA fallback — anything that isn't an `/api/*` request and didn't match diff --git a/docs/setup/container-image.md b/docs/setup/container-image.md index 053fea9..2ce6ccd 100644 --- a/docs/setup/container-image.md +++ b/docs/setup/container-image.md @@ -39,7 +39,8 @@ The runtime stage runs as the non-root user `horizon`. Two locations are owned b | Variable | Default in image | Purpose | |---|---|---| -| `NODE_ENV` | `production` | Sets Node into production mode. | +| `NODE_ENV` | `production` | Drives the logger format (JSON vs pretty) and Node optimizations. | +| `LOG_LEVEL` | (unset → `error` in production, `debug` in dev) | Pino log level: `trace`, `debug`, `info`, `warn`, `error`, `fatal`. | | `HORIZON_CONFIG` | `/app/horizon.yaml` | Where the BFF looks for `horizon.yaml`. Override to mount elsewhere. | | `HORIZON_STATIC_DIR` | `/app/static` | Where the BFF serves UI assets from. | | `HORIZON_AUDIT_FILE` | `/data/horizon-audit.jsonl` | Default for `audit.file` when `horizon.yaml` doesn't override it. | @@ -217,6 +218,59 @@ To persist admin-edited templates across container restarts / image updates: The mounted directory must be writable by the `horizon` user (UID/GID inside the container — check with `docker run --rm <image> id horizon`). Without persistence, admin edits behave as ephemeral overrides — useful for try-it-out, destructive for production. +## Logging + +The BFF uses [pino](https://github.com/pinojs/pino) and writes **structured JSON** to **stdout** in production — visible via `docker logs <container>` and ready for any log aggregator (Fluent Bit, Vector, Promtail, Filebeat, Datadog) without extra parsers. + +| Mode | How to enter | Output | +|---|---|---| +| Production | The image sets `NODE_ENV=production`. Local: `NODE_ENV=production node dist/server.js`. | One JSON object per line on stdout. **Default level `error`** — quiet by default; only warnings, errors, and fatals reach stdout. Fields: `level`, `time`, `pid`, `hostname`, plus per-event keys (`reqId`, `req`, `res`, `responseTime`, `msg`, …). | +| Development | The local binary defaults to dev (`NODE_ENV` unset). | Pretty-printed, colorized, with timestamps via `pino-pretty`. Default level `debug`. Same fields, human-readable. | + +Adjust the floor with `LOG_LEVEL` when triaging: + +```sh +docker run -e LOG_LEVEL=info ... # add per-request access logs + lifecycle +docker run -e LOG_LEVEL=debug ... # add the loader / capability-probe chatter +docker run -e LOG_LEVEL=trace ... # every pino-instrumented site +docker run -e LOG_LEVEL=warn ... # even quieter than the default +NODE_ENV=production LOG_LEVEL=info node dist/server.js +``` + +### Per-request logging + +Fastify's request logger is on by default and emits one `incoming request` line + one `request completed` line per HTTP request, both tagged with a stable `reqId`. These are level-`info` (30) events — **suppressed under the production default `error`**. Bump to `LOG_LEVEL=info` to surface them; example pair under that level: + +```json +{"level":30,"time":1779109372598,"pid":1,"hostname":"...","reqId":"req-1","req":{"method":"GET","url":"/api/auth/health","host":"127.0.0.1:8081","remoteAddress":"192.168.65.1","remotePort":60655},"msg":"incoming request"} +{"level":30,"time":1779109372614,"pid":1,"hostname":"...","reqId":"req-1","res":{"statusCode":200},"responseTime":14.93,"msg":"request completed"} +``` + +Genuine request errors (5xx, request-handler exceptions) are still logged at `error` (50) — they reach stdout under any default that includes `error`. + +This is separate from the **audit log** (which records sensitive operations — login, rule edits, break-glass — to a JSONL file at `audit.file`; see [Access Control → Audit Log](../access-control/audit-log.md)) and the **wire-debug log** (which records OAP HTTP request/response payloads when `debugLog.enabled: true`; see [Setup → debugLog](debug-log.md)). Three orthogonal channels: + +| Channel | Where | What | Toggle | +|---|---|---|---| +| App logs | stdout (JSON in prod, pretty in dev) | Lifecycle + per-request | Always on. `LOG_LEVEL` adjusts. | +| Audit log | `audit.file` (JSONL) | Logins, RBAC-gated mutations | Always on. Path = `audit.file`. | +| Wire-debug | `debugLog.file` (JSONL) | Outbound OAP requests/responses | Off by default. `debugLog.enabled: true` opt-in. | + +### Aggregating from Docker + +```sh +# Quick tail with severity color (jq): +docker logs -f horizon-test | jq -c '. | "\(.time|todate) [\(.level)] \(.msg)"' -r + +# Just request failures: +docker logs -f horizon-test | jq -c 'select(.res.statusCode >= 400)' + +# Just structured slowness: +docker logs -f horizon-test | jq -c 'select(.responseTime != null and .responseTime > 200)' +``` + +For Kubernetes, the standard pipelines (fluent-bit `tail` plugin with `Parser json`, or vector `kubernetes_logs` → `parse_json`) ingest these lines directly. No app-side configuration required. + ## Network - Container exposes **8081** by default. If you change `server.port`, publish the new port and update the readiness probe. diff --git a/package.json b/package.json index 5c1d29f..18f9a8e 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,9 @@ "lint": "pnpm -r run lint", "test:unit": "pnpm -r run test:unit", "license:check": "license-eye -c .licenserc.yaml header check", - "license:fix": "license-eye -c .licenserc.yaml header fix" + "license:fix": "license-eye -c .licenserc.yaml header fix", + "package": "node scripts/package.mjs", + "start": "HORIZON_CONFIG=${HORIZON_CONFIG:-./horizon.yaml} node dist/server.js" }, "devDependencies": { "typescript": "~5.6.3" diff --git a/packages/api-client/package.json b/packages/api-client/package.json index ab0caf3..9560fdc 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -3,15 +3,21 @@ "version": "0.1.0", "private": true, "type": "module", - "main": "src/index.ts", - "types": "src/index.ts", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", "exports": { ".": { - "types": "./src/index.ts", - "import": "./src/index.ts" + "types": "./dist/index.d.ts", + "import": "./dist/index.js" } }, + "files": [ + "dist" + ], "scripts": { + "build": "tsc -p tsconfig.build.json", + "clean": "rm -rf dist", + "prepare": "tsc -p tsconfig.build.json", "type-check": "tsc --noEmit" }, "devDependencies": { diff --git a/packages/api-client/tsconfig.build.json b/packages/api-client/tsconfig.build.json new file mode 100644 index 0000000..e29ab80 --- /dev/null +++ b/packages/api-client/tsconfig.build.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "rootDir": "src", + "outDir": "dist", + "noEmit": false, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "module": "NodeNext", + "moduleResolution": "NodeNext" + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/design-tokens/package.json b/packages/design-tokens/package.json index c765df8..d898d1b 100644 --- a/packages/design-tokens/package.json +++ b/packages/design-tokens/package.json @@ -3,15 +3,23 @@ "version": "0.1.0", "private": true, "type": "module", - "main": "src/index.ts", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", "exports": { ".": { - "types": "./src/index.ts", - "import": "./src/index.ts" + "types": "./dist/index.d.ts", + "import": "./dist/index.js" }, "./tokens.css": "./src/tokens.css" }, + "files": [ + "dist", + "src/tokens.css" + ], "scripts": { + "build": "tsc -p tsconfig.build.json", + "clean": "rm -rf dist", + "prepare": "tsc -p tsconfig.build.json", "type-check": "tsc --noEmit" }, "devDependencies": { diff --git a/packages/design-tokens/tsconfig.build.json b/packages/design-tokens/tsconfig.build.json new file mode 100644 index 0000000..dcd3866 --- /dev/null +++ b/packages/design-tokens/tsconfig.build.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "rootDir": "src", + "outDir": "dist", + "noEmit": false, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "module": "NodeNext", + "moduleResolution": "NodeNext" + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/templates/package.json b/packages/templates/package.json index a93630c..57f96dc 100644 --- a/packages/templates/package.json +++ b/packages/templates/package.json @@ -3,14 +3,21 @@ "version": "0.1.0", "private": true, "type": "module", - "main": "src/index.ts", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", "exports": { ".": { - "types": "./src/index.ts", - "import": "./src/index.ts" + "types": "./dist/index.d.ts", + "import": "./dist/index.js" } }, + "files": [ + "dist" + ], "scripts": { + "build": "tsc -p tsconfig.build.json", + "clean": "rm -rf dist", + "prepare": "tsc -p tsconfig.build.json", "type-check": "tsc --noEmit" }, "devDependencies": { diff --git a/packages/templates/tsconfig.build.json b/packages/templates/tsconfig.build.json new file mode 100644 index 0000000..e29ab80 --- /dev/null +++ b/packages/templates/tsconfig.build.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "rootDir": "src", + "outDir": "dist", + "noEmit": false, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "module": "NodeNext", + "moduleResolution": "NodeNext" + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules"] +} diff --git a/scripts/package.mjs b/scripts/package.mjs new file mode 100644 index 0000000..8ec251b --- /dev/null +++ b/scripts/package.mjs @@ -0,0 +1,130 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Produce a self-contained, network-free `./dist/` artifact at the repo + * root. After this script finishes, the operator can: + * + * HORIZON_CONFIG=./horizon.yaml node dist/server.js + * + * — without `pnpm install`, without network access, without any other + * checkout state. The folder contains everything the BFF needs: + * + * dist/ + * server.js — esbuild-bundled BFF (single ESM file) + * package.json — Node ESM marker + module type + * node_modules/ — production deps (npm + workspace dists) + * bundled_templates/ — layer + overview JSON templates + * static/ — built UI (Vite dist) + * horizon.example.yaml — copy-and-edit starting point + * + * The build: + * 1. Builds each workspace package under `packages/*` (tsc → dist/). + * 2. Builds the BFF (esbuild → apps/bff/dist/server.js). + * 3. Builds the UI (vite → apps/ui/dist/). + * 4. Runs `pnpm deploy --legacy --prod` to materialize a production + * install tree under `./_deploy_tmp/` (with workspace deps already + * resolved as the `dist/` we built in step 1). + * 5. Re-layouts the deploy output into the flat `./dist/` shape above + * and cleans up `_deploy_tmp`. + * + * The BFF's `locateConfigDir()` probes (in the layers + overview + * loaders) include `__dirname/bundled_templates`, so the flat sibling + * layout resolves without any env-var override. + */ + +import { execSync } from 'node:child_process'; +import { cpSync, existsSync, mkdirSync, renameSync, rmSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, '..'); +const dist = resolve(root, 'dist'); +const tmp = resolve(root, '_deploy_tmp'); + +function step(name) { + process.stdout.write(`\n[1m▸ ${name}[0m\n`); +} +function run(cmd) { + execSync(cmd, { cwd: root, stdio: 'inherit' }); +} + +step('Cleaning ./dist and ./_deploy_tmp'); +rmSync(dist, { recursive: true, force: true }); +rmSync(tmp, { recursive: true, force: true }); + +step('Building workspace packages (api-client / design-tokens / templates)'); +run("pnpm -r --filter './packages/*' run build"); + +step('Building BFF (esbuild bundle)'); +run('pnpm --filter @skywalking-horizon-ui/bff build'); + +step('Building UI (vite production build)'); +run('pnpm --filter @skywalking-horizon-ui/ui build'); + +step('Materializing production install tree (pnpm deploy)'); +// `--legacy` is required under pnpm 10+ for non-injected workspaces; see +// the matching note in the Dockerfile. We deploy directly into ./dist +// (renaming through an intermediate) rather than copying out, because +// the produced node_modules contains pnpm-style symlinks into an +// in-tree `.pnpm/` store — copying would either preserve broken +// symlinks (cpSync default) or explode the size 5-10x (dereference). +run(`pnpm deploy --legacy --filter @skywalking-horizon-ui/bff --prod ${tmp}`); + +step('Re-layouting into flat ./dist/'); +// Move the deploy output wholesale to ./dist. Internal `.pnpm/` +// symlinks stay valid because they're all relative within the tree. +renameSync(tmp, dist); +// pnpm deploy preserved the BFF's original layout, so server.js is at +// dist/dist/server.js. Lift it up one level and drop the empty wrapper. +renameSync(resolve(dist, 'dist', 'server.js'), resolve(dist, 'server.js')); +rmSync(resolve(dist, 'dist'), { recursive: true, force: true }); +// Copy the operator-facing assets in: writable bundled_templates (per- +// key admin saves write here), built UI, copy-and-edit yaml. +cpSync( + resolve(root, 'apps/bff/src/bundled_templates'), + resolve(dist, 'bundled_templates'), + { recursive: true }, +); +cpSync(resolve(root, 'apps/ui/dist'), resolve(dist, 'static'), { recursive: true }); +cpSync(resolve(root, 'horizon.example.yaml'), resolve(dist, 'horizon.example.yaml')); + +step('Done'); +console.log(` +Target binary: ${resolve(dist, 'server.js')} + +Boot it: + + cp horizon.example.yaml horizon.yaml # configure once + HORIZON_CONFIG=./horizon.yaml \\ + HORIZON_STATIC_DIR=./dist/static \\ + node dist/server.js + +Or from inside dist/ (cwd-relative fallback resolves bundled_templates): + + cd dist + HORIZON_CONFIG=../horizon.yaml HORIZON_STATIC_DIR=./static node server.js + +The folder is fully self-contained — copy it anywhere, no \`pnpm install\` +required, no network access at boot. +`); + +if (!existsSync(resolve(dist, 'server.js'))) { + console.error('FATAL: dist/server.js missing after package'); + process.exit(1); +}
