shiplocal 0.1.4 → 0.1.6

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/index.js CHANGED
@@ -1,10 +1,121 @@
1
1
  #!/usr/bin/env node
2
2
  var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
3
7
  var __export = (target, all) => {
4
8
  for (var name in all)
5
9
  __defProp(target, name, { get: all[name], enumerable: true });
6
10
  };
7
11
 
12
+ // src/env-rewrite.ts
13
+ var env_rewrite_exports = {};
14
+ __export(env_rewrite_exports, {
15
+ runEnvRewrite: () => runEnvRewrite
16
+ });
17
+ import { copyFile, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
18
+ import { join as join2 } from "node:path";
19
+ function localhostPattern(port) {
20
+ return new RegExp(`https?://(?:127\\.0\\.0\\.1|localhost):${String(port)}(?:/|$)`, "i");
21
+ }
22
+ function findReplacements(file, content, port, publicUrl, siblingUrls) {
23
+ const replacements = [];
24
+ const lines = content.split("\n");
25
+ for (const line of lines) {
26
+ const trimmed = line.trim();
27
+ if (!trimmed || trimmed.startsWith("#")) continue;
28
+ const eqIndex = trimmed.indexOf("=");
29
+ if (eqIndex === -1) continue;
30
+ const key = trimmed.slice(0, eqIndex).trim();
31
+ const value = trimmed.slice(eqIndex + 1).trim().replace(/^["']|["']$/g, "");
32
+ if (!ENV_KEY_PATTERN.test(key)) continue;
33
+ if (localhostPattern(port).test(value)) {
34
+ replacements.push({ file, key, from: value, to: publicUrl });
35
+ continue;
36
+ }
37
+ for (const sibling of siblingUrls) {
38
+ if (localhostPattern(sibling.port).test(value)) {
39
+ replacements.push({ file, key, from: value, to: sibling.publicUrl });
40
+ break;
41
+ }
42
+ }
43
+ }
44
+ return replacements;
45
+ }
46
+ function applyReplacements(content, replacements) {
47
+ let updated = content;
48
+ for (const replacement of replacements) {
49
+ const pattern = new RegExp(
50
+ `^(${replacement.key}\\s*=\\s*["']?)${replacement.from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(["']?)`,
51
+ "m"
52
+ );
53
+ updated = updated.replace(pattern, `$1${replacement.to}$2`);
54
+ }
55
+ return updated;
56
+ }
57
+ async function runEnvRewrite(options) {
58
+ const allReplacements = [];
59
+ for (const fileName of ENV_FILES) {
60
+ const filePath = join2(process.cwd(), fileName);
61
+ try {
62
+ const content = await readFile2(filePath, "utf8");
63
+ allReplacements.push(
64
+ ...findReplacements(
65
+ fileName,
66
+ content,
67
+ options.port,
68
+ options.publicUrl,
69
+ options.siblingUrls
70
+ )
71
+ );
72
+ } catch {
73
+ }
74
+ }
75
+ if (allReplacements.length === 0) {
76
+ if (options.write) {
77
+ console.log("No matching localhost env vars found to rewrite.");
78
+ }
79
+ return;
80
+ }
81
+ console.log("");
82
+ console.log(options.write ? "Applying .env updates:" : "Suggested .env updates:");
83
+ for (const replacement of allReplacements) {
84
+ console.log(` ${replacement.file}: ${replacement.key}`);
85
+ console.log(` ${replacement.from}`);
86
+ console.log(` \u2192 ${replacement.to}`);
87
+ }
88
+ if (!options.write) {
89
+ console.log("");
90
+ console.log("Run with --rewrite-env to apply these changes.");
91
+ return;
92
+ }
93
+ const byFile = /* @__PURE__ */ new Map();
94
+ for (const replacement of allReplacements) {
95
+ const list = byFile.get(replacement.file) ?? [];
96
+ list.push(replacement);
97
+ byFile.set(replacement.file, list);
98
+ }
99
+ for (const [fileName, replacements] of byFile) {
100
+ const filePath = join2(process.cwd(), fileName);
101
+ const content = await readFile2(filePath, "utf8");
102
+ const backupPath = `${filePath}.shiplocal.bak`;
103
+ await copyFile(filePath, backupPath);
104
+ const updated = applyReplacements(content, replacements);
105
+ await writeFile2(filePath, updated, "utf8");
106
+ console.log(` Updated ${fileName} (backup: ${fileName}.shiplocal.bak)`);
107
+ }
108
+ console.log("");
109
+ }
110
+ var ENV_FILES, ENV_KEY_PATTERN;
111
+ var init_env_rewrite = __esm({
112
+ "src/env-rewrite.ts"() {
113
+ "use strict";
114
+ ENV_FILES = [".env", ".env.local", ".env.development"];
115
+ ENV_KEY_PATTERN = /^(NEXT_PUBLIC_|VITE_|REACT_APP_|PUBLIC_|API_|BACKEND_|FRONTEND_)/;
116
+ }
117
+ });
118
+
8
119
  // src/index.ts
9
120
  import { createInterface } from "node:readline/promises";
10
121
  import { stdin as input, stdout as output } from "node:process";
@@ -4061,8 +4172,20 @@ var loginSchema = external_exports.object({
4061
4172
  email: external_exports.string().email(),
4062
4173
  password: external_exports.string().min(1).max(128)
4063
4174
  });
4175
+ var forgotPasswordSchema = external_exports.object({
4176
+ email: external_exports.string().email()
4177
+ });
4178
+ var resetPasswordSchema = external_exports.object({
4179
+ token: external_exports.string().min(1),
4180
+ password: external_exports.string().min(8).max(128)
4181
+ });
4182
+ var changePasswordSchema = external_exports.object({
4183
+ currentPassword: external_exports.string().min(1).max(128).optional(),
4184
+ newPassword: external_exports.string().min(8).max(128)
4185
+ });
4064
4186
  var createProjectSchema = external_exports.object({
4065
- name: external_exports.string().min(1).max(100)
4187
+ name: external_exports.string().min(1).max(100),
4188
+ slug: external_exports.string().min(2).max(48).optional()
4066
4189
  });
4067
4190
 
4068
4191
  // ../shared/dist/constants.js
@@ -4073,11 +4196,19 @@ var TUNNEL_WS_PATH = "/tunnel";
4073
4196
  var DEFAULT_TUNNEL_EXPIRY_MS = 2 * 60 * 60 * 1e3;
4074
4197
  var MAX_BODY_BYTES = 50 * 1024 * 1024;
4075
4198
  var headersSchema = external_exports.record(external_exports.union([external_exports.string(), external_exports.array(external_exports.string())]));
4199
+ var siblingTunnelSchema = external_exports.object({
4200
+ name: external_exports.string(),
4201
+ publicUrl: external_exports.string().url(),
4202
+ port: external_exports.number().int()
4203
+ });
4076
4204
  var registerMessageSchema = external_exports.object({
4077
4205
  type: external_exports.literal("register"),
4078
4206
  localPort: external_exports.number().int().min(1).max(65535),
4079
4207
  token: external_exports.string().optional(),
4080
4208
  projectId: external_exports.string().optional(),
4209
+ projectSlug: external_exports.string().optional(),
4210
+ targetName: external_exports.string().optional(),
4211
+ tunnelId: external_exports.string().optional(),
4081
4212
  password: external_exports.string().min(4).max(128).optional()
4082
4213
  });
4083
4214
  var registeredMessageSchema = external_exports.object({
@@ -4085,7 +4216,10 @@ var registeredMessageSchema = external_exports.object({
4085
4216
  tunnelId: external_exports.string(),
4086
4217
  subdomain: external_exports.string(),
4087
4218
  publicUrl: external_exports.string().url(),
4088
- expiresAt: external_exports.string()
4219
+ expiresAt: external_exports.string(),
4220
+ projectSlug: external_exports.string().optional(),
4221
+ targetName: external_exports.string().optional(),
4222
+ siblingUrls: external_exports.array(siblingTunnelSchema).optional()
4089
4223
  });
4090
4224
  var pingMessageSchema = external_exports.object({ type: external_exports.literal("ping") });
4091
4225
  var pongMessageSchema = external_exports.object({ type: external_exports.literal("pong") });
@@ -4105,10 +4239,32 @@ var tunnelResponseMessageSchema = external_exports.object({
4105
4239
  headers: headersSchema,
4106
4240
  body: external_exports.string().optional()
4107
4241
  });
4242
+ var tunnelWebSocketOpenMessageSchema = external_exports.object({
4243
+ type: external_exports.literal("ws-open"),
4244
+ id: external_exports.string(),
4245
+ path: external_exports.string(),
4246
+ query: external_exports.string(),
4247
+ headers: headersSchema
4248
+ });
4249
+ var tunnelWebSocketMessageSchema = external_exports.object({
4250
+ type: external_exports.literal("ws-message"),
4251
+ id: external_exports.string(),
4252
+ body: external_exports.string()
4253
+ });
4254
+ var tunnelWebSocketCloseMessageSchema = external_exports.object({
4255
+ type: external_exports.literal("ws-close"),
4256
+ id: external_exports.string(),
4257
+ code: external_exports.number().int().optional(),
4258
+ reason: external_exports.string().optional()
4259
+ });
4108
4260
  var errorMessageSchema = external_exports.object({
4109
4261
  type: external_exports.literal("error"),
4110
4262
  message: external_exports.string()
4111
4263
  });
4264
+ var terminatedMessageSchema = external_exports.object({
4265
+ type: external_exports.literal("terminated"),
4266
+ message: external_exports.string()
4267
+ });
4112
4268
  var tunnelMessageSchema = external_exports.discriminatedUnion("type", [
4113
4269
  registerMessageSchema,
4114
4270
  registeredMessageSchema,
@@ -4116,7 +4272,11 @@ var tunnelMessageSchema = external_exports.discriminatedUnion("type", [
4116
4272
  pongMessageSchema,
4117
4273
  tunnelRequestMessageSchema,
4118
4274
  tunnelResponseMessageSchema,
4119
- errorMessageSchema
4275
+ tunnelWebSocketOpenMessageSchema,
4276
+ tunnelWebSocketMessageSchema,
4277
+ tunnelWebSocketCloseMessageSchema,
4278
+ errorMessageSchema,
4279
+ terminatedMessageSchema
4120
4280
  ]);
4121
4281
  function parseTunnelMessage(data) {
4122
4282
  const parsed = typeof data === "string" ? JSON.parse(data) : data;
@@ -4148,12 +4308,22 @@ var healthResponseSchema = external_exports.object({
4148
4308
  timestamp: external_exports.string()
4149
4309
  });
4150
4310
 
4311
+ // ../shared/dist/slug.js
4312
+ function normalizeTargetName(name) {
4313
+ return name.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-").slice(0, 32);
4314
+ }
4315
+
4151
4316
  // ../tunnel-client/dist/client.js
4152
4317
  import { WebSocket } from "ws";
4153
4318
 
4154
4319
  // ../tunnel-client/dist/local-proxy.js
4155
4320
  import http from "node:http";
4321
+ import { promisify } from "node:util";
4322
+ import { brotliCompress, constants as zlibConstants, gzip } from "node:zlib";
4156
4323
  var LOOPBACK_HOSTS = ["127.0.0.1", "::1"];
4324
+ var gzipAsync = promisify(gzip);
4325
+ var brotliCompressAsync = promisify(brotliCompress);
4326
+ var MIN_COMPRESS_BYTES = 1024;
4157
4327
  var HOP_BY_HOP_HEADERS = /* @__PURE__ */ new Set([
4158
4328
  "connection",
4159
4329
  "keep-alive",
@@ -4164,11 +4334,19 @@ var HOP_BY_HOP_HEADERS = /* @__PURE__ */ new Set([
4164
4334
  "transfer-encoding",
4165
4335
  "upgrade"
4166
4336
  ]);
4167
- var STRIP_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
4168
- ...HOP_BY_HOP_HEADERS,
4169
- "content-encoding",
4170
- "content-length"
4171
- ]);
4337
+ var STRIP_RESPONSE_HEADERS = /* @__PURE__ */ new Set([...HOP_BY_HOP_HEADERS, "content-length"]);
4338
+ var COMPRESSIBLE_CONTENT_TYPES = [
4339
+ "application/javascript",
4340
+ "application/json",
4341
+ "application/manifest+json",
4342
+ "application/rss+xml",
4343
+ "application/vnd.apple.mpegurl",
4344
+ "application/wasm",
4345
+ "application/x-javascript",
4346
+ "application/xml",
4347
+ "image/svg+xml",
4348
+ "text/"
4349
+ ];
4172
4350
  function isConnectionRefused(err) {
4173
4351
  return err instanceof Error && "code" in err && err.code === "ECONNREFUSED";
4174
4352
  }
@@ -4176,7 +4354,7 @@ function sanitizeRequestHeaders(headers, localPort) {
4176
4354
  const result = {};
4177
4355
  for (const [key, value] of Object.entries(headers)) {
4178
4356
  const lower = key.toLowerCase();
4179
- if (HOP_BY_HOP_HEADERS.has(lower) || lower === "host" || lower === "accept-encoding") {
4357
+ if (HOP_BY_HOP_HEADERS.has(lower) || lower === "host") {
4180
4358
  continue;
4181
4359
  }
4182
4360
  result[key] = value;
@@ -4196,6 +4374,73 @@ function sanitizeResponseHeaders(headers) {
4196
4374
  }
4197
4375
  return result;
4198
4376
  }
4377
+ function headerValue(value) {
4378
+ if (value === void 0)
4379
+ return void 0;
4380
+ return Array.isArray(value) ? value[0] : value;
4381
+ }
4382
+ function getHeader(headers, name) {
4383
+ const lowerName = name.toLowerCase();
4384
+ for (const [key, value] of Object.entries(headers)) {
4385
+ if (key.toLowerCase() === lowerName)
4386
+ return headerValue(value);
4387
+ }
4388
+ return void 0;
4389
+ }
4390
+ function mergeVary(existing, value) {
4391
+ if (!existing)
4392
+ return value;
4393
+ const parts = existing.split(",").map((part) => part.trim().toLowerCase());
4394
+ if (parts.includes(value.toLowerCase()))
4395
+ return existing;
4396
+ return `${existing}, ${value}`;
4397
+ }
4398
+ function setHeader(headers, name, value) {
4399
+ const next = Object.fromEntries(Object.entries(headers).filter(([key]) => key.toLowerCase() !== name.toLowerCase()));
4400
+ next[name] = value;
4401
+ return next;
4402
+ }
4403
+ function stripHeader(headers, name) {
4404
+ return Object.fromEntries(Object.entries(headers).filter(([key]) => key.toLowerCase() !== name.toLowerCase()));
4405
+ }
4406
+ function isCompressibleContentType(contentType) {
4407
+ if (!contentType)
4408
+ return false;
4409
+ const normalized = contentType.toLowerCase();
4410
+ if (normalized.includes("text/html"))
4411
+ return false;
4412
+ return COMPRESSIBLE_CONTENT_TYPES.some((type) => normalized.includes(type));
4413
+ }
4414
+ async function maybeCompressResponse(body, responseHeaders, requestHeaders) {
4415
+ const headers = sanitizeResponseHeaders(responseHeaders);
4416
+ if (body.length < MIN_COMPRESS_BYTES || getHeader(responseHeaders, "content-encoding") || getHeader(responseHeaders, "content-range") || !isCompressibleContentType(getHeader(responseHeaders, "content-type"))) {
4417
+ return { body, headers };
4418
+ }
4419
+ const acceptEncoding = getHeader(requestHeaders, "accept-encoding") ?? "";
4420
+ if (/\bbr\b/.test(acceptEncoding)) {
4421
+ const compressed = await brotliCompressAsync(body, {
4422
+ params: {
4423
+ [zlibConstants.BROTLI_PARAM_QUALITY]: 4
4424
+ }
4425
+ });
4426
+ const vary = mergeVary(getHeader(headers, "vary"), "Accept-Encoding");
4427
+ const compressedHeaders = setHeader(stripHeader(headers, "etag"), "content-encoding", "br");
4428
+ return {
4429
+ body: compressed,
4430
+ headers: setHeader(compressedHeaders, "vary", vary)
4431
+ };
4432
+ }
4433
+ if (/\bgzip\b/.test(acceptEncoding)) {
4434
+ const compressed = await gzipAsync(body, { level: 6 });
4435
+ const vary = mergeVary(getHeader(headers, "vary"), "Accept-Encoding");
4436
+ const compressedHeaders = setHeader(stripHeader(headers, "etag"), "content-encoding", "gzip");
4437
+ return {
4438
+ body: compressed,
4439
+ headers: setHeader(compressedHeaders, "vary", vary)
4440
+ };
4441
+ }
4442
+ return { body, headers };
4443
+ }
4199
4444
  function forwardToHost(hostname, localPort, message, body, pathWithQuery) {
4200
4445
  return new Promise((resolve, reject) => {
4201
4446
  const req = http.request({
@@ -4217,14 +4462,17 @@ function forwardToHost(hostname, localPort, message, body, pathWithQuery) {
4217
4462
  chunks.push(chunk);
4218
4463
  });
4219
4464
  res.on("end", () => {
4220
- const responseBody = Buffer.concat(chunks);
4221
- resolve({
4222
- type: "response",
4223
- id: message.id,
4224
- status: res.statusCode ?? 502,
4225
- headers: sanitizeResponseHeaders(res.headers),
4226
- body: responseBody.length > 0 ? encodeBody(responseBody) : void 0
4227
- });
4465
+ void (async () => {
4466
+ const responseBody = Buffer.concat(chunks);
4467
+ const { body: encodedBody, headers } = await maybeCompressResponse(responseBody, res.headers, message.headers);
4468
+ resolve({
4469
+ type: "response",
4470
+ id: message.id,
4471
+ status: res.statusCode ?? 502,
4472
+ headers,
4473
+ body: encodedBody.length > 0 ? encodeBody(encodedBody) : void 0
4474
+ });
4475
+ })().catch(reject);
4228
4476
  });
4229
4477
  });
4230
4478
  req.on("error", (err) => {
@@ -4251,7 +4499,10 @@ async function forwardToLocal(localPort, message) {
4251
4499
  throw err;
4252
4500
  }
4253
4501
  }
4254
- throw lastError ?? new Error("Local server unreachable");
4502
+ if (lastError instanceof Error) {
4503
+ throw lastError;
4504
+ }
4505
+ throw new Error("Local server unreachable");
4255
4506
  }
4256
4507
 
4257
4508
  // ../tunnel-client/dist/client.js
@@ -4285,16 +4536,65 @@ function rawDataToString(raw) {
4285
4536
  return Buffer.concat(raw).toString("utf8");
4286
4537
  return Buffer.from(raw).toString("utf8");
4287
4538
  }
4539
+ function rawDataToBuffer(raw) {
4540
+ if (typeof raw === "string")
4541
+ return Buffer.from(raw, "utf8");
4542
+ if (Buffer.isBuffer(raw))
4543
+ return raw;
4544
+ if (Array.isArray(raw))
4545
+ return Buffer.concat(raw);
4546
+ return Buffer.from(raw);
4547
+ }
4548
+ function sanitizeWebSocketHeaders(headers, localPort) {
4549
+ const result = {};
4550
+ const skip = /* @__PURE__ */ new Set([
4551
+ "connection",
4552
+ "host",
4553
+ "keep-alive",
4554
+ "proxy-authenticate",
4555
+ "proxy-authorization",
4556
+ "sec-websocket-accept",
4557
+ "sec-websocket-extensions",
4558
+ "sec-websocket-key",
4559
+ "sec-websocket-version",
4560
+ "te",
4561
+ "trailers",
4562
+ "transfer-encoding",
4563
+ "upgrade"
4564
+ ]);
4565
+ for (const [key, value] of Object.entries(headers)) {
4566
+ const lower = key.toLowerCase();
4567
+ if (skip.has(lower))
4568
+ continue;
4569
+ result[key] = lower === "origin" ? `http://localhost:${String(localPort)}` : value;
4570
+ }
4571
+ result["host"] = `localhost:${String(localPort)}`;
4572
+ return result;
4573
+ }
4574
+ function toLocalWebSocketUrl(localPort, message) {
4575
+ const url = new URL(`ws://localhost:${String(localPort)}`);
4576
+ url.pathname = message.path;
4577
+ url.search = message.query ? `?${message.query}` : "";
4578
+ return url.toString();
4579
+ }
4580
+ function validCloseCode(code) {
4581
+ if (code === void 0)
4582
+ return void 0;
4583
+ return code >= 1e3 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006 || code >= 3e3 && code <= 4999 ? code : void 0;
4584
+ }
4288
4585
  function createTunnelClient(options) {
4289
4586
  let ws = null;
4290
4587
  let reconnectTimer = null;
4291
4588
  let reconnectAttempt = 0;
4292
4589
  let intentionalClose = false;
4293
4590
  let publicUrl = null;
4591
+ let registeredTunnelId = options.tunnelId ?? null;
4294
4592
  let connectPromise = null;
4295
4593
  let connectResolve = null;
4296
4594
  let connectReject = null;
4297
4595
  let hasRegistered = false;
4596
+ const localWebSockets = /* @__PURE__ */ new Map();
4597
+ const pendingLocalWebSocketMessages = /* @__PURE__ */ new Map();
4298
4598
  const clearReconnect = () => {
4299
4599
  if (reconnectTimer) {
4300
4600
  clearTimeout(reconnectTimer);
@@ -4307,6 +4607,72 @@ function createTunnelClient(options) {
4307
4607
  connectReject = reject;
4308
4608
  });
4309
4609
  };
4610
+ const sendControlMessage = (message) => {
4611
+ if (ws?.readyState === WebSocket.OPEN) {
4612
+ ws.send(JSON.stringify(message));
4613
+ }
4614
+ };
4615
+ const closeLocalWebSockets = () => {
4616
+ for (const socket of localWebSockets.values()) {
4617
+ socket.removeAllListeners();
4618
+ if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
4619
+ socket.close();
4620
+ }
4621
+ }
4622
+ localWebSockets.clear();
4623
+ };
4624
+ const closeLocalWebSocket = (message) => {
4625
+ const socket = localWebSockets.get(message.id);
4626
+ if (!socket)
4627
+ return;
4628
+ localWebSockets.delete(message.id);
4629
+ pendingLocalWebSocketMessages.delete(message.id);
4630
+ if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
4631
+ socket.close(validCloseCode(message.code), message.reason);
4632
+ }
4633
+ };
4634
+ const openLocalWebSocket = (message) => {
4635
+ const localSocket = new WebSocket(toLocalWebSocketUrl(options.localPort, message), {
4636
+ headers: sanitizeWebSocketHeaders(message.headers, options.localPort),
4637
+ maxPayload: 64 * 1024 * 1024
4638
+ });
4639
+ localWebSockets.set(message.id, localSocket);
4640
+ pendingLocalWebSocketMessages.set(message.id, []);
4641
+ localSocket.on("open", () => {
4642
+ const pendingMessages = pendingLocalWebSocketMessages.get(message.id) ?? [];
4643
+ pendingLocalWebSocketMessages.delete(message.id);
4644
+ for (const pendingMessage of pendingMessages) {
4645
+ localSocket.send(pendingMessage);
4646
+ }
4647
+ });
4648
+ localSocket.on("message", (data) => {
4649
+ sendControlMessage({
4650
+ type: "ws-message",
4651
+ id: message.id,
4652
+ body: encodeBody(rawDataToBuffer(data))
4653
+ });
4654
+ });
4655
+ localSocket.on("close", (code, reason) => {
4656
+ localWebSockets.delete(message.id);
4657
+ pendingLocalWebSocketMessages.delete(message.id);
4658
+ sendControlMessage({
4659
+ type: "ws-close",
4660
+ id: message.id,
4661
+ code,
4662
+ reason: reason.toString("utf8")
4663
+ });
4664
+ });
4665
+ localSocket.on("error", () => {
4666
+ localWebSockets.delete(message.id);
4667
+ pendingLocalWebSocketMessages.delete(message.id);
4668
+ sendControlMessage({
4669
+ type: "ws-close",
4670
+ id: message.id,
4671
+ code: 1011,
4672
+ reason: "Local WebSocket connection failed"
4673
+ });
4674
+ });
4675
+ };
4310
4676
  const scheduleReconnect = () => {
4311
4677
  if (intentionalClose)
4312
4678
  return;
@@ -4328,6 +4694,7 @@ function createTunnelClient(options) {
4328
4694
  }
4329
4695
  if (message.type === "registered") {
4330
4696
  publicUrl = message.publicUrl;
4697
+ registeredTunnelId = message.tunnelId;
4331
4698
  reconnectAttempt = 0;
4332
4699
  options.onRegistered?.(message);
4333
4700
  if (!hasRegistered) {
@@ -4349,6 +4716,13 @@ function createTunnelClient(options) {
4349
4716
  }
4350
4717
  return;
4351
4718
  }
4719
+ if (message.type === "terminated") {
4720
+ intentionalClose = true;
4721
+ publicUrl = null;
4722
+ options.onTerminated?.(message.message);
4723
+ ws?.close();
4724
+ return;
4725
+ }
4352
4726
  if (message.type === "ping") {
4353
4727
  ws?.send(JSON.stringify({ type: "pong" }));
4354
4728
  return;
@@ -4356,7 +4730,7 @@ function createTunnelClient(options) {
4356
4730
  if (message.type === "request") {
4357
4731
  try {
4358
4732
  const response = await forwardToLocal(options.localPort, message);
4359
- ws?.send(JSON.stringify(response));
4733
+ sendControlMessage(response);
4360
4734
  } catch (err) {
4361
4735
  const messageText = formatLocalProxyError(err, options.localPort);
4362
4736
  const errorResponse = {
@@ -4366,8 +4740,26 @@ function createTunnelClient(options) {
4366
4740
  headers: { "content-type": "text/plain; charset=utf-8" },
4367
4741
  body: Buffer.from(messageText).toString("base64")
4368
4742
  };
4369
- ws?.send(JSON.stringify(errorResponse));
4743
+ sendControlMessage(errorResponse);
4744
+ }
4745
+ return;
4746
+ }
4747
+ if (message.type === "ws-open") {
4748
+ openLocalWebSocket(message);
4749
+ return;
4750
+ }
4751
+ if (message.type === "ws-message") {
4752
+ const localSocket = localWebSockets.get(message.id);
4753
+ const body = decodeBody(message.body);
4754
+ if (localSocket?.readyState === WebSocket.OPEN) {
4755
+ localSocket.send(body);
4756
+ } else if (localSocket?.readyState === WebSocket.CONNECTING) {
4757
+ pendingLocalWebSocketMessages.get(message.id)?.push(body);
4370
4758
  }
4759
+ return;
4760
+ }
4761
+ if (message.type === "ws-close") {
4762
+ closeLocalWebSocket(message);
4371
4763
  }
4372
4764
  };
4373
4765
  const openConnection = () => {
@@ -4389,7 +4781,11 @@ function createTunnelClient(options) {
4389
4781
  type: "register",
4390
4782
  localPort: options.localPort,
4391
4783
  token: options.token,
4392
- ...options.password ? { password: options.password } : {}
4784
+ ...options.password ? { password: options.password } : {},
4785
+ ...options.projectSlug ? { projectSlug: options.projectSlug } : {},
4786
+ ...options.projectId ? { projectId: options.projectId } : {},
4787
+ ...options.targetName ? { targetName: options.targetName } : {},
4788
+ ...registeredTunnelId ? { tunnelId: registeredTunnelId } : {}
4393
4789
  }));
4394
4790
  });
4395
4791
  socket.on("message", (data) => {
@@ -4397,6 +4793,7 @@ function createTunnelClient(options) {
4397
4793
  });
4398
4794
  socket.on("close", () => {
4399
4795
  publicUrl = null;
4796
+ closeLocalWebSockets();
4400
4797
  options.onDisconnect?.();
4401
4798
  if (!intentionalClose && !hasRegistered && connectReject) {
4402
4799
  connectReject(new Error("Connection closed before registration"));
@@ -4426,6 +4823,7 @@ function createTunnelClient(options) {
4426
4823
  disconnect() {
4427
4824
  intentionalClose = true;
4428
4825
  clearReconnect();
4826
+ closeLocalWebSockets();
4429
4827
  if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
4430
4828
  ws.close();
4431
4829
  }
@@ -4448,6 +4846,9 @@ import { homedir } from "node:os";
4448
4846
  import { join } from "node:path";
4449
4847
  var CONFIG_DIR = join(homedir(), ".shiplocal");
4450
4848
  var CONFIG_FILE = join(CONFIG_DIR, "config.json");
4849
+ function tunnelConfigKey(projectSlug, targetName) {
4850
+ return `${projectSlug}:${targetName}`;
4851
+ }
4451
4852
  async function loadConfig() {
4452
4853
  try {
4453
4854
  const raw = await readFile(CONFIG_FILE, "utf8");
@@ -4462,12 +4863,26 @@ async function saveConfig(config) {
4462
4863
  await mkdir(CONFIG_DIR, { recursive: true });
4463
4864
  await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), "utf8");
4464
4865
  }
4866
+ async function updateTunnelConfig(projectSlug, targetName, tunnelId, publicUrl) {
4867
+ const config = await loadConfig() ?? { apiUrl: resolveApiUrl(), token: "" };
4868
+ config.defaultProjectSlug = projectSlug;
4869
+ config.tunnels ??= {};
4870
+ config.tunnels[tunnelConfigKey(projectSlug, targetName)] = { tunnelId, publicUrl };
4871
+ await saveConfig(config);
4872
+ }
4873
+ async function getSavedTunnelId(projectSlug, targetName) {
4874
+ const config = await loadConfig();
4875
+ return config?.tunnels?.[tunnelConfigKey(projectSlug, targetName)]?.tunnelId;
4876
+ }
4465
4877
  async function clearConfig() {
4466
4878
  try {
4467
4879
  await writeFile(CONFIG_FILE, "{}", "utf8");
4468
4880
  } catch {
4469
4881
  }
4470
4882
  }
4883
+ function resolveApiUrl() {
4884
+ return process.env["SHIPLOCAL_API_URL"] ?? "http://localhost:4000";
4885
+ }
4471
4886
  var DEFAULT_CLOUD_API_URL = "https://shiplocal.cloud";
4472
4887
  async function resolveApiUrlAsync() {
4473
4888
  if (process.env["SHIPLOCAL_API_URL"]) {
@@ -4561,7 +4976,7 @@ async function isLocalPortOpen(port, timeoutMs = 2e3) {
4561
4976
 
4562
4977
  // src/index.ts
4563
4978
  var program = new Command();
4564
- program.name("shiplocal").description("Share localhost with clients in seconds").version("0.1.4");
4979
+ program.name("shiplocal").description("Share localhost with clients in seconds").version("0.1.6");
4565
4980
  program.command("login").description("Authenticate with ShipLocal Cloud").action(async () => {
4566
4981
  const rl = createInterface({ input, output });
4567
4982
  const apiUrl = await resolveApiUrlAsync();
@@ -4592,78 +5007,131 @@ program.command("logout").description("Remove saved credentials").action(async (
4592
5007
  await clearConfig();
4593
5008
  console.log("Logged out.");
4594
5009
  });
4595
- program.argument("[port]", "Local port to expose", String(DEFAULT_TUNNEL_PORT)).option("-p, --password <password>", "Require a password to open the public URL").description("Start a tunnel to your local server").action(async (portArg, options) => {
4596
- const port = Number.parseInt(portArg, 10);
4597
- if (Number.isNaN(port) || port < 1 || port > 65535) {
4598
- console.error("Error: port must be a number between 1 and 65535");
4599
- process.exit(1);
4600
- }
4601
- const serverUrl = await resolveApiUrlAsync();
4602
- const token = await resolveToken();
4603
- if (!token) {
4604
- console.error("Not authenticated. Run `shiplocal login` first.");
4605
- process.exit(1);
4606
- }
4607
- if (!token.startsWith("sl_")) {
4608
- console.error("Invalid saved token. Run `shiplocal login` again.");
4609
- process.exit(1);
4610
- }
4611
- const portOpen = await isLocalPortOpen(port);
4612
- if (!portOpen) {
4613
- console.warn("");
4614
- console.warn(`Warning: nothing is listening on http://localhost:${String(port)}`);
4615
- console.warn("Start your local app first, or pass the port it uses.");
4616
- console.warn("Example: pnpm tunnel 3001 (dashboard in this repo runs on 3001)");
4617
- console.warn("");
4618
- }
4619
- let printed = false;
4620
- const client = createTunnelClient({
4621
- serverUrl,
4622
- localPort: port,
4623
- token,
4624
- password: options.password,
4625
- onRegistered: (info) => {
4626
- if (!printed) {
4627
- console.log("");
4628
- console.log("\u{1F680} ShipLocal running");
4629
- console.log("");
4630
- console.log(`Local: http://localhost:${String(port)}`);
4631
- console.log(`Public: ${info.publicUrl}`);
4632
- if (options.password) {
4633
- console.log(`Password: ${options.password} (share with your client)`);
5010
+ program.argument("[port]", "Local port to expose", String(DEFAULT_TUNNEL_PORT)).option("-p, --password <password>", "Require a password to open the public URL").option("--project <slug>", "Project slug for coordinated multi-target URLs").option("--name <target>", "Target name within project (default: web)", "web").option("--rewrite-env", "Suggest or apply .env URL rewrites for tunnel URLs").description("Start a tunnel to your local server").action(
5011
+ async (portArg, options) => {
5012
+ const port = Number.parseInt(portArg, 10);
5013
+ if (Number.isNaN(port) || port < 1 || port > 65535) {
5014
+ console.error("Error: port must be a number between 1 and 65535");
5015
+ process.exit(1);
5016
+ }
5017
+ const serverUrl = await resolveApiUrlAsync();
5018
+ const token = await resolveToken();
5019
+ if (!token) {
5020
+ console.error("Not authenticated. Run `shiplocal login` first.");
5021
+ process.exit(1);
5022
+ }
5023
+ if (!token.startsWith("sl_")) {
5024
+ console.error("Invalid saved token. Run `shiplocal login` again.");
5025
+ process.exit(1);
5026
+ }
5027
+ const projectSlug = options.project?.toLowerCase();
5028
+ const targetName = normalizeTargetName(options.name);
5029
+ const savedTunnelId = projectSlug !== void 0 ? await getSavedTunnelId(projectSlug, targetName) : void 0;
5030
+ const portOpen = await isLocalPortOpen(port);
5031
+ if (!portOpen) {
5032
+ console.warn("");
5033
+ console.warn(`Warning: nothing is listening on http://localhost:${String(port)}`);
5034
+ console.warn("Start your local app first, or pass the port it uses.");
5035
+ console.warn("Example: pnpm tunnel 3001 (dashboard in this repo runs on 3001)");
5036
+ console.warn("");
5037
+ }
5038
+ let printed = false;
5039
+ const client = createTunnelClient({
5040
+ serverUrl,
5041
+ localPort: port,
5042
+ token,
5043
+ password: options.password,
5044
+ projectSlug,
5045
+ targetName: projectSlug ? targetName : void 0,
5046
+ tunnelId: savedTunnelId,
5047
+ onRegistered: (info) => {
5048
+ if (info.projectSlug && info.targetName) {
5049
+ void updateTunnelConfig(
5050
+ info.projectSlug,
5051
+ info.targetName,
5052
+ info.tunnelId,
5053
+ info.publicUrl
5054
+ );
5055
+ }
5056
+ if (!printed) {
5057
+ console.log("");
5058
+ console.log("\u{1F680} ShipLocal running");
5059
+ console.log("");
5060
+ console.log(`Local: http://localhost:${String(port)}`);
5061
+ console.log(`Public: ${info.publicUrl}`);
5062
+ if (info.projectSlug) {
5063
+ console.log(`Project: ${info.projectSlug} (${info.targetName ?? targetName})`);
5064
+ }
5065
+ if (info.siblingUrls && info.siblingUrls.length > 0) {
5066
+ console.log("");
5067
+ console.log("Other targets in this project:");
5068
+ for (const sibling of info.siblingUrls) {
5069
+ console.log(
5070
+ ` ${sibling.name}: ${sibling.publicUrl} (port ${String(sibling.port)})`
5071
+ );
5072
+ }
5073
+ }
5074
+ if (options.password) {
5075
+ console.log(`Password: ${options.password} (share with your client)`);
5076
+ }
5077
+ console.log("");
5078
+ console.log("Share this with your client.");
5079
+ console.log("Press Ctrl+C to stop.");
5080
+ printed = true;
5081
+ if (options.rewriteEnv) {
5082
+ void Promise.resolve().then(() => (init_env_rewrite(), env_rewrite_exports)).then(
5083
+ ({ runEnvRewrite: runEnvRewrite2 }) => runEnvRewrite2({
5084
+ port,
5085
+ publicUrl: info.publicUrl,
5086
+ siblingUrls: info.siblingUrls ?? [],
5087
+ write: true
5088
+ })
5089
+ );
5090
+ } else if (projectSlug) {
5091
+ void Promise.resolve().then(() => (init_env_rewrite(), env_rewrite_exports)).then(
5092
+ ({ runEnvRewrite: runEnvRewrite2 }) => runEnvRewrite2({
5093
+ port,
5094
+ publicUrl: info.publicUrl,
5095
+ siblingUrls: info.siblingUrls ?? [],
5096
+ write: false
5097
+ })
5098
+ );
5099
+ }
5100
+ } else {
5101
+ console.log(`Reconnected: ${info.publicUrl}`);
4634
5102
  }
5103
+ },
5104
+ onReconnecting: (attempt) => {
5105
+ console.log(`Reconnecting\u2026 (attempt ${String(attempt)})`);
5106
+ },
5107
+ onTerminated: (message) => {
5108
+ console.log("");
5109
+ console.log(message);
5110
+ console.log("Run shiplocal again to start a new tunnel.");
4635
5111
  console.log("");
4636
- console.log("Share this with your client.");
4637
- console.log("Press Ctrl+C to stop.");
4638
- printed = true;
4639
- } else {
4640
- console.log(`Reconnected: ${info.publicUrl}`);
4641
5112
  }
4642
- },
4643
- onReconnecting: (attempt) => {
4644
- console.log(`Reconnecting\u2026 (attempt ${String(attempt)})`);
4645
- }
4646
- });
4647
- const shutdown = () => {
4648
- void client.disconnect().then(() => {
4649
- process.exit(0);
4650
5113
  });
4651
- };
4652
- process.on("SIGINT", shutdown);
4653
- process.on("SIGTERM", shutdown);
4654
- try {
4655
- await client.connect();
4656
- } catch (err) {
4657
- const apiUrl = await resolveApiUrlAsync();
4658
- const message = err instanceof Error && err.message.includes("ECONNREFUSED") ? [
4659
- `Cannot reach ShipLocal server at ${apiUrl}.`,
4660
- "",
4661
- "Start the API server first:",
4662
- " pnpm dev"
4663
- ].join("\n") : err instanceof Error ? err.message : String(err);
4664
- console.error(message);
4665
- process.exit(1);
5114
+ const shutdown = () => {
5115
+ void client.disconnect().then(() => {
5116
+ process.exit(0);
5117
+ });
5118
+ };
5119
+ process.on("SIGINT", shutdown);
5120
+ process.on("SIGTERM", shutdown);
5121
+ try {
5122
+ await client.connect();
5123
+ } catch (err) {
5124
+ const apiUrl = await resolveApiUrlAsync();
5125
+ const message = err instanceof Error && err.message.includes("ECONNREFUSED") ? [
5126
+ `Cannot reach ShipLocal server at ${apiUrl}.`,
5127
+ "",
5128
+ "Start the API server first:",
5129
+ " pnpm dev"
5130
+ ].join("\n") : err instanceof Error ? err.message : String(err);
5131
+ console.error(message);
5132
+ process.exit(1);
5133
+ }
4666
5134
  }
4667
- });
5135
+ );
4668
5136
  program.parse();
4669
5137
  //# sourceMappingURL=index.js.map