nbound 1.0.3 → 1.0.4
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/dist/bin/nbound.js +374 -48
- package/dist/index.js +375 -49
- package/dist/index.js.map +12 -11
- package/package.json +1 -1
package/dist/bin/nbound.js
CHANGED
|
@@ -5223,7 +5223,7 @@ var require_websocket = __commonJS((exports, module) => {
|
|
|
5223
5223
|
var http = __require("http");
|
|
5224
5224
|
var net = __require("net");
|
|
5225
5225
|
var tls = __require("tls");
|
|
5226
|
-
var { randomBytes, createHash } = __require("crypto");
|
|
5226
|
+
var { randomBytes: randomBytes2, createHash } = __require("crypto");
|
|
5227
5227
|
var { Duplex, Readable } = __require("stream");
|
|
5228
5228
|
var { URL: URL2 } = __require("url");
|
|
5229
5229
|
var PerMessageDeflate = require_permessage_deflate();
|
|
@@ -5632,7 +5632,7 @@ var require_websocket = __commonJS((exports, module) => {
|
|
|
5632
5632
|
}
|
|
5633
5633
|
}
|
|
5634
5634
|
const defaultPort = isSecure ? 443 : 80;
|
|
5635
|
-
const key =
|
|
5635
|
+
const key = randomBytes2(16).toString("base64");
|
|
5636
5636
|
const request2 = isSecure ? https.request : http.request;
|
|
5637
5637
|
const protocolSet = new Set;
|
|
5638
5638
|
let perMessageDeflate;
|
|
@@ -6971,17 +6971,45 @@ function nanoid(size = 21) {
|
|
|
6971
6971
|
}
|
|
6972
6972
|
|
|
6973
6973
|
// src/lib/config.ts
|
|
6974
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
|
6974
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, renameSync, chmodSync } from "node:fs";
|
|
6975
6975
|
import { homedir } from "node:os";
|
|
6976
6976
|
import { join } from "node:path";
|
|
6977
|
+
import { randomBytes } from "node:crypto";
|
|
6977
6978
|
var CONFIG_DIR = ".nbound";
|
|
6978
6979
|
var CONFIG_FILE = "config.json";
|
|
6980
|
+
var EXPIRY_WARNING_MS = 7 * 24 * 60 * 60 * 1000;
|
|
6979
6981
|
function getConfigDir() {
|
|
6980
6982
|
return join(homedir(), CONFIG_DIR);
|
|
6981
6983
|
}
|
|
6982
6984
|
function getConfigPath() {
|
|
6983
6985
|
return join(getConfigDir(), CONFIG_FILE);
|
|
6984
6986
|
}
|
|
6987
|
+
function checkTokenExpiry(config) {
|
|
6988
|
+
if (!config.expiresAt) {
|
|
6989
|
+
return "valid";
|
|
6990
|
+
}
|
|
6991
|
+
const expiresAt = new Date(config.expiresAt).getTime();
|
|
6992
|
+
const now = Date.now();
|
|
6993
|
+
if (now >= expiresAt) {
|
|
6994
|
+
return "expired";
|
|
6995
|
+
}
|
|
6996
|
+
if (expiresAt - now <= EXPIRY_WARNING_MS) {
|
|
6997
|
+
return "expiring_soon";
|
|
6998
|
+
}
|
|
6999
|
+
return "valid";
|
|
7000
|
+
}
|
|
7001
|
+
function getDaysUntilExpiry(config) {
|
|
7002
|
+
if (!config.expiresAt) {
|
|
7003
|
+
return null;
|
|
7004
|
+
}
|
|
7005
|
+
const expiresAt = new Date(config.expiresAt).getTime();
|
|
7006
|
+
const now = Date.now();
|
|
7007
|
+
const msRemaining = expiresAt - now;
|
|
7008
|
+
if (msRemaining <= 0) {
|
|
7009
|
+
return 0;
|
|
7010
|
+
}
|
|
7011
|
+
return Math.ceil(msRemaining / (24 * 60 * 60 * 1000));
|
|
7012
|
+
}
|
|
6985
7013
|
function loadConfig() {
|
|
6986
7014
|
const envToken = process.env.NBOUND_TOKEN;
|
|
6987
7015
|
if (envToken) {
|
|
@@ -6999,7 +7027,12 @@ function loadConfig() {
|
|
|
6999
7027
|
}
|
|
7000
7028
|
try {
|
|
7001
7029
|
const content = readFileSync(configPath, "utf-8");
|
|
7002
|
-
|
|
7030
|
+
const config = JSON.parse(content);
|
|
7031
|
+
const expiry = checkTokenExpiry(config);
|
|
7032
|
+
if (expiry === "expired") {
|
|
7033
|
+
return null;
|
|
7034
|
+
}
|
|
7035
|
+
return config;
|
|
7003
7036
|
} catch {
|
|
7004
7037
|
return null;
|
|
7005
7038
|
}
|
|
@@ -7007,10 +7040,30 @@ function loadConfig() {
|
|
|
7007
7040
|
function saveConfig(config) {
|
|
7008
7041
|
const configDir = getConfigDir();
|
|
7009
7042
|
const configPath = getConfigPath();
|
|
7010
|
-
|
|
7011
|
-
|
|
7043
|
+
try {
|
|
7044
|
+
if (!existsSync(configDir)) {
|
|
7045
|
+
mkdirSync(configDir, { recursive: true, mode: 448 });
|
|
7046
|
+
} else {
|
|
7047
|
+
chmodSync(configDir, 448);
|
|
7048
|
+
}
|
|
7049
|
+
} catch (err) {
|
|
7050
|
+
if (!existsSync(configDir)) {
|
|
7051
|
+
throw err;
|
|
7052
|
+
}
|
|
7053
|
+
}
|
|
7054
|
+
const tempPath = join(configDir, `.config.${randomBytes(8).toString("hex")}.tmp`);
|
|
7055
|
+
try {
|
|
7056
|
+
writeFileSync(tempPath, JSON.stringify(config, null, 2), { mode: 384 });
|
|
7057
|
+
chmodSync(tempPath, 384);
|
|
7058
|
+
renameSync(tempPath, configPath);
|
|
7059
|
+
} catch (err) {
|
|
7060
|
+
try {
|
|
7061
|
+
if (existsSync(tempPath)) {
|
|
7062
|
+
unlinkSync(tempPath);
|
|
7063
|
+
}
|
|
7064
|
+
} catch {}
|
|
7065
|
+
throw err;
|
|
7012
7066
|
}
|
|
7013
|
-
writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 384 });
|
|
7014
7067
|
}
|
|
7015
7068
|
function deleteConfig() {
|
|
7016
7069
|
const configPath = getConfigPath();
|
|
@@ -7032,6 +7085,10 @@ function getWsUrl() {
|
|
|
7032
7085
|
}
|
|
7033
7086
|
|
|
7034
7087
|
// src/lib/api.ts
|
|
7088
|
+
var REQUEST_TIMEOUT_MS = 1e4;
|
|
7089
|
+
var MAX_RETRIES = 3;
|
|
7090
|
+
var RETRY_DELAYS = [1000, 2000, 4000];
|
|
7091
|
+
|
|
7035
7092
|
class ApiError extends Error {
|
|
7036
7093
|
status;
|
|
7037
7094
|
code;
|
|
@@ -7042,22 +7099,57 @@ class ApiError extends Error {
|
|
|
7042
7099
|
this.name = "ApiError";
|
|
7043
7100
|
}
|
|
7044
7101
|
}
|
|
7102
|
+
function sleep(ms) {
|
|
7103
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
7104
|
+
}
|
|
7105
|
+
function isRetryable(status) {
|
|
7106
|
+
return status >= 500 && status < 600;
|
|
7107
|
+
}
|
|
7045
7108
|
async function request(path2, token, options = {}) {
|
|
7046
7109
|
const apiUrl = getApiUrl();
|
|
7047
7110
|
const url = `${apiUrl}${path2}`;
|
|
7048
|
-
|
|
7049
|
-
|
|
7050
|
-
|
|
7051
|
-
|
|
7052
|
-
|
|
7053
|
-
|
|
7111
|
+
let lastError = null;
|
|
7112
|
+
for (let attempt = 0;attempt <= MAX_RETRIES; attempt++) {
|
|
7113
|
+
try {
|
|
7114
|
+
const response = await fetch(url, {
|
|
7115
|
+
...options,
|
|
7116
|
+
headers: {
|
|
7117
|
+
"Content-Type": "application/json",
|
|
7118
|
+
Authorization: `Bearer ${token}`,
|
|
7119
|
+
...options.headers
|
|
7120
|
+
},
|
|
7121
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
7122
|
+
});
|
|
7123
|
+
const json = await response.json();
|
|
7124
|
+
if (!response.ok || !json.success) {
|
|
7125
|
+
const error = new ApiError(response.status, json.error?.code ?? "UNKNOWN_ERROR", json.error?.message ?? "An unknown error occurred");
|
|
7126
|
+
if (isRetryable(response.status) && attempt < MAX_RETRIES) {
|
|
7127
|
+
lastError = error;
|
|
7128
|
+
await sleep(RETRY_DELAYS[attempt]);
|
|
7129
|
+
continue;
|
|
7130
|
+
}
|
|
7131
|
+
throw error;
|
|
7132
|
+
}
|
|
7133
|
+
return json.data;
|
|
7134
|
+
} catch (error) {
|
|
7135
|
+
if (error instanceof ApiError && !isRetryable(error.status)) {
|
|
7136
|
+
throw error;
|
|
7137
|
+
}
|
|
7138
|
+
if (attempt < MAX_RETRIES) {
|
|
7139
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
7140
|
+
await sleep(RETRY_DELAYS[attempt]);
|
|
7141
|
+
continue;
|
|
7142
|
+
}
|
|
7143
|
+
if (error instanceof Error) {
|
|
7144
|
+
if (error.name === "TimeoutError" || error.name === "AbortError") {
|
|
7145
|
+
throw new ApiError(0, "TIMEOUT", `Request timed out after ${REQUEST_TIMEOUT_MS}ms`);
|
|
7146
|
+
}
|
|
7147
|
+
throw new ApiError(0, "NETWORK_ERROR", error.message);
|
|
7148
|
+
}
|
|
7149
|
+
throw error;
|
|
7054
7150
|
}
|
|
7055
|
-
});
|
|
7056
|
-
const json = await response.json();
|
|
7057
|
-
if (!response.ok || !json.success) {
|
|
7058
|
-
throw new ApiError(response.status, json.error?.code ?? "UNKNOWN_ERROR", json.error?.message ?? "An unknown error occurred");
|
|
7059
7151
|
}
|
|
7060
|
-
|
|
7152
|
+
throw lastError ?? new ApiError(0, "UNKNOWN_ERROR", "Request failed");
|
|
7061
7153
|
}
|
|
7062
7154
|
async function getUser(token) {
|
|
7063
7155
|
const result = await request("/api/auth/me", token);
|
|
@@ -7067,12 +7159,20 @@ async function listEndpoints(token) {
|
|
|
7067
7159
|
const result = await request("/api/endpoints", token);
|
|
7068
7160
|
return result;
|
|
7069
7161
|
}
|
|
7162
|
+
async function createEndpoint(token, input) {
|
|
7163
|
+
const result = await request("/api/endpoints", token, {
|
|
7164
|
+
method: "POST",
|
|
7165
|
+
body: JSON.stringify(input)
|
|
7166
|
+
});
|
|
7167
|
+
return result;
|
|
7168
|
+
}
|
|
7070
7169
|
async function exchangeCliCode(sessionId, code) {
|
|
7071
7170
|
const apiUrl = getApiUrl();
|
|
7072
7171
|
const response = await fetch(`${apiUrl}/api/auth/cli-exchange`, {
|
|
7073
7172
|
method: "POST",
|
|
7074
7173
|
headers: { "Content-Type": "application/json" },
|
|
7075
|
-
body: JSON.stringify({ sessionId, code })
|
|
7174
|
+
body: JSON.stringify({ sessionId, code }),
|
|
7175
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
7076
7176
|
});
|
|
7077
7177
|
const json = await response.json();
|
|
7078
7178
|
if (!response.ok || !json.success) {
|
|
@@ -7080,6 +7180,22 @@ async function exchangeCliCode(sessionId, code) {
|
|
|
7080
7180
|
}
|
|
7081
7181
|
return json.data;
|
|
7082
7182
|
}
|
|
7183
|
+
async function revokeCliToken(token) {
|
|
7184
|
+
const apiUrl = getApiUrl();
|
|
7185
|
+
try {
|
|
7186
|
+
const response = await fetch(`${apiUrl}/api/cli/revoke`, {
|
|
7187
|
+
method: "POST",
|
|
7188
|
+
headers: {
|
|
7189
|
+
"Content-Type": "application/json",
|
|
7190
|
+
Authorization: `Bearer ${token}`
|
|
7191
|
+
},
|
|
7192
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
7193
|
+
});
|
|
7194
|
+
if (!response.ok) {
|
|
7195
|
+
return;
|
|
7196
|
+
}
|
|
7197
|
+
} catch {}
|
|
7198
|
+
}
|
|
7083
7199
|
|
|
7084
7200
|
// src/ui/logger.ts
|
|
7085
7201
|
var import_picocolors = __toESM(require_picocolors(), 1);
|
|
@@ -8457,6 +8573,11 @@ async function loginCommand(options) {
|
|
|
8457
8573
|
try {
|
|
8458
8574
|
const user = await getUser(existingConfig.token);
|
|
8459
8575
|
log.info(`Already logged in as ${user.email}`);
|
|
8576
|
+
const expiry = checkTokenExpiry(existingConfig);
|
|
8577
|
+
if (expiry === "expiring_soon") {
|
|
8578
|
+
const daysLeft = getDaysUntilExpiry(existingConfig);
|
|
8579
|
+
log.warning(`Your token expires in ${daysLeft} day${daysLeft === 1 ? "" : "s"}. Run: nbound login to refresh.`);
|
|
8580
|
+
}
|
|
8460
8581
|
log.dim("Run: nbound logout to switch accounts");
|
|
8461
8582
|
return;
|
|
8462
8583
|
} catch {}
|
|
@@ -8507,12 +8628,13 @@ async function loginCommand(options) {
|
|
|
8507
8628
|
const spinner = createSpinner("Verifying code...");
|
|
8508
8629
|
spinner.start();
|
|
8509
8630
|
try {
|
|
8510
|
-
const { token, user } = await exchangeCliCode(sessionId, code);
|
|
8631
|
+
const { token, user, expiresAt } = await exchangeCliCode(sessionId, code);
|
|
8511
8632
|
saveConfig({
|
|
8512
8633
|
token,
|
|
8513
8634
|
user: { id: user.id, email: user.email },
|
|
8514
8635
|
apiUrl,
|
|
8515
|
-
wsUrl: apiUrl.replace("https://", "wss://").replace("http://", "ws://") + "/ws"
|
|
8636
|
+
wsUrl: apiUrl.replace("https://", "wss://").replace("http://", "ws://") + "/ws",
|
|
8637
|
+
expiresAt
|
|
8516
8638
|
});
|
|
8517
8639
|
spinner.succeed(`Logged in as ${user.email}`);
|
|
8518
8640
|
log.blank();
|
|
@@ -8533,6 +8655,12 @@ async function logoutCommand() {
|
|
|
8533
8655
|
log.info("Not logged in");
|
|
8534
8656
|
return;
|
|
8535
8657
|
}
|
|
8658
|
+
const config = loadConfig();
|
|
8659
|
+
if (config?.token) {
|
|
8660
|
+
try {
|
|
8661
|
+
await revokeCliToken(config.token);
|
|
8662
|
+
} catch {}
|
|
8663
|
+
}
|
|
8536
8664
|
deleteConfig();
|
|
8537
8665
|
log.success("Logged out");
|
|
8538
8666
|
log.dim(`Removed ${configPath}`);
|
|
@@ -12667,17 +12795,19 @@ function getBackoffDelay(attempt, baseMs = 1000, maxMs = 30000, jitter = true) {
|
|
|
12667
12795
|
}
|
|
12668
12796
|
// src/lib/ws-client.ts
|
|
12669
12797
|
var PING_INTERVAL_MS = 30000;
|
|
12798
|
+
var PONG_TIMEOUT_MS = 1e4;
|
|
12670
12799
|
var AUTH_TIMEOUT_MS = 1e4;
|
|
12671
12800
|
|
|
12672
12801
|
class WebSocketClient {
|
|
12673
12802
|
ws = null;
|
|
12674
12803
|
options;
|
|
12675
12804
|
pingInterval = null;
|
|
12805
|
+
pongTimeout = null;
|
|
12676
12806
|
authTimeout = null;
|
|
12807
|
+
reconnectTimeout = null;
|
|
12677
12808
|
reconnectAttempt = 0;
|
|
12678
|
-
|
|
12809
|
+
state = "disconnected";
|
|
12679
12810
|
shouldReconnect = true;
|
|
12680
|
-
authenticated = false;
|
|
12681
12811
|
userId = null;
|
|
12682
12812
|
plan = null;
|
|
12683
12813
|
constructor(options) {
|
|
@@ -12691,11 +12821,13 @@ class WebSocketClient {
|
|
|
12691
12821
|
if (this.ws) {
|
|
12692
12822
|
this.ws.removeAllListeners();
|
|
12693
12823
|
this.ws.close();
|
|
12824
|
+
this.ws = null;
|
|
12694
12825
|
}
|
|
12695
|
-
this.
|
|
12826
|
+
this.state = "connecting";
|
|
12696
12827
|
this.ws = new wrapper_default(this.options.wsUrl);
|
|
12697
12828
|
this.ws.on("open", () => {
|
|
12698
12829
|
this.reconnectAttempt = 0;
|
|
12830
|
+
this.state = "authenticating";
|
|
12699
12831
|
this.sendAuth();
|
|
12700
12832
|
this.startAuthTimeout();
|
|
12701
12833
|
});
|
|
@@ -12703,20 +12835,26 @@ class WebSocketClient {
|
|
|
12703
12835
|
try {
|
|
12704
12836
|
const message = JSON.parse(data.toString());
|
|
12705
12837
|
this.handleMessage(message);
|
|
12706
|
-
} catch {
|
|
12838
|
+
} catch (err) {
|
|
12839
|
+
this.options.onError(new Error(`Failed to parse WebSocket message: ${err instanceof Error ? err.message : "Unknown error"}`));
|
|
12840
|
+
}
|
|
12707
12841
|
});
|
|
12708
12842
|
this.ws.on("close", (code, reason) => {
|
|
12843
|
+
const prevState = this.state;
|
|
12709
12844
|
this.cleanup();
|
|
12710
12845
|
const reasonStr = reason?.toString() || `Code ${code}`;
|
|
12711
|
-
if (this.shouldReconnect &&
|
|
12846
|
+
if (this.shouldReconnect && prevState === "subscribed") {
|
|
12712
12847
|
this.scheduleReconnect();
|
|
12713
12848
|
this.options.onDisconnected(reasonStr);
|
|
12714
|
-
} else if (
|
|
12715
|
-
this.
|
|
12849
|
+
} else if (prevState === "authenticating" && this.shouldReconnect) {
|
|
12850
|
+
this.shouldReconnect = false;
|
|
12851
|
+
this.options.onError(new Error(`Connection closed during auth: ${reasonStr}`));
|
|
12852
|
+
} else if (this.shouldReconnect && prevState === "connecting") {
|
|
12853
|
+
this.scheduleReconnect();
|
|
12716
12854
|
}
|
|
12717
12855
|
});
|
|
12718
12856
|
this.ws.on("error", (error) => {
|
|
12719
|
-
if (
|
|
12857
|
+
if (this.state !== "subscribed" && this.shouldReconnect) {
|
|
12720
12858
|
this.scheduleReconnect();
|
|
12721
12859
|
} else {
|
|
12722
12860
|
this.options.onError(error);
|
|
@@ -12727,7 +12865,7 @@ class WebSocketClient {
|
|
|
12727
12865
|
switch (message.type) {
|
|
12728
12866
|
case "auth_success": {
|
|
12729
12867
|
const payload = message.payload;
|
|
12730
|
-
this.
|
|
12868
|
+
this.state = "authenticated";
|
|
12731
12869
|
this.userId = payload.user?.id ?? payload.userId ?? null;
|
|
12732
12870
|
this.plan = payload.plan;
|
|
12733
12871
|
this.clearAuthTimeout();
|
|
@@ -12744,7 +12882,7 @@ class WebSocketClient {
|
|
|
12744
12882
|
}
|
|
12745
12883
|
case "subscribed": {
|
|
12746
12884
|
const payload = message.payload;
|
|
12747
|
-
this.
|
|
12885
|
+
this.state = "subscribed";
|
|
12748
12886
|
this.options.onConnected(payload.endpointIds, payload.webhookUrls ?? {});
|
|
12749
12887
|
break;
|
|
12750
12888
|
}
|
|
@@ -12757,32 +12895,59 @@ class WebSocketClient {
|
|
|
12757
12895
|
break;
|
|
12758
12896
|
}
|
|
12759
12897
|
case "pong":
|
|
12898
|
+
this.clearPongTimeout();
|
|
12760
12899
|
break;
|
|
12761
12900
|
case "error": {
|
|
12762
12901
|
const payload = message.payload;
|
|
12763
12902
|
this.options.onError(new Error(`Server error: ${payload.message}`));
|
|
12764
12903
|
break;
|
|
12765
12904
|
}
|
|
12905
|
+
case "token_revoked":
|
|
12906
|
+
case "session_revoked": {
|
|
12907
|
+
this.shouldReconnect = false;
|
|
12908
|
+
this.options.onError(new Error("Your CLI token has been revoked. Please run `nbound login` again."));
|
|
12909
|
+
this.disconnect();
|
|
12910
|
+
break;
|
|
12911
|
+
}
|
|
12912
|
+
case "plan_changed": {
|
|
12913
|
+
const payload = message.payload;
|
|
12914
|
+
this.plan = payload.plan;
|
|
12915
|
+
break;
|
|
12916
|
+
}
|
|
12917
|
+
case "endpoint_deleted": {
|
|
12918
|
+
this.shouldReconnect = false;
|
|
12919
|
+
this.options.onError(new Error("This endpoint has been deleted."));
|
|
12920
|
+
this.disconnect();
|
|
12921
|
+
break;
|
|
12922
|
+
}
|
|
12923
|
+
default: {
|
|
12924
|
+
const unknownType2 = message.type;
|
|
12925
|
+
if (unknownType2) {}
|
|
12926
|
+
}
|
|
12766
12927
|
}
|
|
12767
12928
|
}
|
|
12768
12929
|
sendAuth() {
|
|
12769
|
-
this.send({
|
|
12930
|
+
if (!this.send({
|
|
12770
12931
|
type: "auth",
|
|
12771
12932
|
payload: { token: this.options.token }
|
|
12772
|
-
})
|
|
12933
|
+
})) {
|
|
12934
|
+
this.options.onError(new Error("Failed to send auth message"));
|
|
12935
|
+
}
|
|
12773
12936
|
}
|
|
12774
12937
|
sendForwardSubscribe() {
|
|
12775
|
-
this.send({
|
|
12938
|
+
if (!this.send({
|
|
12776
12939
|
type: "forward_subscribe",
|
|
12777
12940
|
payload: {
|
|
12778
12941
|
endpointIds: [this.options.endpointId],
|
|
12779
12942
|
port: this.options.port,
|
|
12780
12943
|
host: this.options.host
|
|
12781
12944
|
}
|
|
12782
|
-
})
|
|
12945
|
+
})) {
|
|
12946
|
+
this.options.onError(new Error("Failed to send subscribe message"));
|
|
12947
|
+
}
|
|
12783
12948
|
}
|
|
12784
12949
|
sendForwardResponse(requestId, response) {
|
|
12785
|
-
this.send({
|
|
12950
|
+
return this.send({
|
|
12786
12951
|
type: "forward_response",
|
|
12787
12952
|
payload: {
|
|
12788
12953
|
requestId,
|
|
@@ -12796,7 +12961,7 @@ class WebSocketClient {
|
|
|
12796
12961
|
});
|
|
12797
12962
|
}
|
|
12798
12963
|
sendForwardError(requestId, error) {
|
|
12799
|
-
this.send({
|
|
12964
|
+
return this.send({
|
|
12800
12965
|
type: "forward_error",
|
|
12801
12966
|
payload: {
|
|
12802
12967
|
requestId,
|
|
@@ -12809,17 +12974,42 @@ class WebSocketClient {
|
|
|
12809
12974
|
}
|
|
12810
12975
|
send(message) {
|
|
12811
12976
|
if (this.ws && this.ws.readyState === wrapper_default.OPEN) {
|
|
12812
|
-
|
|
12977
|
+
try {
|
|
12978
|
+
this.ws.send(JSON.stringify(message));
|
|
12979
|
+
return true;
|
|
12980
|
+
} catch {
|
|
12981
|
+
return false;
|
|
12982
|
+
}
|
|
12813
12983
|
}
|
|
12984
|
+
return false;
|
|
12814
12985
|
}
|
|
12815
12986
|
startPingInterval() {
|
|
12816
12987
|
this.pingInterval = setInterval(() => {
|
|
12817
|
-
this.send({ type: "ping" })
|
|
12988
|
+
if (this.send({ type: "ping" })) {
|
|
12989
|
+
this.startPongTimeout();
|
|
12990
|
+
}
|
|
12818
12991
|
}, PING_INTERVAL_MS);
|
|
12819
12992
|
}
|
|
12993
|
+
startPongTimeout() {
|
|
12994
|
+
this.clearPongTimeout();
|
|
12995
|
+
this.pongTimeout = setTimeout(() => {
|
|
12996
|
+
if (this.state === "subscribed" && this.shouldReconnect) {
|
|
12997
|
+
this.options.onError(new Error("Connection timeout - no pong received"));
|
|
12998
|
+
this.cleanup();
|
|
12999
|
+
this.scheduleReconnect();
|
|
13000
|
+
this.options.onDisconnected("Ping timeout");
|
|
13001
|
+
}
|
|
13002
|
+
}, PONG_TIMEOUT_MS);
|
|
13003
|
+
}
|
|
13004
|
+
clearPongTimeout() {
|
|
13005
|
+
if (this.pongTimeout) {
|
|
13006
|
+
clearTimeout(this.pongTimeout);
|
|
13007
|
+
this.pongTimeout = null;
|
|
13008
|
+
}
|
|
13009
|
+
}
|
|
12820
13010
|
startAuthTimeout() {
|
|
12821
13011
|
this.authTimeout = setTimeout(() => {
|
|
12822
|
-
if (
|
|
13012
|
+
if (this.state === "authenticating") {
|
|
12823
13013
|
this.shouldReconnect = false;
|
|
12824
13014
|
this.options.onError(new Error("Authentication timed out"));
|
|
12825
13015
|
this.disconnect();
|
|
@@ -12833,18 +13023,28 @@ class WebSocketClient {
|
|
|
12833
13023
|
}
|
|
12834
13024
|
}
|
|
12835
13025
|
cleanup() {
|
|
12836
|
-
this.
|
|
13026
|
+
this.state = "disconnected";
|
|
12837
13027
|
this.clearAuthTimeout();
|
|
13028
|
+
this.clearPongTimeout();
|
|
13029
|
+
this.clearReconnectTimeout();
|
|
12838
13030
|
if (this.pingInterval) {
|
|
12839
13031
|
clearInterval(this.pingInterval);
|
|
12840
13032
|
this.pingInterval = null;
|
|
12841
13033
|
}
|
|
12842
13034
|
}
|
|
13035
|
+
clearReconnectTimeout() {
|
|
13036
|
+
if (this.reconnectTimeout) {
|
|
13037
|
+
clearTimeout(this.reconnectTimeout);
|
|
13038
|
+
this.reconnectTimeout = null;
|
|
13039
|
+
}
|
|
13040
|
+
}
|
|
12843
13041
|
scheduleReconnect() {
|
|
13042
|
+
this.clearReconnectTimeout();
|
|
12844
13043
|
this.reconnectAttempt++;
|
|
12845
13044
|
const delay = getBackoffDelay(this.reconnectAttempt, 1000, 30000, true);
|
|
12846
13045
|
this.options.onReconnecting(this.reconnectAttempt);
|
|
12847
|
-
setTimeout(() => {
|
|
13046
|
+
this.reconnectTimeout = setTimeout(() => {
|
|
13047
|
+
this.reconnectTimeout = null;
|
|
12848
13048
|
if (this.shouldReconnect) {
|
|
12849
13049
|
this.doConnect();
|
|
12850
13050
|
}
|
|
@@ -12860,7 +13060,7 @@ class WebSocketClient {
|
|
|
12860
13060
|
}
|
|
12861
13061
|
}
|
|
12862
13062
|
get connected() {
|
|
12863
|
-
return this.
|
|
13063
|
+
return this.state === "subscribed";
|
|
12864
13064
|
}
|
|
12865
13065
|
}
|
|
12866
13066
|
|
|
@@ -12907,7 +13107,6 @@ class MultiEndpointClient {
|
|
|
12907
13107
|
const url = new URL(baseUrl);
|
|
12908
13108
|
url.searchParams.set("endpointId", endpointId);
|
|
12909
13109
|
url.searchParams.set("type", "cli");
|
|
12910
|
-
url.searchParams.set("token", this.options.token);
|
|
12911
13110
|
return url.toString();
|
|
12912
13111
|
}
|
|
12913
13112
|
handleEndpointConnected(endpointId, subscribedIds, urls) {
|
|
@@ -12967,6 +13166,7 @@ class MultiEndpointClient {
|
|
|
12967
13166
|
}
|
|
12968
13167
|
|
|
12969
13168
|
// src/lib/forwarder.ts
|
|
13169
|
+
import { posix } from "node:path";
|
|
12970
13170
|
var HOP_BY_HOP_HEADERS = new Set([
|
|
12971
13171
|
"connection",
|
|
12972
13172
|
"keep-alive",
|
|
@@ -12978,10 +13178,39 @@ var HOP_BY_HOP_HEADERS = new Set([
|
|
|
12978
13178
|
"upgrade",
|
|
12979
13179
|
"host"
|
|
12980
13180
|
]);
|
|
13181
|
+
var ALLOWED_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
|
13182
|
+
var MAX_RESPONSE_SIZE = 1024 * 1024;
|
|
13183
|
+
function normalizePath(pathPrefix, requestPath) {
|
|
13184
|
+
const prefix = pathPrefix.startsWith("/") ? pathPrefix : "/" + pathPrefix;
|
|
13185
|
+
const normalizedPrefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
13186
|
+
const path2 = requestPath.startsWith("/") ? requestPath : "/" + requestPath;
|
|
13187
|
+
const combined = normalizedPrefix + path2;
|
|
13188
|
+
const normalized = posix.normalize(combined);
|
|
13189
|
+
const expectedRoot = normalizedPrefix || "/";
|
|
13190
|
+
if (!normalized.startsWith(expectedRoot) && normalized !== expectedRoot.slice(0, -1)) {
|
|
13191
|
+
if (!normalized.startsWith("/")) {
|
|
13192
|
+
return null;
|
|
13193
|
+
}
|
|
13194
|
+
}
|
|
13195
|
+
return normalized;
|
|
13196
|
+
}
|
|
12981
13197
|
async function forwardRequest(request2, options) {
|
|
12982
13198
|
const startTime = Date.now();
|
|
13199
|
+
if (!ALLOWED_HOSTS.has(options.host.toLowerCase())) {
|
|
13200
|
+
throw {
|
|
13201
|
+
code: "INVALID_HOST",
|
|
13202
|
+
message: `Host '${options.host}' is not allowed. Only localhost, 127.0.0.1, and ::1 are permitted.`
|
|
13203
|
+
};
|
|
13204
|
+
}
|
|
13205
|
+
const normalizedPath = normalizePath(options.pathPrefix, request2.path);
|
|
13206
|
+
if (normalizedPath === null) {
|
|
13207
|
+
throw {
|
|
13208
|
+
code: "INVALID_PATH",
|
|
13209
|
+
message: `Path traversal attempt detected in '${request2.path}'`
|
|
13210
|
+
};
|
|
13211
|
+
}
|
|
12983
13212
|
const url = new URL(`http://${options.host}:${options.port}`);
|
|
12984
|
-
url.pathname =
|
|
13213
|
+
url.pathname = normalizedPath;
|
|
12985
13214
|
for (const [key, value] of Object.entries(request2.query)) {
|
|
12986
13215
|
url.searchParams.set(key, value);
|
|
12987
13216
|
}
|
|
@@ -12997,15 +13226,48 @@ async function forwardRequest(request2, options) {
|
|
|
12997
13226
|
method: request2.method,
|
|
12998
13227
|
headers,
|
|
12999
13228
|
body: request2.body,
|
|
13000
|
-
signal: AbortSignal.timeout(30000)
|
|
13229
|
+
signal: AbortSignal.timeout(30000),
|
|
13230
|
+
redirect: "manual"
|
|
13001
13231
|
});
|
|
13232
|
+
const contentLength = response.headers.get("content-length");
|
|
13233
|
+
if (contentLength && parseInt(contentLength, 10) > MAX_RESPONSE_SIZE) {
|
|
13234
|
+
throw {
|
|
13235
|
+
code: "RESPONSE_TOO_LARGE",
|
|
13236
|
+
message: `Response size ${contentLength} bytes exceeds limit of ${MAX_RESPONSE_SIZE} bytes`
|
|
13237
|
+
};
|
|
13238
|
+
}
|
|
13002
13239
|
const responseHeaders = {};
|
|
13003
13240
|
response.headers.forEach((value, key) => {
|
|
13004
13241
|
if (!HOP_BY_HOP_HEADERS.has(key.toLowerCase())) {
|
|
13005
13242
|
responseHeaders[key] = value;
|
|
13006
13243
|
}
|
|
13007
13244
|
});
|
|
13008
|
-
const
|
|
13245
|
+
const reader = response.body?.getReader();
|
|
13246
|
+
if (!reader) {
|
|
13247
|
+
return {
|
|
13248
|
+
status: response.status,
|
|
13249
|
+
headers: responseHeaders,
|
|
13250
|
+
body: null,
|
|
13251
|
+
timeMs: Date.now() - startTime
|
|
13252
|
+
};
|
|
13253
|
+
}
|
|
13254
|
+
const chunks = [];
|
|
13255
|
+
let totalSize = 0;
|
|
13256
|
+
while (true) {
|
|
13257
|
+
const { done, value } = await reader.read();
|
|
13258
|
+
if (done)
|
|
13259
|
+
break;
|
|
13260
|
+
totalSize += value.length;
|
|
13261
|
+
if (totalSize > MAX_RESPONSE_SIZE) {
|
|
13262
|
+
reader.cancel();
|
|
13263
|
+
throw {
|
|
13264
|
+
code: "RESPONSE_TOO_LARGE",
|
|
13265
|
+
message: `Response size exceeds limit of ${MAX_RESPONSE_SIZE} bytes`
|
|
13266
|
+
};
|
|
13267
|
+
}
|
|
13268
|
+
chunks.push(value);
|
|
13269
|
+
}
|
|
13270
|
+
const body = new TextDecoder().decode(chunks.length === 1 ? chunks[0] : concatUint8Arrays(chunks));
|
|
13009
13271
|
return {
|
|
13010
13272
|
status: response.status,
|
|
13011
13273
|
headers: responseHeaders,
|
|
@@ -13013,7 +13275,9 @@ async function forwardRequest(request2, options) {
|
|
|
13013
13275
|
timeMs: Date.now() - startTime
|
|
13014
13276
|
};
|
|
13015
13277
|
} catch (error) {
|
|
13016
|
-
|
|
13278
|
+
if (error && typeof error === "object" && "code" in error) {
|
|
13279
|
+
throw error;
|
|
13280
|
+
}
|
|
13017
13281
|
if (error instanceof Error) {
|
|
13018
13282
|
if (error.cause && typeof error.cause === "object" && "code" in error.cause) {
|
|
13019
13283
|
const code = error.cause.code;
|
|
@@ -13037,6 +13301,16 @@ async function forwardRequest(request2, options) {
|
|
|
13037
13301
|
};
|
|
13038
13302
|
}
|
|
13039
13303
|
}
|
|
13304
|
+
function concatUint8Arrays(arrays) {
|
|
13305
|
+
const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
|
|
13306
|
+
const result = new Uint8Array(totalLength);
|
|
13307
|
+
let offset = 0;
|
|
13308
|
+
for (const arr of arrays) {
|
|
13309
|
+
result.set(arr, offset);
|
|
13310
|
+
offset += arr.length;
|
|
13311
|
+
}
|
|
13312
|
+
return result;
|
|
13313
|
+
}
|
|
13040
13314
|
|
|
13041
13315
|
// src/commands/connect.ts
|
|
13042
13316
|
async function connectCommand(port, options) {
|
|
@@ -13254,6 +13528,57 @@ async function endpointsCommand(options) {
|
|
|
13254
13528
|
}
|
|
13255
13529
|
}
|
|
13256
13530
|
|
|
13531
|
+
// src/commands/create.ts
|
|
13532
|
+
async function createCommand2(nameArg, options) {
|
|
13533
|
+
const config = loadConfig();
|
|
13534
|
+
if (!config) {
|
|
13535
|
+
log.error("Not logged in");
|
|
13536
|
+
log.info("Run: nbound login");
|
|
13537
|
+
process.exit(2);
|
|
13538
|
+
}
|
|
13539
|
+
const name = nameArg || options.name;
|
|
13540
|
+
if (!name) {
|
|
13541
|
+
log.error("Endpoint name is required");
|
|
13542
|
+
log.info("Usage: nbound create <name>");
|
|
13543
|
+
log.info(" or: nbound create --name <name>");
|
|
13544
|
+
process.exit(4);
|
|
13545
|
+
}
|
|
13546
|
+
const spinner = createSpinner("Creating endpoint...");
|
|
13547
|
+
spinner.start();
|
|
13548
|
+
try {
|
|
13549
|
+
const endpoint = await createEndpoint(config.token, {
|
|
13550
|
+
name,
|
|
13551
|
+
description: options.description
|
|
13552
|
+
});
|
|
13553
|
+
spinner.succeed("Endpoint created");
|
|
13554
|
+
log.blank();
|
|
13555
|
+
log.bold(endpoint.name);
|
|
13556
|
+
log.blank();
|
|
13557
|
+
log.info(` URL: ${endpoint.url}`);
|
|
13558
|
+
log.info(` Slug: ${endpoint.slug}`);
|
|
13559
|
+
log.info(` ID: ${endpoint.id}`);
|
|
13560
|
+
log.blank();
|
|
13561
|
+
log.dim("Run 'nbound connect <port>' to start forwarding");
|
|
13562
|
+
log.blank();
|
|
13563
|
+
} catch (error) {
|
|
13564
|
+
if (error instanceof ApiError) {
|
|
13565
|
+
if (error.status === 401) {
|
|
13566
|
+
spinner.fail("Session expired");
|
|
13567
|
+
log.info("Run: nbound login");
|
|
13568
|
+
process.exit(2);
|
|
13569
|
+
}
|
|
13570
|
+
if (error.code === "PLAN_LIMIT") {
|
|
13571
|
+
spinner.fail("Endpoint limit reached");
|
|
13572
|
+
log.info("Upgrade at https://app.nbound.dev/settings/billing");
|
|
13573
|
+
process.exit(2);
|
|
13574
|
+
}
|
|
13575
|
+
spinner.fail(error.message);
|
|
13576
|
+
process.exit(1);
|
|
13577
|
+
}
|
|
13578
|
+
throw error;
|
|
13579
|
+
}
|
|
13580
|
+
}
|
|
13581
|
+
|
|
13257
13582
|
// src/index.ts
|
|
13258
13583
|
program.name("nbound").description("Forward webhooks to localhost for development").version("1.0.0");
|
|
13259
13584
|
program.command("login").description("Authenticate with nbound").option("--token <token>", "Use an existing API token").action(loginCommand);
|
|
@@ -13261,4 +13586,5 @@ program.command("logout").description("Clear stored credentials").action(logoutC
|
|
|
13261
13586
|
program.command("whoami").description("Show current authenticated user").action(whoamiCommand);
|
|
13262
13587
|
program.command("connect <port>").description("Forward webhooks to localhost").option("-e, --endpoint <id>", "Specific endpoint ID or slug").option("-p, --path <path>", "Path prefix for localhost").option("-h, --host <host>", "Local hostname", "localhost").option("--no-color", "Disable colored output").option("--json", "Output as JSON lines for scripting").option("-q, --quiet", "Minimal output").action(connectCommand);
|
|
13263
13588
|
program.command("endpoints").description("List your endpoints").option("--json", "Output as JSON").action(endpointsCommand);
|
|
13589
|
+
program.command("create [name]").description("Create a new endpoint").option("-n, --name <name>", "Endpoint name").option("-d, --description <description>", "Endpoint description").action(createCommand2);
|
|
13264
13590
|
program.parse();
|