@hookflo/tern-dev 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hookflo
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,93 @@
1
+ # tern-dev
2
+
3
+ ![npm version](https://img.shields.io/npm/v/%40hookflo%2Ftern-dev)
4
+ ![MIT License](https://img.shields.io/badge/license-MIT-green)
5
+ ![Node 18+](https://img.shields.io/badge/node-%3E%3D18-10b981)
6
+
7
+ **Local webhook tunnel. No ngrok. No account. Nothing stored.**
8
+
9
+ ## Quickstart
10
+
11
+ ```bash
12
+ npx @hookflo/tern-dev --port 3000
13
+ ```
14
+
15
+ Output:
16
+
17
+ ```text
18
+ tern ● https://tern-relay.hookflo-tern.workers.dev/s/abc12345 → localhost:3000
19
+ ● Dashboard → http://localhost:2019
20
+ ```
21
+
22
+ 1. Copy the tunnel URL.
23
+ 2. Paste it as your webhook endpoint in Stripe/GitHub/etc (append `/webhook` or whatever path your server uses).
24
+ 3. Open `http://localhost:2019` to see events arrive live.
25
+
26
+ ## How it works
27
+
28
+ tern-dev opens a WebSocket to the relay server and receives a public tunnel URL. When a platform sends a webhook to that URL, the relay pipes the raw request to your machine over the WebSocket. Your local server receives it exactly as if the platform called it directly.
29
+
30
+ ## CLI flags
31
+
32
+ | Flag | Default | Description |
33
+ |------|---------|-------------|
34
+ | `--port` | required | Local port to forward to |
35
+ | `--path` | `/` | Path on local server |
36
+ | `--ui-port` | `2019` | Dashboard port |
37
+ | `--no-ui` | false | Disable dashboard |
38
+ | `--relay` | hosted relay | Custom relay URL (for self-hosting) |
39
+ | `--max-events` | `500` | Events kept in memory |
40
+
41
+ ## Dashboard features
42
+
43
+ - Live event log — every webhook that arrives
44
+ - Payload tab — full request body, pretty-printed JSON
45
+ - Headers tab — all request headers, signature headers highlighted
46
+ - Response tab — HTTP status, latency, response body
47
+ - DLQ — failed events (4xx/5xx) grouped for easy replay
48
+ - Replay — re-send any event to localhost with one click
49
+ - Replay all — bulk replay all failed events
50
+ - Copy as curl — get a curl command for any event
51
+ - Copy as fetch — get a fetch() snippet for any event
52
+
53
+ ## Using with Tern SDK
54
+
55
+ ```ts
56
+ import { WebhookVerificationService } from '@hookflo/tern'
57
+
58
+ app.post('/webhook', express.raw({ type: '*/*' }), async (req, res) => {
59
+ const result = await WebhookVerificationService.verifyWithPlatformConfig(
60
+ req,
61
+ 'stripe',
62
+ process.env.STRIPE_WEBHOOK_SECRET!
63
+ )
64
+ if (!result.isValid) return res.status(400).json({ error: result.error })
65
+ console.log('✅ verified:', result.payload.type)
66
+ res.json({ received: true })
67
+ })
68
+ ```
69
+
70
+ > `express.raw({ type: '*/*' })` is required. Never use `express.json()` — it re-parses the body and breaks HMAC signature verification.
71
+
72
+ ## Self-hosting the relay
73
+
74
+ If you need full data isolation, run your own relay:
75
+
76
+ ```bash
77
+ git clone https://github.com/hookflo/tern-relay
78
+ # follow its README to deploy to Cloudflare Workers
79
+ # then:
80
+ RELAY_URL=wss://your-relay.your-account.workers.dev \
81
+ npx @hookflo/tern-dev --port 3000
82
+ ```
83
+
84
+ ## Privacy
85
+
86
+ - Nothing is stored to disk
87
+ - Nothing is sent to Hookflo servers (except through the relay, which stores nothing)
88
+ - All event data is session-scoped in memory — cleared when you close the terminal
89
+ - The relay source is small and fully auditable
90
+
91
+ ## License
92
+
93
+ MIT
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __importDefault = (this && this.__importDefault) || function (mod) {
4
+ return (mod && mod.__esModule) ? mod : { "default": mod };
5
+ };
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ const minimist_1 = __importDefault(require("minimist"));
8
+ const event_store_1 = require("./event-store");
9
+ const forwarder_1 = require("./forwarder");
10
+ const logger_1 = require("./logger");
11
+ const relay_client_1 = require("./relay-client");
12
+ const ui_server_1 = require("./ui-server");
13
+ const ws_server_1 = require("./ws-server");
14
+ function parseConfig() {
15
+ const args = (0, minimist_1.default)(process.argv.slice(2), {
16
+ boolean: ["no-ui"],
17
+ string: ["relay", "path"],
18
+ default: {
19
+ path: "/",
20
+ "ui-port": 2019,
21
+ "max-events": 500,
22
+ relay: process.env.RELAY_URL ?? "wss://relay.tern.hookflo.com"
23
+ }
24
+ });
25
+ const port = Number(args.port);
26
+ if (!Number.isFinite(port) || port <= 0) {
27
+ throw new Error("--port is required (example: --port 3000)");
28
+ }
29
+ const uiPort = Number(args["ui-port"]);
30
+ const maxEvents = Number(args["max-events"]);
31
+ return {
32
+ port,
33
+ path: String(args.path || "/"),
34
+ uiPort,
35
+ wsPort: uiPort + 1,
36
+ noUi: Boolean(args["no-ui"]),
37
+ relayUrl: String(args.relay),
38
+ maxEvents: Number.isFinite(maxEvents) ? maxEvents : 500
39
+ };
40
+ }
41
+ function loadUiBundle() {
42
+ try {
43
+ const uiBundle = require("./ui-bundle");
44
+ return uiBundle.UI_HTML;
45
+ }
46
+ catch {
47
+ return "<html><body><h1>Build missing</h1><p>Run npm run bundle-ui.</p></body></html>";
48
+ }
49
+ }
50
+ function loadVersion() {
51
+ try {
52
+ const pkg = require("../package.json");
53
+ return pkg.version ?? "0.0.0";
54
+ }
55
+ catch {
56
+ return "0.0.0";
57
+ }
58
+ }
59
+ async function main() {
60
+ const config = parseConfig();
61
+ const version = loadVersion();
62
+ const eventStore = new event_store_1.EventStore(config.maxEvents);
63
+ const relayClient = new relay_client_1.RelayClient();
64
+ const wsServer = new ws_server_1.WsServer();
65
+ let status = {
66
+ connected: false,
67
+ state: "connecting",
68
+ tunnelUrl: "https://connecting.relay.tern.dev",
69
+ sessionId: ""
70
+ };
71
+ const setStatus = (next) => {
72
+ status = { ...status, ...next };
73
+ wsServer.setStatus(status);
74
+ };
75
+ relayClient.on("connected", (payload) => {
76
+ const tunnelChanged = Boolean(status.tunnelUrl) && status.tunnelUrl !== payload.url;
77
+ setStatus({
78
+ connected: true,
79
+ state: "live",
80
+ tunnelUrl: payload.url,
81
+ sessionId: payload.sessionId
82
+ });
83
+ if (tunnelChanged) {
84
+ (0, logger_1.warn)("Tunnel URL changed after reconnect — update your webhook endpoint.");
85
+ }
86
+ (0, logger_1.printBanner)(payload.url, config.port, config.uiPort, config.noUi);
87
+ });
88
+ relayClient.on("reconnecting", ({ attempt, delayMs }) => {
89
+ setStatus({ connected: false, state: "reconnecting" });
90
+ (0, logger_1.warn)(`Relay disconnected. Reconnecting in ${delayMs}ms (attempt ${attempt}).`);
91
+ });
92
+ relayClient.on("disconnect", () => {
93
+ if (status.state !== "reconnecting") {
94
+ setStatus({ connected: false, state: "reconnecting" });
95
+ }
96
+ });
97
+ relayClient.on("request", async (request) => {
98
+ const event = await (0, forwarder_1.forward)(request, config.port);
99
+ eventStore.add(event);
100
+ wsServer.broadcast({ type: "event", event });
101
+ const statusLabel = event.status ? `${event.status}` : "ERR";
102
+ (0, logger_1.info)(`${event.method} ${event.path} → ${statusLabel} ${event.latency ?? 0}ms`);
103
+ });
104
+ let uiServer = null;
105
+ if (!config.noUi) {
106
+ uiServer = new ui_server_1.UiServer({
107
+ eventStore,
108
+ localPort: config.port,
109
+ uiHtml: loadUiBundle(),
110
+ onReplay: (event) => wsServer.broadcast({ type: "event", event }),
111
+ onClear: () => wsServer.broadcast({ type: "clear" }),
112
+ getStatus: () => status,
113
+ version,
114
+ wsPort: config.wsPort
115
+ });
116
+ uiServer.start(config.uiPort);
117
+ wsServer.start(config.wsPort);
118
+ wsServer.setStatus(status);
119
+ (0, logger_1.info)(`Dashboard listening on http://localhost:${config.uiPort}`);
120
+ }
121
+ const shutdown = () => {
122
+ relayClient.close();
123
+ wsServer.close();
124
+ uiServer?.close();
125
+ process.exit(0);
126
+ };
127
+ process.on("SIGINT", shutdown);
128
+ process.on("SIGTERM", shutdown);
129
+ relayClient.connect(config.relayUrl);
130
+ }
131
+ main().catch((err) => {
132
+ (0, logger_1.error)(err instanceof Error ? err.message : "Unknown startup error");
133
+ process.exit(1);
134
+ });
135
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,10 @@
1
+ import { TernEvent } from "./types";
2
+ export declare class EventStore {
3
+ private readonly maxEvents;
4
+ private events;
5
+ constructor(maxEvents: number);
6
+ add(event: TernEvent): void;
7
+ list(): TernEvent[];
8
+ get(id: string): TernEvent | undefined;
9
+ clear(): void;
10
+ }
@@ -0,0 +1,27 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EventStore = void 0;
4
+ class EventStore {
5
+ maxEvents;
6
+ events = [];
7
+ constructor(maxEvents) {
8
+ this.maxEvents = maxEvents;
9
+ }
10
+ add(event) {
11
+ this.events.unshift(event);
12
+ if (this.events.length > this.maxEvents) {
13
+ this.events.splice(this.maxEvents);
14
+ }
15
+ }
16
+ list() {
17
+ return [...this.events];
18
+ }
19
+ get(id) {
20
+ return this.events.find((event) => event.id === id);
21
+ }
22
+ clear() {
23
+ this.events = [];
24
+ }
25
+ }
26
+ exports.EventStore = EventStore;
27
+ //# sourceMappingURL=event-store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"event-store.js","sourceRoot":"","sources":["../src/event-store.ts"],"names":[],"mappings":";;;AAEA,MAAa,UAAU;IAGQ;IAFrB,MAAM,GAAgB,EAAE,CAAC;IAEjC,YAA6B,SAAiB;QAAjB,cAAS,GAAT,SAAS,CAAQ;IAAG,CAAC;IAElD,GAAG,CAAC,KAAgB;QAClB,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;QAC3B,IAAI,IAAI,CAAC,MAAM,CAAC,MAAM,GAAG,IAAI,CAAC,SAAS,EAAE,CAAC;YACxC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;QACrC,CAAC;IACH,CAAC;IAED,IAAI;QACF,OAAO,CAAC,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC;IAC1B,CAAC;IAED,GAAG,CAAC,EAAU;QACZ,OAAO,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC;IACtD,CAAC;IAED,KAAK;QACH,IAAI,CAAC,MAAM,GAAG,EAAE,CAAC;IACnB,CAAC;CACF;AAvBD,gCAuBC"}
@@ -0,0 +1,3 @@
1
+ import { RelayMessage, TernEvent } from "./types";
2
+ export declare function forward(request: RelayMessage, localPort: number): Promise<TernEvent>;
3
+ export declare function replay(event: TernEvent, localPort: number): Promise<TernEvent>;
@@ -0,0 +1,103 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.forward = forward;
4
+ exports.replay = replay;
5
+ const STRIP_HEADERS = new Set(["content-length", "transfer-encoding", "host", "connection"]);
6
+ function detectPlatform(headers, body) {
7
+ const keys = Object.keys(headers).map((key) => key.toLowerCase());
8
+ if (keys.some((key) => key.includes("stripe")))
9
+ return "stripe";
10
+ if (keys.some((key) => key.includes("github")))
11
+ return "github";
12
+ if (keys.some((key) => key.includes("clerk")))
13
+ return "clerk";
14
+ if (body.includes("payment_intent") || body.includes("stripe"))
15
+ return "stripe";
16
+ if (body.includes("zen") || body.includes("repository"))
17
+ return "github";
18
+ return null;
19
+ }
20
+ function safeJsonParse(value) {
21
+ try {
22
+ return JSON.parse(value);
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ function buildForwardHeaders(incomingHeaders) {
29
+ const headers = new Headers();
30
+ for (const [key, value] of Object.entries(incomingHeaders)) {
31
+ if (!STRIP_HEADERS.has(key.toLowerCase())) {
32
+ headers.set(key, String(value));
33
+ }
34
+ }
35
+ return headers;
36
+ }
37
+ async function forward(request, localPort) {
38
+ const start = Date.now();
39
+ const headers = buildForwardHeaders(request.headers);
40
+ const targetUrl = `http://127.0.0.1:${localPort}${request.path}`;
41
+ const event = {
42
+ id: request.id,
43
+ receivedAt: request.receivedAt,
44
+ method: request.method,
45
+ path: request.path,
46
+ headers: request.headers,
47
+ body: request.body,
48
+ bodyParsed: safeJsonParse(request.body),
49
+ status: null,
50
+ latency: null,
51
+ failed: false,
52
+ error: null,
53
+ platform: detectPlatform(request.headers, request.body),
54
+ replay: false,
55
+ replayOf: null
56
+ };
57
+ const controller = new AbortController();
58
+ const timeout = setTimeout(() => controller.abort(), 30_000);
59
+ try {
60
+ const hasBody = !["GET", "HEAD"].includes(request.method.toUpperCase());
61
+ const response = await fetch(targetUrl, {
62
+ method: request.method,
63
+ headers,
64
+ body: hasBody ? request.body : undefined,
65
+ signal: controller.signal
66
+ });
67
+ event.status = response.status;
68
+ event.failed = response.status >= 400;
69
+ event.latency = Date.now() - start;
70
+ }
71
+ catch (err) {
72
+ event.status = null;
73
+ event.latency = Date.now() - start;
74
+ event.failed = true;
75
+ if (err instanceof Error && err.name === "AbortError") {
76
+ event.error = `Forward timed out after 30s — is localhost:${localPort} running?`;
77
+ }
78
+ else {
79
+ event.error = err instanceof Error ? err.message : "Forward failed";
80
+ }
81
+ }
82
+ finally {
83
+ clearTimeout(timeout);
84
+ }
85
+ return event;
86
+ }
87
+ async function replay(event, localPort) {
88
+ const replayId = `evt_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
89
+ const request = {
90
+ type: "request",
91
+ id: replayId,
92
+ method: event.method,
93
+ path: event.path,
94
+ headers: event.headers,
95
+ body: event.body,
96
+ receivedAt: new Date().toISOString()
97
+ };
98
+ const replayed = await forward(request, localPort);
99
+ replayed.replay = true;
100
+ replayed.replayOf = event.id;
101
+ return replayed;
102
+ }
103
+ //# sourceMappingURL=forwarder.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"forwarder.js","sourceRoot":"","sources":["../src/forwarder.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,5 @@
1
+ export declare function info(message: string): void;
2
+ export declare function success(message: string): void;
3
+ export declare function warn(message: string): void;
4
+ export declare function error(message: string): void;
5
+ export declare function printBanner(tunnelUrl: string, localPort: number, uiPort: number, noUi: boolean): void;
package/dist/logger.js ADDED
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.info = info;
4
+ exports.success = success;
5
+ exports.warn = warn;
6
+ exports.error = error;
7
+ exports.printBanner = printBanner;
8
+ const ANSI = {
9
+ reset: "\x1b[0m",
10
+ dim: "\x1b[2m",
11
+ red: "\x1b[31m",
12
+ yellow: "\x1b[33m",
13
+ cyan: "\x1b[36m",
14
+ green: "\x1b[32m"
15
+ };
16
+ function colorize(color, text) {
17
+ return `${ANSI[color]}${text}${ANSI.reset}`;
18
+ }
19
+ function info(message) {
20
+ process.stdout.write(`${colorize("cyan", "tern")}${ANSI.dim} ›${ANSI.reset} ${message}\n`);
21
+ }
22
+ function success(message) {
23
+ process.stdout.write(`${colorize("green", "tern")}${ANSI.dim} ›${ANSI.reset} ${message}\n`);
24
+ }
25
+ function warn(message) {
26
+ process.stdout.write(`${colorize("yellow", "tern")}${ANSI.dim} ›${ANSI.reset} ${message}\n`);
27
+ }
28
+ function error(message) {
29
+ process.stderr.write(`${colorize("red", "tern")}${ANSI.dim} ›${ANSI.reset} ${message}\n`);
30
+ }
31
+ function printBanner(tunnelUrl, localPort, uiPort, noUi) {
32
+ process.stdout.write("\n");
33
+ process.stdout.write(`${colorize("cyan", "tern")} ● ${tunnelUrl} → localhost:${localPort}\n`);
34
+ if (!noUi) {
35
+ process.stdout.write(` ● Dashboard → http://localhost:${uiPort}\n`);
36
+ }
37
+ process.stdout.write(" Ctrl+C to stop\n\n");
38
+ }
39
+ //# sourceMappingURL=logger.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"logger.js","sourceRoot":"","sources":["../src/logger.ts"],"names":[],"mappings":";;AAaA,oBAEC;AAED,0BAEC;AAED,oBAEC;AAED,sBAEC;AAED,kCAOC;AApCD,MAAM,IAAI,GAAG;IACX,KAAK,EAAE,SAAS;IAChB,GAAG,EAAE,SAAS;IACd,GAAG,EAAE,UAAU;IACf,MAAM,EAAE,UAAU;IAClB,IAAI,EAAE,UAAU;IAChB,KAAK,EAAE,UAAU;CACT,CAAC;AAEX,SAAS,QAAQ,CAAC,KAAwB,EAAE,IAAY;IACtD,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,IAAI,GAAG,IAAI,CAAC,KAAK,EAAE,CAAC;AAC9C,CAAC;AAED,SAAgB,IAAI,CAAC,OAAe;IAClC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAAC,GAAG,KAAK,IAAI,CAAC,KAAK,IAAI,OAAO,IAAI,CAAC,CAAC;AAC7F,CAAC;AAED,SAAgB,OAAO,CAAC,OAAe;IACrC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC,GAAG,IAAI,CAAC,GAAG,KAAK,IAAI,CAAC,KAAK,IAAI,OAAO,IAAI,CAAC,CAAC;AAC9F,CAAC;AAED,SAAgB,IAAI,CAAC,OAAe;IAClC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC,GAAG,IAAI,CAAC,GAAG,KAAK,IAAI,CAAC,KAAK,IAAI,OAAO,IAAI,CAAC,CAAC;AAC/F,CAAC;AAED,SAAgB,KAAK,CAAC,OAAe;IACnC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC,GAAG,IAAI,CAAC,GAAG,KAAK,IAAI,CAAC,KAAK,IAAI,OAAO,IAAI,CAAC,CAAC;AAC5F,CAAC;AAED,SAAgB,WAAW,CAAC,SAAiB,EAAE,SAAiB,EAAE,MAAc,EAAE,IAAa;IAC7F,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAC3B,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,QAAQ,SAAS,gBAAgB,SAAS,IAAI,CAAC,CAAC;IAChG,IAAI,CAAC,IAAI,EAAE,CAAC;QACV,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,0CAA0C,MAAM,IAAI,CAAC,CAAC;IAC7E,CAAC;IACD,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,2BAA2B,CAAC,CAAC;AACpD,CAAC"}
@@ -0,0 +1,25 @@
1
+ import EventEmitter from "events";
2
+ import { RelayConnectedMessage, RelayMessage } from "./types";
3
+ interface RelayClientEvents {
4
+ connected: (payload: RelayConnectedMessage) => void;
5
+ request: (payload: RelayMessage) => void;
6
+ reconnecting: (payload: {
7
+ attempt: number;
8
+ delayMs: number;
9
+ }) => void;
10
+ disconnect: () => void;
11
+ }
12
+ export declare class RelayClient extends EventEmitter {
13
+ private socket;
14
+ private reconnectAttempt;
15
+ private reconnectTimer;
16
+ private closedByUser;
17
+ private readonly maxBackoffMs;
18
+ private currentUrl;
19
+ connect(url: string): void;
20
+ close(): void;
21
+ on<K extends keyof RelayClientEvents>(event: K, listener: RelayClientEvents[K]): this;
22
+ private toConnectUrl;
23
+ private scheduleReconnect;
24
+ }
25
+ export {};
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.RelayClient = void 0;
7
+ const events_1 = __importDefault(require("events"));
8
+ const ws_1 = __importDefault(require("ws"));
9
+ class RelayClient extends events_1.default {
10
+ socket = null;
11
+ reconnectAttempt = 0;
12
+ reconnectTimer = null;
13
+ closedByUser = false;
14
+ maxBackoffMs = 30_000;
15
+ currentUrl = "";
16
+ connect(url) {
17
+ this.closedByUser = false;
18
+ this.currentUrl = url;
19
+ const wsUrl = this.toConnectUrl(url);
20
+ this.socket = new ws_1.default(wsUrl);
21
+ this.socket.on("open", () => {
22
+ this.reconnectAttempt = 0;
23
+ this.socket?.send(JSON.stringify({ type: "register" }));
24
+ });
25
+ this.socket.on("message", (data) => {
26
+ try {
27
+ const parsed = JSON.parse(data.toString());
28
+ if (parsed.type === "connected") {
29
+ this.emit("connected", parsed);
30
+ return;
31
+ }
32
+ if (parsed.type === "request") {
33
+ this.emit("request", parsed);
34
+ }
35
+ }
36
+ catch {
37
+ // Ignore malformed relay payloads.
38
+ }
39
+ });
40
+ this.socket.on("close", () => {
41
+ this.emit("disconnect");
42
+ this.scheduleReconnect();
43
+ });
44
+ this.socket.on("error", () => {
45
+ // close event will trigger reconnect flow
46
+ });
47
+ }
48
+ close() {
49
+ this.closedByUser = true;
50
+ if (this.reconnectTimer) {
51
+ clearTimeout(this.reconnectTimer);
52
+ this.reconnectTimer = null;
53
+ }
54
+ this.socket?.close();
55
+ this.socket = null;
56
+ }
57
+ on(event, listener) {
58
+ return super.on(event, listener);
59
+ }
60
+ toConnectUrl(url) {
61
+ const trimmed = url.replace(/\/$/, "");
62
+ return trimmed.endsWith("/connect") ? trimmed : `${trimmed}/connect`;
63
+ }
64
+ scheduleReconnect() {
65
+ if (this.closedByUser || !this.currentUrl || this.reconnectTimer) {
66
+ return;
67
+ }
68
+ const delayMs = Math.min(1000 * 2 ** this.reconnectAttempt, this.maxBackoffMs);
69
+ this.reconnectAttempt += 1;
70
+ this.emit("reconnecting", { attempt: this.reconnectAttempt, delayMs });
71
+ this.reconnectTimer = setTimeout(() => {
72
+ this.reconnectTimer = null;
73
+ this.connect(this.currentUrl);
74
+ }, delayMs);
75
+ }
76
+ }
77
+ exports.RelayClient = RelayClient;
78
+ //# sourceMappingURL=relay-client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"relay-client.js","sourceRoot":"","sources":["../src/relay-client.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1 @@
1
+ export { replay as replayEvent } from "./forwarder";
package/dist/replay.js ADDED
@@ -0,0 +1,6 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.replayEvent = void 0;
4
+ var forwarder_1 = require("./forwarder");
5
+ Object.defineProperty(exports, "replayEvent", { enumerable: true, get: function () { return forwarder_1.replay; } });
6
+ //# sourceMappingURL=replay.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"replay.js","sourceRoot":"","sources":["../src/replay.ts"],"names":[],"mappings":";;;AAAA,yCAAoD;AAA3C,wGAAA,MAAM,OAAe"}
@@ -0,0 +1,46 @@
1
+ export type RelayConnectionState = "connecting" | "live" | "reconnecting" | "disconnected";
2
+ export interface TernEvent {
3
+ id: string;
4
+ receivedAt: string;
5
+ method: string;
6
+ path: string;
7
+ headers: Record<string, string>;
8
+ body: string;
9
+ bodyParsed: unknown | null;
10
+ status: number | null;
11
+ latency: number | null;
12
+ failed: boolean;
13
+ error: string | null;
14
+ platform: string | null;
15
+ replay: boolean;
16
+ replayOf: string | null;
17
+ }
18
+ export interface RelayMessage {
19
+ type: "request";
20
+ id: string;
21
+ method: string;
22
+ path: string;
23
+ headers: Record<string, string>;
24
+ body: string;
25
+ receivedAt: string;
26
+ }
27
+ export interface Config {
28
+ port: number;
29
+ path: string;
30
+ uiPort: number;
31
+ wsPort: number;
32
+ noUi: boolean;
33
+ relayUrl: string;
34
+ maxEvents: number;
35
+ }
36
+ export interface RelayConnectedMessage {
37
+ type: "connected";
38
+ url: string;
39
+ sessionId: string;
40
+ }
41
+ export interface StatusPayload {
42
+ connected: boolean;
43
+ state: RelayConnectionState;
44
+ tunnelUrl: string;
45
+ sessionId: string;
46
+ }
package/dist/types.js ADDED
@@ -0,0 +1,3 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
@@ -0,0 +1 @@
1
+ export declare const UI_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>tern \u00B7 dashboard</title>\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\" />\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin />\n <link href=\"https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap\" rel=\"stylesheet\" />\n <style>\n :root { color-scheme: dark; }\n * { box-sizing: border-box; }\n body { margin: 0; font-family: \"IBM Plex Mono\", monospace; color: #dcfce7; background: #040b09; }\n body::before { content: \"\"; position: fixed; inset: 0; pointer-events: none; opacity: .14; background-image: radial-gradient(rgba(16,185,129,.28) 1px, transparent 1px); background-size: 3px 3px; }\n .shell { display: flex; flex-direction: column; height: 100vh; }\n .top { border-bottom: 1px solid #134e4a; background: linear-gradient(180deg, rgba(2,10,8,.9), rgba(2,10,8,.7)); padding: 10px 14px; display: flex; align-items: center; gap: 12px; }\n .badge { font-size: 11px; color: #6ee7b7; letter-spacing: .08em; }\n .url-wrap { flex: 1; display: flex; gap: 8px; }\n #tunnel-url { flex: 1; background: #041611; color: #6ee7b7; border: 1px solid #14532d; border-radius: 6px; padding: 8px; }\n button { border: 1px solid #14532d; background: #052e22; color: #a7f3d0; border-radius: 6px; padding: 8px 10px; cursor: pointer; font-family: inherit; }\n .status { min-width: 190px; text-align: right; color: #86efac; }\n .dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 6px; background: #f59e0b; }\n .dot.live { background: #10b981; box-shadow: 0 0 10px #10b981; }\n .dot.connecting,.dot.reconnecting { background: #f59e0b; animation: pulse 1s infinite; }\n .dot.disconnected { background: #ef4444; }\n @keyframes pulse { 50% { opacity: .35; } }\n .notice { display: none; margin: 10px 14px 0; padding: 10px; border: 1px solid #f59e0b; border-radius: 8px; color: #fde68a; background: rgba(245,158,11,.11); }\n .notice.show { display: flex; align-items: center; gap: 8px; }\n .grid { display: grid; grid-template-columns: 360px 1fr; gap: 0; min-height: 0; flex: 1; }\n .left { border-right: 1px solid #134e4a; overflow: hidden; display: flex; flex-direction: column; }\n .tabs,.actions,.filters { padding: 8px; display: flex; gap: 8px; border-bottom: 1px solid #134e4a; }\n .filters button.active,.tabs button.active { background: #14532d; }\n .dlq { margin: 8px; padding: 8px; border: 1px solid #7f1d1d; color: #fca5a5; background: rgba(127,29,29,.25); border-radius: 6px; display: none; }\n .list { overflow: auto; }\n .row { padding: 9px; border-bottom: 1px solid #0f2e27; cursor: pointer; }\n .row.sel { background: #06251e; }\n .row.fail { border-left: 2px solid #ef4444; }\n .meta { display: flex; justify-content: space-between; font-size: 12px; }\n .path { color: #6ee7b7; }\n .small { color: #86efac; opacity: .8; font-size: 11px; }\n .status-badge { border: 1px solid #14532d; border-radius: 4px; padding: 0 6px; }\n .status-badge.fail { color: #fca5a5; border-color: #ef4444; }\n .right { display: flex; flex-direction: column; min-height: 0; }\n #detail { margin: 10px; padding: 10px; border: 1px solid #14532d; border-radius: 8px; background: rgba(5,46,34,.35); white-space: pre-wrap; overflow: auto; flex: 1; }\n .empty { display: grid; place-items: center; text-align: center; color: #86efac; opacity: .8; height: 100%; }\n .toast { position: fixed; right: 14px; bottom: 14px; padding: 10px; border-radius: 8px; border: 1px solid #14532d; background: #052e22; display: none; }\n .toast.show { display: block; }\n </style>\n</head>\n<body>\n <div class=\"shell\">\n <header class=\"top\">\n <div class=\"badge\">TERN DEV</div>\n <div class=\"url-wrap\">\n <input id=\"tunnel-url\" value=\"__TERN_TUNNEL_URL__\" readonly />\n <button id=\"copy-url\">copy \u2398</button>\n </div>\n <div class=\"status\"><span id=\"status-dot\" class=\"dot connecting\"></span><span id=\"status-label\">connecting...</span></div>\n </header>\n <div id=\"url-changed\" class=\"notice\">\u26A0 Tunnel URL changed \u2014 update your webhook endpoint <button id=\"copy-new-url\">copy</button></div>\n <main class=\"grid\">\n <section class=\"left\">\n <div class=\"filters\"><button data-filter=\"all\" class=\"active\">All</button><button data-filter=\"failed\">DLQ</button></div>\n <div id=\"dlq\" class=\"dlq\"><span id=\"dlq-count\">0 failed events</span><button id=\"replay-all\">replay all</button></div>\n <div id=\"event-list\" class=\"list\"></div>\n </section>\n <section class=\"right\">\n <div class=\"tabs\"><button data-tab=\"payload\" class=\"active\">Payload</button><button data-tab=\"headers\">Headers</button><button data-tab=\"response\">Response</button></div>\n <pre id=\"detail\"></pre>\n <div class=\"actions\"><button id=\"replay-btn\">Replay</button><button id=\"copy-curl\">Copy as curl</button><button id=\"copy-fetch\">Copy as fetch</button><button id=\"clear-btn\">Clear</button></div>\n </section>\n </main>\n </div>\n <div id=\"toast\" class=\"toast\"></div>\n <script>\n const WS_PORT = __TERN_WS_PORT__;\n const state = { events: [], selectedId: null, tab: 'payload', filter: 'all', tunnelUrl: '__TERN_TUNNEL_URL__', sessionId: '', conn: 'connecting' };\n const q = (id) => document.getElementById(id);\n\n function toast(msg){ const t=q('toast'); t.textContent=msg; t.classList.add('show'); setTimeout(()=>t.classList.remove('show'),1500); }\n function setStatus(s){ state.conn=s.state||state.conn; q('status-dot').className='dot '+state.conn; q('status-label').textContent = state.conn==='live'?'live':(state.conn==='reconnecting'?'reconnecting...':(state.conn==='disconnected'?'disconnected':'connecting...')); }\n\n function selected(){ return state.events.find((e)=>e.id===state.selectedId) || null; }\n function listEvents(){ return state.events.filter((e)=> state.filter==='all' ? true : e.failed); }\n\n function renderList(){\n const list=q('event-list'); const events=listEvents();\n list.innerHTML = events.map((e)=>`<div class=\"row ${e.failed?'fail':''} ${state.selectedId===e.id?'sel':''}\" data-id=\"${e.id}\"><div class=\"meta\"><span>${e.method}</span><span class=\"status-badge ${e.failed?'fail':''}\">${e.status ?? 'ERR'}</span></div><div class=\"path\">${e.path}</div><div class=\"small\">${new Date(e.receivedAt).toLocaleTimeString()} \u00B7 ${e.latency ?? 0}ms ${e.replay?'\u00B7 replay':''}</div></div>`).join('');\n for(const n of list.querySelectorAll('.row')) n.addEventListener('click',()=>{ state.selectedId=n.dataset.id; render(); });\n const failed=state.events.filter((e)=>e.failed);\n q('dlq-count').textContent=`${failed.length} failed events`;\n q('dlq').style.display=failed.length?'flex':'none';\n }\n\n function renderDetail(){\n const el=q('detail'); const ev=selected();\n if(!ev){ el.innerHTML='<div class=\"empty\">\u2316\nawaiting signal\npaste the tunnel url above as your webhook endpoint in stripe, github, or any provider</div>'; return; }\n if(state.tab==='payload') el.textContent = JSON.stringify(ev.bodyParsed ?? ev.body, null, 2);\n if(state.tab==='headers') el.textContent = JSON.stringify(ev.headers, null, 2);\n if(state.tab==='response') el.textContent = JSON.stringify({ status: ev.status, failed: ev.failed, latency: ev.latency, error: ev.error }, null, 2);\n }\n\n function render(){ renderList(); renderDetail(); }\n\n async function load(){\n const [s,e]=await Promise.all([fetch('/api/status').then(r=>r.json()), fetch('/api/events').then(r=>r.json())]);\n setStatus(s); state.tunnelUrl=s.tunnelUrl; state.sessionId=s.sessionId; q('tunnel-url').value=s.tunnelUrl; state.events=e.events || []; if(state.events[0]) state.selectedId=state.events[0].id; render();\n }\n\n function connectWs(){\n const ws = new WebSocket(`ws://${location.hostname}:${WS_PORT}`);\n ws.onmessage = (msg) => {\n const data = JSON.parse(msg.data);\n if(data.type==='event'){ state.events.unshift(data.event); if(!state.selectedId) state.selectedId=data.event.id; render(); }\n if(data.type==='clear'){ state.events=[]; state.selectedId=null; render(); }\n if(data.type==='status'){\n setStatus(data);\n if(data.tunnelUrl && data.tunnelUrl!==state.tunnelUrl){ state.tunnelUrl=data.tunnelUrl; q('tunnel-url').value=data.tunnelUrl; if(state.sessionId && data.sessionId!==state.sessionId){ q('url-changed').classList.add('show'); } state.sessionId=data.sessionId||state.sessionId; }\n }\n };\n ws.onclose = () => setStatus({ state: 'reconnecting' });\n }\n\n q('copy-url').addEventListener('click', async ()=>{ await navigator.clipboard.writeText(q('tunnel-url').value); q('copy-url').textContent='\u2713 copied'; toast('Tunnel URL copied \u2014 paste this as your webhook endpoint'); setTimeout(()=>q('copy-url').textContent='copy \u2398',1500); });\n q('copy-new-url').addEventListener('click', async ()=>{ await navigator.clipboard.writeText(q('tunnel-url').value); q('url-changed').classList.remove('show'); toast('Tunnel URL copied \u2014 paste this as your webhook endpoint'); });\n\n for(const b of document.querySelectorAll('.filters button')) b.addEventListener('click',()=>{ for(const n of document.querySelectorAll('.filters button')) n.classList.remove('active'); b.classList.add('active'); state.filter=b.dataset.filter; render(); });\n for(const b of document.querySelectorAll('.tabs button')) b.addEventListener('click',()=>{ for(const n of document.querySelectorAll('.tabs button')) n.classList.remove('active'); b.classList.add('active'); state.tab=b.dataset.tab; renderDetail(); });\n\n q('replay-btn').addEventListener('click', async ()=>{ const ev=selected(); if(!ev) return; const r=await fetch('/api/replay',{ method:'POST', headers:{'content-type':'application/json'}, body:JSON.stringify({id:ev.id})}); const out=await r.json(); if(out.event){ state.events.unshift(out.event); state.selectedId=out.event.id; render(); } });\n q('replay-all').addEventListener('click', async ()=>{ for(const ev of state.events.filter((e)=>e.failed)){ await fetch('/api/replay',{ method:'POST', headers:{'content-type':'application/json'}, body:JSON.stringify({id:ev.id})}); } });\n q('clear-btn').addEventListener('click', ()=>fetch('/api/clear',{method:'POST'}));\n q('copy-curl').addEventListener('click', async ()=>{ const ev=selected(); if(!ev)return; const hs=Object.entries(ev.headers).map(([k,v])=>`-H '${k}: ${String(v).replace(/'/g,\"'\\''\")}'`).join(' '); await navigator.clipboard.writeText(`curl -X ${ev.method} '${state.tunnelUrl}${ev.path}' ${hs} --data-binary '${String(ev.body).replace(/'/g,\"'\\''\")}'`); toast('curl copied'); });\n q('copy-fetch').addEventListener('click', async ()=>{ const ev=selected(); if(!ev)return; await navigator.clipboard.writeText(`await fetch('http://127.0.0.1:${location.port}${ev.path}', { method: '${ev.method}', headers: ${JSON.stringify(ev.headers, null, 2)}, body: ${JSON.stringify(ev.body)} });`); toast('fetch copied'); });\n\n load().then(connectWs);\n </script>\n</body>\n</html>\n";
@@ -0,0 +1,144 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.UI_HTML = void 0;
4
+ exports.UI_HTML = `<!doctype html>
5
+ <html lang="en">
6
+ <head>
7
+ <meta charset="UTF-8" />
8
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
9
+ <title>tern · dashboard</title>
10
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
11
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
12
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
13
+ <style>
14
+ :root { color-scheme: dark; }
15
+ * { box-sizing: border-box; }
16
+ body { margin: 0; font-family: "IBM Plex Mono", monospace; color: #dcfce7; background: #040b09; }
17
+ body::before { content: ""; position: fixed; inset: 0; pointer-events: none; opacity: .14; background-image: radial-gradient(rgba(16,185,129,.28) 1px, transparent 1px); background-size: 3px 3px; }
18
+ .shell { display: flex; flex-direction: column; height: 100vh; }
19
+ .top { border-bottom: 1px solid #134e4a; background: linear-gradient(180deg, rgba(2,10,8,.9), rgba(2,10,8,.7)); padding: 10px 14px; display: flex; align-items: center; gap: 12px; }
20
+ .badge { font-size: 11px; color: #6ee7b7; letter-spacing: .08em; }
21
+ .url-wrap { flex: 1; display: flex; gap: 8px; }
22
+ #tunnel-url { flex: 1; background: #041611; color: #6ee7b7; border: 1px solid #14532d; border-radius: 6px; padding: 8px; }
23
+ button { border: 1px solid #14532d; background: #052e22; color: #a7f3d0; border-radius: 6px; padding: 8px 10px; cursor: pointer; font-family: inherit; }
24
+ .status { min-width: 190px; text-align: right; color: #86efac; }
25
+ .dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 6px; background: #f59e0b; }
26
+ .dot.live { background: #10b981; box-shadow: 0 0 10px #10b981; }
27
+ .dot.connecting,.dot.reconnecting { background: #f59e0b; animation: pulse 1s infinite; }
28
+ .dot.disconnected { background: #ef4444; }
29
+ @keyframes pulse { 50% { opacity: .35; } }
30
+ .notice { display: none; margin: 10px 14px 0; padding: 10px; border: 1px solid #f59e0b; border-radius: 8px; color: #fde68a; background: rgba(245,158,11,.11); }
31
+ .notice.show { display: flex; align-items: center; gap: 8px; }
32
+ .grid { display: grid; grid-template-columns: 360px 1fr; gap: 0; min-height: 0; flex: 1; }
33
+ .left { border-right: 1px solid #134e4a; overflow: hidden; display: flex; flex-direction: column; }
34
+ .tabs,.actions,.filters { padding: 8px; display: flex; gap: 8px; border-bottom: 1px solid #134e4a; }
35
+ .filters button.active,.tabs button.active { background: #14532d; }
36
+ .dlq { margin: 8px; padding: 8px; border: 1px solid #7f1d1d; color: #fca5a5; background: rgba(127,29,29,.25); border-radius: 6px; display: none; }
37
+ .list { overflow: auto; }
38
+ .row { padding: 9px; border-bottom: 1px solid #0f2e27; cursor: pointer; }
39
+ .row.sel { background: #06251e; }
40
+ .row.fail { border-left: 2px solid #ef4444; }
41
+ .meta { display: flex; justify-content: space-between; font-size: 12px; }
42
+ .path { color: #6ee7b7; }
43
+ .small { color: #86efac; opacity: .8; font-size: 11px; }
44
+ .status-badge { border: 1px solid #14532d; border-radius: 4px; padding: 0 6px; }
45
+ .status-badge.fail { color: #fca5a5; border-color: #ef4444; }
46
+ .right { display: flex; flex-direction: column; min-height: 0; }
47
+ #detail { margin: 10px; padding: 10px; border: 1px solid #14532d; border-radius: 8px; background: rgba(5,46,34,.35); white-space: pre-wrap; overflow: auto; flex: 1; }
48
+ .empty { display: grid; place-items: center; text-align: center; color: #86efac; opacity: .8; height: 100%; }
49
+ .toast { position: fixed; right: 14px; bottom: 14px; padding: 10px; border-radius: 8px; border: 1px solid #14532d; background: #052e22; display: none; }
50
+ .toast.show { display: block; }
51
+ </style>
52
+ </head>
53
+ <body>
54
+ <div class="shell">
55
+ <header class="top">
56
+ <div class="badge">TERN DEV</div>
57
+ <div class="url-wrap">
58
+ <input id="tunnel-url" value="__TERN_TUNNEL_URL__" readonly />
59
+ <button id="copy-url">copy ⎘</button>
60
+ </div>
61
+ <div class="status"><span id="status-dot" class="dot connecting"></span><span id="status-label">connecting...</span></div>
62
+ </header>
63
+ <div id="url-changed" class="notice">⚠ Tunnel URL changed — update your webhook endpoint <button id="copy-new-url">copy</button></div>
64
+ <main class="grid">
65
+ <section class="left">
66
+ <div class="filters"><button data-filter="all" class="active">All</button><button data-filter="failed">DLQ</button></div>
67
+ <div id="dlq" class="dlq"><span id="dlq-count">0 failed events</span><button id="replay-all">replay all</button></div>
68
+ <div id="event-list" class="list"></div>
69
+ </section>
70
+ <section class="right">
71
+ <div class="tabs"><button data-tab="payload" class="active">Payload</button><button data-tab="headers">Headers</button><button data-tab="response">Response</button></div>
72
+ <pre id="detail"></pre>
73
+ <div class="actions"><button id="replay-btn">Replay</button><button id="copy-curl">Copy as curl</button><button id="copy-fetch">Copy as fetch</button><button id="clear-btn">Clear</button></div>
74
+ </section>
75
+ </main>
76
+ </div>
77
+ <div id="toast" class="toast"></div>
78
+ <script>
79
+ const WS_PORT = __TERN_WS_PORT__;
80
+ const state = { events: [], selectedId: null, tab: 'payload', filter: 'all', tunnelUrl: '__TERN_TUNNEL_URL__', sessionId: '', conn: 'connecting' };
81
+ const q = (id) => document.getElementById(id);
82
+
83
+ function toast(msg){ const t=q('toast'); t.textContent=msg; t.classList.add('show'); setTimeout(()=>t.classList.remove('show'),1500); }
84
+ function setStatus(s){ state.conn=s.state||state.conn; q('status-dot').className='dot '+state.conn; q('status-label').textContent = state.conn==='live'?'live':(state.conn==='reconnecting'?'reconnecting...':(state.conn==='disconnected'?'disconnected':'connecting...')); }
85
+
86
+ function selected(){ return state.events.find((e)=>e.id===state.selectedId) || null; }
87
+ function listEvents(){ return state.events.filter((e)=> state.filter==='all' ? true : e.failed); }
88
+
89
+ function renderList(){
90
+ const list=q('event-list'); const events=listEvents();
91
+ list.innerHTML = events.map((e)=>\`<div class="row \${e.failed?'fail':''} \${state.selectedId===e.id?'sel':''}" data-id="\${e.id}"><div class="meta"><span>\${e.method}</span><span class="status-badge \${e.failed?'fail':''}">\${e.status ?? 'ERR'}</span></div><div class="path">\${e.path}</div><div class="small">\${new Date(e.receivedAt).toLocaleTimeString()} · \${e.latency ?? 0}ms \${e.replay?'· replay':''}</div></div>\`).join('');
92
+ for(const n of list.querySelectorAll('.row')) n.addEventListener('click',()=>{ state.selectedId=n.dataset.id; render(); });
93
+ const failed=state.events.filter((e)=>e.failed);
94
+ q('dlq-count').textContent=\`\${failed.length} failed events\`;
95
+ q('dlq').style.display=failed.length?'flex':'none';
96
+ }
97
+
98
+ function renderDetail(){
99
+ const el=q('detail'); const ev=selected();
100
+ if(!ev){ el.innerHTML='<div class="empty">⌖\nawaiting signal\npaste the tunnel url above as your webhook endpoint in stripe, github, or any provider</div>'; return; }
101
+ if(state.tab==='payload') el.textContent = JSON.stringify(ev.bodyParsed ?? ev.body, null, 2);
102
+ if(state.tab==='headers') el.textContent = JSON.stringify(ev.headers, null, 2);
103
+ if(state.tab==='response') el.textContent = JSON.stringify({ status: ev.status, failed: ev.failed, latency: ev.latency, error: ev.error }, null, 2);
104
+ }
105
+
106
+ function render(){ renderList(); renderDetail(); }
107
+
108
+ async function load(){
109
+ const [s,e]=await Promise.all([fetch('/api/status').then(r=>r.json()), fetch('/api/events').then(r=>r.json())]);
110
+ setStatus(s); state.tunnelUrl=s.tunnelUrl; state.sessionId=s.sessionId; q('tunnel-url').value=s.tunnelUrl; state.events=e.events || []; if(state.events[0]) state.selectedId=state.events[0].id; render();
111
+ }
112
+
113
+ function connectWs(){
114
+ const ws = new WebSocket(\`ws://\${location.hostname}:\${WS_PORT}\`);
115
+ ws.onmessage = (msg) => {
116
+ const data = JSON.parse(msg.data);
117
+ if(data.type==='event'){ state.events.unshift(data.event); if(!state.selectedId) state.selectedId=data.event.id; render(); }
118
+ if(data.type==='clear'){ state.events=[]; state.selectedId=null; render(); }
119
+ if(data.type==='status'){
120
+ setStatus(data);
121
+ if(data.tunnelUrl && data.tunnelUrl!==state.tunnelUrl){ state.tunnelUrl=data.tunnelUrl; q('tunnel-url').value=data.tunnelUrl; if(state.sessionId && data.sessionId!==state.sessionId){ q('url-changed').classList.add('show'); } state.sessionId=data.sessionId||state.sessionId; }
122
+ }
123
+ };
124
+ ws.onclose = () => setStatus({ state: 'reconnecting' });
125
+ }
126
+
127
+ q('copy-url').addEventListener('click', async ()=>{ await navigator.clipboard.writeText(q('tunnel-url').value); q('copy-url').textContent='✓ copied'; toast('Tunnel URL copied — paste this as your webhook endpoint'); setTimeout(()=>q('copy-url').textContent='copy ⎘',1500); });
128
+ q('copy-new-url').addEventListener('click', async ()=>{ await navigator.clipboard.writeText(q('tunnel-url').value); q('url-changed').classList.remove('show'); toast('Tunnel URL copied — paste this as your webhook endpoint'); });
129
+
130
+ for(const b of document.querySelectorAll('.filters button')) b.addEventListener('click',()=>{ for(const n of document.querySelectorAll('.filters button')) n.classList.remove('active'); b.classList.add('active'); state.filter=b.dataset.filter; render(); });
131
+ for(const b of document.querySelectorAll('.tabs button')) b.addEventListener('click',()=>{ for(const n of document.querySelectorAll('.tabs button')) n.classList.remove('active'); b.classList.add('active'); state.tab=b.dataset.tab; renderDetail(); });
132
+
133
+ q('replay-btn').addEventListener('click', async ()=>{ const ev=selected(); if(!ev) return; const r=await fetch('/api/replay',{ method:'POST', headers:{'content-type':'application/json'}, body:JSON.stringify({id:ev.id})}); const out=await r.json(); if(out.event){ state.events.unshift(out.event); state.selectedId=out.event.id; render(); } });
134
+ q('replay-all').addEventListener('click', async ()=>{ for(const ev of state.events.filter((e)=>e.failed)){ await fetch('/api/replay',{ method:'POST', headers:{'content-type':'application/json'}, body:JSON.stringify({id:ev.id})}); } });
135
+ q('clear-btn').addEventListener('click', ()=>fetch('/api/clear',{method:'POST'}));
136
+ q('copy-curl').addEventListener('click', async ()=>{ const ev=selected(); if(!ev)return; const hs=Object.entries(ev.headers).map(([k,v])=>\`-H '\${k}: \${String(v).replace(/'/g,"'\\''")}'\`).join(' '); await navigator.clipboard.writeText(\`curl -X \${ev.method} '\${state.tunnelUrl}\${ev.path}' \${hs} --data-binary '\${String(ev.body).replace(/'/g,"'\\''")}'\`); toast('curl copied'); });
137
+ q('copy-fetch').addEventListener('click', async ()=>{ const ev=selected(); if(!ev)return; await navigator.clipboard.writeText(\`await fetch('http://127.0.0.1:\${location.port}\${ev.path}', { method: '\${ev.method}', headers: \${JSON.stringify(ev.headers, null, 2)}, body: \${JSON.stringify(ev.body)} });\`); toast('fetch copied'); });
138
+
139
+ load().then(connectWs);
140
+ </script>
141
+ </body>
142
+ </html>
143
+ `;
144
+ //# sourceMappingURL=ui-bundle.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ui-bundle.js","sourceRoot":"","sources":["../src/ui-bundle.ts"],"names":[],"mappings":";;;AAAa,QAAA,OAAO,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2ItB,CAAC"}
@@ -0,0 +1,22 @@
1
+ import { EventStore } from "./event-store";
2
+ import { StatusPayload, TernEvent } from "./types";
3
+ interface UiServerOptions {
4
+ eventStore: EventStore;
5
+ localPort: number;
6
+ uiHtml: string;
7
+ onReplay: (event: TernEvent) => void;
8
+ onClear: () => void;
9
+ getStatus: () => StatusPayload;
10
+ version: string;
11
+ wsPort: number;
12
+ }
13
+ export declare class UiServer {
14
+ private readonly options;
15
+ private server;
16
+ constructor(options: UiServerOptions);
17
+ start(port: number): void;
18
+ close(): void;
19
+ private sendJson;
20
+ private readBody;
21
+ }
22
+ export {};
@@ -0,0 +1,127 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.UiServer = void 0;
7
+ const http_1 = __importDefault(require("http"));
8
+ const forwarder_1 = require("./forwarder");
9
+ const logger_1 = require("./logger");
10
+ class UiServer {
11
+ options;
12
+ server = null;
13
+ constructor(options) {
14
+ this.options = options;
15
+ }
16
+ start(port) {
17
+ this.server = http_1.default.createServer(async (req, res) => {
18
+ if (!req.url || !req.method) {
19
+ this.sendJson(res, 400, { error: "Bad request" });
20
+ return;
21
+ }
22
+ if (req.method === "GET" && req.url === "/") {
23
+ const html = this.options.uiHtml
24
+ .replace("__TERN_TUNNEL_URL__", this.options.getStatus().tunnelUrl)
25
+ .replace("__TERN_WS_PORT__", String(this.options.wsPort));
26
+ res.statusCode = 200;
27
+ res.setHeader("content-type", "text/html; charset=utf-8");
28
+ res.end(html);
29
+ return;
30
+ }
31
+ if (req.method === "GET" && req.url === "/api/status") {
32
+ const status = this.options.getStatus();
33
+ this.sendJson(res, 200, {
34
+ connected: status.connected,
35
+ state: status.state,
36
+ tunnelUrl: status.tunnelUrl,
37
+ sessionId: status.sessionId,
38
+ port: this.options.localPort,
39
+ version: this.options.version
40
+ });
41
+ return;
42
+ }
43
+ if (req.method === "GET" && req.url === "/api/events") {
44
+ this.sendJson(res, 200, { events: this.options.eventStore.list() });
45
+ return;
46
+ }
47
+ if (req.method === "POST" && req.url === "/api/clear") {
48
+ this.options.eventStore.clear();
49
+ this.options.onClear();
50
+ this.sendJson(res, 200, { ok: true });
51
+ return;
52
+ }
53
+ if (req.method === "POST" && req.url === "/api/replay") {
54
+ const contentLength = Number(req.headers["content-length"] ?? 0);
55
+ if (Number.isFinite(contentLength) && contentLength > 16_384) {
56
+ this.sendJson(res, 413, { error: "Request body too large" });
57
+ return;
58
+ }
59
+ const body = await this.readBody(req).catch(() => null);
60
+ if (body === null) {
61
+ this.sendJson(res, 413, { error: "Request body too large" });
62
+ return;
63
+ }
64
+ let eventId = "";
65
+ try {
66
+ const parsed = JSON.parse(body);
67
+ eventId = parsed.id ?? "";
68
+ }
69
+ catch {
70
+ this.sendJson(res, 400, { error: "Invalid JSON" });
71
+ return;
72
+ }
73
+ const event = this.options.eventStore.get(eventId);
74
+ if (!event) {
75
+ this.sendJson(res, 404, { error: "Event not found" });
76
+ return;
77
+ }
78
+ const replayed = await (0, forwarder_1.replay)(event, this.options.localPort);
79
+ this.options.eventStore.add(replayed);
80
+ this.options.onReplay(replayed);
81
+ this.sendJson(res, 200, { event: replayed });
82
+ return;
83
+ }
84
+ this.sendJson(res, 404, { error: "Not found" });
85
+ });
86
+ this.server.on("error", (err) => {
87
+ if (err.code === "EADDRINUSE") {
88
+ (0, logger_1.error)(`Dashboard port ${port} is already in use. ` +
89
+ `Use --ui-port to choose a different port.`);
90
+ }
91
+ else {
92
+ (0, logger_1.error)(`Dashboard server error: ${err.message}`);
93
+ }
94
+ process.exit(1);
95
+ });
96
+ this.server.listen(port);
97
+ }
98
+ close() {
99
+ this.server?.close();
100
+ this.server = null;
101
+ }
102
+ sendJson(res, statusCode, payload) {
103
+ res.statusCode = statusCode;
104
+ res.setHeader("content-type", "application/json; charset=utf-8");
105
+ res.end(JSON.stringify(payload));
106
+ }
107
+ readBody(req, maxBytes = 16_384) {
108
+ return new Promise((resolve, reject) => {
109
+ let body = "";
110
+ let size = 0;
111
+ req.setEncoding("utf8");
112
+ req.on("data", (chunk) => {
113
+ size += Buffer.byteLength(chunk);
114
+ if (size > maxBytes) {
115
+ req.destroy();
116
+ reject(new Error("Request body too large"));
117
+ return;
118
+ }
119
+ body += chunk;
120
+ });
121
+ req.on("end", () => resolve(body));
122
+ req.on("error", reject);
123
+ });
124
+ }
125
+ }
126
+ exports.UiServer = UiServer;
127
+ //# sourceMappingURL=ui-server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ui-server.js","sourceRoot":"","sources":["../src/ui-server.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,17 @@
1
+ import { StatusPayload, TernEvent } from "./types";
2
+ export type BrowserMessage = {
3
+ type: "event";
4
+ event: TernEvent;
5
+ } | {
6
+ type: "clear";
7
+ } | ({
8
+ type: "status";
9
+ } & StatusPayload);
10
+ export declare class WsServer {
11
+ private wss;
12
+ private status;
13
+ start(port: number): void;
14
+ setStatus(status: StatusPayload): void;
15
+ broadcast(payload: BrowserMessage): void;
16
+ close(): void;
17
+ }
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.WsServer = void 0;
37
+ const ws_1 = __importStar(require("ws"));
38
+ class WsServer {
39
+ wss = null;
40
+ status = {
41
+ connected: false,
42
+ state: "connecting",
43
+ tunnelUrl: "",
44
+ sessionId: ""
45
+ };
46
+ start(port) {
47
+ this.wss = new ws_1.WebSocketServer({ port });
48
+ this.wss.on("connection", (socket) => {
49
+ socket.send(JSON.stringify({ type: "status", ...this.status }));
50
+ });
51
+ }
52
+ setStatus(status) {
53
+ this.status = status;
54
+ this.broadcast({ type: "status", ...status });
55
+ }
56
+ broadcast(payload) {
57
+ if (!this.wss)
58
+ return;
59
+ const message = JSON.stringify(payload);
60
+ for (const client of this.wss.clients) {
61
+ if (client.readyState === ws_1.default.OPEN) {
62
+ client.send(message);
63
+ }
64
+ }
65
+ }
66
+ close() {
67
+ this.wss?.close();
68
+ this.wss = null;
69
+ }
70
+ }
71
+ exports.WsServer = WsServer;
72
+ //# sourceMappingURL=ws-server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ws-server.js","sourceRoot":"","sources":["../src/ws-server.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,yCAAgD;AAQhD,MAAa,QAAQ;IACX,GAAG,GAA2B,IAAI,CAAC;IACnC,MAAM,GAAkB;QAC9B,SAAS,EAAE,KAAK;QAChB,KAAK,EAAE,YAAY;QACnB,SAAS,EAAE,EAAE;QACb,SAAS,EAAE,EAAE;KACd,CAAC;IAEF,KAAK,CAAC,IAAY;QAChB,IAAI,CAAC,GAAG,GAAG,IAAI,oBAAe,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC;QACzC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,YAAY,EAAE,CAAC,MAAM,EAAE,EAAE;YACnC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;QAClE,CAAC,CAAC,CAAC;IACL,CAAC;IAED,SAAS,CAAC,MAAqB;QAC7B,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,SAAS,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,GAAG,MAAM,EAAE,CAAC,CAAC;IAChD,CAAC;IAED,SAAS,CAAC,OAAuB;QAC/B,IAAI,CAAC,IAAI,CAAC,GAAG;YAAE,OAAO;QAEtB,MAAM,OAAO,GAAG,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC;QACxC,KAAK,MAAM,MAAM,IAAI,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,CAAC;YACtC,IAAI,MAAM,CAAC,UAAU,KAAK,YAAS,CAAC,IAAI,EAAE,CAAC;gBACzC,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;YACvB,CAAC;QACH,CAAC;IACH,CAAC;IAED,KAAK;QACH,IAAI,CAAC,GAAG,EAAE,KAAK,EAAE,CAAC;QAClB,IAAI,CAAC,GAAG,GAAG,IAAI,CAAC;IAClB,CAAC;CACF;AApCD,4BAoCC"}
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@hookflo/tern-dev",
3
+ "version": "0.1.0",
4
+ "description": "Local webhook tunnel and inspector. Zero storage.",
5
+ "license": "MIT",
6
+ "author": "Hookflo <hello@hookflo.com>",
7
+ "homepage": "https://tern.hookflo.com",
8
+ "repository": { "type": "git", "url": "https://github.com/hookflo/tern-dev" },
9
+ "engines": { "node": ">=18.0.0" },
10
+ "bin": { "tern-dev": "./dist/cli.js" },
11
+ "main": "./dist/cli.js",
12
+ "files": ["dist", "README.md", "LICENSE"],
13
+ "scripts": {
14
+ "build": "npm run bundle-ui && tsc",
15
+ "bundle-ui": "node scripts/bundle-ui.js",
16
+ "dev": "tsx src/cli.ts --port 3000",
17
+ "typecheck": "tsc --noEmit",
18
+ "clean": "rm -rf dist"
19
+ },
20
+ "dependencies": {
21
+ "ws": "^8.18.0",
22
+ "minimist": "^1.2.8"
23
+ },
24
+ "devDependencies": {
25
+ "@types/ws": "^8.5.13",
26
+ "@types/minimist": "^1.2.5",
27
+ "@types/node": "^20.0.0",
28
+ "typescript": "^5.4.0",
29
+ "tsx": "^4.0.0"
30
+ },
31
+ "keywords": ["webhook", "tunnel", "devtools", "hookflo", "tern", "local"]
32
+ }