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 74ac4f1b615446486c9ecc4e5eb25df6b643155c
Author: Wu Sheng <[email protected]>
AuthorDate: Tue May 12 09:56:58 2026 +0800

    bff: fastify skeleton with horizon.yaml config and hot reload
---
 .gitignore                    |   5 ++
 apps/bff/src/config/loader.ts |  76 ++++++++++++++++++++++++++
 apps/bff/src/config/schema.ts | 123 ++++++++++++++++++++++++++++++++++++++++++
 apps/bff/src/errors.ts        |  42 +++++++++++++++
 apps/bff/src/logger.ts        |  36 +++++++++++++
 apps/bff/src/server.ts        |  61 +++++++++++++++++++++
 horizon.example.yaml          |  58 ++++++++++++++++++++
 7 files changed, 401 insertions(+)

diff --git a/.gitignore b/.gitignore
index dfaacad..8173b86 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,3 +35,8 @@ Thumbs.db
 
 # Local design / planning docs — never committed
 docs/design/
+
+# BFF runtime files
+horizon.yaml
+horizon-audit.jsonl
+horizon-wire.jsonl
diff --git a/apps/bff/src/config/loader.ts b/apps/bff/src/config/loader.ts
new file mode 100644
index 0000000..02e069a
--- /dev/null
+++ b/apps/bff/src/config/loader.ts
@@ -0,0 +1,76 @@
+/*
+ * 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.
+ */
+
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import chokidar from 'chokidar';
+import YAML from 'yaml';
+import { configSchema, type HorizonConfig } from './schema.js';
+
+export interface ConfigSource {
+  readonly current: HorizonConfig;
+  readonly path: string;
+  onChange(fn: (cfg: HorizonConfig) => void): () => void;
+  close(): Promise<void>;
+}
+
+function parseFile(absPath: string): HorizonConfig {
+  let raw = '';
+  try {
+    raw = readFileSync(absPath, 'utf8');
+  } catch (err) {
+    if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
+      // No config file → use full defaults.
+      return configSchema.parse({});
+    }
+    throw err;
+  }
+  const parsed = YAML.parse(raw) ?? {};
+  return configSchema.parse(parsed);
+}
+
+export function loadConfig(configPath: string): ConfigSource {
+  const absPath = resolve(configPath);
+  let current = parseFile(absPath);
+  const listeners = new Set<(cfg: HorizonConfig) => void>();
+
+  const watcher = chokidar.watch(absPath, { ignoreInitial: true, 
awaitWriteFinish: true });
+  watcher.on('change', () => {
+    try {
+      const next = parseFile(absPath);
+      current = next;
+      for (const fn of listeners) fn(next);
+    } catch {
+      // Swallow; the server logs the parse error elsewhere when it tries to
+      // use the new config. We don't want a malformed reload to kill the 
watcher.
+    }
+  });
+
+  return {
+    get current() {
+      return current;
+    },
+    path: absPath,
+    onChange(fn) {
+      listeners.add(fn);
+      return () => listeners.delete(fn);
+    },
+    async close() {
+      await watcher.close();
+    },
+  };
+}
diff --git a/apps/bff/src/config/schema.ts b/apps/bff/src/config/schema.ts
new file mode 100644
index 0000000..36e7f53
--- /dev/null
+++ b/apps/bff/src/config/schema.ts
@@ -0,0 +1,123 @@
+/*
+ * 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.
+ */
+
+import { z } from 'zod';
+
+const serverSchema = z
+  .object({
+    host: z.string().default('127.0.0.1'),
+    port: z.number().int().positive().default(8081),
+    staticDir: z.string().optional(),
+  })
+  .strict();
+
+const oapSchema = z
+  .object({
+    // The OAP admin host. Default 17128 per the upstream Armeria binding.
+    adminUrls: z.array(z.string().url()).default(['http://127.0.0.1:17128']),
+    // The OAP query/status host (GraphQL + /status/*).
+    statusUrl: z.string().url().default('http://127.0.0.1:12800'),
+    timeoutMs: z.number().int().positive().default(15000),
+    mqe: z
+      .object({
+        host: z.string().optional(),
+        port: z.number().int().positive().optional(),
+      })
+      .strict()
+      .default({}),
+  })
+  .strict();
+
+const localUserSchema = z
+  .object({
+    username: z.string().min(1),
+    passwordHash: z.string().min(1),
+    roles: z.array(z.string().min(1)).default([]),
+  })
+  .strict();
+
+const authSchema = z
+  .object({
+    backend: z.literal('local').default('local'),
+    local: z
+      .object({
+        users: z.array(localUserSchema).default([]),
+      })
+      .strict()
+      .default({ users: [] }),
+  })
+  .strict()
+  .default({ backend: 'local', local: { users: [] } });
+
+const rbacSchema = z
+  .object({
+    enabled: z.boolean().default(false),
+    roles: z
+      .record(z.string(), z.array(z.string().min(1)))
+      .default({
+        viewer: ['*:read'],
+        editor: ['*:read', 'rule:write', 'rule:debug', 'inspect:read'],
+        admin: ['*'],
+      }),
+  })
+  .strict()
+  .default({ enabled: false, roles: {} });
+
+const sessionSchema = z
+  .object({
+    ttlMinutes: z.number().int().positive().default(60),
+    cookieName: z.string().default('horizon_sid'),
+    cookieSecure: z.boolean().default(false),
+  })
+  .strict()
+  .default({ ttlMinutes: 60, cookieName: 'horizon_sid', cookieSecure: false });
+
+const auditSchema = z
+  .object({
+    file: z.string().default('./horizon-audit.jsonl'),
+  })
+  .strict()
+  .default({ file: './horizon-audit.jsonl' });
+
+const debugLogSchema = z
+  .object({
+    enabled: z.boolean().default(false),
+    file: z.string().default('./horizon-wire.jsonl'),
+    maxBodyChars: z.number().int().nonnegative().default(8192),
+    redactAuthHeaders: z.boolean().default(true),
+  })
+  .strict()
+  .default({
+    enabled: false,
+    file: './horizon-wire.jsonl',
+    maxBodyChars: 8192,
+    redactAuthHeaders: true,
+  });
+
+export const configSchema = z
+  .object({
+    server: serverSchema.default({}),
+    oap: oapSchema.default({}),
+    auth: authSchema,
+    rbac: rbacSchema,
+    session: sessionSchema,
+    audit: auditSchema,
+    debugLog: debugLogSchema,
+  })
+  .strict();
+
+export type HorizonConfig = z.infer<typeof configSchema>;
diff --git a/apps/bff/src/errors.ts b/apps/bff/src/errors.ts
new file mode 100644
index 0000000..adcbe9f
--- /dev/null
+++ b/apps/bff/src/errors.ts
@@ -0,0 +1,42 @@
+/*
+ * 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.
+ */
+
+/**
+ * Errors thrown by BFF routes that should map to non-500 HTTP responses.
+ * The Fastify error handler reads `statusCode` and `code`.
+ */
+export class HttpError extends Error {
+  readonly statusCode: number;
+  readonly code: string;
+  readonly details?: unknown;
+
+  constructor(statusCode: number, code: string, message: string, details?: 
unknown) {
+    super(message);
+    this.name = 'HttpError';
+    this.statusCode = statusCode;
+    this.code = code;
+    this.details = details;
+  }
+}
+
+export const unauthorized = (msg = 'unauthorized') => new HttpError(401, 
'unauthorized', msg);
+export const forbidden = (msg = 'forbidden') => new HttpError(403, 
'forbidden', msg);
+export const notFound = (msg = 'not found') => new HttpError(404, 'not_found', 
msg);
+export const badRequest = (msg = 'bad request', details?: unknown) =>
+  new HttpError(400, 'bad_request', msg, details);
+export const upstreamFailure = (msg = 'upstream failure', details?: unknown) =>
+  new HttpError(502, 'upstream_failure', msg, details);
diff --git a/apps/bff/src/logger.ts b/apps/bff/src/logger.ts
new file mode 100644
index 0000000..702478e
--- /dev/null
+++ b/apps/bff/src/logger.ts
@@ -0,0 +1,36 @@
+/*
+ * 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.
+ */
+
+import pino from 'pino';
+
+const isDev = process.env.NODE_ENV !== 'production';
+
+export const logger = pino({
+  level: process.env.LOG_LEVEL ?? (isDev ? 'debug' : 'info'),
+  ...(isDev
+    ? {
+        transport: {
+          target: 'pino-pretty',
+          options: {
+            colorize: true,
+            translateTime: 'HH:MM:ss.l',
+            ignore: 'pid,hostname',
+          },
+        },
+      }
+    : {}),
+});
diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts
new file mode 100644
index 0000000..7706acb
--- /dev/null
+++ b/apps/bff/src/server.ts
@@ -0,0 +1,61 @@
+/*
+ * 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.
+ */
+
+import Fastify from 'fastify';
+import { loadConfig, type ConfigSource } from './config/loader.js';
+import { HttpError } from './errors.js';
+import { logger } from './logger.js';
+
+const configPath = process.env.HORIZON_CONFIG ?? './horizon.yaml';
+
+const source: ConfigSource = loadConfig(configPath);
+logger.info({ configPath: source.path }, 'config loaded');
+source.onChange((cfg) => logger.info({ users: cfg.auth.local.users.length }, 
'config reloaded'));
+
+const app = Fastify({ loggerInstance: logger });
+
+app.setErrorHandler((err, _req, reply) => {
+  if (err instanceof HttpError) {
+    return reply.status(err.statusCode).send({ code: err.code, message: 
err.message, details: err.details });
+  }
+  const message = err instanceof Error ? err.message : 'internal error';
+  reply.log.error({ err }, 'unhandled');
+  return reply.status(500).send({ code: 'internal_error', message });
+});
+
+app.get('/api/health', async () => ({
+  status: 'ok',
+  version: process.env.HORIZON_VERSION ?? '0.1.0',
+}));
+
+const { host, port } = source.current.server;
+app.listen({ host, port }).then(
+  () => logger.info(`BFF listening on http://${host}:${port}`),
+  (err) => {
+    logger.fatal({ err }, 'failed to start BFF');
+    process.exit(1);
+  },
+);
+
+async function shutdown(signal: string) {
+  logger.info({ signal }, 'shutting down');
+  await app.close();
+  await source.close();
+  process.exit(0);
+}
+process.on('SIGINT', () => void shutdown('SIGINT'));
+process.on('SIGTERM', () => void shutdown('SIGTERM'));
diff --git a/horizon.example.yaml b/horizon.example.yaml
new file mode 100644
index 0000000..edb3cb8
--- /dev/null
+++ b/horizon.example.yaml
@@ -0,0 +1,58 @@
+# 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.
+
+# Copy this file to `horizon.yaml` and edit. Hot-reload picks up changes.
+
+server:
+  host: 127.0.0.1
+  port: 8081
+
+oap:
+  # OAP admin host (port 17128 by default; runtime-rule / dsl-debugging / 
inspect / status live here).
+  adminUrls:
+    - http://127.0.0.1:17128
+  # OAP query/status host (port 12800 by default; GraphQL + /status/*).
+  statusUrl: http://127.0.0.1:12800
+  timeoutMs: 15000
+
+auth:
+  backend: local
+  local:
+    users: []
+    # Example user (generate the argon2 hash with `pnpm --filter bff 
cli:hash`):
+    # - username: admin
+    #   passwordHash: "$argon2id$v=19$..."
+    #   roles: [admin]
+
+rbac:
+  enabled: false
+  roles:
+    viewer: ["*:read"]
+    editor: ["*:read", "rule:write", "rule:debug", "inspect:read"]
+    admin: ["*"]
+
+session:
+  ttlMinutes: 60
+  cookieName: horizon_sid
+  cookieSecure: false
+
+audit:
+  file: ./horizon-audit.jsonl
+
+debugLog:
+  enabled: false
+  file: ./horizon-wire.jsonl
+  maxBodyChars: 8192
+  redactAuthHeaders: true

Reply via email to