@piwitests/instrumentation-nitro 0.52.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,7 +28,21 @@ export { default } from '@piwitests/instrumentation-nitro'
28
28
 
29
29
  That's all. Nitro auto-loads every file in that directory on startup (`server/plugins/` in a Nuxt app, `plugins/` under the Nitro `srcDir` in a standalone Nitro app).
30
30
 
31
- A runnable end-to-end demo lives in [`examples/playwright-fixtures`](../../examples/playwright-fixtures) — a standalone Nitro app instrumented with this package, with a Playwright spec showing the captured logs in the dashboard.
31
+ [`examples/sample-app`](../../examples/sample-app) runs a standalone Nitro app with this package wired in (`backends/nitro/`), and a Playwright suite that checks what it sends: backend logs, the route manifest, server spans and server probes.
32
+
33
+ ## Route manifest
34
+
35
+ Outside production, the plugin serves `GET /__piwi/manifest`: the routes the app declares. The Piwi reporter fetches it when a run starts and uploads it, so a declared route no test reaches becomes a **declared, never hit** gap in the Test Map. Nitro has no route table to read while it runs, so add the package's module, which reads it when the server is bundled (again on each rebuild in development):
36
+
37
+ ```typescript
38
+ // Standalone Nitro: nitro.config.ts
39
+ export default defineNitroConfig({ modules: ['@piwitests/instrumentation-nitro/module'] })
40
+
41
+ // Nuxt: nuxt.config.ts
42
+ export default defineNuxtConfig({ nitro: { modules: ['@piwitests/instrumentation-nitro/module'] } })
43
+ ```
44
+
45
+ The manifest then lists every route Nitro scanned (`routes/`, `api/`) and every handler the config declares, middleware and a fallback catch-all (`/**`) left out, each with its method (`GET` for a route that answers any method), its pattern (`/api/orders/:id`) and its file relative to the git repository. Without the module, it lists only the routes the server has answered since it started, which the reporter, reading it before any test runs, rarely sees. Nuxt pages are rendered by one catch-all route, so they are not listed: name them in a `piwi.manifest.json`.
32
46
 
33
47
  ## What gets captured
34
48
 
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { NitroAppPlugin } from 'nitropack';
2
- export { verifyProbeHeader, signProbeMessage, probeSigningMessage, PROBE_TTL_MS, type PiwiProbeSpec, type SignedProbe, } from './probe';
3
- export { buildRouteManifest, recordObservedRoute, collapsePathPattern, type RouteManifest } from './manifest';
4
- export * from './faults';
2
+ export { verifyProbeHeader, signProbeMessage, probeSigningMessage, PROBE_TTL_MS, type PiwiProbeSpec, type SignedProbe, } from './probe.js';
3
+ export { buildRouteManifest, recordObservedRoute, collapsePathPattern, type RouteManifest } from './manifest.js';
4
+ export * from './faults.js';
5
5
  export interface PiwiTestLogEntry {
6
6
  timestamp: number;
7
7
  level: string;
package/dist/index.js CHANGED
@@ -3,12 +3,13 @@ import { randomBytes } from 'node:crypto';
3
3
  import { gzipSync } from 'node:zlib';
4
4
  import { consola } from 'consola';
5
5
  import { createError } from 'h3';
6
- import { verifyProbeHeader, ProbeNonceCache } from './probe';
7
- import { buildRouteManifest, recordObservedRoute, collapsePathPattern, } from './manifest';
8
- import { routeMatchesRequest, faultStatus, faultDelayMs, isThrowFault, isExtremeFault, isDataFault, isDependencyFault, dependencyCallMatches, mutateResponseBody, appliedFaultLabel, } from './faults';
9
- export { verifyProbeHeader, signProbeMessage, probeSigningMessage, PROBE_TTL_MS, } from './probe';
10
- export { buildRouteManifest, recordObservedRoute, collapsePathPattern } from './manifest';
11
- export * from './faults';
6
+ import { verifyProbeHeader, ProbeNonceCache } from './probe.js';
7
+ import { buildRouteManifest, recordObservedRoute, collapsePathPattern, } from './manifest.js';
8
+ import { declaredRoutes } from './route-table.js';
9
+ import { routeMatchesRequest, faultStatus, faultDelayMs, isThrowFault, isExtremeFault, isDataFault, isDependencyFault, dependencyCallMatches, mutateResponseBody, appliedFaultLabel, } from './faults.js';
10
+ export { verifyProbeHeader, signProbeMessage, probeSigningMessage, PROBE_TTL_MS, } from './probe.js';
11
+ export { buildRouteManifest, recordObservedRoute, collapsePathPattern } from './manifest.js';
12
+ export * from './faults.js';
12
13
  const MAX_ENTRIES = 50;
13
14
  const MAX_MSG_LENGTH = 500;
14
15
  const MAX_STACK_FRAMES = 5;
@@ -90,8 +91,9 @@ const PROBE_SECRET = process.env.PIWI_PROBE_SECRET || undefined;
90
91
  /** The declared-surface route path the plugin serves outside production. */
91
92
  const MANIFEST_PATH = '/__piwi/manifest';
92
93
  /**
93
- * Routes the server has matched this process, accumulated for `/__piwi/manifest`.
94
- * Nitro exposes no runtime route table, so the manifest is built from observation.
94
+ * Routes the server has matched this process, accumulated for `/__piwi/manifest`
95
+ * next to the declared ones: Nitro exposes no runtime route table, so without the
96
+ * module (`./module`, which reads it at build time) only these are listed.
95
97
  */
96
98
  const observedRoutes = new Map();
97
99
  /**
@@ -141,13 +143,17 @@ const piwiTestLogs = (nitroApp) => {
141
143
  const originalHandler = nitroApp.h3App.handler;
142
144
  nitroApp.h3App.handler = ((event) => {
143
145
  // Serve the declared-surface manifest outside production (the plugin already
144
- // returned early when log capture is disabled). Built from observed routes.
146
+ // returned early when log capture is disabled): the routes the module declared,
147
+ // then the ones observed.
145
148
  const requestPath = String(event.path ?? event.node.req.url ?? '').split('?')[0] ?? '';
146
149
  if (requestPath === MANIFEST_PATH) {
150
+ const routes = new Map();
151
+ for (const route of [...declaredRoutes(), ...observedRoutes.values()])
152
+ recordObservedRoute(routes, route);
147
153
  const res = event.node.res;
148
154
  res.statusCode = 200;
149
155
  res.setHeader('Content-Type', 'application/json');
150
- res.end(JSON.stringify(buildRouteManifest(observedRoutes)));
156
+ res.end(JSON.stringify(buildRouteManifest(routes)));
151
157
  return;
152
158
  }
153
159
  const store = { logs: [], spans: [], startMs: Date.now() };
@@ -220,8 +226,10 @@ const piwiTestLogs = (nitroApp) => {
220
226
  const traceId = parseTraceparent(event.node.req.headers['traceparent']) ?? randomBytes(16).toString('hex');
221
227
  const handlerFile = resolveHandlerFile(event);
222
228
  // Record this route for the declared-surface manifest. Prefer the matched
223
- // route's own pattern; fall back to collapsing the concrete path.
224
- const matchedPattern = event.context?.matchedRoute?.route;
229
+ // route's own pattern (`path` in h3 1.x, `route` in later versions); fall
230
+ // back to collapsing the concrete path.
231
+ const matchedRoute = event.context?.matchedRoute;
232
+ const matchedPattern = matchedRoute?.route ?? matchedRoute?.path;
225
233
  const pattern = typeof matchedPattern === 'string' && matchedPattern ? matchedPattern : collapsePathPattern(path);
226
234
  recordObservedRoute(observedRoutes, { method, pattern, handler: handlerFile });
227
235
  const rootSpan = {
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * The declared-surface route manifest the plugin serves at `/__piwi/manifest`
3
3
  * outside production. Nitro has no stable public API to enumerate its route table
4
- * at runtime, so the manifest is accumulated from the routes the server actually
5
- * matches — each request contributes its method, matched pattern and handler
6
- * file. The Piwi reporter fetches it once at run start and uploads it, and a
7
- * declared route no test reaches becomes a "declared, never hit" gap.
4
+ * at runtime: the module (`./module`) reads it at build time (`route-table.ts`),
5
+ * and the routes the server matches are added as they come, each request with its
6
+ * method, matched pattern and handler file. The Piwi reporter fetches it once at
7
+ * run start and uploads it, and a declared route no test reaches becomes a
8
+ * "declared, never hit" gap, which only the module's routes can give.
8
9
  */
9
10
  /** One declared route: method, path pattern and the handler's source file. */
10
11
  export interface ManifestRouteEntry {
package/dist/manifest.js CHANGED
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * The declared-surface route manifest the plugin serves at `/__piwi/manifest`
3
3
  * outside production. Nitro has no stable public API to enumerate its route table
4
- * at runtime, so the manifest is accumulated from the routes the server actually
5
- * matches — each request contributes its method, matched pattern and handler
6
- * file. The Piwi reporter fetches it once at run start and uploads it, and a
7
- * declared route no test reaches becomes a "declared, never hit" gap.
4
+ * at runtime: the module (`./module`) reads it at build time (`route-table.ts`),
5
+ * and the routes the server matches are added as they come, each request with its
6
+ * method, matched pattern and handler file. The Piwi reporter fetches it once at
7
+ * run start and uploads it, and a declared route no test reaches becomes a
8
+ * "declared, never hit" gap, which only the module's routes can give.
8
9
  */
9
10
  /**
10
11
  * Collapse a concrete request path to a pattern: numeric and uuid segments become
@@ -0,0 +1,3 @@
1
+ import type { NitroModule } from 'nitropack';
2
+ declare const piwiRouteTable: NitroModule;
3
+ export default piwiRouteTable;
package/dist/module.js ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The Nitro module that gives the route manifest Nitro's whole route table:
3
+ *
4
+ * ```ts
5
+ * export default defineNitroConfig({ modules: ['@piwitests/instrumentation-nitro/module'] });
6
+ * ```
7
+ *
8
+ * In Nuxt, the same entry goes under `nitro.modules`. Without it, `/__piwi/manifest`
9
+ * lists only the routes the server has answered. Modules run before Nitro scans its
10
+ * route files, so the table is read when the server is bundled, again on each
11
+ * rebuild in development.
12
+ */
13
+ import { existsSync } from 'node:fs';
14
+ import { dirname, join } from 'node:path';
15
+ import { ROUTE_TABLE_ID, declaredRoutesOf, routeTablePlugin } from './route-table.js';
16
+ /** The repository root above `dir` (the closest folder holding `.git`), else `dir` itself. */
17
+ function repositoryRoot(dir) {
18
+ for (let current = dir;; current = dirname(current)) {
19
+ if (existsSync(join(current, '.git')))
20
+ return current;
21
+ if (dirname(current) === current)
22
+ return dir;
23
+ }
24
+ }
25
+ const piwiRouteTable = {
26
+ name: 'piwi-route-table',
27
+ setup(nitro) {
28
+ const root = repositoryRoot(nitro.options.rootDir);
29
+ nitro.options.virtual[ROUTE_TABLE_ID] = () => routeTablePlugin(declaredRoutesOf([...nitro.scannedHandlers, ...nitro.options.handlers], root));
30
+ nitro.options.plugins.push(ROUTE_TABLE_ID);
31
+ },
32
+ };
33
+ export default piwiRouteTable;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The route table the Nitro module (`@piwitests/instrumentation-nitro/module`)
3
+ * reads at build time, from the routes Nitro scanned (`routes/`, `api/`) and the
4
+ * handlers its config declares. The module writes it into a small generated
5
+ * plugin, which hands it to the runtime plugin through a global, so
6
+ * `/__piwi/manifest` lists every declared route from the server's first request,
7
+ * reached or not.
8
+ */
9
+ import type { ManifestRouteEntry } from './manifest.js';
10
+ /** The id of the generated plugin that carries the route table. */
11
+ export declare const ROUTE_TABLE_ID = "#piwi/route-table";
12
+ /** A Nitro handler, as `nitro.scannedHandlers` and `nitro.options.handlers` list it. */
13
+ export interface NitroHandlerLike {
14
+ route?: string;
15
+ method?: string;
16
+ handler?: string;
17
+ middleware?: boolean;
18
+ }
19
+ /**
20
+ * The declared routes of Nitro's handlers: every handler with a route, middleware
21
+ * and a fallback catch-all left out, one entry per method and pattern. A handler
22
+ * without a method answers every method and is declared as `GET`. Its file is
23
+ * named relative to `root` (the repository root, else the app's root) with `/`
24
+ * separators.
25
+ */
26
+ export declare function declaredRoutesOf(handlers: NitroHandlerLike[], root: string): ManifestRouteEntry[];
27
+ /** The source of the generated plugin: it sets the route table on the global the runtime plugin reads. */
28
+ export declare function routeTablePlugin(routes: ManifestRouteEntry[]): string;
29
+ /** The routes the generated plugin set; none when the app does not use the module. */
30
+ export declare function declaredRoutes(): ManifestRouteEntry[];
@@ -0,0 +1,42 @@
1
+ /** The id of the generated plugin that carries the route table. */
2
+ export const ROUTE_TABLE_ID = '#piwi/route-table';
3
+ /** The global the generated plugin sets the route table on. */
4
+ const DECLARED_ROUTES_KEY = '__piwiDeclaredRoutes';
5
+ /** A route that is only a catch-all (`/**`, `/**:path`): an app's fallback, such as Nuxt's page renderer. */
6
+ const FALLBACK_ROUTE = /^\/\*\*(?::[^/]*)?$/;
7
+ /**
8
+ * The declared routes of Nitro's handlers: every handler with a route, middleware
9
+ * and a fallback catch-all left out, one entry per method and pattern. A handler
10
+ * without a method answers every method and is declared as `GET`. Its file is
11
+ * named relative to `root` (the repository root, else the app's root) with `/`
12
+ * separators.
13
+ */
14
+ export function declaredRoutesOf(handlers, root) {
15
+ const routes = new Map();
16
+ for (const handler of handlers) {
17
+ if (handler.middleware || !handler.route || FALLBACK_ROUTE.test(handler.route))
18
+ continue;
19
+ const method = (handler.method || 'get').toUpperCase();
20
+ const key = `${method} ${handler.route}`;
21
+ if (routes.has(key))
22
+ continue;
23
+ const file = handler.handler ? relativeFile(root, handler.handler) : undefined;
24
+ routes.set(key, { method, pattern: handler.route, ...(file ? { handler: file } : {}) });
25
+ }
26
+ return [...routes.values()];
27
+ }
28
+ /** `file` relative to `root`, with `/` separators; undefined when it is not under `root`. */
29
+ function relativeFile(root, file) {
30
+ const normalizedRoot = root.replace(/\\/g, '/').replace(/\/$/, '');
31
+ const normalizedFile = file.replace(/\\/g, '/');
32
+ return normalizedFile.startsWith(`${normalizedRoot}/`) ? normalizedFile.slice(normalizedRoot.length + 1) : undefined;
33
+ }
34
+ /** The source of the generated plugin: it sets the route table on the global the runtime plugin reads. */
35
+ export function routeTablePlugin(routes) {
36
+ return `export default function piwiRouteTable() {\n globalThis.${DECLARED_ROUTES_KEY} = ${JSON.stringify(routes)};\n}\n`;
37
+ }
38
+ /** The routes the generated plugin set; none when the app does not use the module. */
39
+ export function declaredRoutes() {
40
+ const routes = globalThis[DECLARED_ROUTES_KEY];
41
+ return Array.isArray(routes) ? routes : [];
42
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piwitests/instrumentation-nitro",
3
- "version": "0.52.0",
3
+ "version": "0.53.0",
4
4
  "description": "Nitro/Nuxt server plugin for sending backend logs to Piwi Dashboard via the X-Piwi-Logs response header",
5
5
  "homepage": "https://piwitests.dev",
6
6
  "repository": {
@@ -17,6 +17,10 @@
17
17
  ".": {
18
18
  "types": "./dist/index.d.ts",
19
19
  "import": "./dist/index.js"
20
+ },
21
+ "./module": {
22
+ "types": "./dist/module.d.ts",
23
+ "import": "./dist/module.js"
20
24
  }
21
25
  },
22
26
  "keywords": [