@nowo-tech/beacon-express 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +33 -2
- package/dist/.build-stamp +1 -0
- package/dist/index.cjs +172 -0
- package/dist/index.cjs.map +7 -0
- package/dist/index.d.cts +50 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.mjs +149 -0
- package/dist/index.mjs.map +7 -0
- package/package.json +76 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nowo-tech
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,34 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @nowo-tech/beacon-express
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Beacon middleware for Express 4.18+ and 5. `express` is a required peer dependency.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
pnpm add @nowo-tech/beacon-node @nowo-tech/beacon-express
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import express from 'express';
|
|
11
|
+
import * as Beacon from '@nowo-tech/beacon-node';
|
|
12
|
+
import { beaconErrorHandler, beaconRequestHandler } from '@nowo-tech/beacon-express';
|
|
13
|
+
|
|
14
|
+
Beacon.init({ dsn: process.env.BEACON_DSN!, release: 'api@1.4.2' });
|
|
15
|
+
|
|
16
|
+
const app = express();
|
|
17
|
+
app.use(beaconRequestHandler()); // first: one isolated scope per request
|
|
18
|
+
// … routes …
|
|
19
|
+
app.use(beaconErrorHandler()); // after the routes: captures, then calls next(err)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
- `beaconRequestHandler({ includeBody?, allowHeaders? })`: forks the scope for the request (breadcrumbs, user and
|
|
23
|
+
tags never leak between requests) and records the method, the scrubbed URL and an **allow-list** of headers:
|
|
24
|
+
`accept`, `accept-encoding`, `accept-language`, `content-length`, `content-type`, `referer` (without its query)
|
|
25
|
+
and `user-agent`, plus the names in `allowHeaders`. Client-address headers (`*forwarded*`, `*client-ip*`,
|
|
26
|
+
`*real-ip*`, `cf-*`, `fastly-*`, `x-vercel-*`, `true-client-ip`, …), cookies, `Authorization` and other credential
|
|
27
|
+
headers are never recorded, even when listed or with `sendDefaultPii`.
|
|
28
|
+
- The body is recorded only with `includeBody: true`. JSON and form bodies are parsed, their keys scrubbed and their
|
|
29
|
+
values credential-masked **before** any size check; a body over 10 kB becomes `"[truncated]"`, and one that
|
|
30
|
+
cannot be parsed becomes `"[unparsed]"`. Raw text is never sent.
|
|
31
|
+
- Both handlers swallow their own failures: the app's request and error always go on unchanged.
|
|
32
|
+
- `beaconErrorHandler({ shouldHandleError? })`: captures errors without a status or with a status of 500 or more
|
|
33
|
+
(mechanism `express`, `handled: false`), adds the route (`request.route`, tag `route`), stores the event id in
|
|
34
|
+
`res.locals.beaconEventId`, and calls `next(err)`. Async handler rejections reach it natively in Express 5.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@nowo-tech/beacon-express@0.1.0
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
BODY_TRUNCATED: () => BODY_TRUNCATED,
|
|
24
|
+
BODY_UNPARSED: () => BODY_UNPARSED,
|
|
25
|
+
DEFAULT_ALLOWED_HEADERS: () => DEFAULT_ALLOWED_HEADERS,
|
|
26
|
+
beaconErrorHandler: () => beaconErrorHandler,
|
|
27
|
+
beaconRequestHandler: () => beaconRequestHandler,
|
|
28
|
+
bodyOf: () => bodyOf,
|
|
29
|
+
defaultShouldHandleError: () => defaultShouldHandleError,
|
|
30
|
+
requestDataOf: () => requestDataOf
|
|
31
|
+
});
|
|
32
|
+
module.exports = __toCommonJS(index_exports);
|
|
33
|
+
var import_beacon_node = require("@nowo-tech/beacon-node");
|
|
34
|
+
var MAX_BODY_BYTES = 10 * 1024;
|
|
35
|
+
var DEFAULT_ALLOWED_HEADERS = [
|
|
36
|
+
"accept",
|
|
37
|
+
"accept-encoding",
|
|
38
|
+
"accept-language",
|
|
39
|
+
"content-length",
|
|
40
|
+
"content-type",
|
|
41
|
+
"referer",
|
|
42
|
+
"user-agent"
|
|
43
|
+
];
|
|
44
|
+
var NEVER_HEADERS = /cookie|authorization|^x-beacon-auth$|api-?key|token|secret|session|csrf|xsrf|password|credential/i;
|
|
45
|
+
function isNeverRecorded(name) {
|
|
46
|
+
return (0, import_beacon_node.isClientAddressHeader)(name) || NEVER_HEADERS.test(name);
|
|
47
|
+
}
|
|
48
|
+
var BODY_TRUNCATED = "[truncated]";
|
|
49
|
+
var BODY_UNPARSED = "[unparsed]";
|
|
50
|
+
function headersOf(req, allowHeaders) {
|
|
51
|
+
const allowed = /* @__PURE__ */ new Set([
|
|
52
|
+
...DEFAULT_ALLOWED_HEADERS,
|
|
53
|
+
...allowHeaders.map((name) => name.toLowerCase())
|
|
54
|
+
]);
|
|
55
|
+
const out = {};
|
|
56
|
+
for (const [name, value] of Object.entries(req.headers)) {
|
|
57
|
+
const lower = name.toLowerCase();
|
|
58
|
+
if (value === void 0 || !allowed.has(lower) || isNeverRecorded(name)) continue;
|
|
59
|
+
const text = Array.isArray(value) ? value.join(", ") : value;
|
|
60
|
+
out[name] = lower === "referer" ? (0, import_beacon_node.scrubUrl)(text).replace(/\?.*$/, "") : text;
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
function parsedBody(body, contentType) {
|
|
65
|
+
if (typeof body !== "string" && !(body instanceof Uint8Array)) return body;
|
|
66
|
+
const text = typeof body === "string" ? body : new TextDecoder().decode(body);
|
|
67
|
+
if (/json/i.test(contentType)) return JSON.parse(text);
|
|
68
|
+
if (/x-www-form-urlencoded/i.test(contentType))
|
|
69
|
+
return Object.fromEntries(new URLSearchParams(text));
|
|
70
|
+
throw new Error("unsupported body");
|
|
71
|
+
}
|
|
72
|
+
function bodyOf(body, contentType = "") {
|
|
73
|
+
if (body === void 0) return void 0;
|
|
74
|
+
let value;
|
|
75
|
+
try {
|
|
76
|
+
value = parsedBody(body, contentType);
|
|
77
|
+
} catch {
|
|
78
|
+
return BODY_UNPARSED;
|
|
79
|
+
}
|
|
80
|
+
const scrubbed = maskValues((0, import_beacon_node.scrubObject)(value, MASK_DEPTH));
|
|
81
|
+
const normalized = (0, import_beacon_node.normalize)(scrubbed, {
|
|
82
|
+
depth: 5,
|
|
83
|
+
maxKeys: 100,
|
|
84
|
+
maxArray: 100,
|
|
85
|
+
maxString: MAX_BODY_BYTES
|
|
86
|
+
});
|
|
87
|
+
return JSON.stringify(normalized).length > MAX_BODY_BYTES ? BODY_TRUNCATED : normalized;
|
|
88
|
+
}
|
|
89
|
+
var MASK_DEPTH = 20;
|
|
90
|
+
function maskValues(value, depth = MASK_DEPTH) {
|
|
91
|
+
if (typeof value === "string") return (0, import_beacon_node.maskCredentials)(value);
|
|
92
|
+
if (!value || typeof value !== "object") return value;
|
|
93
|
+
if (depth <= 0) return "[Filtered]";
|
|
94
|
+
if (Array.isArray(value)) return value.map((item) => maskValues(item, depth - 1));
|
|
95
|
+
return Object.fromEntries(
|
|
96
|
+
Object.entries(value).map(([key, item]) => [key, maskValues(item, depth - 1)])
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
function requestDataOf(req, options = {}) {
|
|
100
|
+
const data = {
|
|
101
|
+
method: req.method,
|
|
102
|
+
url: (0, import_beacon_node.scrubUrl)(`${req.protocol}://${req.get("host") ?? "localhost"}${req.originalUrl}`),
|
|
103
|
+
headers: headersOf(req, options.allowHeaders ?? [])
|
|
104
|
+
};
|
|
105
|
+
if (options.includeBody) {
|
|
106
|
+
const body = bodyOf(req.body, String(req.get("content-type") ?? ""));
|
|
107
|
+
if (body !== void 0) data.data = body;
|
|
108
|
+
}
|
|
109
|
+
return data;
|
|
110
|
+
}
|
|
111
|
+
function beaconRequestHandler(options = {}) {
|
|
112
|
+
return function beaconRequestHandler2(req, _res, next) {
|
|
113
|
+
let request;
|
|
114
|
+
try {
|
|
115
|
+
request = requestDataOf(req, options);
|
|
116
|
+
} catch {
|
|
117
|
+
request = void 0;
|
|
118
|
+
}
|
|
119
|
+
(0, import_beacon_node.withScope)((scope) => {
|
|
120
|
+
if (request) scope.request = request;
|
|
121
|
+
next();
|
|
122
|
+
});
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function statusOf(error) {
|
|
126
|
+
if (typeof error !== "object" || error === null) return void 0;
|
|
127
|
+
const candidate = error;
|
|
128
|
+
const status = candidate.status ?? candidate.statusCode ?? candidate.output?.statusCode;
|
|
129
|
+
const number = Number(status);
|
|
130
|
+
return status === void 0 || !Number.isFinite(number) ? void 0 : number;
|
|
131
|
+
}
|
|
132
|
+
function defaultShouldHandleError(error) {
|
|
133
|
+
const status = statusOf(error);
|
|
134
|
+
return status === void 0 || status >= 500;
|
|
135
|
+
}
|
|
136
|
+
function beaconErrorHandler(options = {}) {
|
|
137
|
+
const shouldHandle = options.shouldHandleError ?? defaultShouldHandleError;
|
|
138
|
+
return function beaconErrorHandler2(error, req, res, next) {
|
|
139
|
+
try {
|
|
140
|
+
const client = (0, import_beacon_node.getClient)();
|
|
141
|
+
if (client && shouldHandle(error)) {
|
|
142
|
+
const scope = (0, import_beacon_node.getCurrentScope)().clone();
|
|
143
|
+
const request = { ...scope.request ?? requestDataOf(req) };
|
|
144
|
+
const route = req.route?.path;
|
|
145
|
+
if (typeof route === "string") {
|
|
146
|
+
request["route"] = route;
|
|
147
|
+
scope.setTag("route", `${req.method} ${route}`);
|
|
148
|
+
}
|
|
149
|
+
scope.request = request;
|
|
150
|
+
const eventId = client.captureException(error, scope, void 0, {
|
|
151
|
+
mechanism: { type: "express", handled: false },
|
|
152
|
+
logger: "express"
|
|
153
|
+
});
|
|
154
|
+
res.locals["beaconEventId"] = eventId;
|
|
155
|
+
}
|
|
156
|
+
} catch {
|
|
157
|
+
}
|
|
158
|
+
next(error);
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
162
|
+
0 && (module.exports = {
|
|
163
|
+
BODY_TRUNCATED,
|
|
164
|
+
BODY_UNPARSED,
|
|
165
|
+
DEFAULT_ALLOWED_HEADERS,
|
|
166
|
+
beaconErrorHandler,
|
|
167
|
+
beaconRequestHandler,
|
|
168
|
+
bodyOf,
|
|
169
|
+
defaultShouldHandleError,
|
|
170
|
+
requestDataOf
|
|
171
|
+
});
|
|
172
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/index.ts"],
|
|
4
|
+
"sourcesContent": ["import {\n getClient,\n getCurrentScope,\n isClientAddressHeader,\n maskCredentials,\n normalize,\n scrubObject,\n scrubUrl,\n withScope,\n type RequestData,\n} from '@nowo-tech/beacon-node';\nimport type { ErrorRequestHandler, Request, RequestHandler } from 'express';\n\nexport interface RequestHandlerOptions {\n /** Attach `req.body` (keys scrubbed; dropped when unparsable or over 10 kB). Default false. */\n includeBody?: boolean;\n /**\n * Extra request headers to record, on top of the default allow-list. Client-address, cookie and authorization\n * headers are never recorded, even when listed here.\n */\n allowHeaders?: string[];\n}\n\nexport interface ErrorHandlerOptions {\n /** Default: errors without a status, and statuses of 500 or more. */\n shouldHandleError?: (error: unknown) => boolean;\n}\n\nconst MAX_BODY_BYTES = 10 * 1024;\n\n/** The request headers recorded by default (contract \u00A75, allow-list). */\nexport const DEFAULT_ALLOWED_HEADERS: readonly string[] = [\n 'accept',\n 'accept-encoding',\n 'accept-language',\n 'content-length',\n 'content-type',\n 'referer',\n 'user-agent',\n];\n\n/** Never recorded, whatever the allow-list says: client addresses, cookies and credentials. */\nconst NEVER_HEADERS =\n /cookie|authorization|^x-beacon-auth$|api-?key|token|secret|session|csrf|xsrf|password|credential/i;\n\n/** Client-address headers (FVD-L1) and credential headers are never recorded, even when opted in. */\nfunction isNeverRecorded(name: string): boolean {\n return isClientAddressHeader(name) || NEVER_HEADERS.test(name);\n}\n\nexport const BODY_TRUNCATED = '[truncated]';\nexport const BODY_UNPARSED = '[unparsed]';\n\nfunction headersOf(req: Request, allowHeaders: readonly string[]): Record<string, string> {\n const allowed = new Set([\n ...DEFAULT_ALLOWED_HEADERS,\n ...allowHeaders.map((name) => name.toLowerCase()),\n ]);\n const out: Record<string, string> = {};\n for (const [name, value] of Object.entries(req.headers)) {\n const lower = name.toLowerCase();\n if (value === undefined || !allowed.has(lower) || isNeverRecorded(name)) continue;\n const text = Array.isArray(value) ? value.join(', ') : value;\n // The referer is another page's URL: its query string and fragment are never kept.\n out[name] = lower === 'referer' ? scrubUrl(text).replace(/\\?.*$/, '') : text;\n }\n return out;\n}\n\n/** Parses JSON and form bodies that arrive as text, so that their keys can be scrubbed. */\nfunction parsedBody(body: unknown, contentType: string): unknown {\n if (typeof body !== 'string' && !(body instanceof Uint8Array)) return body;\n const text = typeof body === 'string' ? body : new TextDecoder().decode(body);\n if (/json/i.test(contentType)) return JSON.parse(text) as unknown;\n if (/x-www-form-urlencoded/i.test(contentType))\n return Object.fromEntries(new URLSearchParams(text));\n throw new Error('unsupported body');\n}\n\n/**\n * The request body for the event (FV-M1): parsed, keys scrubbed **before** anything is cut, string values\n * credential-masked, and then either kept whole (\u2264 10 kB) or replaced by `[truncated]`. Raw text is never sent:\n * a body that cannot be parsed becomes `[unparsed]`.\n */\nexport function bodyOf(body: unknown, contentType = ''): unknown {\n if (body === undefined) return undefined;\n let value: unknown;\n try {\n value = parsedBody(body, contentType);\n } catch {\n return BODY_UNPARSED;\n }\n const scrubbed = maskValues(scrubObject(value, MASK_DEPTH));\n const normalized = normalize(scrubbed, {\n depth: 5,\n maxKeys: 100,\n maxArray: 100,\n maxString: MAX_BODY_BYTES,\n });\n return JSON.stringify(normalized).length > MAX_BODY_BYTES ? BODY_TRUNCATED : normalized;\n}\n\nconst MASK_DEPTH = 20;\n\n/**\n * Masks credential-looking strings, at most {@link MASK_DEPTH} levels deep (FVD-I2). Card numbers sent as numbers\n * are already masked by `scrubObject`.\n */\nfunction maskValues(value: unknown, depth = MASK_DEPTH): unknown {\n if (typeof value === 'string') return maskCredentials(value);\n if (!value || typeof value !== 'object') return value;\n // Deeper (or cyclic) structures are not walked: they are cut here instead of throwing.\n if (depth <= 0) return '[Filtered]';\n if (Array.isArray(value)) return value.map((item) => maskValues(item, depth - 1));\n return Object.fromEntries(\n Object.entries(value).map(([key, item]) => [key, maskValues(item, depth - 1)]),\n );\n}\n\n/** Request data for the event (contract \u00A78.2): method, scrubbed URL, allow-listed headers, never cookies. */\nexport function requestDataOf(req: Request, options: RequestHandlerOptions = {}): RequestData {\n const data: RequestData = {\n method: req.method,\n url: scrubUrl(`${req.protocol}://${req.get('host') ?? 'localhost'}${req.originalUrl}`),\n headers: headersOf(req, options.allowHeaders ?? []),\n };\n if (options.includeBody) {\n const body = bodyOf(req.body as unknown, String(req.get('content-type') ?? ''));\n if (body !== undefined) data.data = body;\n }\n return data;\n}\n\n/** Install first: an isolated scope per request, with the request context. */\nexport function beaconRequestHandler(options: RequestHandlerOptions = {}): RequestHandler {\n return function beaconRequestHandler(req, _res, next) {\n let request: RequestData | undefined;\n try {\n request = requestDataOf(req, options);\n } catch {\n // The SDK never changes the app's behaviour: without request data, the request still goes on.\n request = undefined;\n }\n withScope((scope) => {\n if (request) scope.request = request;\n next();\n });\n };\n}\n\nfunction statusOf(error: unknown): number | undefined {\n if (typeof error !== 'object' || error === null) return undefined;\n const candidate = error as {\n status?: unknown;\n statusCode?: unknown;\n output?: { statusCode?: unknown };\n };\n const status = candidate.status ?? candidate.statusCode ?? candidate.output?.statusCode;\n const number = Number(status);\n return status === undefined || !Number.isFinite(number) ? undefined : number;\n}\n\n/** The default filter: errors without a status, and statuses of 500 or more. */\nexport function defaultShouldHandleError(error: unknown): boolean {\n const status = statusOf(error);\n return status === undefined || status >= 500;\n}\n\n/**\n * Install after the routes: captures the error (mechanism `express`, unhandled) with the route, then calls\n * `next(err)` so that Express (or the app's own error handler) still answers. Works with Express 4 and 5.\n */\nexport function beaconErrorHandler(options: ErrorHandlerOptions = {}): ErrorRequestHandler {\n const shouldHandle = options.shouldHandleError ?? defaultShouldHandleError;\n return function beaconErrorHandler(error, req, res, next) {\n try {\n const client = getClient();\n if (client && shouldHandle(error)) {\n const scope = getCurrentScope().clone();\n const request = { ...(scope.request ?? requestDataOf(req)) };\n const route = (req.route as { path?: unknown } | undefined)?.path;\n if (typeof route === 'string') {\n request['route'] = route;\n scope.setTag('route', `${req.method} ${route}`);\n }\n scope.request = request;\n const eventId = client.captureException(error, scope, undefined, {\n mechanism: { type: 'express', handled: false },\n logger: 'express',\n });\n (res.locals as Record<string, unknown>)['beaconEventId'] = eventId;\n }\n } catch {\n // Whatever happens in the SDK, the app's own error goes on unchanged.\n }\n next(error);\n };\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": ["beaconRequestHandler", "beaconErrorHandler"]
|
|
7
|
+
}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ErrorRequestHandler } from 'express';
|
|
2
|
+
import type { Request as Request_2 } from 'express';
|
|
3
|
+
import { RequestData } from '@nowo-tech/beacon-node';
|
|
4
|
+
import type { RequestHandler } from 'express';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Install after the routes: captures the error (mechanism `express`, unhandled) with the route, then calls
|
|
8
|
+
* `next(err)` so that Express (or the app's own error handler) still answers. Works with Express 4 and 5.
|
|
9
|
+
*/
|
|
10
|
+
export declare function beaconErrorHandler(options?: ErrorHandlerOptions): ErrorRequestHandler;
|
|
11
|
+
|
|
12
|
+
/** Install first: an isolated scope per request, with the request context. */
|
|
13
|
+
export declare function beaconRequestHandler(options?: RequestHandlerOptions): RequestHandler;
|
|
14
|
+
|
|
15
|
+
export declare const BODY_TRUNCATED = "[truncated]";
|
|
16
|
+
|
|
17
|
+
export declare const BODY_UNPARSED = "[unparsed]";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The request body for the event (FV-M1): parsed, keys scrubbed **before** anything is cut, string values
|
|
21
|
+
* credential-masked, and then either kept whole (≤ 10 kB) or replaced by `[truncated]`. Raw text is never sent:
|
|
22
|
+
* a body that cannot be parsed becomes `[unparsed]`.
|
|
23
|
+
*/
|
|
24
|
+
export declare function bodyOf(body: unknown, contentType?: string): unknown;
|
|
25
|
+
|
|
26
|
+
/** The request headers recorded by default (contract §5, allow-list). */
|
|
27
|
+
export declare const DEFAULT_ALLOWED_HEADERS: readonly string[];
|
|
28
|
+
|
|
29
|
+
/** The default filter: errors without a status, and statuses of 500 or more. */
|
|
30
|
+
export declare function defaultShouldHandleError(error: unknown): boolean;
|
|
31
|
+
|
|
32
|
+
export declare interface ErrorHandlerOptions {
|
|
33
|
+
/** Default: errors without a status, and statuses of 500 or more. */
|
|
34
|
+
shouldHandleError?: (error: unknown) => boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Request data for the event (contract §8.2): method, scrubbed URL, allow-listed headers, never cookies. */
|
|
38
|
+
export declare function requestDataOf(req: Request_2, options?: RequestHandlerOptions): RequestData;
|
|
39
|
+
|
|
40
|
+
export declare interface RequestHandlerOptions {
|
|
41
|
+
/** Attach `req.body` (keys scrubbed; dropped when unparsable or over 10 kB). Default false. */
|
|
42
|
+
includeBody?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Extra request headers to record, on top of the default allow-list. Client-address, cookie and authorization
|
|
45
|
+
* headers are never recorded, even when listed here.
|
|
46
|
+
*/
|
|
47
|
+
allowHeaders?: string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export { }
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ErrorRequestHandler } from 'express';
|
|
2
|
+
import type { Request as Request_2 } from 'express';
|
|
3
|
+
import { RequestData } from '@nowo-tech/beacon-node';
|
|
4
|
+
import type { RequestHandler } from 'express';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Install after the routes: captures the error (mechanism `express`, unhandled) with the route, then calls
|
|
8
|
+
* `next(err)` so that Express (or the app's own error handler) still answers. Works with Express 4 and 5.
|
|
9
|
+
*/
|
|
10
|
+
export declare function beaconErrorHandler(options?: ErrorHandlerOptions): ErrorRequestHandler;
|
|
11
|
+
|
|
12
|
+
/** Install first: an isolated scope per request, with the request context. */
|
|
13
|
+
export declare function beaconRequestHandler(options?: RequestHandlerOptions): RequestHandler;
|
|
14
|
+
|
|
15
|
+
export declare const BODY_TRUNCATED = "[truncated]";
|
|
16
|
+
|
|
17
|
+
export declare const BODY_UNPARSED = "[unparsed]";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The request body for the event (FV-M1): parsed, keys scrubbed **before** anything is cut, string values
|
|
21
|
+
* credential-masked, and then either kept whole (≤ 10 kB) or replaced by `[truncated]`. Raw text is never sent:
|
|
22
|
+
* a body that cannot be parsed becomes `[unparsed]`.
|
|
23
|
+
*/
|
|
24
|
+
export declare function bodyOf(body: unknown, contentType?: string): unknown;
|
|
25
|
+
|
|
26
|
+
/** The request headers recorded by default (contract §5, allow-list). */
|
|
27
|
+
export declare const DEFAULT_ALLOWED_HEADERS: readonly string[];
|
|
28
|
+
|
|
29
|
+
/** The default filter: errors without a status, and statuses of 500 or more. */
|
|
30
|
+
export declare function defaultShouldHandleError(error: unknown): boolean;
|
|
31
|
+
|
|
32
|
+
export declare interface ErrorHandlerOptions {
|
|
33
|
+
/** Default: errors without a status, and statuses of 500 or more. */
|
|
34
|
+
shouldHandleError?: (error: unknown) => boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Request data for the event (contract §8.2): method, scrubbed URL, allow-listed headers, never cookies. */
|
|
38
|
+
export declare function requestDataOf(req: Request_2, options?: RequestHandlerOptions): RequestData;
|
|
39
|
+
|
|
40
|
+
export declare interface RequestHandlerOptions {
|
|
41
|
+
/** Attach `req.body` (keys scrubbed; dropped when unparsable or over 10 kB). Default false. */
|
|
42
|
+
includeBody?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Extra request headers to record, on top of the default allow-list. Client-address, cookie and authorization
|
|
45
|
+
* headers are never recorded, even when listed here.
|
|
46
|
+
*/
|
|
47
|
+
allowHeaders?: string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export { }
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import {
|
|
3
|
+
getClient,
|
|
4
|
+
getCurrentScope,
|
|
5
|
+
isClientAddressHeader,
|
|
6
|
+
maskCredentials,
|
|
7
|
+
normalize,
|
|
8
|
+
scrubObject,
|
|
9
|
+
scrubUrl,
|
|
10
|
+
withScope
|
|
11
|
+
} from "@nowo-tech/beacon-node";
|
|
12
|
+
var MAX_BODY_BYTES = 10 * 1024;
|
|
13
|
+
var DEFAULT_ALLOWED_HEADERS = [
|
|
14
|
+
"accept",
|
|
15
|
+
"accept-encoding",
|
|
16
|
+
"accept-language",
|
|
17
|
+
"content-length",
|
|
18
|
+
"content-type",
|
|
19
|
+
"referer",
|
|
20
|
+
"user-agent"
|
|
21
|
+
];
|
|
22
|
+
var NEVER_HEADERS = /cookie|authorization|^x-beacon-auth$|api-?key|token|secret|session|csrf|xsrf|password|credential/i;
|
|
23
|
+
function isNeverRecorded(name) {
|
|
24
|
+
return isClientAddressHeader(name) || NEVER_HEADERS.test(name);
|
|
25
|
+
}
|
|
26
|
+
var BODY_TRUNCATED = "[truncated]";
|
|
27
|
+
var BODY_UNPARSED = "[unparsed]";
|
|
28
|
+
function headersOf(req, allowHeaders) {
|
|
29
|
+
const allowed = /* @__PURE__ */ new Set([
|
|
30
|
+
...DEFAULT_ALLOWED_HEADERS,
|
|
31
|
+
...allowHeaders.map((name) => name.toLowerCase())
|
|
32
|
+
]);
|
|
33
|
+
const out = {};
|
|
34
|
+
for (const [name, value] of Object.entries(req.headers)) {
|
|
35
|
+
const lower = name.toLowerCase();
|
|
36
|
+
if (value === void 0 || !allowed.has(lower) || isNeverRecorded(name)) continue;
|
|
37
|
+
const text = Array.isArray(value) ? value.join(", ") : value;
|
|
38
|
+
out[name] = lower === "referer" ? scrubUrl(text).replace(/\?.*$/, "") : text;
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
function parsedBody(body, contentType) {
|
|
43
|
+
if (typeof body !== "string" && !(body instanceof Uint8Array)) return body;
|
|
44
|
+
const text = typeof body === "string" ? body : new TextDecoder().decode(body);
|
|
45
|
+
if (/json/i.test(contentType)) return JSON.parse(text);
|
|
46
|
+
if (/x-www-form-urlencoded/i.test(contentType))
|
|
47
|
+
return Object.fromEntries(new URLSearchParams(text));
|
|
48
|
+
throw new Error("unsupported body");
|
|
49
|
+
}
|
|
50
|
+
function bodyOf(body, contentType = "") {
|
|
51
|
+
if (body === void 0) return void 0;
|
|
52
|
+
let value;
|
|
53
|
+
try {
|
|
54
|
+
value = parsedBody(body, contentType);
|
|
55
|
+
} catch {
|
|
56
|
+
return BODY_UNPARSED;
|
|
57
|
+
}
|
|
58
|
+
const scrubbed = maskValues(scrubObject(value, MASK_DEPTH));
|
|
59
|
+
const normalized = normalize(scrubbed, {
|
|
60
|
+
depth: 5,
|
|
61
|
+
maxKeys: 100,
|
|
62
|
+
maxArray: 100,
|
|
63
|
+
maxString: MAX_BODY_BYTES
|
|
64
|
+
});
|
|
65
|
+
return JSON.stringify(normalized).length > MAX_BODY_BYTES ? BODY_TRUNCATED : normalized;
|
|
66
|
+
}
|
|
67
|
+
var MASK_DEPTH = 20;
|
|
68
|
+
function maskValues(value, depth = MASK_DEPTH) {
|
|
69
|
+
if (typeof value === "string") return maskCredentials(value);
|
|
70
|
+
if (!value || typeof value !== "object") return value;
|
|
71
|
+
if (depth <= 0) return "[Filtered]";
|
|
72
|
+
if (Array.isArray(value)) return value.map((item) => maskValues(item, depth - 1));
|
|
73
|
+
return Object.fromEntries(
|
|
74
|
+
Object.entries(value).map(([key, item]) => [key, maskValues(item, depth - 1)])
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
function requestDataOf(req, options = {}) {
|
|
78
|
+
const data = {
|
|
79
|
+
method: req.method,
|
|
80
|
+
url: scrubUrl(`${req.protocol}://${req.get("host") ?? "localhost"}${req.originalUrl}`),
|
|
81
|
+
headers: headersOf(req, options.allowHeaders ?? [])
|
|
82
|
+
};
|
|
83
|
+
if (options.includeBody) {
|
|
84
|
+
const body = bodyOf(req.body, String(req.get("content-type") ?? ""));
|
|
85
|
+
if (body !== void 0) data.data = body;
|
|
86
|
+
}
|
|
87
|
+
return data;
|
|
88
|
+
}
|
|
89
|
+
function beaconRequestHandler(options = {}) {
|
|
90
|
+
return function beaconRequestHandler2(req, _res, next) {
|
|
91
|
+
let request;
|
|
92
|
+
try {
|
|
93
|
+
request = requestDataOf(req, options);
|
|
94
|
+
} catch {
|
|
95
|
+
request = void 0;
|
|
96
|
+
}
|
|
97
|
+
withScope((scope) => {
|
|
98
|
+
if (request) scope.request = request;
|
|
99
|
+
next();
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function statusOf(error) {
|
|
104
|
+
if (typeof error !== "object" || error === null) return void 0;
|
|
105
|
+
const candidate = error;
|
|
106
|
+
const status = candidate.status ?? candidate.statusCode ?? candidate.output?.statusCode;
|
|
107
|
+
const number = Number(status);
|
|
108
|
+
return status === void 0 || !Number.isFinite(number) ? void 0 : number;
|
|
109
|
+
}
|
|
110
|
+
function defaultShouldHandleError(error) {
|
|
111
|
+
const status = statusOf(error);
|
|
112
|
+
return status === void 0 || status >= 500;
|
|
113
|
+
}
|
|
114
|
+
function beaconErrorHandler(options = {}) {
|
|
115
|
+
const shouldHandle = options.shouldHandleError ?? defaultShouldHandleError;
|
|
116
|
+
return function beaconErrorHandler2(error, req, res, next) {
|
|
117
|
+
try {
|
|
118
|
+
const client = getClient();
|
|
119
|
+
if (client && shouldHandle(error)) {
|
|
120
|
+
const scope = getCurrentScope().clone();
|
|
121
|
+
const request = { ...scope.request ?? requestDataOf(req) };
|
|
122
|
+
const route = req.route?.path;
|
|
123
|
+
if (typeof route === "string") {
|
|
124
|
+
request["route"] = route;
|
|
125
|
+
scope.setTag("route", `${req.method} ${route}`);
|
|
126
|
+
}
|
|
127
|
+
scope.request = request;
|
|
128
|
+
const eventId = client.captureException(error, scope, void 0, {
|
|
129
|
+
mechanism: { type: "express", handled: false },
|
|
130
|
+
logger: "express"
|
|
131
|
+
});
|
|
132
|
+
res.locals["beaconEventId"] = eventId;
|
|
133
|
+
}
|
|
134
|
+
} catch {
|
|
135
|
+
}
|
|
136
|
+
next(error);
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
export {
|
|
140
|
+
BODY_TRUNCATED,
|
|
141
|
+
BODY_UNPARSED,
|
|
142
|
+
DEFAULT_ALLOWED_HEADERS,
|
|
143
|
+
beaconErrorHandler,
|
|
144
|
+
beaconRequestHandler,
|
|
145
|
+
bodyOf,
|
|
146
|
+
defaultShouldHandleError,
|
|
147
|
+
requestDataOf
|
|
148
|
+
};
|
|
149
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/index.ts"],
|
|
4
|
+
"sourcesContent": ["import {\n getClient,\n getCurrentScope,\n isClientAddressHeader,\n maskCredentials,\n normalize,\n scrubObject,\n scrubUrl,\n withScope,\n type RequestData,\n} from '@nowo-tech/beacon-node';\nimport type { ErrorRequestHandler, Request, RequestHandler } from 'express';\n\nexport interface RequestHandlerOptions {\n /** Attach `req.body` (keys scrubbed; dropped when unparsable or over 10 kB). Default false. */\n includeBody?: boolean;\n /**\n * Extra request headers to record, on top of the default allow-list. Client-address, cookie and authorization\n * headers are never recorded, even when listed here.\n */\n allowHeaders?: string[];\n}\n\nexport interface ErrorHandlerOptions {\n /** Default: errors without a status, and statuses of 500 or more. */\n shouldHandleError?: (error: unknown) => boolean;\n}\n\nconst MAX_BODY_BYTES = 10 * 1024;\n\n/** The request headers recorded by default (contract \u00A75, allow-list). */\nexport const DEFAULT_ALLOWED_HEADERS: readonly string[] = [\n 'accept',\n 'accept-encoding',\n 'accept-language',\n 'content-length',\n 'content-type',\n 'referer',\n 'user-agent',\n];\n\n/** Never recorded, whatever the allow-list says: client addresses, cookies and credentials. */\nconst NEVER_HEADERS =\n /cookie|authorization|^x-beacon-auth$|api-?key|token|secret|session|csrf|xsrf|password|credential/i;\n\n/** Client-address headers (FVD-L1) and credential headers are never recorded, even when opted in. */\nfunction isNeverRecorded(name: string): boolean {\n return isClientAddressHeader(name) || NEVER_HEADERS.test(name);\n}\n\nexport const BODY_TRUNCATED = '[truncated]';\nexport const BODY_UNPARSED = '[unparsed]';\n\nfunction headersOf(req: Request, allowHeaders: readonly string[]): Record<string, string> {\n const allowed = new Set([\n ...DEFAULT_ALLOWED_HEADERS,\n ...allowHeaders.map((name) => name.toLowerCase()),\n ]);\n const out: Record<string, string> = {};\n for (const [name, value] of Object.entries(req.headers)) {\n const lower = name.toLowerCase();\n if (value === undefined || !allowed.has(lower) || isNeverRecorded(name)) continue;\n const text = Array.isArray(value) ? value.join(', ') : value;\n // The referer is another page's URL: its query string and fragment are never kept.\n out[name] = lower === 'referer' ? scrubUrl(text).replace(/\\?.*$/, '') : text;\n }\n return out;\n}\n\n/** Parses JSON and form bodies that arrive as text, so that their keys can be scrubbed. */\nfunction parsedBody(body: unknown, contentType: string): unknown {\n if (typeof body !== 'string' && !(body instanceof Uint8Array)) return body;\n const text = typeof body === 'string' ? body : new TextDecoder().decode(body);\n if (/json/i.test(contentType)) return JSON.parse(text) as unknown;\n if (/x-www-form-urlencoded/i.test(contentType))\n return Object.fromEntries(new URLSearchParams(text));\n throw new Error('unsupported body');\n}\n\n/**\n * The request body for the event (FV-M1): parsed, keys scrubbed **before** anything is cut, string values\n * credential-masked, and then either kept whole (\u2264 10 kB) or replaced by `[truncated]`. Raw text is never sent:\n * a body that cannot be parsed becomes `[unparsed]`.\n */\nexport function bodyOf(body: unknown, contentType = ''): unknown {\n if (body === undefined) return undefined;\n let value: unknown;\n try {\n value = parsedBody(body, contentType);\n } catch {\n return BODY_UNPARSED;\n }\n const scrubbed = maskValues(scrubObject(value, MASK_DEPTH));\n const normalized = normalize(scrubbed, {\n depth: 5,\n maxKeys: 100,\n maxArray: 100,\n maxString: MAX_BODY_BYTES,\n });\n return JSON.stringify(normalized).length > MAX_BODY_BYTES ? BODY_TRUNCATED : normalized;\n}\n\nconst MASK_DEPTH = 20;\n\n/**\n * Masks credential-looking strings, at most {@link MASK_DEPTH} levels deep (FVD-I2). Card numbers sent as numbers\n * are already masked by `scrubObject`.\n */\nfunction maskValues(value: unknown, depth = MASK_DEPTH): unknown {\n if (typeof value === 'string') return maskCredentials(value);\n if (!value || typeof value !== 'object') return value;\n // Deeper (or cyclic) structures are not walked: they are cut here instead of throwing.\n if (depth <= 0) return '[Filtered]';\n if (Array.isArray(value)) return value.map((item) => maskValues(item, depth - 1));\n return Object.fromEntries(\n Object.entries(value).map(([key, item]) => [key, maskValues(item, depth - 1)]),\n );\n}\n\n/** Request data for the event (contract \u00A78.2): method, scrubbed URL, allow-listed headers, never cookies. */\nexport function requestDataOf(req: Request, options: RequestHandlerOptions = {}): RequestData {\n const data: RequestData = {\n method: req.method,\n url: scrubUrl(`${req.protocol}://${req.get('host') ?? 'localhost'}${req.originalUrl}`),\n headers: headersOf(req, options.allowHeaders ?? []),\n };\n if (options.includeBody) {\n const body = bodyOf(req.body as unknown, String(req.get('content-type') ?? ''));\n if (body !== undefined) data.data = body;\n }\n return data;\n}\n\n/** Install first: an isolated scope per request, with the request context. */\nexport function beaconRequestHandler(options: RequestHandlerOptions = {}): RequestHandler {\n return function beaconRequestHandler(req, _res, next) {\n let request: RequestData | undefined;\n try {\n request = requestDataOf(req, options);\n } catch {\n // The SDK never changes the app's behaviour: without request data, the request still goes on.\n request = undefined;\n }\n withScope((scope) => {\n if (request) scope.request = request;\n next();\n });\n };\n}\n\nfunction statusOf(error: unknown): number | undefined {\n if (typeof error !== 'object' || error === null) return undefined;\n const candidate = error as {\n status?: unknown;\n statusCode?: unknown;\n output?: { statusCode?: unknown };\n };\n const status = candidate.status ?? candidate.statusCode ?? candidate.output?.statusCode;\n const number = Number(status);\n return status === undefined || !Number.isFinite(number) ? undefined : number;\n}\n\n/** The default filter: errors without a status, and statuses of 500 or more. */\nexport function defaultShouldHandleError(error: unknown): boolean {\n const status = statusOf(error);\n return status === undefined || status >= 500;\n}\n\n/**\n * Install after the routes: captures the error (mechanism `express`, unhandled) with the route, then calls\n * `next(err)` so that Express (or the app's own error handler) still answers. Works with Express 4 and 5.\n */\nexport function beaconErrorHandler(options: ErrorHandlerOptions = {}): ErrorRequestHandler {\n const shouldHandle = options.shouldHandleError ?? defaultShouldHandleError;\n return function beaconErrorHandler(error, req, res, next) {\n try {\n const client = getClient();\n if (client && shouldHandle(error)) {\n const scope = getCurrentScope().clone();\n const request = { ...(scope.request ?? requestDataOf(req)) };\n const route = (req.route as { path?: unknown } | undefined)?.path;\n if (typeof route === 'string') {\n request['route'] = route;\n scope.setTag('route', `${req.method} ${route}`);\n }\n scope.request = request;\n const eventId = client.captureException(error, scope, undefined, {\n mechanism: { type: 'express', handled: false },\n logger: 'express',\n });\n (res.locals as Record<string, unknown>)['beaconEventId'] = eventId;\n }\n } catch {\n // Whatever happens in the SDK, the app's own error goes on unchanged.\n }\n next(error);\n };\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": ["beaconRequestHandler", "beaconErrorHandler"]
|
|
7
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,79 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nowo-tech/beacon-express",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Beacon middleware for Express 4 and 5: request isolation, scrubbed request context and error capture.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"beacon",
|
|
7
|
+
"error-tracking",
|
|
8
|
+
"express",
|
|
9
|
+
"middleware"
|
|
10
|
+
],
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"author": "nowo-tech",
|
|
13
|
+
"homepage": "https://github.com/nowo-tech/beacon-js/tree/main/packages/express#readme",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/nowo-tech/beacon-js.git",
|
|
17
|
+
"directory": "packages/express"
|
|
18
|
+
},
|
|
19
|
+
"bugs": "https://github.com/nowo-tech/beacon-js/issues",
|
|
20
|
+
"type": "module",
|
|
21
|
+
"sideEffects": false,
|
|
22
|
+
"main": "./dist/index.cjs",
|
|
23
|
+
"module": "./dist/index.mjs",
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"import": {
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"default": "./dist/index.mjs"
|
|
30
|
+
},
|
|
31
|
+
"require": {
|
|
32
|
+
"types": "./dist/index.d.cts",
|
|
33
|
+
"default": "./dist/index.cjs"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"./package.json": "./package.json"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"dist",
|
|
40
|
+
"README.md",
|
|
41
|
+
"CHANGELOG.md"
|
|
42
|
+
],
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=20"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public",
|
|
48
|
+
"provenance": true
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@nowo-tech/beacon-node": "0.1.0"
|
|
52
|
+
},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"express": ">=4.18"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@types/express": "5.0.6",
|
|
58
|
+
"express": "5.3.0"
|
|
59
|
+
},
|
|
60
|
+
"beaconBuild": {
|
|
61
|
+
"platform": "node",
|
|
62
|
+
"target": "node20",
|
|
63
|
+
"entries": [
|
|
64
|
+
{
|
|
65
|
+
"in": "src/index.ts",
|
|
66
|
+
"out": "index",
|
|
67
|
+
"formats": [
|
|
68
|
+
"esm",
|
|
69
|
+
"cjs"
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
]
|
|
73
|
+
},
|
|
74
|
+
"scripts": {
|
|
75
|
+
"build": "node ../../tooling/build.mjs",
|
|
76
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
77
|
+
"test": "vitest run --coverage"
|
|
78
|
+
}
|
|
6
79
|
}
|