@dispatchitapp/node 1.0.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 +52 -0
- package/dist/index.cjs +263 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +70 -0
- package/dist/index.d.ts +70 -0
- package/dist/index.js +229 -0
- package/dist/index.js.map +1 -0
- package/package.json +59 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Recursivity LLC
|
|
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
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# @dispatchitapp/node
|
|
2
|
+
|
|
3
|
+
The Node.js server SDK for [Dispatch](https://dispatchit.app). Builds on
|
|
4
|
+
[`@dispatchitapp/core`](../core), adding the two things a server needs that the core can't do
|
|
5
|
+
portably:
|
|
6
|
+
|
|
7
|
+
- **V8 stack frames with source context** — in-app frames carry the lines around the failing
|
|
8
|
+
line (`pre_context`/`context_line`/`post_context`), read from disk, exactly like the gem.
|
|
9
|
+
- **Global error handlers** — `process` `uncaughtException` / `unhandledRejection`, the
|
|
10
|
+
runtime-level analogue of the gem's Rack middleware + `Rails.error` subscriber.
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { init, captureException, report } from "@dispatchitapp/node";
|
|
14
|
+
|
|
15
|
+
init({
|
|
16
|
+
apiKey: process.env.DISPATCH_API_KEY!,
|
|
17
|
+
environment: process.env.NODE_ENV,
|
|
18
|
+
release: process.env.GIT_SHA,
|
|
19
|
+
// installGlobalHandlers: true (default) — capture uncaught errors + rejections
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
doRiskyThing();
|
|
24
|
+
} catch (err) {
|
|
25
|
+
captureException(err, { tags: { area: "import" } });
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Framework middleware (Express, Fastify) lands in `@dispatchitapp/express` / `@dispatchitapp/fastify` and
|
|
30
|
+
builds on this package. Everything from `@dispatchitapp/core` is re-exported here, so a Node app
|
|
31
|
+
imports solely from `@dispatchitapp/node`.
|
|
32
|
+
|
|
33
|
+
## Notes
|
|
34
|
+
|
|
35
|
+
- `init` sets `platform: "node"`, the `dispatch-node` SDK identity, and a source-context stack
|
|
36
|
+
parser rooted at `cwd` (default `process.cwd()`). `in_app` = under the project root and not in
|
|
37
|
+
`node_modules` / `node:` internals.
|
|
38
|
+
- The handlers preserve Node's native crash behavior — capture, then print the error and
|
|
39
|
+
`exit(1)` (registering a listener would otherwise suppress both). `uncaughtException`:
|
|
40
|
+
override with `onFatalError` or `exitOnUncaught: false`. `unhandledRejection`: fatal by
|
|
41
|
+
default exactly like Node's own `--unhandled-rejections=throw`; opt out with
|
|
42
|
+
`exitOnUnhandledRejection: false` (this keeps the process alive, which Node alone would not).
|
|
43
|
+
- Shutdown flush: the event queue drains on `beforeExit` and before any fatal exit, budgeted by
|
|
44
|
+
`shutdownTimeout` (ms, default 3000; the gem's `shutdown_timeout` analogue). Disable the
|
|
45
|
+
`beforeExit` hook with `flushOnBeforeExit: false`. Fatal events are tagged
|
|
46
|
+
`source: uncaughtException` / `unhandledRejection` (the gem's `at_exit` / `rake` analogues).
|
|
47
|
+
|
|
48
|
+
## Scripts
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pnpm test · pnpm typecheck · pnpm build
|
|
52
|
+
```
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
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
|
+
CONTEXT_LINES: () => CONTEXT_LINES,
|
|
24
|
+
Client: () => import_core3.Client,
|
|
25
|
+
MAX_CONTEXT_FRAMES: () => MAX_CONTEXT_FRAMES,
|
|
26
|
+
SDK_NAME: () => SDK_NAME,
|
|
27
|
+
SDK_VERSION: () => SDK_VERSION,
|
|
28
|
+
addSourceContext: () => addSourceContext,
|
|
29
|
+
buildEvent: () => import_core3.buildEvent,
|
|
30
|
+
buildTicketPayload: () => import_core3.buildTicketPayload,
|
|
31
|
+
captureException: () => import_core3.captureException,
|
|
32
|
+
clearSourceCache: () => clearSourceCache,
|
|
33
|
+
close: () => import_core3.close,
|
|
34
|
+
extractRequest: () => extractRequest,
|
|
35
|
+
flush: () => import_core3.flush,
|
|
36
|
+
getClient: () => import_core3.getClient,
|
|
37
|
+
init: () => init,
|
|
38
|
+
installGlobalHandlers: () => installGlobalHandlers,
|
|
39
|
+
nodeStackParser: () => nodeStackParser,
|
|
40
|
+
normalizeUser: () => normalizeUser,
|
|
41
|
+
parseStack: () => import_core3.parseStack,
|
|
42
|
+
report: () => import_core3.report,
|
|
43
|
+
resolveConfig: () => import_core3.resolveConfig
|
|
44
|
+
});
|
|
45
|
+
module.exports = __toCommonJS(index_exports);
|
|
46
|
+
var import_core2 = require("@dispatchitapp/core");
|
|
47
|
+
|
|
48
|
+
// src/handlers.ts
|
|
49
|
+
function installGlobalHandlers(client, options = {}) {
|
|
50
|
+
const target = options.target ?? process;
|
|
51
|
+
const exit = options.exit ?? ((code) => process.exit(code));
|
|
52
|
+
const flushTimeout = options.flushTimeout ?? client.config.shutdownTimeout;
|
|
53
|
+
const installed = [];
|
|
54
|
+
const flushThenExit = (err) => {
|
|
55
|
+
if (!options.onFatalError) console.error(err);
|
|
56
|
+
void client.flush(flushTimeout).finally(() => {
|
|
57
|
+
if (options.onFatalError) options.onFatalError(err);
|
|
58
|
+
else exit(1);
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
if (options.captureUncaughtException !== false) {
|
|
62
|
+
const onUncaught = (err) => {
|
|
63
|
+
client.captureException(err, { handled: false, tags: { source: "uncaughtException" } });
|
|
64
|
+
if (options.exitOnUncaught !== false || options.onFatalError) flushThenExit(err);
|
|
65
|
+
};
|
|
66
|
+
target.on("uncaughtException", onUncaught);
|
|
67
|
+
installed.push(["uncaughtException", onUncaught]);
|
|
68
|
+
}
|
|
69
|
+
if (options.captureUnhandledRejections !== false) {
|
|
70
|
+
const onRejection = (reason) => {
|
|
71
|
+
client.captureException(reason, { handled: false, tags: { source: "unhandledRejection" } });
|
|
72
|
+
if (options.exitOnUnhandledRejection !== false || options.onFatalError) flushThenExit(reason);
|
|
73
|
+
};
|
|
74
|
+
target.on("unhandledRejection", onRejection);
|
|
75
|
+
installed.push(["unhandledRejection", onRejection]);
|
|
76
|
+
}
|
|
77
|
+
if (options.flushOnBeforeExit !== false) {
|
|
78
|
+
const onBeforeExit = () => {
|
|
79
|
+
void client.flush(flushTimeout);
|
|
80
|
+
};
|
|
81
|
+
target.on("beforeExit", onBeforeExit);
|
|
82
|
+
installed.push(["beforeExit", onBeforeExit]);
|
|
83
|
+
}
|
|
84
|
+
return () => {
|
|
85
|
+
for (const [event, listener] of installed) target.removeListener(event, listener);
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// src/stack.ts
|
|
90
|
+
var import_node_path = require("path");
|
|
91
|
+
var import_node_url = require("url");
|
|
92
|
+
var import_core = require("@dispatchitapp/core");
|
|
93
|
+
|
|
94
|
+
// src/sourceContext.ts
|
|
95
|
+
var import_node_fs = require("fs");
|
|
96
|
+
var CONTEXT_LINES = 5;
|
|
97
|
+
var MAX_CONTEXT_FRAMES = 12;
|
|
98
|
+
var sourceCache = /* @__PURE__ */ new Map();
|
|
99
|
+
function rstrip(line) {
|
|
100
|
+
return line.replace(/\s+$/, "");
|
|
101
|
+
}
|
|
102
|
+
function sourceLines(path) {
|
|
103
|
+
const cached = sourceCache.get(path);
|
|
104
|
+
if (cached !== void 0) return cached;
|
|
105
|
+
let lines = null;
|
|
106
|
+
try {
|
|
107
|
+
lines = (0, import_node_fs.readFileSync)(path, "utf8").split("\n");
|
|
108
|
+
} catch {
|
|
109
|
+
lines = null;
|
|
110
|
+
}
|
|
111
|
+
sourceCache.set(path, lines);
|
|
112
|
+
return lines;
|
|
113
|
+
}
|
|
114
|
+
function applyContext(frame) {
|
|
115
|
+
const path = frame.abs_path;
|
|
116
|
+
const lineno = frame.lineno;
|
|
117
|
+
if (!path || !lineno) return;
|
|
118
|
+
const lines = sourceLines(path);
|
|
119
|
+
if (!lines) return;
|
|
120
|
+
const idx = lineno - 1;
|
|
121
|
+
if (idx < 0 || idx >= lines.length) return;
|
|
122
|
+
frame.pre_context = lines.slice(Math.max(0, idx - CONTEXT_LINES), idx).map(rstrip);
|
|
123
|
+
frame.context_line = rstrip(lines[idx]);
|
|
124
|
+
frame.post_context = lines.slice(idx + 1, idx + 1 + CONTEXT_LINES).map(rstrip);
|
|
125
|
+
}
|
|
126
|
+
function addSourceContext(frames) {
|
|
127
|
+
let budget = MAX_CONTEXT_FRAMES;
|
|
128
|
+
for (let i = frames.length - 1; i >= 0 && budget > 0; i--) {
|
|
129
|
+
const frame = frames[i];
|
|
130
|
+
if (!frame.in_app) continue;
|
|
131
|
+
budget--;
|
|
132
|
+
applyContext(frame);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function clearSourceCache() {
|
|
136
|
+
sourceCache.clear();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// src/stack.ts
|
|
140
|
+
function underRoot(absPath, root) {
|
|
141
|
+
return absPath === root || absPath.startsWith(root + import_node_path.sep);
|
|
142
|
+
}
|
|
143
|
+
function nodeStackParser(cwd) {
|
|
144
|
+
return (stack) => {
|
|
145
|
+
const frames = (0, import_core.parseStack)(stack);
|
|
146
|
+
for (const frame of frames) {
|
|
147
|
+
let abs = frame.abs_path ?? null;
|
|
148
|
+
if (abs && abs.startsWith("file://")) {
|
|
149
|
+
try {
|
|
150
|
+
abs = (0, import_node_url.fileURLToPath)(abs);
|
|
151
|
+
} catch {
|
|
152
|
+
}
|
|
153
|
+
frame.abs_path = abs;
|
|
154
|
+
}
|
|
155
|
+
if (abs && (0, import_node_path.isAbsolute)(abs) && !abs.includes("node_modules") && underRoot(abs, cwd)) {
|
|
156
|
+
frame.in_app = true;
|
|
157
|
+
frame.filename = (0, import_node_path.relative)(cwd, abs);
|
|
158
|
+
} else {
|
|
159
|
+
frame.in_app = false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
addSourceContext(frames);
|
|
163
|
+
return frames;
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// src/version.ts
|
|
168
|
+
var SDK_NAME = "dispatch-node";
|
|
169
|
+
var SDK_VERSION = "1.0.0";
|
|
170
|
+
|
|
171
|
+
// src/request.ts
|
|
172
|
+
var SAFE_HEADERS = [
|
|
173
|
+
["user-agent", "User-Agent"],
|
|
174
|
+
["referer", "Referer"],
|
|
175
|
+
["accept", "Accept"],
|
|
176
|
+
["content-type", "Content-Type"],
|
|
177
|
+
["host", "Host"],
|
|
178
|
+
["x-request-id", "X-Request-Id"]
|
|
179
|
+
];
|
|
180
|
+
function headerValue(headers, name) {
|
|
181
|
+
const value = headers[name];
|
|
182
|
+
if (Array.isArray(value)) return value[0];
|
|
183
|
+
return value ?? void 0;
|
|
184
|
+
}
|
|
185
|
+
function safeHeaders(headers) {
|
|
186
|
+
const out = {};
|
|
187
|
+
for (const [lower, canonical] of SAFE_HEADERS) {
|
|
188
|
+
const value = headers[lower];
|
|
189
|
+
if (value === void 0) continue;
|
|
190
|
+
out[canonical] = Array.isArray(value) ? value.join(", ") : value;
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
function extractRequest(req) {
|
|
195
|
+
const headers = req.headers ?? {};
|
|
196
|
+
const rawUrl = req.originalUrl ?? req.url ?? "";
|
|
197
|
+
const q = rawUrl.indexOf("?");
|
|
198
|
+
const path = q >= 0 ? rawUrl.slice(0, q) : rawUrl;
|
|
199
|
+
const query = q >= 0 ? rawUrl.slice(q + 1) : void 0;
|
|
200
|
+
const host = headerValue(headers, "host");
|
|
201
|
+
const scheme = req.protocol || headerValue(headers, "x-forwarded-proto") || (req.socket?.encrypted ? "https" : "http");
|
|
202
|
+
const forwarded = headerValue(headers, "x-forwarded-for");
|
|
203
|
+
const ip = req.ip || req.socket?.remoteAddress || (forwarded ? forwarded.split(",")[0].trim() : void 0);
|
|
204
|
+
const out = {};
|
|
205
|
+
const url = host ? `${scheme}://${host}${path}` : path || void 0;
|
|
206
|
+
if (url) out.url = url;
|
|
207
|
+
if (req.method) out.method = req.method;
|
|
208
|
+
if (query) out.query_string = query;
|
|
209
|
+
const hdrs = safeHeaders(headers);
|
|
210
|
+
if (Object.keys(hdrs).length > 0) out.headers = hdrs;
|
|
211
|
+
if (ip) out.env = { REMOTE_ADDR: ip };
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
function normalizeUser(user) {
|
|
215
|
+
if (user === null || typeof user !== "object") return void 0;
|
|
216
|
+
const u = user;
|
|
217
|
+
const id = u["id"] ?? u["external_id"];
|
|
218
|
+
const out = {};
|
|
219
|
+
if (id !== void 0 && id !== null) out.id = String(id);
|
|
220
|
+
if (typeof u["email"] === "string") out.email = u["email"];
|
|
221
|
+
return out.id !== void 0 || out.email !== void 0 ? out : void 0;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// src/index.ts
|
|
225
|
+
var import_core3 = require("@dispatchitapp/core");
|
|
226
|
+
function init(options) {
|
|
227
|
+
const cwd = options.cwd ?? process.cwd();
|
|
228
|
+
const client = (0, import_core2.init)({
|
|
229
|
+
...options,
|
|
230
|
+
platform: options.platform ?? "node",
|
|
231
|
+
sdk: options.sdk ?? { name: SDK_NAME, version: SDK_VERSION },
|
|
232
|
+
parseStack: options.parseStack ?? nodeStackParser(cwd)
|
|
233
|
+
});
|
|
234
|
+
if (options.installGlobalHandlers !== false) {
|
|
235
|
+
installGlobalHandlers(client, options);
|
|
236
|
+
}
|
|
237
|
+
return client;
|
|
238
|
+
}
|
|
239
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
240
|
+
0 && (module.exports = {
|
|
241
|
+
CONTEXT_LINES,
|
|
242
|
+
Client,
|
|
243
|
+
MAX_CONTEXT_FRAMES,
|
|
244
|
+
SDK_NAME,
|
|
245
|
+
SDK_VERSION,
|
|
246
|
+
addSourceContext,
|
|
247
|
+
buildEvent,
|
|
248
|
+
buildTicketPayload,
|
|
249
|
+
captureException,
|
|
250
|
+
clearSourceCache,
|
|
251
|
+
close,
|
|
252
|
+
extractRequest,
|
|
253
|
+
flush,
|
|
254
|
+
getClient,
|
|
255
|
+
init,
|
|
256
|
+
installGlobalHandlers,
|
|
257
|
+
nodeStackParser,
|
|
258
|
+
normalizeUser,
|
|
259
|
+
parseStack,
|
|
260
|
+
report,
|
|
261
|
+
resolveConfig
|
|
262
|
+
});
|
|
263
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/handlers.ts","../src/stack.ts","../src/sourceContext.ts","../src/version.ts","../src/request.ts"],"sourcesContent":["// @dispatchitapp/node — the Node.js server SDK.\n//\n// Adds, on top of @dispatchitapp/core: V8 stack frames with source context (the gem's in-app\n// `pre/context/post_context`), and process-level global error handlers — the runtime-level\n// analogue of the gem's Rack middleware + Rails.error subscriber. Framework middleware\n// (Express/Fastify) builds on this in @dispatchitapp/express / @dispatchitapp/fastify.\n\nimport { type Client, type ClientOptions, init as coreInit } from \"@dispatchitapp/core\";\nimport { type GlobalHandlerOptions, installGlobalHandlers } from \"./handlers\";\nimport { nodeStackParser } from \"./stack\";\nimport { SDK_NAME, SDK_VERSION } from \"./version\";\n\nexport interface NodeOptions extends ClientOptions, GlobalHandlerOptions {\n /** Project root for in_app detection + relative filenames. Default process.cwd(). */\n cwd?: string;\n /** Install process-level uncaught/unhandledRejection handlers. Default true. */\n installGlobalHandlers?: boolean;\n}\n\n// Initialise the default Node client: a \"node\" platform, the dispatch-node SDK identity, the\n// source-context stack parser, and (by default) global handlers. Returns the client.\nexport function init(options: NodeOptions): Client {\n const cwd = options.cwd ?? process.cwd();\n const client = coreInit({\n ...options,\n platform: options.platform ?? \"node\",\n sdk: options.sdk ?? { name: SDK_NAME, version: SDK_VERSION },\n parseStack: options.parseStack ?? nodeStackParser(cwd),\n });\n\n if (options.installGlobalHandlers !== false) {\n installGlobalHandlers(client, options);\n }\n return client;\n}\n\n// Node-specific exports.\nexport { nodeStackParser } from \"./stack\";\nexport { addSourceContext, clearSourceCache, CONTEXT_LINES, MAX_CONTEXT_FRAMES } from \"./sourceContext\";\nexport { installGlobalHandlers } from \"./handlers\";\nexport type { GlobalHandlerOptions, ProcessLike } from \"./handlers\";\nexport { extractRequest, normalizeUser } from \"./request\";\nexport type { HttpRequestLike } from \"./request\";\nexport { SDK_NAME, SDK_VERSION } from \"./version\";\n\n// Re-export the core runtime API so consumers import everything from @dispatchitapp/node.\nexport {\n Client,\n captureException,\n report,\n flush,\n close,\n getClient,\n buildEvent,\n buildTicketPayload,\n parseStack,\n resolveConfig,\n} from \"@dispatchitapp/core\";\nexport type {\n CaptureContext,\n ClientOptions,\n DispatchOptions,\n DispatchConfig,\n DispatchEvent,\n DispatchExceptionValue,\n DispatchFrame,\n DispatchUser,\n DispatchRequest,\n Level,\n ReportInput,\n Tags,\n TicketPayload,\n TicketResponse,\n Transport,\n} from \"@dispatchitapp/core\";\n","import type { Client } from \"@dispatchitapp/core\";\n\ntype Listener = (...args: unknown[]) => void;\n\n// The slice of `process` (or a test double) we need: register and remove listeners.\nexport interface ProcessLike {\n on(event: string, listener: Listener): unknown;\n removeListener(event: string, listener: Listener): unknown;\n}\n\nexport interface GlobalHandlerOptions {\n /** Capture uncaught exceptions. Default true. */\n captureUncaughtException?: boolean;\n /** Capture unhandled promise rejections. Default true. */\n captureUnhandledRejections?: boolean;\n /** After capturing+flushing an uncaught exception, exit(1). Default true (Node would have\n * crashed anyway — continuing leaves the process in an undefined state). */\n exitOnUncaught?: boolean;\n /** After capturing+flushing an unhandled rejection, exit(1). Default true: Node's own\n * default (--unhandled-rejections=throw) crashes the process, and merely installing a\n * listener would silently disable that. We report, then preserve the native outcome. */\n exitOnUnhandledRejection?: boolean;\n /** Drain the event queue when the event loop empties (the at_exit-flush analogue of the\n * gem's shutdown_timeout drain). Default true. */\n flushOnBeforeExit?: boolean;\n /** Flush budget before exit, ms. Default: config.shutdownTimeout (3000). */\n flushTimeout?: number;\n /** Called after flush instead of exiting — for custom shutdown (and tests). When set, the\n * fatal error is not printed to stderr; the callback owns the response. */\n onFatalError?: (err: unknown) => void;\n /** Event target. Defaults to the global process; injectable for tests. */\n target?: ProcessLike;\n /** Exit function. Defaults to process.exit; injectable for tests. */\n exit?: (code: number) => void;\n}\n\n// Wire the client into Node's global error events — the runtime-level analogue of the gem's\n// Rack middleware + at_exit hook. The contract mirrors the gem's: capture, then preserve the\n// runtime's native crash behavior (print the error, exit non-zero) — never swallow.\n// Returns an uninstall function.\nexport function installGlobalHandlers(\n client: Client,\n options: GlobalHandlerOptions = {},\n): () => void {\n const target = options.target ?? (process as unknown as ProcessLike);\n const exit = options.exit ?? ((code: number) => process.exit(code));\n const flushTimeout = options.flushTimeout ?? client.config.shutdownTimeout;\n const installed: Array<[string, Listener]> = [];\n\n // Flush, then hand off to onFatalError or exit(1). Registering a listener suppresses\n // Node's native crash report, so we print the error first to keep the trace visible.\n const flushThenExit = (err: unknown) => {\n if (!options.onFatalError) console.error(err);\n void client.flush(flushTimeout).finally(() => {\n if (options.onFatalError) options.onFatalError(err);\n else exit(1);\n });\n };\n\n if (options.captureUncaughtException !== false) {\n const onUncaught: Listener = (err) => {\n client.captureException(err, { handled: false, tags: { source: \"uncaughtException\" } });\n if (options.exitOnUncaught !== false || options.onFatalError) flushThenExit(err);\n };\n target.on(\"uncaughtException\", onUncaught);\n installed.push([\"uncaughtException\", onUncaught]);\n }\n\n if (options.captureUnhandledRejections !== false) {\n const onRejection: Listener = (reason) => {\n client.captureException(reason, { handled: false, tags: { source: \"unhandledRejection\" } });\n if (options.exitOnUnhandledRejection !== false || options.onFatalError) flushThenExit(reason);\n };\n target.on(\"unhandledRejection\", onRejection);\n installed.push([\"unhandledRejection\", onRejection]);\n }\n\n if (options.flushOnBeforeExit !== false) {\n // A pending fetch keeps the loop alive, so this is mostly insurance for custom\n // transports — and the closest Node analogue of the gem's at_exit queue drain.\n const onBeforeExit: Listener = () => {\n void client.flush(flushTimeout);\n };\n target.on(\"beforeExit\", onBeforeExit);\n installed.push([\"beforeExit\", onBeforeExit]);\n }\n\n return () => {\n for (const [event, listener] of installed) target.removeListener(event, listener);\n };\n}\n","import { isAbsolute, relative, sep } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { parseStack as coreParseStack, type DispatchFrame } from \"@dispatchitapp/core\";\nimport { addSourceContext } from \"./sourceContext\";\n\nfunction underRoot(absPath: string, root: string): boolean {\n return absPath === root || absPath.startsWith(root + sep);\n}\n\n// A parseStack tailored to a Node server: starts from core's portable V8 parse, then resolves\n// file:// URLs, recomputes in_app/filename against the project root, and reads source context\n// for in-app frames. Mirrors the gem's EventBuilder frame handling (relative filename, in_app,\n// pre/context/post). Pass the result as the Client's `parseStack`.\nexport function nodeStackParser(cwd: string): (stack?: string | null) => DispatchFrame[] {\n return (stack?: string | null): DispatchFrame[] => {\n const frames = coreParseStack(stack);\n\n for (const frame of frames) {\n let abs = frame.abs_path ?? null;\n if (abs && abs.startsWith(\"file://\")) {\n try {\n abs = fileURLToPath(abs);\n } catch {\n /* keep the original */\n }\n frame.abs_path = abs;\n }\n\n if (abs && isAbsolute(abs) && !abs.includes(\"node_modules\") && underRoot(abs, cwd)) {\n frame.in_app = true;\n frame.filename = relative(cwd, abs);\n } else {\n // node: internals, dependencies, or paths outside the project are not app code.\n frame.in_app = false;\n }\n }\n\n addSourceContext(frames);\n return frames;\n };\n}\n","import { readFileSync } from \"node:fs\";\nimport type { DispatchFrame } from \"@dispatchitapp/core\";\n\nexport const CONTEXT_LINES = 5;\nexport const MAX_CONTEXT_FRAMES = 12;\n\n// Cache file contents per path for the process lifetime — a single request's stack often\n// revisits the same file, and source doesn't change under a running process.\nconst sourceCache = new Map<string, string[] | null>();\n\nfunction rstrip(line: string): string {\n return line.replace(/\\s+$/, \"\");\n}\n\nfunction sourceLines(path: string): string[] | null {\n const cached = sourceCache.get(path);\n if (cached !== undefined) return cached;\n let lines: string[] | null = null;\n try {\n lines = readFileSync(path, \"utf8\").split(\"\\n\");\n } catch {\n lines = null;\n }\n sourceCache.set(path, lines);\n return lines;\n}\n\nfunction applyContext(frame: DispatchFrame): void {\n const path = frame.abs_path;\n const lineno = frame.lineno;\n if (!path || !lineno) return;\n const lines = sourceLines(path);\n if (!lines) return;\n const idx = lineno - 1;\n if (idx < 0 || idx >= lines.length) return;\n frame.pre_context = lines.slice(Math.max(0, idx - CONTEXT_LINES), idx).map(rstrip);\n frame.context_line = rstrip(lines[idx]!);\n frame.post_context = lines.slice(idx + 1, idx + 1 + CONTEXT_LINES).map(rstrip);\n}\n\n// Add source context to the most-recent in-app frames (the tail of the oldest-first array),\n// within a per-event budget. Mirrors EventBuilder#annotate_context.\nexport function addSourceContext(frames: DispatchFrame[]): void {\n let budget = MAX_CONTEXT_FRAMES;\n for (let i = frames.length - 1; i >= 0 && budget > 0; i--) {\n const frame = frames[i]!;\n if (!frame.in_app) continue;\n budget--;\n applyContext(frame);\n }\n}\n\n// Exposed for tests so a fixture file's freshly-written contents aren't masked by the cache.\nexport function clearSourceCache(): void {\n sourceCache.clear();\n}\n","export const SDK_NAME = \"dispatch-node\";\nexport const SDK_VERSION = \"1.0.0\";\n","import type { DispatchRequest, DispatchUser } from \"@dispatchitapp/core\";\n\n// The header allow-list, lowercased (Node lowercases header keys) → canonical wire name.\n// Same set as the gem's EventBuilder::SAFE_HEADERS.\nconst SAFE_HEADERS: Array<[string, string]> = [\n [\"user-agent\", \"User-Agent\"],\n [\"referer\", \"Referer\"],\n [\"accept\", \"Accept\"],\n [\"content-type\", \"Content-Type\"],\n [\"host\", \"Host\"],\n [\"x-request-id\", \"X-Request-Id\"],\n];\n\ntype HeaderBag = Record<string, string | string[] | undefined>;\n\n// The slice of a Node/Express/Fastify request we read. Kept structural so we depend on no\n// framework types.\nexport interface HttpRequestLike {\n method?: string;\n url?: string;\n originalUrl?: string;\n headers?: HeaderBag;\n protocol?: string;\n ip?: string;\n socket?: { remoteAddress?: string; encrypted?: boolean };\n}\n\nfunction headerValue(headers: HeaderBag, name: string): string | undefined {\n const value = headers[name];\n if (Array.isArray(value)) return value[0];\n return value ?? undefined;\n}\n\nfunction safeHeaders(headers: HeaderBag): Record<string, string> {\n const out: Record<string, string> = {};\n for (const [lower, canonical] of SAFE_HEADERS) {\n const value = headers[lower];\n if (value === undefined) continue;\n out[canonical] = Array.isArray(value) ? value.join(\", \") : value;\n }\n return out;\n}\n\n// Build the contract `request` object from a Node-style request. Best-effort and never throws;\n// only allow-listed headers are included. Mirrors EventBuilder#request_hash.\nexport function extractRequest(req: HttpRequestLike): DispatchRequest {\n const headers = req.headers ?? {};\n const rawUrl = req.originalUrl ?? req.url ?? \"\";\n const q = rawUrl.indexOf(\"?\");\n const path = q >= 0 ? rawUrl.slice(0, q) : rawUrl;\n const query = q >= 0 ? rawUrl.slice(q + 1) : undefined;\n\n const host = headerValue(headers, \"host\");\n const scheme =\n req.protocol ||\n headerValue(headers, \"x-forwarded-proto\") ||\n (req.socket?.encrypted ? \"https\" : \"http\");\n const forwarded = headerValue(headers, \"x-forwarded-for\");\n const ip =\n req.ip ||\n req.socket?.remoteAddress ||\n (forwarded ? forwarded.split(\",\")[0]!.trim() : undefined);\n\n const out: DispatchRequest = {};\n const url = host ? `${scheme}://${host}${path}` : path || undefined;\n if (url) out.url = url;\n if (req.method) out.method = req.method;\n if (query) out.query_string = query;\n const hdrs = safeHeaders(headers);\n if (Object.keys(hdrs).length > 0) out.headers = hdrs;\n if (ip) out.env = { REMOTE_ADDR: ip };\n return out;\n}\n\n// Normalise a resolved user object (from a `user` callback or req.user) into the contract's\n// { id, email } shape — accepting both `id` and the gem's `external_id`. Returns undefined when\n// there's nothing usable, so the event falls back to the configured user.\nexport function normalizeUser(user: unknown): DispatchUser | undefined {\n if (user === null || typeof user !== \"object\") return undefined;\n const u = user as Record<string, unknown>;\n const id = u[\"id\"] ?? u[\"external_id\"];\n const out: DispatchUser = {};\n if (id !== undefined && id !== null) out.id = String(id);\n if (typeof u[\"email\"] === \"string\") out.email = u[\"email\"];\n return out.id !== undefined || out.email !== undefined ? out : undefined;\n}\n"],"mappings":"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","names":["import_core","coreParseStack","import_core","coreInit"]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Client, DispatchFrame, DispatchRequest, DispatchUser, ClientOptions } from '@dispatchitapp/core';
|
|
2
|
+
export { CaptureContext, Client, ClientOptions, DispatchConfig, DispatchEvent, DispatchExceptionValue, DispatchFrame, DispatchOptions, DispatchRequest, DispatchUser, Level, ReportInput, Tags, TicketPayload, TicketResponse, Transport, buildEvent, buildTicketPayload, captureException, close, flush, getClient, parseStack, report, resolveConfig } from '@dispatchitapp/core';
|
|
3
|
+
|
|
4
|
+
type Listener = (...args: unknown[]) => void;
|
|
5
|
+
interface ProcessLike {
|
|
6
|
+
on(event: string, listener: Listener): unknown;
|
|
7
|
+
removeListener(event: string, listener: Listener): unknown;
|
|
8
|
+
}
|
|
9
|
+
interface GlobalHandlerOptions {
|
|
10
|
+
/** Capture uncaught exceptions. Default true. */
|
|
11
|
+
captureUncaughtException?: boolean;
|
|
12
|
+
/** Capture unhandled promise rejections. Default true. */
|
|
13
|
+
captureUnhandledRejections?: boolean;
|
|
14
|
+
/** After capturing+flushing an uncaught exception, exit(1). Default true (Node would have
|
|
15
|
+
* crashed anyway — continuing leaves the process in an undefined state). */
|
|
16
|
+
exitOnUncaught?: boolean;
|
|
17
|
+
/** After capturing+flushing an unhandled rejection, exit(1). Default true: Node's own
|
|
18
|
+
* default (--unhandled-rejections=throw) crashes the process, and merely installing a
|
|
19
|
+
* listener would silently disable that. We report, then preserve the native outcome. */
|
|
20
|
+
exitOnUnhandledRejection?: boolean;
|
|
21
|
+
/** Drain the event queue when the event loop empties (the at_exit-flush analogue of the
|
|
22
|
+
* gem's shutdown_timeout drain). Default true. */
|
|
23
|
+
flushOnBeforeExit?: boolean;
|
|
24
|
+
/** Flush budget before exit, ms. Default: config.shutdownTimeout (3000). */
|
|
25
|
+
flushTimeout?: number;
|
|
26
|
+
/** Called after flush instead of exiting — for custom shutdown (and tests). When set, the
|
|
27
|
+
* fatal error is not printed to stderr; the callback owns the response. */
|
|
28
|
+
onFatalError?: (err: unknown) => void;
|
|
29
|
+
/** Event target. Defaults to the global process; injectable for tests. */
|
|
30
|
+
target?: ProcessLike;
|
|
31
|
+
/** Exit function. Defaults to process.exit; injectable for tests. */
|
|
32
|
+
exit?: (code: number) => void;
|
|
33
|
+
}
|
|
34
|
+
declare function installGlobalHandlers(client: Client, options?: GlobalHandlerOptions): () => void;
|
|
35
|
+
|
|
36
|
+
declare function nodeStackParser(cwd: string): (stack?: string | null) => DispatchFrame[];
|
|
37
|
+
|
|
38
|
+
declare const CONTEXT_LINES = 5;
|
|
39
|
+
declare const MAX_CONTEXT_FRAMES = 12;
|
|
40
|
+
declare function addSourceContext(frames: DispatchFrame[]): void;
|
|
41
|
+
declare function clearSourceCache(): void;
|
|
42
|
+
|
|
43
|
+
type HeaderBag = Record<string, string | string[] | undefined>;
|
|
44
|
+
interface HttpRequestLike {
|
|
45
|
+
method?: string;
|
|
46
|
+
url?: string;
|
|
47
|
+
originalUrl?: string;
|
|
48
|
+
headers?: HeaderBag;
|
|
49
|
+
protocol?: string;
|
|
50
|
+
ip?: string;
|
|
51
|
+
socket?: {
|
|
52
|
+
remoteAddress?: string;
|
|
53
|
+
encrypted?: boolean;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
declare function extractRequest(req: HttpRequestLike): DispatchRequest;
|
|
57
|
+
declare function normalizeUser(user: unknown): DispatchUser | undefined;
|
|
58
|
+
|
|
59
|
+
declare const SDK_NAME = "dispatch-node";
|
|
60
|
+
declare const SDK_VERSION = "1.0.0";
|
|
61
|
+
|
|
62
|
+
interface NodeOptions extends ClientOptions, GlobalHandlerOptions {
|
|
63
|
+
/** Project root for in_app detection + relative filenames. Default process.cwd(). */
|
|
64
|
+
cwd?: string;
|
|
65
|
+
/** Install process-level uncaught/unhandledRejection handlers. Default true. */
|
|
66
|
+
installGlobalHandlers?: boolean;
|
|
67
|
+
}
|
|
68
|
+
declare function init(options: NodeOptions): Client;
|
|
69
|
+
|
|
70
|
+
export { CONTEXT_LINES, type GlobalHandlerOptions, type HttpRequestLike, MAX_CONTEXT_FRAMES, type NodeOptions, type ProcessLike, SDK_NAME, SDK_VERSION, addSourceContext, clearSourceCache, extractRequest, init, installGlobalHandlers, nodeStackParser, normalizeUser };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Client, DispatchFrame, DispatchRequest, DispatchUser, ClientOptions } from '@dispatchitapp/core';
|
|
2
|
+
export { CaptureContext, Client, ClientOptions, DispatchConfig, DispatchEvent, DispatchExceptionValue, DispatchFrame, DispatchOptions, DispatchRequest, DispatchUser, Level, ReportInput, Tags, TicketPayload, TicketResponse, Transport, buildEvent, buildTicketPayload, captureException, close, flush, getClient, parseStack, report, resolveConfig } from '@dispatchitapp/core';
|
|
3
|
+
|
|
4
|
+
type Listener = (...args: unknown[]) => void;
|
|
5
|
+
interface ProcessLike {
|
|
6
|
+
on(event: string, listener: Listener): unknown;
|
|
7
|
+
removeListener(event: string, listener: Listener): unknown;
|
|
8
|
+
}
|
|
9
|
+
interface GlobalHandlerOptions {
|
|
10
|
+
/** Capture uncaught exceptions. Default true. */
|
|
11
|
+
captureUncaughtException?: boolean;
|
|
12
|
+
/** Capture unhandled promise rejections. Default true. */
|
|
13
|
+
captureUnhandledRejections?: boolean;
|
|
14
|
+
/** After capturing+flushing an uncaught exception, exit(1). Default true (Node would have
|
|
15
|
+
* crashed anyway — continuing leaves the process in an undefined state). */
|
|
16
|
+
exitOnUncaught?: boolean;
|
|
17
|
+
/** After capturing+flushing an unhandled rejection, exit(1). Default true: Node's own
|
|
18
|
+
* default (--unhandled-rejections=throw) crashes the process, and merely installing a
|
|
19
|
+
* listener would silently disable that. We report, then preserve the native outcome. */
|
|
20
|
+
exitOnUnhandledRejection?: boolean;
|
|
21
|
+
/** Drain the event queue when the event loop empties (the at_exit-flush analogue of the
|
|
22
|
+
* gem's shutdown_timeout drain). Default true. */
|
|
23
|
+
flushOnBeforeExit?: boolean;
|
|
24
|
+
/** Flush budget before exit, ms. Default: config.shutdownTimeout (3000). */
|
|
25
|
+
flushTimeout?: number;
|
|
26
|
+
/** Called after flush instead of exiting — for custom shutdown (and tests). When set, the
|
|
27
|
+
* fatal error is not printed to stderr; the callback owns the response. */
|
|
28
|
+
onFatalError?: (err: unknown) => void;
|
|
29
|
+
/** Event target. Defaults to the global process; injectable for tests. */
|
|
30
|
+
target?: ProcessLike;
|
|
31
|
+
/** Exit function. Defaults to process.exit; injectable for tests. */
|
|
32
|
+
exit?: (code: number) => void;
|
|
33
|
+
}
|
|
34
|
+
declare function installGlobalHandlers(client: Client, options?: GlobalHandlerOptions): () => void;
|
|
35
|
+
|
|
36
|
+
declare function nodeStackParser(cwd: string): (stack?: string | null) => DispatchFrame[];
|
|
37
|
+
|
|
38
|
+
declare const CONTEXT_LINES = 5;
|
|
39
|
+
declare const MAX_CONTEXT_FRAMES = 12;
|
|
40
|
+
declare function addSourceContext(frames: DispatchFrame[]): void;
|
|
41
|
+
declare function clearSourceCache(): void;
|
|
42
|
+
|
|
43
|
+
type HeaderBag = Record<string, string | string[] | undefined>;
|
|
44
|
+
interface HttpRequestLike {
|
|
45
|
+
method?: string;
|
|
46
|
+
url?: string;
|
|
47
|
+
originalUrl?: string;
|
|
48
|
+
headers?: HeaderBag;
|
|
49
|
+
protocol?: string;
|
|
50
|
+
ip?: string;
|
|
51
|
+
socket?: {
|
|
52
|
+
remoteAddress?: string;
|
|
53
|
+
encrypted?: boolean;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
declare function extractRequest(req: HttpRequestLike): DispatchRequest;
|
|
57
|
+
declare function normalizeUser(user: unknown): DispatchUser | undefined;
|
|
58
|
+
|
|
59
|
+
declare const SDK_NAME = "dispatch-node";
|
|
60
|
+
declare const SDK_VERSION = "1.0.0";
|
|
61
|
+
|
|
62
|
+
interface NodeOptions extends ClientOptions, GlobalHandlerOptions {
|
|
63
|
+
/** Project root for in_app detection + relative filenames. Default process.cwd(). */
|
|
64
|
+
cwd?: string;
|
|
65
|
+
/** Install process-level uncaught/unhandledRejection handlers. Default true. */
|
|
66
|
+
installGlobalHandlers?: boolean;
|
|
67
|
+
}
|
|
68
|
+
declare function init(options: NodeOptions): Client;
|
|
69
|
+
|
|
70
|
+
export { CONTEXT_LINES, type GlobalHandlerOptions, type HttpRequestLike, MAX_CONTEXT_FRAMES, type NodeOptions, type ProcessLike, SDK_NAME, SDK_VERSION, addSourceContext, clearSourceCache, extractRequest, init, installGlobalHandlers, nodeStackParser, normalizeUser };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { init as coreInit } from "@dispatchitapp/core";
|
|
3
|
+
|
|
4
|
+
// src/handlers.ts
|
|
5
|
+
function installGlobalHandlers(client, options = {}) {
|
|
6
|
+
const target = options.target ?? process;
|
|
7
|
+
const exit = options.exit ?? ((code) => process.exit(code));
|
|
8
|
+
const flushTimeout = options.flushTimeout ?? client.config.shutdownTimeout;
|
|
9
|
+
const installed = [];
|
|
10
|
+
const flushThenExit = (err) => {
|
|
11
|
+
if (!options.onFatalError) console.error(err);
|
|
12
|
+
void client.flush(flushTimeout).finally(() => {
|
|
13
|
+
if (options.onFatalError) options.onFatalError(err);
|
|
14
|
+
else exit(1);
|
|
15
|
+
});
|
|
16
|
+
};
|
|
17
|
+
if (options.captureUncaughtException !== false) {
|
|
18
|
+
const onUncaught = (err) => {
|
|
19
|
+
client.captureException(err, { handled: false, tags: { source: "uncaughtException" } });
|
|
20
|
+
if (options.exitOnUncaught !== false || options.onFatalError) flushThenExit(err);
|
|
21
|
+
};
|
|
22
|
+
target.on("uncaughtException", onUncaught);
|
|
23
|
+
installed.push(["uncaughtException", onUncaught]);
|
|
24
|
+
}
|
|
25
|
+
if (options.captureUnhandledRejections !== false) {
|
|
26
|
+
const onRejection = (reason) => {
|
|
27
|
+
client.captureException(reason, { handled: false, tags: { source: "unhandledRejection" } });
|
|
28
|
+
if (options.exitOnUnhandledRejection !== false || options.onFatalError) flushThenExit(reason);
|
|
29
|
+
};
|
|
30
|
+
target.on("unhandledRejection", onRejection);
|
|
31
|
+
installed.push(["unhandledRejection", onRejection]);
|
|
32
|
+
}
|
|
33
|
+
if (options.flushOnBeforeExit !== false) {
|
|
34
|
+
const onBeforeExit = () => {
|
|
35
|
+
void client.flush(flushTimeout);
|
|
36
|
+
};
|
|
37
|
+
target.on("beforeExit", onBeforeExit);
|
|
38
|
+
installed.push(["beforeExit", onBeforeExit]);
|
|
39
|
+
}
|
|
40
|
+
return () => {
|
|
41
|
+
for (const [event, listener] of installed) target.removeListener(event, listener);
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// src/stack.ts
|
|
46
|
+
import { isAbsolute, relative, sep } from "path";
|
|
47
|
+
import { fileURLToPath } from "url";
|
|
48
|
+
import { parseStack as coreParseStack } from "@dispatchitapp/core";
|
|
49
|
+
|
|
50
|
+
// src/sourceContext.ts
|
|
51
|
+
import { readFileSync } from "fs";
|
|
52
|
+
var CONTEXT_LINES = 5;
|
|
53
|
+
var MAX_CONTEXT_FRAMES = 12;
|
|
54
|
+
var sourceCache = /* @__PURE__ */ new Map();
|
|
55
|
+
function rstrip(line) {
|
|
56
|
+
return line.replace(/\s+$/, "");
|
|
57
|
+
}
|
|
58
|
+
function sourceLines(path) {
|
|
59
|
+
const cached = sourceCache.get(path);
|
|
60
|
+
if (cached !== void 0) return cached;
|
|
61
|
+
let lines = null;
|
|
62
|
+
try {
|
|
63
|
+
lines = readFileSync(path, "utf8").split("\n");
|
|
64
|
+
} catch {
|
|
65
|
+
lines = null;
|
|
66
|
+
}
|
|
67
|
+
sourceCache.set(path, lines);
|
|
68
|
+
return lines;
|
|
69
|
+
}
|
|
70
|
+
function applyContext(frame) {
|
|
71
|
+
const path = frame.abs_path;
|
|
72
|
+
const lineno = frame.lineno;
|
|
73
|
+
if (!path || !lineno) return;
|
|
74
|
+
const lines = sourceLines(path);
|
|
75
|
+
if (!lines) return;
|
|
76
|
+
const idx = lineno - 1;
|
|
77
|
+
if (idx < 0 || idx >= lines.length) return;
|
|
78
|
+
frame.pre_context = lines.slice(Math.max(0, idx - CONTEXT_LINES), idx).map(rstrip);
|
|
79
|
+
frame.context_line = rstrip(lines[idx]);
|
|
80
|
+
frame.post_context = lines.slice(idx + 1, idx + 1 + CONTEXT_LINES).map(rstrip);
|
|
81
|
+
}
|
|
82
|
+
function addSourceContext(frames) {
|
|
83
|
+
let budget = MAX_CONTEXT_FRAMES;
|
|
84
|
+
for (let i = frames.length - 1; i >= 0 && budget > 0; i--) {
|
|
85
|
+
const frame = frames[i];
|
|
86
|
+
if (!frame.in_app) continue;
|
|
87
|
+
budget--;
|
|
88
|
+
applyContext(frame);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function clearSourceCache() {
|
|
92
|
+
sourceCache.clear();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// src/stack.ts
|
|
96
|
+
function underRoot(absPath, root) {
|
|
97
|
+
return absPath === root || absPath.startsWith(root + sep);
|
|
98
|
+
}
|
|
99
|
+
function nodeStackParser(cwd) {
|
|
100
|
+
return (stack) => {
|
|
101
|
+
const frames = coreParseStack(stack);
|
|
102
|
+
for (const frame of frames) {
|
|
103
|
+
let abs = frame.abs_path ?? null;
|
|
104
|
+
if (abs && abs.startsWith("file://")) {
|
|
105
|
+
try {
|
|
106
|
+
abs = fileURLToPath(abs);
|
|
107
|
+
} catch {
|
|
108
|
+
}
|
|
109
|
+
frame.abs_path = abs;
|
|
110
|
+
}
|
|
111
|
+
if (abs && isAbsolute(abs) && !abs.includes("node_modules") && underRoot(abs, cwd)) {
|
|
112
|
+
frame.in_app = true;
|
|
113
|
+
frame.filename = relative(cwd, abs);
|
|
114
|
+
} else {
|
|
115
|
+
frame.in_app = false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
addSourceContext(frames);
|
|
119
|
+
return frames;
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// src/version.ts
|
|
124
|
+
var SDK_NAME = "dispatch-node";
|
|
125
|
+
var SDK_VERSION = "1.0.0";
|
|
126
|
+
|
|
127
|
+
// src/request.ts
|
|
128
|
+
var SAFE_HEADERS = [
|
|
129
|
+
["user-agent", "User-Agent"],
|
|
130
|
+
["referer", "Referer"],
|
|
131
|
+
["accept", "Accept"],
|
|
132
|
+
["content-type", "Content-Type"],
|
|
133
|
+
["host", "Host"],
|
|
134
|
+
["x-request-id", "X-Request-Id"]
|
|
135
|
+
];
|
|
136
|
+
function headerValue(headers, name) {
|
|
137
|
+
const value = headers[name];
|
|
138
|
+
if (Array.isArray(value)) return value[0];
|
|
139
|
+
return value ?? void 0;
|
|
140
|
+
}
|
|
141
|
+
function safeHeaders(headers) {
|
|
142
|
+
const out = {};
|
|
143
|
+
for (const [lower, canonical] of SAFE_HEADERS) {
|
|
144
|
+
const value = headers[lower];
|
|
145
|
+
if (value === void 0) continue;
|
|
146
|
+
out[canonical] = Array.isArray(value) ? value.join(", ") : value;
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
function extractRequest(req) {
|
|
151
|
+
const headers = req.headers ?? {};
|
|
152
|
+
const rawUrl = req.originalUrl ?? req.url ?? "";
|
|
153
|
+
const q = rawUrl.indexOf("?");
|
|
154
|
+
const path = q >= 0 ? rawUrl.slice(0, q) : rawUrl;
|
|
155
|
+
const query = q >= 0 ? rawUrl.slice(q + 1) : void 0;
|
|
156
|
+
const host = headerValue(headers, "host");
|
|
157
|
+
const scheme = req.protocol || headerValue(headers, "x-forwarded-proto") || (req.socket?.encrypted ? "https" : "http");
|
|
158
|
+
const forwarded = headerValue(headers, "x-forwarded-for");
|
|
159
|
+
const ip = req.ip || req.socket?.remoteAddress || (forwarded ? forwarded.split(",")[0].trim() : void 0);
|
|
160
|
+
const out = {};
|
|
161
|
+
const url = host ? `${scheme}://${host}${path}` : path || void 0;
|
|
162
|
+
if (url) out.url = url;
|
|
163
|
+
if (req.method) out.method = req.method;
|
|
164
|
+
if (query) out.query_string = query;
|
|
165
|
+
const hdrs = safeHeaders(headers);
|
|
166
|
+
if (Object.keys(hdrs).length > 0) out.headers = hdrs;
|
|
167
|
+
if (ip) out.env = { REMOTE_ADDR: ip };
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
function normalizeUser(user) {
|
|
171
|
+
if (user === null || typeof user !== "object") return void 0;
|
|
172
|
+
const u = user;
|
|
173
|
+
const id = u["id"] ?? u["external_id"];
|
|
174
|
+
const out = {};
|
|
175
|
+
if (id !== void 0 && id !== null) out.id = String(id);
|
|
176
|
+
if (typeof u["email"] === "string") out.email = u["email"];
|
|
177
|
+
return out.id !== void 0 || out.email !== void 0 ? out : void 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// src/index.ts
|
|
181
|
+
import {
|
|
182
|
+
Client,
|
|
183
|
+
captureException,
|
|
184
|
+
report,
|
|
185
|
+
flush,
|
|
186
|
+
close,
|
|
187
|
+
getClient,
|
|
188
|
+
buildEvent,
|
|
189
|
+
buildTicketPayload,
|
|
190
|
+
parseStack,
|
|
191
|
+
resolveConfig
|
|
192
|
+
} from "@dispatchitapp/core";
|
|
193
|
+
function init(options) {
|
|
194
|
+
const cwd = options.cwd ?? process.cwd();
|
|
195
|
+
const client = coreInit({
|
|
196
|
+
...options,
|
|
197
|
+
platform: options.platform ?? "node",
|
|
198
|
+
sdk: options.sdk ?? { name: SDK_NAME, version: SDK_VERSION },
|
|
199
|
+
parseStack: options.parseStack ?? nodeStackParser(cwd)
|
|
200
|
+
});
|
|
201
|
+
if (options.installGlobalHandlers !== false) {
|
|
202
|
+
installGlobalHandlers(client, options);
|
|
203
|
+
}
|
|
204
|
+
return client;
|
|
205
|
+
}
|
|
206
|
+
export {
|
|
207
|
+
CONTEXT_LINES,
|
|
208
|
+
Client,
|
|
209
|
+
MAX_CONTEXT_FRAMES,
|
|
210
|
+
SDK_NAME,
|
|
211
|
+
SDK_VERSION,
|
|
212
|
+
addSourceContext,
|
|
213
|
+
buildEvent,
|
|
214
|
+
buildTicketPayload,
|
|
215
|
+
captureException,
|
|
216
|
+
clearSourceCache,
|
|
217
|
+
close,
|
|
218
|
+
extractRequest,
|
|
219
|
+
flush,
|
|
220
|
+
getClient,
|
|
221
|
+
init,
|
|
222
|
+
installGlobalHandlers,
|
|
223
|
+
nodeStackParser,
|
|
224
|
+
normalizeUser,
|
|
225
|
+
parseStack,
|
|
226
|
+
report,
|
|
227
|
+
resolveConfig
|
|
228
|
+
};
|
|
229
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/handlers.ts","../src/stack.ts","../src/sourceContext.ts","../src/version.ts","../src/request.ts"],"sourcesContent":["// @dispatchitapp/node — the Node.js server SDK.\n//\n// Adds, on top of @dispatchitapp/core: V8 stack frames with source context (the gem's in-app\n// `pre/context/post_context`), and process-level global error handlers — the runtime-level\n// analogue of the gem's Rack middleware + Rails.error subscriber. Framework middleware\n// (Express/Fastify) builds on this in @dispatchitapp/express / @dispatchitapp/fastify.\n\nimport { type Client, type ClientOptions, init as coreInit } from \"@dispatchitapp/core\";\nimport { type GlobalHandlerOptions, installGlobalHandlers } from \"./handlers\";\nimport { nodeStackParser } from \"./stack\";\nimport { SDK_NAME, SDK_VERSION } from \"./version\";\n\nexport interface NodeOptions extends ClientOptions, GlobalHandlerOptions {\n /** Project root for in_app detection + relative filenames. Default process.cwd(). */\n cwd?: string;\n /** Install process-level uncaught/unhandledRejection handlers. Default true. */\n installGlobalHandlers?: boolean;\n}\n\n// Initialise the default Node client: a \"node\" platform, the dispatch-node SDK identity, the\n// source-context stack parser, and (by default) global handlers. Returns the client.\nexport function init(options: NodeOptions): Client {\n const cwd = options.cwd ?? process.cwd();\n const client = coreInit({\n ...options,\n platform: options.platform ?? \"node\",\n sdk: options.sdk ?? { name: SDK_NAME, version: SDK_VERSION },\n parseStack: options.parseStack ?? nodeStackParser(cwd),\n });\n\n if (options.installGlobalHandlers !== false) {\n installGlobalHandlers(client, options);\n }\n return client;\n}\n\n// Node-specific exports.\nexport { nodeStackParser } from \"./stack\";\nexport { addSourceContext, clearSourceCache, CONTEXT_LINES, MAX_CONTEXT_FRAMES } from \"./sourceContext\";\nexport { installGlobalHandlers } from \"./handlers\";\nexport type { GlobalHandlerOptions, ProcessLike } from \"./handlers\";\nexport { extractRequest, normalizeUser } from \"./request\";\nexport type { HttpRequestLike } from \"./request\";\nexport { SDK_NAME, SDK_VERSION } from \"./version\";\n\n// Re-export the core runtime API so consumers import everything from @dispatchitapp/node.\nexport {\n Client,\n captureException,\n report,\n flush,\n close,\n getClient,\n buildEvent,\n buildTicketPayload,\n parseStack,\n resolveConfig,\n} from \"@dispatchitapp/core\";\nexport type {\n CaptureContext,\n ClientOptions,\n DispatchOptions,\n DispatchConfig,\n DispatchEvent,\n DispatchExceptionValue,\n DispatchFrame,\n DispatchUser,\n DispatchRequest,\n Level,\n ReportInput,\n Tags,\n TicketPayload,\n TicketResponse,\n Transport,\n} from \"@dispatchitapp/core\";\n","import type { Client } from \"@dispatchitapp/core\";\n\ntype Listener = (...args: unknown[]) => void;\n\n// The slice of `process` (or a test double) we need: register and remove listeners.\nexport interface ProcessLike {\n on(event: string, listener: Listener): unknown;\n removeListener(event: string, listener: Listener): unknown;\n}\n\nexport interface GlobalHandlerOptions {\n /** Capture uncaught exceptions. Default true. */\n captureUncaughtException?: boolean;\n /** Capture unhandled promise rejections. Default true. */\n captureUnhandledRejections?: boolean;\n /** After capturing+flushing an uncaught exception, exit(1). Default true (Node would have\n * crashed anyway — continuing leaves the process in an undefined state). */\n exitOnUncaught?: boolean;\n /** After capturing+flushing an unhandled rejection, exit(1). Default true: Node's own\n * default (--unhandled-rejections=throw) crashes the process, and merely installing a\n * listener would silently disable that. We report, then preserve the native outcome. */\n exitOnUnhandledRejection?: boolean;\n /** Drain the event queue when the event loop empties (the at_exit-flush analogue of the\n * gem's shutdown_timeout drain). Default true. */\n flushOnBeforeExit?: boolean;\n /** Flush budget before exit, ms. Default: config.shutdownTimeout (3000). */\n flushTimeout?: number;\n /** Called after flush instead of exiting — for custom shutdown (and tests). When set, the\n * fatal error is not printed to stderr; the callback owns the response. */\n onFatalError?: (err: unknown) => void;\n /** Event target. Defaults to the global process; injectable for tests. */\n target?: ProcessLike;\n /** Exit function. Defaults to process.exit; injectable for tests. */\n exit?: (code: number) => void;\n}\n\n// Wire the client into Node's global error events — the runtime-level analogue of the gem's\n// Rack middleware + at_exit hook. The contract mirrors the gem's: capture, then preserve the\n// runtime's native crash behavior (print the error, exit non-zero) — never swallow.\n// Returns an uninstall function.\nexport function installGlobalHandlers(\n client: Client,\n options: GlobalHandlerOptions = {},\n): () => void {\n const target = options.target ?? (process as unknown as ProcessLike);\n const exit = options.exit ?? ((code: number) => process.exit(code));\n const flushTimeout = options.flushTimeout ?? client.config.shutdownTimeout;\n const installed: Array<[string, Listener]> = [];\n\n // Flush, then hand off to onFatalError or exit(1). Registering a listener suppresses\n // Node's native crash report, so we print the error first to keep the trace visible.\n const flushThenExit = (err: unknown) => {\n if (!options.onFatalError) console.error(err);\n void client.flush(flushTimeout).finally(() => {\n if (options.onFatalError) options.onFatalError(err);\n else exit(1);\n });\n };\n\n if (options.captureUncaughtException !== false) {\n const onUncaught: Listener = (err) => {\n client.captureException(err, { handled: false, tags: { source: \"uncaughtException\" } });\n if (options.exitOnUncaught !== false || options.onFatalError) flushThenExit(err);\n };\n target.on(\"uncaughtException\", onUncaught);\n installed.push([\"uncaughtException\", onUncaught]);\n }\n\n if (options.captureUnhandledRejections !== false) {\n const onRejection: Listener = (reason) => {\n client.captureException(reason, { handled: false, tags: { source: \"unhandledRejection\" } });\n if (options.exitOnUnhandledRejection !== false || options.onFatalError) flushThenExit(reason);\n };\n target.on(\"unhandledRejection\", onRejection);\n installed.push([\"unhandledRejection\", onRejection]);\n }\n\n if (options.flushOnBeforeExit !== false) {\n // A pending fetch keeps the loop alive, so this is mostly insurance for custom\n // transports — and the closest Node analogue of the gem's at_exit queue drain.\n const onBeforeExit: Listener = () => {\n void client.flush(flushTimeout);\n };\n target.on(\"beforeExit\", onBeforeExit);\n installed.push([\"beforeExit\", onBeforeExit]);\n }\n\n return () => {\n for (const [event, listener] of installed) target.removeListener(event, listener);\n };\n}\n","import { isAbsolute, relative, sep } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { parseStack as coreParseStack, type DispatchFrame } from \"@dispatchitapp/core\";\nimport { addSourceContext } from \"./sourceContext\";\n\nfunction underRoot(absPath: string, root: string): boolean {\n return absPath === root || absPath.startsWith(root + sep);\n}\n\n// A parseStack tailored to a Node server: starts from core's portable V8 parse, then resolves\n// file:// URLs, recomputes in_app/filename against the project root, and reads source context\n// for in-app frames. Mirrors the gem's EventBuilder frame handling (relative filename, in_app,\n// pre/context/post). Pass the result as the Client's `parseStack`.\nexport function nodeStackParser(cwd: string): (stack?: string | null) => DispatchFrame[] {\n return (stack?: string | null): DispatchFrame[] => {\n const frames = coreParseStack(stack);\n\n for (const frame of frames) {\n let abs = frame.abs_path ?? null;\n if (abs && abs.startsWith(\"file://\")) {\n try {\n abs = fileURLToPath(abs);\n } catch {\n /* keep the original */\n }\n frame.abs_path = abs;\n }\n\n if (abs && isAbsolute(abs) && !abs.includes(\"node_modules\") && underRoot(abs, cwd)) {\n frame.in_app = true;\n frame.filename = relative(cwd, abs);\n } else {\n // node: internals, dependencies, or paths outside the project are not app code.\n frame.in_app = false;\n }\n }\n\n addSourceContext(frames);\n return frames;\n };\n}\n","import { readFileSync } from \"node:fs\";\nimport type { DispatchFrame } from \"@dispatchitapp/core\";\n\nexport const CONTEXT_LINES = 5;\nexport const MAX_CONTEXT_FRAMES = 12;\n\n// Cache file contents per path for the process lifetime — a single request's stack often\n// revisits the same file, and source doesn't change under a running process.\nconst sourceCache = new Map<string, string[] | null>();\n\nfunction rstrip(line: string): string {\n return line.replace(/\\s+$/, \"\");\n}\n\nfunction sourceLines(path: string): string[] | null {\n const cached = sourceCache.get(path);\n if (cached !== undefined) return cached;\n let lines: string[] | null = null;\n try {\n lines = readFileSync(path, \"utf8\").split(\"\\n\");\n } catch {\n lines = null;\n }\n sourceCache.set(path, lines);\n return lines;\n}\n\nfunction applyContext(frame: DispatchFrame): void {\n const path = frame.abs_path;\n const lineno = frame.lineno;\n if (!path || !lineno) return;\n const lines = sourceLines(path);\n if (!lines) return;\n const idx = lineno - 1;\n if (idx < 0 || idx >= lines.length) return;\n frame.pre_context = lines.slice(Math.max(0, idx - CONTEXT_LINES), idx).map(rstrip);\n frame.context_line = rstrip(lines[idx]!);\n frame.post_context = lines.slice(idx + 1, idx + 1 + CONTEXT_LINES).map(rstrip);\n}\n\n// Add source context to the most-recent in-app frames (the tail of the oldest-first array),\n// within a per-event budget. Mirrors EventBuilder#annotate_context.\nexport function addSourceContext(frames: DispatchFrame[]): void {\n let budget = MAX_CONTEXT_FRAMES;\n for (let i = frames.length - 1; i >= 0 && budget > 0; i--) {\n const frame = frames[i]!;\n if (!frame.in_app) continue;\n budget--;\n applyContext(frame);\n }\n}\n\n// Exposed for tests so a fixture file's freshly-written contents aren't masked by the cache.\nexport function clearSourceCache(): void {\n sourceCache.clear();\n}\n","export const SDK_NAME = \"dispatch-node\";\nexport const SDK_VERSION = \"1.0.0\";\n","import type { DispatchRequest, DispatchUser } from \"@dispatchitapp/core\";\n\n// The header allow-list, lowercased (Node lowercases header keys) → canonical wire name.\n// Same set as the gem's EventBuilder::SAFE_HEADERS.\nconst SAFE_HEADERS: Array<[string, string]> = [\n [\"user-agent\", \"User-Agent\"],\n [\"referer\", \"Referer\"],\n [\"accept\", \"Accept\"],\n [\"content-type\", \"Content-Type\"],\n [\"host\", \"Host\"],\n [\"x-request-id\", \"X-Request-Id\"],\n];\n\ntype HeaderBag = Record<string, string | string[] | undefined>;\n\n// The slice of a Node/Express/Fastify request we read. Kept structural so we depend on no\n// framework types.\nexport interface HttpRequestLike {\n method?: string;\n url?: string;\n originalUrl?: string;\n headers?: HeaderBag;\n protocol?: string;\n ip?: string;\n socket?: { remoteAddress?: string; encrypted?: boolean };\n}\n\nfunction headerValue(headers: HeaderBag, name: string): string | undefined {\n const value = headers[name];\n if (Array.isArray(value)) return value[0];\n return value ?? undefined;\n}\n\nfunction safeHeaders(headers: HeaderBag): Record<string, string> {\n const out: Record<string, string> = {};\n for (const [lower, canonical] of SAFE_HEADERS) {\n const value = headers[lower];\n if (value === undefined) continue;\n out[canonical] = Array.isArray(value) ? value.join(\", \") : value;\n }\n return out;\n}\n\n// Build the contract `request` object from a Node-style request. Best-effort and never throws;\n// only allow-listed headers are included. Mirrors EventBuilder#request_hash.\nexport function extractRequest(req: HttpRequestLike): DispatchRequest {\n const headers = req.headers ?? {};\n const rawUrl = req.originalUrl ?? req.url ?? \"\";\n const q = rawUrl.indexOf(\"?\");\n const path = q >= 0 ? rawUrl.slice(0, q) : rawUrl;\n const query = q >= 0 ? rawUrl.slice(q + 1) : undefined;\n\n const host = headerValue(headers, \"host\");\n const scheme =\n req.protocol ||\n headerValue(headers, \"x-forwarded-proto\") ||\n (req.socket?.encrypted ? \"https\" : \"http\");\n const forwarded = headerValue(headers, \"x-forwarded-for\");\n const ip =\n req.ip ||\n req.socket?.remoteAddress ||\n (forwarded ? forwarded.split(\",\")[0]!.trim() : undefined);\n\n const out: DispatchRequest = {};\n const url = host ? `${scheme}://${host}${path}` : path || undefined;\n if (url) out.url = url;\n if (req.method) out.method = req.method;\n if (query) out.query_string = query;\n const hdrs = safeHeaders(headers);\n if (Object.keys(hdrs).length > 0) out.headers = hdrs;\n if (ip) out.env = { REMOTE_ADDR: ip };\n return out;\n}\n\n// Normalise a resolved user object (from a `user` callback or req.user) into the contract's\n// { id, email } shape — accepting both `id` and the gem's `external_id`. Returns undefined when\n// there's nothing usable, so the event falls back to the configured user.\nexport function normalizeUser(user: unknown): DispatchUser | undefined {\n if (user === null || typeof user !== \"object\") return undefined;\n const u = user as Record<string, unknown>;\n const id = u[\"id\"] ?? u[\"external_id\"];\n const out: DispatchUser = {};\n if (id !== undefined && id !== null) out.id = String(id);\n if (typeof u[\"email\"] === \"string\") out.email = u[\"email\"];\n return out.id !== undefined || out.email !== undefined ? out : undefined;\n}\n"],"mappings":"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","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dispatchitapp/node",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Node.js server SDK for Dispatch: global error handlers, V8 stack frames with source context, on top of @dispatchitapp/core.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Dispatch Team <hello@dispatchit.app>",
|
|
7
|
+
"homepage": "https://dispatchit.app",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Recursivity-LLC/dispatch-sdks.git",
|
|
11
|
+
"directory": "packages/node"
|
|
12
|
+
},
|
|
13
|
+
"bugs": "https://github.com/Recursivity-LLC/dispatch-sdks/issues",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"error-tracking",
|
|
16
|
+
"monitoring",
|
|
17
|
+
"dispatch",
|
|
18
|
+
"nodejs",
|
|
19
|
+
"sentry"
|
|
20
|
+
],
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public",
|
|
23
|
+
"provenance": true
|
|
24
|
+
},
|
|
25
|
+
"type": "module",
|
|
26
|
+
"main": "./dist/index.cjs",
|
|
27
|
+
"module": "./dist/index.js",
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"import": "./dist/index.js",
|
|
33
|
+
"require": "./dist/index.cjs"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist"
|
|
38
|
+
],
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=18"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@dispatchitapp/core": "1.0.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/node": "^20.14.0",
|
|
47
|
+
"ajv": "^8.17.0",
|
|
48
|
+
"ajv-formats": "^3.0.1",
|
|
49
|
+
"tsup": "^8.0.0",
|
|
50
|
+
"typescript": "^5.5.0",
|
|
51
|
+
"vitest": "^2.0.0"
|
|
52
|
+
},
|
|
53
|
+
"scripts": {
|
|
54
|
+
"build": "tsup",
|
|
55
|
+
"test": "vitest run",
|
|
56
|
+
"typecheck": "tsc --noEmit",
|
|
57
|
+
"lint": "tsc --noEmit"
|
|
58
|
+
}
|
|
59
|
+
}
|