From 37673850b2d920e70fb82d54c93f0ded5789b469 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 10 Feb 2026 00:09:03 +0900 Subject: [PATCH 01/35] Scaffold `@fedify/debugger` package Add the `@fedify/debugger` package skeleton with build infrastructure: - Create deno.json, package.json, and tsdown.config.ts - Add placeholder src/mod.ts - Register the package in the root deno.json workspace, pnpm-workspace.yaml, README.md Packages table, and CONTRIBUTING.md repository structure list - Add hono as a root-level Deno import for JSR resolution https://github.com/fedify-dev/fedify/issues/561 Co-Authored-By: Claude --- CONTRIBUTING.md | 2 + deno.json | 2 + deno.lock | 9 +++- packages/debugger/deno.json | 19 +++++++++ packages/debugger/package.json | 50 ++++++++++++++++++++++ packages/debugger/src/mod.ts | 9 ++++ packages/debugger/tsdown.config.ts | 25 +++++++++++ packages/fedify/README.md | 3 ++ pnpm-lock.yaml | 68 ++++++++++++++++-------------- pnpm-workspace.yaml | 1 + 10 files changed, 154 insertions(+), 34 deletions(-) create mode 100644 packages/debugger/deno.json create mode 100644 packages/debugger/package.json create mode 100644 packages/debugger/src/mod.ts create mode 100644 packages/debugger/tsdown.config.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 82c6266c7..9d643c4a5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -335,6 +335,8 @@ The repository is organized as a monorepo with the following packages: - *packages/amqp/*: AMQP/RabbitMQ driver (@fedify/amqp) for Fedify. - *packages/cfworkers/*: Cloudflare Workers integration (@fedify/cfworkers) for Fedify. + - *packages/debugger/*: Embedded ActivityPub debug dashboard (@fedify/debugger) + for Fedify. - *packages/denokv/*: Deno KV integration (@fedify/denokv) for Fedify. - *packages/elysia/*: Elysia integration (@fedify/elysia) for Fedify. - *packages/express/*: Express integration (@fedify/express) for Fedify. diff --git a/deno.json b/deno.json index 273d96dd2..17f37cc4e 100644 --- a/deno.json +++ b/deno.json @@ -3,6 +3,7 @@ "./packages/amqp", "./packages/cfworkers", "./packages/cli", + "./packages/debugger", "./packages/denokv", "./packages/express", "./packages/fastify", @@ -49,6 +50,7 @@ "byte-encodings": "npm:byte-encodings@^1.0.11", "es-toolkit": "npm:es-toolkit@^1.43.0", "h3": "npm:h3@^1.15.0", + "hono": "jsr:@hono/hono@^4.8.3", "ioredis": "npm:ioredis@^5.8.2", "json-preserve-indent": "npm:json-preserve-indent@^1.1.3", "postgres": "npm:postgres@^3.4.7", diff --git a/deno.lock b/deno.lock index 9d2895d66..b66b5da5f 100644 --- a/deno.lock +++ b/deno.lock @@ -4194,8 +4194,7 @@ "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", "dependencies": [ "tsscmp" - ], - "deprecated": true + ] }, "keyv@4.5.4": { "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", @@ -5843,6 +5842,7 @@ "workspace": { "dependencies": [ "jsr:@david/dax@~0.43.2", + "jsr:@hono/hono@^4.8.3", "jsr:@logtape/file@2", "jsr:@logtape/logtape@2", "jsr:@std/assert@^1.0.13", @@ -5952,6 +5952,11 @@ ] } }, + "packages/debugger": { + "dependencies": [ + "jsr:@std/assert@^1.0.13" + ] + }, "packages/denokv": { "dependencies": [ "jsr:@std/assert@^1.0.13", diff --git a/packages/debugger/deno.json b/packages/debugger/deno.json new file mode 100644 index 000000000..5836a878d --- /dev/null +++ b/packages/debugger/deno.json @@ -0,0 +1,19 @@ +{ + "name": "@fedify/debugger", + "version": "2.0.0", + "license": "MIT", + "imports": { + "@std/assert": "jsr:@std/assert@^1.0.13" + }, + "exports": { + ".": "./src/mod.ts" + }, + "exclude": ["dist/", "node_modules/"], + "publish": { + "exclude": ["**/*.test.ts", "tsdown.config.ts"] + }, + "tasks": { + "check": "deno fmt --check && deno lint && deno check src/**/*.ts src/**/*.tsx", + "test": "deno test --allow-env" + } +} diff --git a/packages/debugger/package.json b/packages/debugger/package.json new file mode 100644 index 000000000..64bb3f3f8 --- /dev/null +++ b/packages/debugger/package.json @@ -0,0 +1,50 @@ +{ + "name": "@fedify/debugger", + "version": "2.0.0", + "description": "Embedded ActivityPub debug dashboard for Fedify", + "type": "module", + "main": "./dist/mod.cjs", + "module": "./dist/mod.js", + "types": "./dist/mod.d.ts", + "exports": { + ".": { + "types": { + "import": "./dist/mod.d.ts", + "require": "./dist/mod.d.cts", + "default": "./dist/mod.d.ts" + }, + "import": "./dist/mod.js", + "require": "./dist/mod.cjs", + "default": "./dist/mod.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist/", + "package.json" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/fedify-dev/fedify.git", + "directory": "packages/debugger" + }, + "peerDependencies": { + "@fedify/fedify": "workspace:^" + }, + "dependencies": { + "hono": "catalog:" + }, + "devDependencies": { + "@js-temporal/polyfill": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:" + }, + "scripts": { + "build:self": "tsdown", + "build": "pnpm --filter @fedify/debugger... run build:self", + "prepack": "pnpm build", + "prepublish": "pnpm build", + "test": "node --experimental-transform-types --test", + "test:bun": "bun test" + } +} diff --git a/packages/debugger/src/mod.ts b/packages/debugger/src/mod.ts new file mode 100644 index 000000000..166d2bfea --- /dev/null +++ b/packages/debugger/src/mod.ts @@ -0,0 +1,9 @@ +/** + * @module + * Embedded ActivityPub debug dashboard for Fedify. + * + * This module provides a `createFederationDebugger()` function that wraps + * an existing `Federation` object, adding a real-time debug dashboard + * accessible via a configurable path prefix. + */ +export {}; diff --git a/packages/debugger/tsdown.config.ts b/packages/debugger/tsdown.config.ts new file mode 100644 index 000000000..70c812f69 --- /dev/null +++ b/packages/debugger/tsdown.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/mod.ts"], + dts: true, + format: ["esm", "cjs"], + platform: "node", + external: [ + "@fedify/fedify", + "@fedify/fedify/federation", + "@fedify/fedify/otel", + ], + outputOptions(outputOptions, format) { + if (format === "cjs") { + outputOptions.intro = ` + const { Temporal } = require("@js-temporal/polyfill"); + `; + } else { + outputOptions.intro = ` + import { Temporal } from "@js-temporal/polyfill"; + `; + } + return outputOptions; + }, +}); diff --git a/packages/fedify/README.md b/packages/fedify/README.md index 76155c72a..fcbacb8f1 100644 --- a/packages/fedify/README.md +++ b/packages/fedify/README.md @@ -100,6 +100,7 @@ Here is the list of packages: | [@fedify/cli](/packages/cli/) | [JSR][jsr:@fedify/cli] | [npm][npm:@fedify/cli] | CLI toolchain for testing and debugging | | [@fedify/amqp](/packages/amqp/) | [JSR][jsr:@fedify/amqp] | [npm][npm:@fedify/amqp] | AMQP/RabbitMQ driver | | [@fedify/cfworkers](/packages/cfworkers/) | [JSR][jsr:@fedify/cfworkers] | [npm][npm:@fedify/cfworkers] | Cloudflare Workers integration | +| [@fedify/debugger](/packages/debugger/) | [JSR][jsr:@fedify/debugger] | [npm][npm:@fedify/debugger] | Embedded ActivityPub debug dashboard | | [@fedify/denokv](/packages/denokv/) | [JSR][jsr:@fedify/denokv] | | Deno KV integration | | [@fedify/elysia](/packages/elysia/) | | [npm][npm:@fedify/elysia] | Elysia integration | | [@fedify/express](/packages/express/) | [JSR][jsr:@fedify/express] | [npm][npm:@fedify/express] | Express integration | @@ -128,6 +129,8 @@ Here is the list of packages: [npm:@fedify/amqp]: https://www.npmjs.com/package/@fedify/amqp [jsr:@fedify/cfworkers]: https://jsr.io/@fedify/cfworkers [npm:@fedify/cfworkers]: https://www.npmjs.com/package/@fedify/cfworkers +[jsr:@fedify/debugger]: https://jsr.io/@fedify/debugger +[npm:@fedify/debugger]: https://www.npmjs.com/package/@fedify/debugger [jsr:@fedify/denokv]: https://jsr.io/@fedify/denokv [npm:@fedify/elysia]: https://www.npmjs.com/package/@fedify/elysia [jsr:@fedify/express]: https://jsr.io/@fedify/express diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32f3724c7..22396ead6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -833,6 +833,25 @@ importers: specifier: 'catalog:' version: 5.9.3 + packages/debugger: + dependencies: + '@fedify/fedify': + specifier: workspace:^ + version: link:../fedify + hono: + specifier: 'catalog:' + version: 4.8.3 + devDependencies: + '@js-temporal/polyfill': + specifier: 'catalog:' + version: 0.5.1 + tsdown: + specifier: 'catalog:' + version: 0.12.9(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + packages/elysia: dependencies: '@fedify/fedify': @@ -13714,8 +13733,8 @@ snapshots: '@typescript-eslint/parser': 8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3) eslint: 9.32.0(jiti@2.5.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.32.0(jiti@2.5.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.5.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.32.0(jiti@2.5.1)) eslint-plugin-react: 7.37.5(eslint@9.32.0(jiti@2.5.1)) eslint-plugin-react-hooks: 5.2.0(eslint@9.32.0(jiti@2.5.1)) @@ -13734,8 +13753,8 @@ snapshots: '@typescript-eslint/parser': 8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2) eslint: 9.32.0(jiti@2.5.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.32.0(jiti@2.5.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.5.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.32.0(jiti@2.5.1)) eslint-plugin-react: 7.37.5(eslint@9.32.0(jiti@2.5.1)) eslint-plugin-react-hooks: 5.2.0(eslint@9.32.0(jiti@2.5.1)) @@ -13758,22 +13777,22 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.1 - eslint: 9.32.0(jiti@2.5.1) + eslint: 8.57.1 get-tsconfig: 4.10.1 is-bun-module: 2.0.0 stable-hash: 0.0.5 tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.32.0(jiti@2.5.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.1 @@ -13784,22 +13803,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)) - transitivePeerDependencies: - - supports-color - - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): - dependencies: - '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.1 - eslint: 8.57.1 - get-tsconfig: 4.10.1 - is-bun-module: 2.0.0 - stable-hash: 0.0.5 - tinyglobby: 0.2.15 - unrs-resolver: 1.11.1 - optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.5.1)) transitivePeerDependencies: - supports-color @@ -13814,25 +13818,25 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.5.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2) eslint: 9.32.0(jiti@2.5.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.32.0(jiti@2.5.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.5.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3) eslint: 9.32.0(jiti@2.5.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.32.0(jiti@2.5.1)) transitivePeerDependencies: - supports-color @@ -13865,7 +13869,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.5.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -13876,7 +13880,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.32.0(jiti@2.5.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.5.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -13894,7 +13898,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.5.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -13905,7 +13909,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.32.0(jiti@2.5.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.32.0(jiti@2.5.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7577ca83b..6af361a8d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,7 @@ packages: - packages/amqp - packages/cfworkers - packages/cli +- packages/debugger - packages/elysia - packages/express - packages/fastify From 7bca8a922a08c609a65ab53aa11222aa25df28e1 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 10 Feb 2026 00:16:14 +0900 Subject: [PATCH 02/35] Implement `createFederationDebugger()` proxy with tests Add the core `createFederationDebugger()` function that wraps a `Federation` object to intercept requests matching a configurable debug path prefix (default `/__debug__`) and serve them via an internal Hono app. All other methods and non-debug requests are delegated to the inner federation object. Tests cover delegation of all Federation interface methods, debug path interception, custom path prefix, JSON API, and onNotFound passthrough. https://github.com/fedify-dev/fedify/issues/561 Co-Authored-By: Claude --- packages/debugger/src/mod.test.ts | 223 ++++++++++++++++++++++++++++++ packages/debugger/src/mod.ts | 131 +++++++++++++++++- 2 files changed, 351 insertions(+), 3 deletions(-) create mode 100644 packages/debugger/src/mod.test.ts diff --git a/packages/debugger/src/mod.test.ts b/packages/debugger/src/mod.test.ts new file mode 100644 index 000000000..736a19e81 --- /dev/null +++ b/packages/debugger/src/mod.test.ts @@ -0,0 +1,223 @@ +import { test } from "@fedify/fixture"; +import { assertEquals, assertNotEquals } from "@std/assert"; +import { createFederationDebugger } from "./mod.ts"; +import type { + Federation, + FederationFetchOptions, + FederationStartQueueOptions, +} from "@fedify/fedify/federation"; +import type { + FedifySpanExporter, + TraceActivityRecord, + TraceSummary, +} from "@fedify/fedify/otel"; + +function createMockExporter( + traces: TraceSummary[] = [], + activities: TraceActivityRecord[] = [], +): FedifySpanExporter { + return { + export(_spans: unknown, resultCallback: (result: unknown) => void) { + resultCallback({ code: 0 }); + }, + forceFlush() { + return Promise.resolve(); + }, + shutdown() { + return Promise.resolve(); + }, + getRecentTraces() { + return Promise.resolve(traces); + }, + getActivitiesByTraceId(_traceId: string) { + return Promise.resolve(activities); + }, + } as unknown as FedifySpanExporter; +} + +function createMockFederation(): { + federation: Federation; + calls: Record; +} { + const calls: Record = {}; + + function track(name: string) { + if (!calls[name]) calls[name] = []; + // deno-lint-ignore no-explicit-any + return (...args: any[]) => { + calls[name].push(args); + if (name === "fetch") { + const request = args[0] as Request; + const options = args[1] as FederationFetchOptions; + if (options.onNotFound) { + return options.onNotFound(request); + } + return new Response("Federation response", { status: 200 }); + } + if (name === "startQueue") return Promise.resolve(); + if (name === "processQueuedTask") return Promise.resolve(); + if (name === "createContext") return { data: "mock-context" }; + return { setCounter: () => ({}) }; + }; + } + + const federation = { + setNodeInfoDispatcher: track("setNodeInfoDispatcher"), + setWebFingerLinksDispatcher: track("setWebFingerLinksDispatcher"), + setActorDispatcher: track("setActorDispatcher"), + setObjectDispatcher: track("setObjectDispatcher"), + setInboxDispatcher: track("setInboxDispatcher"), + setOutboxDispatcher: track("setOutboxDispatcher"), + setFollowingDispatcher: track("setFollowingDispatcher"), + setFollowersDispatcher: track("setFollowersDispatcher"), + setLikedDispatcher: track("setLikedDispatcher"), + setFeaturedDispatcher: track("setFeaturedDispatcher"), + setFeaturedTagsDispatcher: track("setFeaturedTagsDispatcher"), + setInboxListeners: track("setInboxListeners"), + setCollectionDispatcher: track("setCollectionDispatcher"), + setOrderedCollectionDispatcher: track("setOrderedCollectionDispatcher"), + setOutboxPermanentFailureHandler: track( + "setOutboxPermanentFailureHandler", + ), + startQueue: track("startQueue"), + processQueuedTask: track("processQueuedTask"), + createContext: track("createContext"), + fetch: track("fetch"), + } as unknown as Federation; + + return { federation, calls }; +} + +test("createFederationDebugger returns a Federation object", () => { + const { federation } = createMockFederation(); + const exporter = createMockExporter(); + const dbg = createFederationDebugger(federation, { exporter }); + assertNotEquals(dbg, null); + assertNotEquals(dbg, undefined); + assertEquals(typeof dbg.fetch, "function"); + assertEquals(typeof dbg.startQueue, "function"); + assertEquals(typeof dbg.processQueuedTask, "function"); + assertEquals(typeof dbg.createContext, "function"); +}); + +test("createFederationDebugger delegates startQueue", async () => { + const { federation, calls } = createMockFederation(); + const exporter = createMockExporter(); + const dbg = createFederationDebugger(federation, { exporter }); + const options: FederationStartQueueOptions = { signal: undefined }; + await dbg.startQueue(undefined, options); + assertEquals(calls["startQueue"]?.length, 1); + assertEquals(calls["startQueue"]![0]![0], undefined); + assertEquals(calls["startQueue"]![0]![1], options); +}); + +test("createFederationDebugger delegates processQueuedTask", async () => { + const { federation, calls } = createMockFederation(); + const exporter = createMockExporter(); + const dbg = createFederationDebugger(federation, { exporter }); + const message = { type: "test" }; + await dbg.processQueuedTask( + undefined, + message as unknown as import("@fedify/fedify/federation").Message, + ); + assertEquals(calls["processQueuedTask"]?.length, 1); + assertEquals(calls["processQueuedTask"]![0]![1], message); +}); + +test("createFederationDebugger delegates createContext", () => { + const { federation, calls } = createMockFederation(); + const exporter = createMockExporter(); + const dbg = createFederationDebugger(federation, { exporter }); + const url = new URL("https://example.com"); + dbg.createContext(url, undefined); + assertEquals(calls["createContext"]?.length, 1); + assertEquals(calls["createContext"]![0]![0], url); +}); + +test("createFederationDebugger delegates setActorDispatcher", () => { + const { federation, calls } = createMockFederation(); + const exporter = createMockExporter(); + const dbg = createFederationDebugger(federation, { exporter }); + const dispatcher = () => null; + dbg.setActorDispatcher("/users/{identifier}", dispatcher); + assertEquals(calls["setActorDispatcher"]?.length, 1); + assertEquals(calls["setActorDispatcher"]![0]![0], "/users/{identifier}"); + assertEquals(calls["setActorDispatcher"]![0]![1], dispatcher); +}); + +test("fetch delegates non-debug requests to inner federation", async () => { + const { federation, calls } = createMockFederation(); + const exporter = createMockExporter(); + const dbg = createFederationDebugger(federation, { exporter }); + const request = new Request("https://example.com/users/alice"); + const response = await dbg.fetch(request, { contextData: undefined }); + assertEquals(calls["fetch"]?.length, 1); + assertEquals(response.status, 200); + assertEquals(await response.text(), "Federation response"); +}); + +test("fetch intercepts debug path prefix requests", async () => { + const { federation, calls } = createMockFederation(); + const exporter = createMockExporter(); + const dbg = createFederationDebugger(federation, { exporter }); + const request = new Request("https://example.com/__debug__/"); + const response = await dbg.fetch(request, { contextData: undefined }); + // The debug request should NOT be forwarded to inner federation + assertEquals(calls["fetch"]?.length ?? 0, 0); + assertEquals(response.status, 200); +}); + +test("fetch intercepts custom debug path prefix", async () => { + const { federation } = createMockFederation(); + const exporter = createMockExporter(); + const dbg = createFederationDebugger(federation, { + exporter, + path: "/__my_debug__", + }); + const request = new Request("https://example.com/__my_debug__/"); + const response = await dbg.fetch(request, { contextData: undefined }); + assertEquals(response.status, 200); +}); + +test("JSON API returns traces", async () => { + const traces: TraceSummary[] = [ + { + traceId: "abcdef1234567890abcdef1234567890", + timestamp: "2026-01-01T00:00:00Z", + activityCount: 3, + activityTypes: ["Create", "Follow"], + }, + ]; + const { federation } = createMockFederation(); + const exporter = createMockExporter(traces); + const dbg = createFederationDebugger(federation, { exporter }); + const request = new Request("https://example.com/__debug__/api/traces"); + const response = await dbg.fetch(request, { contextData: undefined }); + assertEquals(response.status, 200); + assertEquals( + response.headers.get("content-type"), + "application/json", + ); + const body = await response.json() as TraceSummary[]; + assertEquals(body.length, 1); + assertEquals(body[0].traceId, "abcdef1234567890abcdef1234567890"); + assertEquals(body[0].activityCount, 3); +}); + +test("fetch passes through onNotFound for non-debug requests", async () => { + const { federation } = createMockFederation(); + const exporter = createMockExporter(); + const dbg = createFederationDebugger(federation, { exporter }); + let notFoundCalled = false; + const request = new Request("https://example.com/unknown"); + const response = await dbg.fetch(request, { + contextData: undefined, + onNotFound: () => { + notFoundCalled = true; + return new Response("Custom Not Found", { status: 404 }); + }, + }); + assertEquals(notFoundCalled, true); + assertEquals(response.status, 404); + assertEquals(await response.text(), "Custom Not Found"); +}); diff --git a/packages/debugger/src/mod.ts b/packages/debugger/src/mod.ts index 166d2bfea..3f467b8aa 100644 --- a/packages/debugger/src/mod.ts +++ b/packages/debugger/src/mod.ts @@ -2,8 +2,133 @@ * @module * Embedded ActivityPub debug dashboard for Fedify. * - * This module provides a `createFederationDebugger()` function that wraps - * an existing `Federation` object, adding a real-time debug dashboard + * This module provides a {@link createFederationDebugger} function that wraps + * an existing {@link Federation} object, adding a real-time debug dashboard * accessible via a configurable path prefix. */ -export {}; +import type { + Federation, + FederationFetchOptions, +} from "@fedify/fedify/federation"; +import type { FedifySpanExporter } from "@fedify/fedify/otel"; +import { Hono } from "hono"; + +/** + * Options for {@link createFederationDebugger}. + */ +export interface FederationDebuggerOptions { + /** + * The path prefix for the debug dashboard. Defaults to `"/__debug__"`. + */ + path?: string; + + /** + * The {@link FedifySpanExporter} to query trace data from. + */ + exporter: FedifySpanExporter; +} + +/** + * Wraps a {@link Federation} object with a debug dashboard. + * + * The returned object fully implements {@link Federation}. Requests matching + * the debug path prefix (default `/__debug__`) are handled by an internal + * Hono app that serves the dashboard. All other requests and method calls + * are delegated to the inner federation object as-is. + * + * @template TContextData The context data type of the federation. + * @param federation The federation object to wrap. + * @param options Options for the debugger. + * @returns A new {@link Federation} object with the debug dashboard attached. + */ +export function createFederationDebugger( + federation: Federation, + options: FederationDebuggerOptions, +): Federation { + const pathPrefix = options.path ?? "/__debug__"; + const exporter = options.exporter; + + const app = createDebugApp(pathPrefix, exporter); + + // deno-lint-ignore no-explicit-any + const proxy: Federation = Object.create(null) as any; + + // Delegate all Federatable methods directly: + const delegatedMethods = [ + "setNodeInfoDispatcher", + "setWebFingerLinksDispatcher", + "setActorDispatcher", + "setObjectDispatcher", + "setInboxDispatcher", + "setOutboxDispatcher", + "setFollowingDispatcher", + "setFollowersDispatcher", + "setLikedDispatcher", + "setFeaturedDispatcher", + "setFeaturedTagsDispatcher", + "setInboxListeners", + "setCollectionDispatcher", + "setOrderedCollectionDispatcher", + "setOutboxPermanentFailureHandler", + // Federation-specific methods: + "startQueue", + "processQueuedTask", + "createContext", + ] as const; + + for (const method of delegatedMethods) { + // deno-lint-ignore no-explicit-any + (proxy as any)[method] = (...args: unknown[]) => { + // deno-lint-ignore no-explicit-any + return (federation as any)[method](...args); + }; + } + + // Override fetch to intercept debug path prefix: + proxy.fetch = async ( + request: Request, + fetchOptions: FederationFetchOptions, + ): Promise => { + const url = new URL(request.url); + if ( + url.pathname === pathPrefix || + url.pathname.startsWith(pathPrefix + "/") + ) { + return await app.fetch(request); + } + return await federation.fetch(request, fetchOptions); + }; + + return proxy; +} + +function createDebugApp( + pathPrefix: string, + exporter: FedifySpanExporter, +): Hono { + const app = new Hono({ strict: false }).basePath(pathPrefix); + + app.get("/api/traces", async (c) => { + const traces = await exporter.getRecentTraces(); + return c.json(traces); + }); + + app.get("/traces/:traceId", async (c) => { + const traceId = c.req.param("traceId"); + const activities = await exporter.getActivitiesByTraceId(traceId); + return c.text( + `Trace ${traceId}: ${activities.length} activities`, + 200, + ); + }); + + app.get("/", async (c) => { + const traces = await exporter.getRecentTraces(); + return c.text( + `Debug Dashboard: ${traces.length} traces`, + 200, + ); + }); + + return app; +} From d8250a645517616971d3cb5bcddcdd919f8de13b Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 10 Feb 2026 00:26:07 +0900 Subject: [PATCH 03/35] Implement SSR views for debug dashboard Replace plain text responses with JSX-based server-side rendered HTML pages using Hono's JSX engine: - Layout component with minimal skeleton CSS styling - Traces list page showing trace IDs, activity types, counts, timestamps, and auto-polling via inline script - Trace detail page showing activity direction, type, actor, signature verification details, inbox URL, and expandable activity JSON Rename mod.ts to mod.tsx to support JSX pragma directives. Add four new tests verifying HTML content in responses. https://github.com/fedify-dev/fedify/issues/561 Co-Authored-By: Claude --- packages/debugger/deno.json | 2 +- packages/debugger/src/mod.test.ts | 135 ++++++++++++++- packages/debugger/src/{mod.ts => mod.tsx} | 18 +- packages/debugger/src/views/layout.tsx | 74 ++++++++ packages/debugger/src/views/trace-detail.tsx | 167 +++++++++++++++++++ packages/debugger/src/views/traces-list.tsx | 102 +++++++++++ packages/debugger/tsdown.config.ts | 4 +- 7 files changed, 492 insertions(+), 10 deletions(-) rename packages/debugger/src/{mod.ts => mod.tsx} (90%) create mode 100644 packages/debugger/src/views/layout.tsx create mode 100644 packages/debugger/src/views/trace-detail.tsx create mode 100644 packages/debugger/src/views/traces-list.tsx diff --git a/packages/debugger/deno.json b/packages/debugger/deno.json index 5836a878d..b9bf9ef97 100644 --- a/packages/debugger/deno.json +++ b/packages/debugger/deno.json @@ -6,7 +6,7 @@ "@std/assert": "jsr:@std/assert@^1.0.13" }, "exports": { - ".": "./src/mod.ts" + ".": "./src/mod.tsx" }, "exclude": ["dist/", "node_modules/"], "publish": { diff --git a/packages/debugger/src/mod.test.ts b/packages/debugger/src/mod.test.ts index 736a19e81..8643cec22 100644 --- a/packages/debugger/src/mod.test.ts +++ b/packages/debugger/src/mod.test.ts @@ -1,6 +1,11 @@ import { test } from "@fedify/fixture"; -import { assertEquals, assertNotEquals } from "@std/assert"; -import { createFederationDebugger } from "./mod.ts"; +import { + assert, + assertEquals, + assertNotEquals, + assertStringIncludes, +} from "@std/assert"; +import { createFederationDebugger } from "./mod.tsx"; import type { Federation, FederationFetchOptions, @@ -221,3 +226,129 @@ test("fetch passes through onNotFound for non-debug requests", async () => { assertEquals(response.status, 404); assertEquals(await response.text(), "Custom Not Found"); }); + +test("traces list page returns HTML with trace IDs", async () => { + const traces: TraceSummary[] = [ + { + traceId: "abcdef1234567890abcdef1234567890", + timestamp: "2026-01-01T00:00:00Z", + activityCount: 2, + activityTypes: ["Create", "Follow"], + }, + { + traceId: "1234567890abcdef1234567890abcdef", + timestamp: "2026-01-02T00:00:00Z", + activityCount: 1, + activityTypes: ["Like"], + }, + ]; + const { federation } = createMockFederation(); + const exporter = createMockExporter(traces); + const dbg = createFederationDebugger(federation, { exporter }); + const request = new Request("https://example.com/__debug__/"); + const response = await dbg.fetch(request, { contextData: undefined }); + assertEquals(response.status, 200); + const ct = response.headers.get("content-type") ?? ""; + assert(ct.includes("text/html"), `Expected text/html, got ${ct}`); + const html = await response.text(); + assertStringIncludes(html, "Fedify Debug Dashboard"); + // Check that truncated trace IDs appear + assertStringIncludes(html, "abcdef12"); + assertStringIncludes(html, "12345678"); + // Check activity types are shown + assertStringIncludes(html, "Create"); + assertStringIncludes(html, "Follow"); + assertStringIncludes(html, "Like"); + // Check trace count + assertStringIncludes(html, "2"); +}); + +test("traces list page shows empty message when no traces", async () => { + const { federation } = createMockFederation(); + const exporter = createMockExporter(); + const dbg = createFederationDebugger(federation, { exporter }); + const request = new Request("https://example.com/__debug__/"); + const response = await dbg.fetch(request, { contextData: undefined }); + assertEquals(response.status, 200); + const html = await response.text(); + assertStringIncludes(html, "No traces captured yet."); + assertStringIncludes(html, "0"); +}); + +test("trace detail page returns HTML with activity details", async () => { + const activities: TraceActivityRecord[] = [ + { + traceId: "abcdef1234567890abcdef1234567890", + spanId: "span123abc", + direction: "inbound", + activityType: "Create", + activityId: "https://remote.example/activities/1", + actorId: "https://remote.example/users/alice", + activityJson: + '{"type":"Create","actor":"https://remote.example/users/alice"}', + verified: true, + signatureDetails: { + httpSignaturesVerified: true, + httpSignaturesKeyId: "https://remote.example/users/alice#main-key", + ldSignaturesVerified: false, + }, + timestamp: "2026-01-01T00:00:00Z", + }, + { + traceId: "abcdef1234567890abcdef1234567890", + spanId: "span456def", + direction: "outbound", + activityType: "Accept", + actorId: "https://local.example/users/bob", + activityJson: '{"type":"Accept"}', + timestamp: "2026-01-01T00:00:01Z", + inboxUrl: "https://remote.example/inbox", + }, + ]; + const { federation } = createMockFederation(); + const exporter = createMockExporter([], activities); + const dbg = createFederationDebugger(federation, { exporter }); + const request = new Request( + "https://example.com/__debug__/traces/abcdef1234567890abcdef1234567890", + ); + const response = await dbg.fetch(request, { contextData: undefined }); + assertEquals(response.status, 200); + const ct = response.headers.get("content-type") ?? ""; + assert(ct.includes("text/html"), `Expected text/html, got ${ct}`); + const html = await response.text(); + // Check page title + assertStringIncludes(html, "Trace abcdef12"); + // Check activity types shown + assertStringIncludes(html, "Create"); + assertStringIncludes(html, "Accept"); + // Check direction badges + assertStringIncludes(html, "inbound"); + assertStringIncludes(html, "outbound"); + // Check actor IDs + assertStringIncludes(html, "https://remote.example/users/alice"); + assertStringIncludes(html, "https://local.example/users/bob"); + // Check activity ID + assertStringIncludes(html, "https://remote.example/activities/1"); + // Check signature details + assertStringIncludes( + html, + "https://remote.example/users/alice#main-key", + ); + // Check inbox URL for outbound + assertStringIncludes(html, "https://remote.example/inbox"); + // Check back link + assertStringIncludes(html, "Back to traces"); +}); + +test("trace detail page shows empty message when no activities", async () => { + const { federation } = createMockFederation(); + const exporter = createMockExporter([], []); + const dbg = createFederationDebugger(federation, { exporter }); + const request = new Request( + "https://example.com/__debug__/traces/0000000000000000", + ); + const response = await dbg.fetch(request, { contextData: undefined }); + assertEquals(response.status, 200); + const html = await response.text(); + assertStringIncludes(html, "No activities found for this trace."); +}); diff --git a/packages/debugger/src/mod.ts b/packages/debugger/src/mod.tsx similarity index 90% rename from packages/debugger/src/mod.ts rename to packages/debugger/src/mod.tsx index 3f467b8aa..b09c79c7c 100644 --- a/packages/debugger/src/mod.ts +++ b/packages/debugger/src/mod.tsx @@ -1,3 +1,5 @@ +/** @jsx react-jsx */ +/** @jsxImportSource hono/jsx */ /** * @module * Embedded ActivityPub debug dashboard for Fedify. @@ -12,6 +14,8 @@ import type { } from "@fedify/fedify/federation"; import type { FedifySpanExporter } from "@fedify/fedify/otel"; import { Hono } from "hono"; +import { TracesListPage } from "./views/traces-list.tsx"; +import { TraceDetailPage } from "./views/trace-detail.tsx"; /** * Options for {@link createFederationDebugger}. @@ -116,17 +120,19 @@ function createDebugApp( app.get("/traces/:traceId", async (c) => { const traceId = c.req.param("traceId"); const activities = await exporter.getActivitiesByTraceId(traceId); - return c.text( - `Trace ${traceId}: ${activities.length} activities`, - 200, + return c.html( + , ); }); app.get("/", async (c) => { const traces = await exporter.getRecentTraces(); - return c.text( - `Debug Dashboard: ${traces.length} traces`, - 200, + return c.html( + , ); }); diff --git a/packages/debugger/src/views/layout.tsx b/packages/debugger/src/views/layout.tsx new file mode 100644 index 000000000..005647b12 --- /dev/null +++ b/packages/debugger/src/views/layout.tsx @@ -0,0 +1,74 @@ +/** @jsx react-jsx */ +/** @jsxImportSource hono/jsx */ +import type { FC, PropsWithChildren } from "hono/jsx"; + +/** + * Props for the {@link Layout} component. + */ +export interface LayoutProps { + /** + * The page title. Appended to "Fedify Debug Dashboard". + */ + title?: string; + + /** + * The path prefix for the debug dashboard (used for linking). + */ + pathPrefix: string; +} + +/** + * Root HTML layout for the debug dashboard. + */ +export const Layout: FC> = ( + { title, pathPrefix, children }, +) => { + return ( + + + + + + {title != null ? `${title} — ` : ""}Fedify Debug Dashboard + + + + +
+

+ Fedify Debug Dashboard +

+
+
+ {children} +
+ + + ); +}; diff --git a/packages/debugger/src/views/trace-detail.tsx b/packages/debugger/src/views/trace-detail.tsx new file mode 100644 index 000000000..d1ad3d35b --- /dev/null +++ b/packages/debugger/src/views/trace-detail.tsx @@ -0,0 +1,167 @@ +/** @jsx react-jsx */ +/** @jsxImportSource hono/jsx */ +import type { FC } from "hono/jsx"; +import type { TraceActivityRecord } from "@fedify/fedify/otel"; +import { Layout } from "./layout.tsx"; + +/** + * Props for the {@link TraceDetailPage} component. + */ +export interface TraceDetailPageProps { + /** + * The trace ID being displayed. + */ + traceId: string; + + /** + * The list of activity records for this trace. + */ + activities: TraceActivityRecord[]; + + /** + * The path prefix for the debug dashboard. + */ + pathPrefix: string; +} + +/** + * The trace detail page of the debug dashboard. + */ +export const TraceDetailPage: FC = ( + { traceId, activities, pathPrefix }, +) => { + return ( + + + +

+ Trace {traceId.slice(0, 8)} +

+

+ Full ID: {traceId} —{" "} + {activities.length}{" "} + activit{activities.length !== 1 ? "ies" : "y"} +

+ + {activities.length === 0 + ?

No activities found for this trace.

+ : ( + activities.map((activity) => ( +
+

+ + {activity.direction} + {" "} + {activity.activityType} +

+ + + + + + + + {activity.parentSpanId != null && ( + + + + + )} + {activity.activityId != null && ( + + + + + )} + {activity.actorId != null && ( + + + + + )} + + + + + {activity.direction === "outbound" && + activity.inboxUrl != null && ( + + + + + )} + {activity.direction === "inbound" && ( + + + + + )} + {activity.signatureDetails != null && ( + + + + + )} + +
Span ID + {activity.spanId} +
Parent Span + {activity.parentSpanId} +
Activity ID + {activity.activityId} +
Actor + {activity.actorId} +
Timestamp + +
Inbox URL + {activity.inboxUrl} +
Verified{activity.verified ? "Yes" : "No"}
Signature Details + HTTP Signatures:{" "} + {activity.signatureDetails.httpSignaturesVerified + ? "verified" + : "not verified"} + {activity.signatureDetails.httpSignaturesKeyId != + null && + ( + +  (key:  + + {activity.signatureDetails.httpSignaturesKeyId} + ) + + )} +
+ LD Signatures:{" "} + {activity.signatureDetails.ldSignaturesVerified + ? "verified" + : "not verified"} +
+ +
+ Activity JSON +
{formatJson(activity.activityJson)}
+
+
+ )) + )} +
+ ); +}; + +function formatJson(json: string): string { + try { + return JSON.stringify(JSON.parse(json), null, 2); + } catch { + return json; + } +} diff --git a/packages/debugger/src/views/traces-list.tsx b/packages/debugger/src/views/traces-list.tsx new file mode 100644 index 000000000..01ff6e961 --- /dev/null +++ b/packages/debugger/src/views/traces-list.tsx @@ -0,0 +1,102 @@ +/** @jsx react-jsx */ +/** @jsxImportSource hono/jsx */ +import type { FC } from "hono/jsx"; +import type { TraceSummary } from "@fedify/fedify/otel"; +import { Layout } from "./layout.tsx"; + +/** + * Props for the {@link TracesListPage} component. + */ +export interface TracesListPageProps { + /** + * The list of trace summaries to display. + */ + traces: TraceSummary[]; + + /** + * The path prefix for the debug dashboard. + */ + pathPrefix: string; +} + +/** + * The traces list page of the debug dashboard. + */ +export const TracesListPage: FC = ( + { traces, pathPrefix }, +) => { + return ( + +

+ Showing {traces.length}{" "} + trace{traces.length !== 1 ? "s" : ""}. +

+ {traces.length === 0 + ?

No traces captured yet.

+ : ( + + + + + + + + + + + {traces.map((trace) => ( + + + + + + + ))} + +
Trace IDActivity TypesActivitiesTimestamp
+ + {trace.traceId.slice(0, 8)} + + + {trace.activityTypes.map((t) => ( + + {t} + + ))} + {trace.activityTypes.length === 0 && ( + none + )} + {trace.activityCount} + +
+ )} + could enable script injection. Now the value is JSON-encoded and < is escaped as \u003c so it cannot break out of the script tag. https://github.com/fedify-dev/fedify/pull/564#discussion_r2790597926 Co-Authored-By: Claude --- packages/debugger/src/mod.test.ts | 21 +++++++++++++++++++++ packages/debugger/src/views/traces-list.tsx | 4 +++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/debugger/src/mod.test.ts b/packages/debugger/src/mod.test.ts index d82abc63a..82c158eed 100644 --- a/packages/debugger/src/mod.test.ts +++ b/packages/debugger/src/mod.test.ts @@ -275,6 +275,27 @@ test("traces list page shows empty message when no traces", async () => { ok(html.includes("0")); }); +test("traces list page escapes pathPrefix in inline script", async () => { + const { federation } = createMockFederation(); + const exporter = createMockExporter(); + const malicious = '/__debug__">'; + const dbg = createFederationDebugger(federation, { + exporter, + path: malicious, + }); + const request = new Request("https://example.com" + malicious + "/"); + const response = await dbg.fetch(request, { contextData: undefined }); + strictEqual(response.status, 200); + const html = await response.text(); + // The malicious pathPrefix must not appear unescaped in the inline script; + // it should be JSON-encoded with < escaped as \u003c to prevent breaking + // out of the '; const dbg = createFederationDebugger(federation, { exporter, + kv, path: malicious, }); const request = new Request("https://example.com" + malicious + "/"); @@ -404,8 +421,8 @@ test("trace detail page returns HTML with activity details", async () => { }, ]; const { federation } = createMockFederation(); - const exporter = createMockExporter([], activities); - const dbg = createFederationDebugger(federation, { exporter }); + const { exporter, kv } = createMockExporter([], activities); + const dbg = createFederationDebugger(federation, { exporter, kv }); const request = new Request( "https://example.com/__debug__/traces/abcdef1234567890abcdef1234567890", ); @@ -437,8 +454,8 @@ test("trace detail page returns HTML with activity details", async () => { test("trace detail page shows empty message when no activities", async () => { const { federation } = createMockFederation(); - const exporter = createMockExporter([], []); - const dbg = createFederationDebugger(federation, { exporter }); + const { exporter, kv } = createMockExporter([], []); + const dbg = createFederationDebugger(federation, { exporter, kv }); const request = new Request( "https://example.com/__debug__/traces/0000000000000000", ); @@ -574,12 +591,12 @@ test("simplified overload JSON API returns traces", async () => { test("auth password static: unauthenticated request shows login form", async () => { const { federation } = createMockFederation(); - const exporter = createMockExporter(); + const { exporter, kv } = createMockExporter(); const auth: FederationDebuggerAuth = { type: "password", password: "secret123", }; - const dbg = createFederationDebugger(federation, { exporter, auth }); + const dbg = createFederationDebugger(federation, { exporter, kv, auth }); const request = new Request("https://example.com/__debug__/"); const response = await dbg.fetch(request, { contextData: undefined }); strictEqual(response.status, 401); @@ -592,12 +609,12 @@ test("auth password static: unauthenticated request shows login form", async () test("auth password static: correct password sets session cookie", async () => { const { federation } = createMockFederation(); - const exporter = createMockExporter(); + const { exporter, kv } = createMockExporter(); const auth: FederationDebuggerAuth = { type: "password", password: "secret123", }; - const dbg = createFederationDebugger(federation, { exporter, auth }); + const dbg = createFederationDebugger(federation, { exporter, kv, auth }); const body = new URLSearchParams({ password: "secret123" }); const request = new Request("https://example.com/__debug__/login", { method: "POST", @@ -619,12 +636,12 @@ test("auth password static: correct password sets session cookie", async () => { test("auth password static: login cookie omits Secure on HTTP", async () => { const { federation } = createMockFederation(); - const exporter = createMockExporter(); + const { exporter, kv } = createMockExporter(); const auth: FederationDebuggerAuth = { type: "password", password: "secret123", }; - const dbg = createFederationDebugger(federation, { exporter, auth }); + const dbg = createFederationDebugger(federation, { exporter, kv, auth }); const body = new URLSearchParams({ password: "secret123" }); const request = new Request("http://example.com/__debug__/login", { method: "POST", @@ -643,12 +660,12 @@ test("auth password static: login cookie omits Secure on HTTP", async () => { test("auth password static: wrong password shows error", async () => { const { federation } = createMockFederation(); - const exporter = createMockExporter(); + const { exporter, kv } = createMockExporter(); const auth: FederationDebuggerAuth = { type: "password", password: "secret123", }; - const dbg = createFederationDebugger(federation, { exporter, auth }); + const dbg = createFederationDebugger(federation, { exporter, kv, auth }); const body = new URLSearchParams({ password: "wrong" }); const request = new Request("https://example.com/__debug__/login", { method: "POST", @@ -665,7 +682,7 @@ test("auth password static: wrong password shows error", async () => { test("auth password callback: authenticate function is called", async () => { const { federation } = createMockFederation(); - const exporter = createMockExporter(); + const { exporter, kv } = createMockExporter(); let receivedPassword = ""; const auth: FederationDebuggerAuth = { type: "password", @@ -674,7 +691,7 @@ test("auth password callback: authenticate function is called", async () => { return password === "callback-pw"; }, }; - const dbg = createFederationDebugger(federation, { exporter, auth }); + const dbg = createFederationDebugger(federation, { exporter, kv, auth }); const body = new URLSearchParams({ password: "callback-pw" }); const request = new Request("https://example.com/__debug__/login", { method: "POST", @@ -690,13 +707,13 @@ test("auth password callback: authenticate function is called", async () => { test("auth usernamePassword static: login form shows username field", async () => { const { federation } = createMockFederation(); - const exporter = createMockExporter(); + const { exporter, kv } = createMockExporter(); const auth: FederationDebuggerAuth = { type: "usernamePassword", username: "admin", password: "secret", }; - const dbg = createFederationDebugger(federation, { exporter, auth }); + const dbg = createFederationDebugger(federation, { exporter, kv, auth }); const request = new Request("https://example.com/__debug__/"); const response = await dbg.fetch(request, { contextData: undefined }); strictEqual(response.status, 401); @@ -707,13 +724,13 @@ test("auth usernamePassword static: login form shows username field", async () = test("auth usernamePassword static: correct credentials set cookie", async () => { const { federation } = createMockFederation(); - const exporter = createMockExporter(); + const { exporter, kv } = createMockExporter(); const auth: FederationDebuggerAuth = { type: "usernamePassword", username: "admin", password: "secret", }; - const dbg = createFederationDebugger(federation, { exporter, auth }); + const dbg = createFederationDebugger(federation, { exporter, kv, auth }); const body = new URLSearchParams({ username: "admin", password: "secret", @@ -732,13 +749,13 @@ test("auth usernamePassword static: correct credentials set cookie", async () => test("auth usernamePassword static: wrong username is rejected", async () => { const { federation } = createMockFederation(); - const exporter = createMockExporter(); + const { exporter, kv } = createMockExporter(); const auth: FederationDebuggerAuth = { type: "usernamePassword", username: "admin", password: "secret", }; - const dbg = createFederationDebugger(federation, { exporter, auth }); + const dbg = createFederationDebugger(federation, { exporter, kv, auth }); const body = new URLSearchParams({ username: "wrong", password: "secret", @@ -758,7 +775,7 @@ test("auth usernamePassword static: wrong username is rejected", async () => { test("auth usernamePassword callback: authenticate receives both args", async () => { const { federation } = createMockFederation(); - const exporter = createMockExporter(); + const { exporter, kv } = createMockExporter(); let receivedUsername = ""; let receivedPassword = ""; const auth: FederationDebuggerAuth = { @@ -769,7 +786,7 @@ test("auth usernamePassword callback: authenticate receives both args", async () return username === "user1" && password === "pass1"; }, }; - const dbg = createFederationDebugger(federation, { exporter, auth }); + const dbg = createFederationDebugger(federation, { exporter, kv, auth }); const body = new URLSearchParams({ username: "user1", password: "pass1", @@ -789,14 +806,14 @@ test("auth usernamePassword callback: authenticate receives both args", async () test("auth request: allowed request passes through", async () => { const { federation } = createMockFederation(); - const exporter = createMockExporter(); + const { exporter, kv } = createMockExporter(); const auth: FederationDebuggerAuth = { type: "request", authenticate(_request: Request) { return true; }, }; - const dbg = createFederationDebugger(federation, { exporter, auth }); + const dbg = createFederationDebugger(federation, { exporter, kv, auth }); const request = new Request("https://example.com/__debug__/"); const response = await dbg.fetch(request, { contextData: undefined }); strictEqual(response.status, 200); @@ -806,14 +823,14 @@ test("auth request: allowed request passes through", async () => { test("auth request: rejected request returns 403", async () => { const { federation } = createMockFederation(); - const exporter = createMockExporter(); + const { exporter, kv } = createMockExporter(); const auth: FederationDebuggerAuth = { type: "request", authenticate(_request: Request) { return false; }, }; - const dbg = createFederationDebugger(federation, { exporter, auth }); + const dbg = createFederationDebugger(federation, { exporter, kv, auth }); const request = new Request("https://example.com/__debug__/"); const response = await dbg.fetch(request, { contextData: undefined }); strictEqual(response.status, 403); @@ -822,7 +839,7 @@ test("auth request: rejected request returns 403", async () => { test("auth request: receives the actual Request object", async () => { const { federation } = createMockFederation(); - const exporter = createMockExporter(); + const { exporter, kv } = createMockExporter(); let receivedHeader = ""; const auth: FederationDebuggerAuth = { type: "request", @@ -831,7 +848,7 @@ test("auth request: receives the actual Request object", async () => { return receivedHeader === "allowed"; }, }; - const dbg = createFederationDebugger(federation, { exporter, auth }); + const dbg = createFederationDebugger(federation, { exporter, kv, auth }); const request = new Request("https://example.com/__debug__/", { headers: { "X-Test-Header": "allowed" }, }); @@ -842,14 +859,14 @@ test("auth request: receives the actual Request object", async () => { test("auth request: non-debug requests bypass auth", async () => { const { federation, calls } = createMockFederation(); - const exporter = createMockExporter(); + const { exporter, kv } = createMockExporter(); const auth: FederationDebuggerAuth = { type: "request", authenticate(_request: Request) { return false; // reject everything }, }; - const dbg = createFederationDebugger(federation, { exporter, auth }); + const dbg = createFederationDebugger(federation, { exporter, kv, auth }); // Non-debug requests should go to the inner federation, not the auth layer const request = new Request("https://example.com/users/alice"); const response = await dbg.fetch(request, { contextData: undefined }); @@ -861,12 +878,12 @@ test("auth request: non-debug requests bypass auth", async () => { test("auth password: logout clears session cookie", async () => { const { federation } = createMockFederation(); - const exporter = createMockExporter(); + const { exporter, kv } = createMockExporter(); const auth: FederationDebuggerAuth = { type: "password", password: "secret123", }; - const dbg = createFederationDebugger(federation, { exporter, auth }); + const dbg = createFederationDebugger(federation, { exporter, kv, auth }); const request = new Request("https://example.com/__debug__/logout"); const response = await dbg.fetch(request, { contextData: undefined }); strictEqual(response.status, 303); @@ -882,12 +899,12 @@ test("auth password: logout clears session cookie", async () => { test("auth password: logout cookie omits Secure on HTTP", async () => { const { federation } = createMockFederation(); - const exporter = createMockExporter(); + const { exporter, kv } = createMockExporter(); const auth: FederationDebuggerAuth = { type: "password", password: "secret123", }; - const dbg = createFederationDebugger(federation, { exporter, auth }); + const dbg = createFederationDebugger(federation, { exporter, kv, auth }); const request = new Request("http://example.com/__debug__/logout"); const response = await dbg.fetch(request, { contextData: undefined }); strictEqual(response.status, 303); @@ -903,12 +920,12 @@ test("auth password: logout cookie omits Secure on HTTP", async () => { test("auth password: valid session cookie grants access", async () => { const { federation } = createMockFederation(); - const exporter = createMockExporter(); + const { exporter, kv } = createMockExporter(); const auth: FederationDebuggerAuth = { type: "password", password: "secret123", }; - const dbg = createFederationDebugger(federation, { exporter, auth }); + const dbg = createFederationDebugger(federation, { exporter, kv, auth }); // Step 1: login to get a session cookie const loginBody = new URLSearchParams({ password: "secret123" }); const loginResponse = await dbg.fetch( @@ -938,12 +955,12 @@ test("auth password: valid session cookie grants access", async () => { test("auth password: forged session cookie is rejected", async () => { const { federation } = createMockFederation(); - const exporter = createMockExporter(); + const { exporter, kv } = createMockExporter(); const auth: FederationDebuggerAuth = { type: "password", password: "secret123", }; - const dbg = createFederationDebugger(federation, { exporter, auth }); + const dbg = createFederationDebugger(federation, { exporter, kv, auth }); // Use a fake/forged cookie value const response = await dbg.fetch( new Request("https://example.com/__debug__/", { @@ -1003,8 +1020,8 @@ test("simplified overload is idempotent: repeated calls share exporter", async ( test("createFederationDebugger exposes a sink property", () => { const { federation } = createMockFederation(); - const exporter = createMockExporter(); - const dbg = createFederationDebugger(federation, { exporter }); + const { exporter, kv } = createMockExporter(); + const dbg = createFederationDebugger(federation, { exporter, kv }); notStrictEqual(dbg.sink, null); notStrictEqual(dbg.sink, undefined); strictEqual(typeof dbg.sink, "function"); @@ -1028,8 +1045,8 @@ test("simplified overload exposes a sink property", () => { test("sink collects logs by traceId and API returns them", async () => { const { federation } = createMockFederation(); - const exporter = createMockExporter(); - const dbg = createFederationDebugger(federation, { exporter }); + const { exporter, kv } = createMockExporter(); + const dbg = createFederationDebugger(federation, { exporter, kv }); // Simulate log records with traceId in properties const traceId = "aaaa1111bbbb2222cccc3333dddd4444"; @@ -1069,8 +1086,8 @@ test("sink collects logs by traceId and API returns them", async () => { test("sink ignores log records without traceId", async () => { const { federation } = createMockFederation(); - const exporter = createMockExporter(); - const dbg = createFederationDebugger(federation, { exporter }); + const { exporter, kv } = createMockExporter(); + const dbg = createFederationDebugger(federation, { exporter, kv }); // Log without traceId — should be silently ignored dbg.sink({ @@ -1094,8 +1111,8 @@ test("sink ignores log records without traceId", async () => { test("multiple logs for the same trace are grouped", async () => { const { federation } = createMockFederation(); - const exporter = createMockExporter(); - const dbg = createFederationDebugger(federation, { exporter }); + const { exporter, kv } = createMockExporter(); + const dbg = createFederationDebugger(federation, { exporter, kv }); const traceId = "aaaa1111bbbb2222cccc3333dddd4444"; for (let i = 0; i < 5; i++) { @@ -1121,8 +1138,8 @@ test("multiple logs for the same trace are grouped", async () => { test("trace detail page shows log records", async () => { const { federation } = createMockFederation(); - const exporter = createMockExporter(); - const dbg = createFederationDebugger(federation, { exporter }); + const { exporter, kv } = createMockExporter(); + const dbg = createFederationDebugger(federation, { exporter, kv }); const traceId = "aaaa1111bbbb2222cccc3333dddd4444"; dbg.sink({ @@ -1150,8 +1167,8 @@ test("trace detail page shows log records", async () => { test("log API returns a snapshot: mutating the array does not affect store", async () => { const { federation } = createMockFederation(); - const exporter = createMockExporter(); - const dbg = createFederationDebugger(federation, { exporter }); + const { exporter, kv } = createMockExporter(); + const dbg = createFederationDebugger(federation, { exporter, kv }); const traceId = "aaaa1111bbbb2222cccc3333dddd4444"; dbg.sink({ @@ -1183,8 +1200,8 @@ test("log API returns a snapshot: mutating the array does not affect store", asy test("trace detail page handles invalid log timestamp gracefully", async () => { const { federation } = createMockFederation(); - const exporter = createMockExporter(); - const dbg = createFederationDebugger(federation, { exporter }); + const { exporter, kv } = createMockExporter(); + const dbg = createFederationDebugger(federation, { exporter, kv }); const traceId = "aaaa1111bbbb2222cccc3333dddd4444"; // Push a log with NaN timestamp (simulates corrupted data) @@ -1209,8 +1226,8 @@ test("trace detail page handles invalid log timestamp gracefully", async () => { test("trace detail page shows empty log message", async () => { const { federation } = createMockFederation(); - const exporter = createMockExporter(); - const dbg = createFederationDebugger(federation, { exporter }); + const { exporter, kv } = createMockExporter(); + const dbg = createFederationDebugger(federation, { exporter, kv }); const request = new Request( "https://example.com/__debug__/traces/0000000000000000", diff --git a/packages/debugger/src/mod.tsx b/packages/debugger/src/mod.tsx index 3705189a8..e254100d3 100644 --- a/packages/debugger/src/mod.tsx +++ b/packages/debugger/src/mod.tsx @@ -11,6 +11,8 @@ import type { Federation, FederationFetchOptions, + KvKey, + KvStore, } from "@fedify/fedify/federation"; import { MemoryKvStore } from "@fedify/fedify/federation"; import { FedifySpanExporter } from "@fedify/fedify/otel"; @@ -61,15 +63,13 @@ export interface SerializedLogRecord { readonly properties: Record; } -const DEFAULT_MAX_LOG_ENTRIES = 10_000; - /** * Cached auto-setup state so that repeated calls to * `createFederationDebugger()` without an explicit exporter reuse the same * global OpenTelemetry tracer provider and exporter instead of registering * duplicate providers and LogTape sinks. */ -let _autoSetup: { exporter: FedifySpanExporter } | undefined; +let _autoSetup: { exporter: FedifySpanExporter; kv: KvStore } | undefined; /** * Resets the internal auto-setup state. This is intended **only for tests** @@ -83,38 +83,50 @@ export function resetAutoSetup(): void { } /** - * In-memory storage for log records grouped by trace ID. + * Persistent storage for log records grouped by trace ID, backed by a + * {@link KvStore}. When the same `KvStore` is shared across web and worker + * processes the dashboard can display logs produced by background tasks. */ class LogStore { - readonly #maxEntries: number; - readonly #logs: Map = new Map(); - #totalEntries = 0; - - constructor(maxEntries: number = DEFAULT_MAX_LOG_ENTRIES) { - this.#maxEntries = maxEntries; + readonly #kv: KvStore; + readonly #keyPrefix: KvKey; + /** Per-trace monotonically increasing counter so entries sort correctly. */ + readonly #seq: Map = new Map(); + /** Chain of pending write promises for flush(). */ + #pending: Promise = Promise.resolve(); + + constructor(kv: KvStore, keyPrefix: KvKey = ["fedify", "debugger", "logs"]) { + this.#kv = kv; + this.#keyPrefix = keyPrefix; } + /** + * Enqueue a log record for writing. The write happens asynchronously; + * call {@link flush} to wait for all pending writes to complete. + */ add(traceId: string, record: SerializedLogRecord): void { - let list = this.#logs.get(traceId); - if (list == null) { - list = []; - this.#logs.set(traceId, list); - } - list.push(record); - this.#totalEntries++; - // Evict oldest trace groups when exceeding max entries - while (this.#totalEntries > this.#maxEntries && this.#logs.size > 0) { - const oldest = this.#logs.keys().next(); - if (oldest.done) break; - const evicted = this.#logs.get(oldest.value); - if (evicted != null) this.#totalEntries -= evicted.length; - this.#logs.delete(oldest.value); - } + const seq = this.#seq.get(traceId) ?? 0; + this.#seq.set(traceId, seq + 1); + const key: KvKey = [ + ...this.#keyPrefix, + traceId, + seq.toString().padStart(10, "0"), + ] as unknown as KvKey; + this.#pending = this.#pending.then(() => this.#kv.set(key, record)); } - get(traceId: string): readonly SerializedLogRecord[] { - const logs = this.#logs.get(traceId); - return logs != null ? [...logs] : []; + /** Wait for all pending writes to complete. */ + flush(): Promise { + return this.#pending; + } + + async get(traceId: string): Promise { + const prefix: KvKey = [...this.#keyPrefix, traceId] as unknown as KvKey; + const logs: SerializedLogRecord[] = []; + for await (const entry of this.#kv.list(prefix)) { + logs.push(entry.value as SerializedLogRecord); + } + return logs; } } @@ -201,6 +213,14 @@ export interface FederationDebuggerOptions { */ exporter: FedifySpanExporter; + /** + * The {@link KvStore} to persist log records to. This should typically be + * the same `KvStore` instance that was passed to the `FedifySpanExporter` + * so that logs and traces are co-located and accessible from all processes + * (e.g., both web and worker nodes). + */ + kv: KvStore; + /** * Authentication configuration for the debug dashboard. When omitted, * the dashboard is accessible without authentication. @@ -304,7 +324,10 @@ export function createFederationDebugger( * spanProcessors: [new SimpleSpanProcessor(exporter)], * }); * const innerFederation = createFederation({ kv, tracerProvider }); - * const federation = createFederationDebugger(innerFederation, { exporter }); + * const federation = createFederationDebugger(innerFederation, { + * exporter, + * kv, + * }); * await configure({ * sinks: { debugger: federation.sink }, * loggers: [ @@ -330,20 +353,21 @@ export function createFederationDebugger( ): Federation & { sink: Sink } { const pathPrefix = validatePathPrefix(options?.path ?? "/__debug__"); - const logStore = new LogStore(); - const sink = createLogSink(logStore); - let exporter: FedifySpanExporter; + let logKv: KvStore; if (options != null && "exporter" in options) { exporter = options.exporter; + logKv = options.kv; } else if (_autoSetup != null) { // Reuse the exporter from a previous auto-setup call so that repeated // calls without an explicit exporter share the same global state. exporter = _autoSetup.exporter; + logKv = _autoSetup.kv; } else { // Auto-setup: create MemoryKvStore, FedifySpanExporter, // BasicTracerProvider, and register globally const kv = new MemoryKvStore(); + logKv = kv; exporter = new FedifySpanExporter(kv); const tracerProvider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)], @@ -357,7 +381,14 @@ export function createFederationDebugger( // is properly injected/extracted across queue boundaries: propagation.setGlobalPropagator(new W3CTraceContextPropagator()); - // Auto-configure LogTape to include the debugger sink + _autoSetup = { exporter, kv }; + } + + const logStore = new LogStore(logKv); + const sink = createLogSink(logStore); + + // Auto-configure LogTape when using the simplified overload: + if (options == null || !("exporter" in options)) { const existingConfig = getConfig(); if (existingConfig != null) { // Merge with existing config @@ -396,8 +427,6 @@ export function createFederationDebugger( contextLocalStorage: new AsyncLocalStorage(), }); } - - _autoSetup = { exporter }; } const auth = options?.auth; @@ -652,16 +681,18 @@ function createDebugApp( return c.json(traces); }); - app.get("/api/logs/:traceId", (c) => { + app.get("/api/logs/:traceId", async (c) => { const traceId = c.req.param("traceId"); - const logs = logStore.get(traceId); + await logStore.flush(); + const logs = await logStore.get(traceId); return c.json(logs); }); app.get("/traces/:traceId", async (c) => { const traceId = c.req.param("traceId"); + await logStore.flush(); const activities = await exporter.getActivitiesByTraceId(traceId); - const logs = logStore.get(traceId); + const logs = await logStore.get(traceId); return c.html( Date: Thu, 12 Feb 2026 04:26:47 +0900 Subject: [PATCH 30/35] Use collision-resistant keys in LogStore Replace the in-memory per-trace sequence counter with a timestamp + random suffix key strategy. This eliminates two problems: - Key collisions when multiple processes share the same KvStore (each process had its own counter starting at 0). - Unbounded memory growth from the #seq Map that was never cleared. https://github.com/fedify-dev/fedify/pull/564#discussion_r2794115675 https://github.com/fedify-dev/fedify/pull/564#discussion_r2794149215 Co-Authored-By: Claude --- packages/debugger/src/mod.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/debugger/src/mod.tsx b/packages/debugger/src/mod.tsx index e254100d3..0886dd50c 100644 --- a/packages/debugger/src/mod.tsx +++ b/packages/debugger/src/mod.tsx @@ -90,8 +90,6 @@ export function resetAutoSetup(): void { class LogStore { readonly #kv: KvStore; readonly #keyPrefix: KvKey; - /** Per-trace monotonically increasing counter so entries sort correctly. */ - readonly #seq: Map = new Map(); /** Chain of pending write promises for flush(). */ #pending: Promise = Promise.resolve(); @@ -103,14 +101,18 @@ class LogStore { /** * Enqueue a log record for writing. The write happens asynchronously; * call {@link flush} to wait for all pending writes to complete. + * + * Keys use a timestamp + random suffix so that entries sort + * chronologically and never collide, even across multiple processes + * sharing the same {@link KvStore}. */ add(traceId: string, record: SerializedLogRecord): void { - const seq = this.#seq.get(traceId) ?? 0; - this.#seq.set(traceId, seq + 1); const key: KvKey = [ ...this.#keyPrefix, traceId, - seq.toString().padStart(10, "0"), + `${Date.now().toString(36).padStart(10, "0")}-${ + Math.random().toString(36).slice(2) + }`, ] as unknown as KvKey; this.#pending = this.#pending.then(() => this.#kv.set(key, record)); } From 351e80d1ec729080101ae237c4805f5e7f6b07b0 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Thu, 12 Feb 2026 04:27:22 +0900 Subject: [PATCH 31/35] Swallow errors in LogStore write chain A single failed KvStore.set() could poison the promise chain, causing all subsequent writes and flush() to reject. It could also trigger an unhandled rejection since the synchronous Sink never awaits the result. Catch and discard errors so logging remains best-effort. https://github.com/fedify-dev/fedify/pull/564#discussion_r2794149248 Co-Authored-By: Claude --- packages/debugger/src/mod.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/debugger/src/mod.tsx b/packages/debugger/src/mod.tsx index 0886dd50c..6de8825ab 100644 --- a/packages/debugger/src/mod.tsx +++ b/packages/debugger/src/mod.tsx @@ -114,7 +114,11 @@ class LogStore { Math.random().toString(36).slice(2) }`, ] as unknown as KvKey; - this.#pending = this.#pending.then(() => this.#kv.set(key, record)); + // Errors are swallowed so a single failed write cannot poison the + // chain or cause an unhandled rejection — logging is best-effort. + this.#pending = this.#pending.then( + () => this.#kv.set(key, record), + ).catch(() => {}); } /** Wait for all pending writes to complete. */ From 7d2cf3a38a16194d579e70befa8a4a61c94ff519 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Thu, 12 Feb 2026 04:33:49 +0900 Subject: [PATCH 32/35] Assert content-type in XSS escape test The existing XSS test only checked that the malicious payload was absent from the response body, but never verified that the debug dashboard HTML was actually rendered. Strengthen the assertion by branching on the Content-Type header: when the response is HTML, confirm the dashboard is present AND the payload is escaped; otherwise, confirm the delegated response also does not contain the payload. Addresses: https://github.com/fedify-dev/fedify/pull/564#discussion_r2794149268 Co-Authored-By: Claude --- packages/debugger/src/mod.test.ts | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/packages/debugger/src/mod.test.ts b/packages/debugger/src/mod.test.ts index 26643b034..5e595ef11 100644 --- a/packages/debugger/src/mod.test.ts +++ b/packages/debugger/src/mod.test.ts @@ -380,14 +380,28 @@ test("traces list page escapes pathPrefix in inline script", async () => { const request = new Request("https://example.com" + malicious + "/"); const response = await dbg.fetch(request, { contextData: undefined }); strictEqual(response.status, 200); - const html = await response.text(); - // The malicious pathPrefix must not appear unescaped in the inline script; - // it should be JSON-encoded with < escaped as \u003c to prevent breaking - // out of the