portless 0.11.1 → 0.12.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/README.md +34 -0
- package/dist/{chunk-RRH2JUIU.js → chunk-3WLVQXFE.js} +24 -1
- package/dist/cli.js +324 -15
- package/dist/index.d.ts +8 -0
- package/dist/index.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -276,6 +276,34 @@ LAN mode depends on the system mDNS tools that portless already spawns: macOS sh
|
|
|
276
276
|
|
|
277
277
|
- **Expo / React Native**: portless always injects `--port`. React Native also gets `--host 127.0.0.1`. Expo gets `--host localhost` outside LAN mode, but in LAN mode portless leaves Metro on its default LAN host behavior instead of forcing `--host` or `HOST`.
|
|
278
278
|
|
|
279
|
+
## Tailscale sharing
|
|
280
|
+
|
|
281
|
+
Share your dev server with teammates on your [Tailscale](https://tailscale.com) network:
|
|
282
|
+
|
|
283
|
+
```bash
|
|
284
|
+
portless myapp --tailscale next dev
|
|
285
|
+
# -> https://myapp.localhost (local)
|
|
286
|
+
# -> https://devbox.yourteam.ts.net (tailnet)
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Each `--tailscale` app is root-mounted on its own Tailscale HTTPS port, so no framework `basePath` configuration is needed. The first app gets port 443, subsequent apps get 8443, 8444, etc.
|
|
290
|
+
|
|
291
|
+
```bash
|
|
292
|
+
portless myapp --tailscale next dev # -> https://devbox.ts.net
|
|
293
|
+
portless api --tailscale pnpm start # -> https://devbox.ts.net:8443
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Use `--funnel` to expose your dev server to the public internet via [Tailscale Funnel](https://tailscale.com/kb/1223/funnel/):
|
|
297
|
+
|
|
298
|
+
```bash
|
|
299
|
+
portless myapp --funnel next dev
|
|
300
|
+
# -> https://devbox.yourteam.ts.net (public)
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Set `PORTLESS_TAILSCALE=1` in your shell profile or `.env` to share every app by default. `portless list` shows both local and tailnet URLs. Tailscale serve registrations are cleaned up automatically when the app exits.
|
|
304
|
+
|
|
305
|
+
Requires the Tailscale CLI to be installed and connected (`tailscale up`).
|
|
306
|
+
|
|
279
307
|
## Commands
|
|
280
308
|
|
|
281
309
|
```bash
|
|
@@ -321,6 +349,8 @@ portless proxy stop # Stop the proxy
|
|
|
321
349
|
--wildcard Allow unregistered subdomains to fall back to parent route
|
|
322
350
|
--script <name> Run a specific package.json script (default: dev)
|
|
323
351
|
--app-port <number> Use a fixed port for the app (skip auto-assignment)
|
|
352
|
+
--tailscale Share the app on your Tailscale network (tailnet)
|
|
353
|
+
--funnel Share the app publicly via Tailscale Funnel
|
|
324
354
|
--force Kill the existing process and take over its route
|
|
325
355
|
--name <name> Use <name> as the app name
|
|
326
356
|
```
|
|
@@ -336,12 +366,15 @@ PORTLESS_LAN=1 Enable LAN mode when set to 1 (auto-detects LAN
|
|
|
336
366
|
PORTLESS_TLD=<tld> Use a custom TLD (e.g. test; default: localhost)
|
|
337
367
|
PORTLESS_WILDCARD=1 Allow unregistered subdomains to fall back to parent route
|
|
338
368
|
PORTLESS_SYNC_HOSTS=0 Disable auto-sync of /etc/hosts (on by default)
|
|
369
|
+
PORTLESS_TAILSCALE=1 Share apps on your Tailscale network (same as --tailscale)
|
|
370
|
+
PORTLESS_FUNNEL=1 Share apps publicly via Tailscale Funnel (same as --funnel)
|
|
339
371
|
PORTLESS_STATE_DIR=<path> Override the state directory
|
|
340
372
|
|
|
341
373
|
# Injected into child processes
|
|
342
374
|
PORT Ephemeral port the child should listen on
|
|
343
375
|
HOST Usually 127.0.0.1 (omitted for Expo in LAN mode)
|
|
344
376
|
PORTLESS_URL Public URL (e.g. https://myapp.localhost)
|
|
377
|
+
PORTLESS_TAILSCALE_URL Tailscale URL of the app (when --tailscale is active)
|
|
345
378
|
NODE_EXTRA_CA_CERTS Path to the portless CA (when HTTPS is active)
|
|
346
379
|
```
|
|
347
380
|
|
|
@@ -422,3 +455,4 @@ pnpm format # Format all files with Prettier
|
|
|
422
455
|
|
|
423
456
|
- Node.js 20+
|
|
424
457
|
- macOS, Linux, or Windows
|
|
458
|
+
- Tailscale CLI (optional, for `--tailscale` and `--funnel`)
|
|
@@ -846,7 +846,8 @@ var RouteStore = class _RouteStore {
|
|
|
846
846
|
}
|
|
847
847
|
}
|
|
848
848
|
const filtered = routes.filter((r) => r.hostname !== hostname);
|
|
849
|
-
|
|
849
|
+
const entry = { hostname, port, pid };
|
|
850
|
+
filtered.push(entry);
|
|
850
851
|
this.saveRoutes(filtered);
|
|
851
852
|
} finally {
|
|
852
853
|
this.releaseLock();
|
|
@@ -906,6 +907,28 @@ var RouteStore = class _RouteStore {
|
|
|
906
907
|
this.releaseLock();
|
|
907
908
|
}
|
|
908
909
|
}
|
|
910
|
+
/**
|
|
911
|
+
* Update metadata on an existing route entry. Only provided fields are
|
|
912
|
+
* merged; the route must already exist (matched by hostname).
|
|
913
|
+
*/
|
|
914
|
+
updateRoute(hostname, fields) {
|
|
915
|
+
this.ensureDir();
|
|
916
|
+
if (!this.acquireLock()) {
|
|
917
|
+
throw new Error("Failed to acquire route lock");
|
|
918
|
+
}
|
|
919
|
+
try {
|
|
920
|
+
const routes = this.loadRoutes(true);
|
|
921
|
+
const route = routes.find((r) => r.hostname === hostname);
|
|
922
|
+
if (!route) return;
|
|
923
|
+
if (fields.tailscaleUrl !== void 0) route.tailscaleUrl = fields.tailscaleUrl;
|
|
924
|
+
if (fields.tailscaleHttpsPort !== void 0)
|
|
925
|
+
route.tailscaleHttpsPort = fields.tailscaleHttpsPort;
|
|
926
|
+
if (fields.tailscaleFunnel !== void 0) route.tailscaleFunnel = fields.tailscaleFunnel;
|
|
927
|
+
this.saveRoutes(routes);
|
|
928
|
+
} finally {
|
|
929
|
+
this.releaseLock();
|
|
930
|
+
}
|
|
931
|
+
}
|
|
909
932
|
removeRoute(hostname) {
|
|
910
933
|
this.ensureDir();
|
|
911
934
|
if (!this.acquireLock()) {
|
package/dist/cli.js
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
parseHostname,
|
|
14
14
|
shouldAutoSyncHosts,
|
|
15
15
|
syncHostsFile
|
|
16
|
-
} from "./chunk-
|
|
16
|
+
} from "./chunk-3WLVQXFE.js";
|
|
17
17
|
|
|
18
18
|
// src/colors.ts
|
|
19
19
|
function supportsColor() {
|
|
@@ -41,7 +41,7 @@ var colors_default = { bold, dim, red, green, yellow, blue, cyan, white, gray };
|
|
|
41
41
|
// src/cli.ts
|
|
42
42
|
import * as fs8 from "fs";
|
|
43
43
|
import * as path8 from "path";
|
|
44
|
-
import { spawn as spawn3, spawnSync as
|
|
44
|
+
import { spawn as spawn3, spawnSync as spawnSync3 } from "child_process";
|
|
45
45
|
import { StringDecoder } from "string_decoder";
|
|
46
46
|
|
|
47
47
|
// src/certs.ts
|
|
@@ -754,6 +754,184 @@ function untrustCAWindows(caCertPath) {
|
|
|
754
754
|
}
|
|
755
755
|
}
|
|
756
756
|
|
|
757
|
+
// src/tailscale.ts
|
|
758
|
+
import { spawnSync } from "child_process";
|
|
759
|
+
var TAILSCALE_BINARY = "tailscale";
|
|
760
|
+
var PREFERRED_SERVE_PORTS = [443, 8443, 8444, 8445, 8446, 8447, 8448, 8449, 8450];
|
|
761
|
+
var FUNNEL_PORTS = [443, 8443, 1e4];
|
|
762
|
+
function defaultRunner(args) {
|
|
763
|
+
const result = spawnSync(TAILSCALE_BINARY, args, { encoding: "utf-8" });
|
|
764
|
+
return {
|
|
765
|
+
status: result.status,
|
|
766
|
+
stdout: result.stdout ?? "",
|
|
767
|
+
stderr: result.stderr ?? "",
|
|
768
|
+
...result.error ? { error: result.error } : {}
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
function trimDot(value) {
|
|
772
|
+
return value.endsWith(".") ? value.slice(0, -1) : value;
|
|
773
|
+
}
|
|
774
|
+
function normalizeSpace(value) {
|
|
775
|
+
return value.trim().replace(/\s+/g, " ");
|
|
776
|
+
}
|
|
777
|
+
function runOrThrow(args, action, runner) {
|
|
778
|
+
const result = runner(args);
|
|
779
|
+
if (result.error) {
|
|
780
|
+
const errno = result.error;
|
|
781
|
+
if (errno.code === "ENOENT") {
|
|
782
|
+
throw new Error(
|
|
783
|
+
"Tailscale CLI not found. Install Tailscale (https://tailscale.com/download) and ensure `tailscale` is on PATH."
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
throw new Error(`Failed to ${action}: ${result.error.message}`);
|
|
787
|
+
}
|
|
788
|
+
if (result.status !== 0) {
|
|
789
|
+
const details = normalizeSpace(result.stderr || result.stdout);
|
|
790
|
+
throw new Error(`Failed to ${action}: ${details || "unknown tailscale error"}`);
|
|
791
|
+
}
|
|
792
|
+
return result;
|
|
793
|
+
}
|
|
794
|
+
function parseStatusJson(raw) {
|
|
795
|
+
try {
|
|
796
|
+
return JSON.parse(raw);
|
|
797
|
+
} catch {
|
|
798
|
+
throw new Error("Failed to parse `tailscale status --json` output.");
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
function statusToDnsName(status) {
|
|
802
|
+
const dnsName = status.Self?.DNSName;
|
|
803
|
+
if (typeof dnsName === "string" && dnsName.length > 0) {
|
|
804
|
+
return trimDot(dnsName);
|
|
805
|
+
}
|
|
806
|
+
const host = status.Self?.HostName;
|
|
807
|
+
const suffix = status.CurrentTailnet?.MagicDNSSuffix;
|
|
808
|
+
if (typeof host === "string" && host.length > 0 && typeof suffix === "string" && suffix.length > 0) {
|
|
809
|
+
return `${host}.${trimDot(suffix)}`;
|
|
810
|
+
}
|
|
811
|
+
throw new Error(
|
|
812
|
+
"Could not determine Tailscale node DNS name from `tailscale status --json`. Is Tailscale connected?"
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
function ensureTailscaleReady(runner = defaultRunner) {
|
|
816
|
+
runOrThrow(["version"], "check tailscale version", runner);
|
|
817
|
+
const statusResult = runOrThrow(["status", "--json"], "read tailscale status", runner);
|
|
818
|
+
const status = parseStatusJson(statusResult.stdout);
|
|
819
|
+
const dnsName = statusToDnsName(status);
|
|
820
|
+
return {
|
|
821
|
+
dnsName,
|
|
822
|
+
baseUrl: `https://${dnsName}`
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
function getUsedServePorts(runner = defaultRunner) {
|
|
826
|
+
const result = runner(["serve", "status", "--json"]);
|
|
827
|
+
if (result.error || result.status !== 0) {
|
|
828
|
+
return /* @__PURE__ */ new Set();
|
|
829
|
+
}
|
|
830
|
+
try {
|
|
831
|
+
const config = JSON.parse(result.stdout);
|
|
832
|
+
const ports = /* @__PURE__ */ new Set();
|
|
833
|
+
if (config.Web) {
|
|
834
|
+
for (const hostPort of Object.keys(config.Web)) {
|
|
835
|
+
const match = hostPort.match(/:(\d+)$/);
|
|
836
|
+
if (match) {
|
|
837
|
+
ports.add(parseInt(match[1], 10));
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
if (config.TCP) {
|
|
842
|
+
for (const portStr of Object.keys(config.TCP)) {
|
|
843
|
+
const p = parseInt(portStr, 10);
|
|
844
|
+
if (!isNaN(p)) ports.add(p);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
return ports;
|
|
848
|
+
} catch {
|
|
849
|
+
return /* @__PURE__ */ new Set();
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
function findAvailableServePort(usedPorts, mode = "serve") {
|
|
853
|
+
const pool = mode === "funnel" ? FUNNEL_PORTS : PREFERRED_SERVE_PORTS;
|
|
854
|
+
for (const port2 of pool) {
|
|
855
|
+
if (!usedPorts.has(port2)) return port2;
|
|
856
|
+
}
|
|
857
|
+
if (mode === "funnel") {
|
|
858
|
+
throw new Error(
|
|
859
|
+
"All Tailscale Funnel ports are in use (443, 8443, 10000). Stop an existing funnel to free a port."
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
let port = PREFERRED_SERVE_PORTS[PREFERRED_SERVE_PORTS.length - 1] + 1;
|
|
863
|
+
while (usedPorts.has(port)) port++;
|
|
864
|
+
return port;
|
|
865
|
+
}
|
|
866
|
+
function isConflictError(stderr, stdout) {
|
|
867
|
+
const text = `${stderr}
|
|
868
|
+
${stdout}`.toLowerCase();
|
|
869
|
+
return text.includes("already in use") || text.includes("already exists") || text.includes("port conflict") || text.includes("address already");
|
|
870
|
+
}
|
|
871
|
+
var CONFLICT_MESSAGES = {
|
|
872
|
+
serve: "Stop the existing serve or let portless auto-assign a different port.",
|
|
873
|
+
funnel: "Tailscale Funnel supports ports 443, 8443, and 10000."
|
|
874
|
+
};
|
|
875
|
+
function register(mode, localPort, httpsPort, runner) {
|
|
876
|
+
const target = `http://127.0.0.1:${localPort}`;
|
|
877
|
+
const result = runner([mode, "--bg", "--yes", `--https=${httpsPort}`, target]);
|
|
878
|
+
if (result.error) {
|
|
879
|
+
const errno = result.error;
|
|
880
|
+
if (errno.code === "ENOENT") {
|
|
881
|
+
throw new Error(
|
|
882
|
+
"Tailscale CLI not found. Install Tailscale (https://tailscale.com/download) and ensure `tailscale` is on PATH."
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
throw new Error(`Failed to register tailscale ${mode}: ${result.error.message}`);
|
|
886
|
+
}
|
|
887
|
+
if (result.status !== 0) {
|
|
888
|
+
if (isConflictError(result.stderr, result.stdout)) {
|
|
889
|
+
throw new Error(
|
|
890
|
+
`Tailscale ${mode === "funnel" ? "Funnel " : ""}HTTPS port ${httpsPort} is already in use. ` + CONFLICT_MESSAGES[mode]
|
|
891
|
+
);
|
|
892
|
+
}
|
|
893
|
+
const details = normalizeSpace(result.stderr || result.stdout);
|
|
894
|
+
throw new Error(
|
|
895
|
+
`Failed to register tailscale ${mode} on port ${httpsPort}: ${details || "unknown tailscale error"}`
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
function unregister(mode, httpsPort, options) {
|
|
900
|
+
const runner = options?.runner ?? defaultRunner;
|
|
901
|
+
const result = runner([mode, "--yes", `--https=${httpsPort}`, "off"]);
|
|
902
|
+
if (result.error) {
|
|
903
|
+
const errno = result.error;
|
|
904
|
+
if (errno.code === "ENOENT") return;
|
|
905
|
+
throw new Error(`Failed to remove tailscale ${mode}: ${result.error.message}`);
|
|
906
|
+
}
|
|
907
|
+
if (result.status !== 0) {
|
|
908
|
+
const text = `${result.stderr}
|
|
909
|
+
${result.stdout}`.toLowerCase();
|
|
910
|
+
const looksLikeMissing = text.includes("not found") || text.includes("no serve config") || text.includes("nothing to remove") || text.includes("does not exist");
|
|
911
|
+
if (options?.ignoreMissing && looksLikeMissing) return;
|
|
912
|
+
const details = normalizeSpace(result.stderr || result.stdout);
|
|
913
|
+
throw new Error(
|
|
914
|
+
`Failed to remove tailscale ${mode} on port ${httpsPort}: ${details || "unknown tailscale error"}`
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
function registerServe(localPort, httpsPort, options) {
|
|
919
|
+
register("serve", localPort, httpsPort, options?.runner ?? defaultRunner);
|
|
920
|
+
}
|
|
921
|
+
function registerFunnel(localPort, httpsPort, options) {
|
|
922
|
+
register("funnel", localPort, httpsPort, options?.runner ?? defaultRunner);
|
|
923
|
+
}
|
|
924
|
+
function unregisterTailscale(route) {
|
|
925
|
+
if (!route.tailscaleHttpsPort) return;
|
|
926
|
+
const mode = route.tailscaleFunnel ? "funnel" : "serve";
|
|
927
|
+
unregister(mode, route.tailscaleHttpsPort, { ignoreMissing: true });
|
|
928
|
+
}
|
|
929
|
+
function formatTailscaleUrl(baseUrl, httpsPort) {
|
|
930
|
+
const trimmed = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
|
|
931
|
+
if (httpsPort === 443) return trimmed;
|
|
932
|
+
return `${trimmed}:${httpsPort}`;
|
|
933
|
+
}
|
|
934
|
+
|
|
757
935
|
// src/auto.ts
|
|
758
936
|
import { createHash as createHash2 } from "crypto";
|
|
759
937
|
import { execFileSync as execFileSync2 } from "child_process";
|
|
@@ -1622,7 +1800,7 @@ function removePortlessStateFiles(dir) {
|
|
|
1622
1800
|
}
|
|
1623
1801
|
|
|
1624
1802
|
// src/mdns.ts
|
|
1625
|
-
import { spawn as spawn2, spawnSync } from "child_process";
|
|
1803
|
+
import { spawn as spawn2, spawnSync as spawnSync2 } from "child_process";
|
|
1626
1804
|
|
|
1627
1805
|
// src/lan-ip.ts
|
|
1628
1806
|
import { createSocket } from "dgram";
|
|
@@ -1738,7 +1916,7 @@ function getMdnsPublisher() {
|
|
|
1738
1916
|
return null;
|
|
1739
1917
|
}
|
|
1740
1918
|
function hasCommand(command, probeArgs) {
|
|
1741
|
-
const result =
|
|
1919
|
+
const result = spawnSync2(command, probeArgs, {
|
|
1742
1920
|
stdio: "ignore",
|
|
1743
1921
|
timeout: 1e3,
|
|
1744
1922
|
windowsHide: true
|
|
@@ -2519,7 +2697,7 @@ function collectPortlessEnvArgs() {
|
|
|
2519
2697
|
function sudoStop(port) {
|
|
2520
2698
|
const stopArgs = [process.execPath, getEntryScript(), "proxy", "stop", "-p", String(port)];
|
|
2521
2699
|
console.log(colors_default.yellow("Proxy is running as root. Elevating with sudo to stop it..."));
|
|
2522
|
-
const result =
|
|
2700
|
+
const result = spawnSync3("sudo", ["env", ...collectPortlessEnvArgs(), ...stopArgs], {
|
|
2523
2701
|
stdio: "inherit",
|
|
2524
2702
|
timeout: SUDO_SPAWN_TIMEOUT_MS
|
|
2525
2703
|
});
|
|
@@ -2859,6 +3037,10 @@ function listRoutes(store, proxyPort, tls2) {
|
|
|
2859
3037
|
console.log(
|
|
2860
3038
|
` ${colors_default.cyan(url)} ${colors_default.gray("->")} ${colors_default.white(`localhost:${route.port}`)} ${colors_default.gray(label)}`
|
|
2861
3039
|
);
|
|
3040
|
+
if (route.tailscaleUrl) {
|
|
3041
|
+
const tsLabel = route.tailscaleFunnel ? "funnel" : "tailscale";
|
|
3042
|
+
console.log(` ${colors_default.gray(tsLabel + ":")} ${colors_default.green(route.tailscaleUrl)}`);
|
|
3043
|
+
}
|
|
2862
3044
|
}
|
|
2863
3045
|
console.log();
|
|
2864
3046
|
}
|
|
@@ -2942,7 +3124,7 @@ async function ensureProxyRunning(proxyPort, tls2, desired) {
|
|
|
2942
3124
|
proxyPort: startPort
|
|
2943
3125
|
});
|
|
2944
3126
|
const startArgs = [getEntryScript(), "proxy", "start", ...proxyStartConfig.args];
|
|
2945
|
-
const result =
|
|
3127
|
+
const result = spawnSync3(process.execPath, startArgs, {
|
|
2946
3128
|
stdio: "inherit",
|
|
2947
3129
|
timeout: SUDO_SPAWN_TIMEOUT_MS
|
|
2948
3130
|
});
|
|
@@ -2976,6 +3158,25 @@ async function runApp(initialStore, proxyPort, stateDir, name, commandArgs, tls2
|
|
|
2976
3158
|
console.log(chalk.blue.bold(`
|
|
2977
3159
|
portless
|
|
2978
3160
|
`));
|
|
3161
|
+
const wantsFunnel = process.env.PORTLESS_FUNNEL === "1" || process.env.PORTLESS_FUNNEL === "true";
|
|
3162
|
+
const wantsTailscale = wantsFunnel || process.env.PORTLESS_TAILSCALE === "1" || process.env.PORTLESS_TAILSCALE === "true";
|
|
3163
|
+
let tsBaseUrl;
|
|
3164
|
+
if (wantsTailscale) {
|
|
3165
|
+
try {
|
|
3166
|
+
const tsReady = ensureTailscaleReady();
|
|
3167
|
+
tsBaseUrl = tsReady.baseUrl;
|
|
3168
|
+
} catch (err) {
|
|
3169
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3170
|
+
console.error(colors_default.red(`Error: ${message}`));
|
|
3171
|
+
if (message.includes("not found")) {
|
|
3172
|
+
console.error(colors_default.blue("Install Tailscale: https://tailscale.com/download"));
|
|
3173
|
+
} else {
|
|
3174
|
+
console.error(colors_default.blue("Make sure Tailscale is connected:"));
|
|
3175
|
+
console.error(colors_default.cyan(" tailscale up"));
|
|
3176
|
+
}
|
|
3177
|
+
process.exit(1);
|
|
3178
|
+
}
|
|
3179
|
+
}
|
|
2979
3180
|
let desired;
|
|
2980
3181
|
try {
|
|
2981
3182
|
desired = resolveProxyDesiredState(lanMode);
|
|
@@ -3059,6 +3260,45 @@ portless
|
|
|
3059
3260
|
console.log(chalk.green(` LAN -> ${finalUrl}`));
|
|
3060
3261
|
console.log(chalk.gray(" (accessible from other devices on the same WiFi network)\n"));
|
|
3061
3262
|
}
|
|
3263
|
+
let tailscaleHttpsPort;
|
|
3264
|
+
let tailscaleUrl;
|
|
3265
|
+
if (wantsTailscale && tsBaseUrl) {
|
|
3266
|
+
const maxAttempts = 3;
|
|
3267
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
3268
|
+
const usedPorts = getUsedServePorts();
|
|
3269
|
+
tailscaleHttpsPort = findAvailableServePort(usedPorts, wantsFunnel ? "funnel" : "serve");
|
|
3270
|
+
try {
|
|
3271
|
+
if (wantsFunnel) {
|
|
3272
|
+
registerFunnel(port, tailscaleHttpsPort);
|
|
3273
|
+
} else {
|
|
3274
|
+
registerServe(port, tailscaleHttpsPort);
|
|
3275
|
+
}
|
|
3276
|
+
break;
|
|
3277
|
+
} catch (err) {
|
|
3278
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3279
|
+
const isConflict = message.includes("already in use");
|
|
3280
|
+
if (isConflict && attempt < maxAttempts) continue;
|
|
3281
|
+
console.error(colors_default.red(`Error: ${message}`));
|
|
3282
|
+
process.exit(1);
|
|
3283
|
+
}
|
|
3284
|
+
}
|
|
3285
|
+
tailscaleUrl = formatTailscaleUrl(tsBaseUrl, tailscaleHttpsPort);
|
|
3286
|
+
const label = wantsFunnel ? "Funnel (public)" : "Tailscale";
|
|
3287
|
+
console.log(chalk.green(` ${label} -> ${tailscaleUrl}`));
|
|
3288
|
+
if (wantsFunnel) {
|
|
3289
|
+
console.log(chalk.gray(" (accessible from the public internet via Tailscale Funnel)\n"));
|
|
3290
|
+
} else {
|
|
3291
|
+
console.log(chalk.gray(" (accessible from your tailnet)\n"));
|
|
3292
|
+
}
|
|
3293
|
+
try {
|
|
3294
|
+
store.updateRoute(hostname, {
|
|
3295
|
+
tailscaleUrl,
|
|
3296
|
+
tailscaleHttpsPort,
|
|
3297
|
+
tailscaleFunnel: wantsFunnel || void 0
|
|
3298
|
+
});
|
|
3299
|
+
} catch {
|
|
3300
|
+
}
|
|
3301
|
+
}
|
|
3062
3302
|
const basename5 = path8.basename(commandArgs[0]);
|
|
3063
3303
|
const isExpo = basename5 === "expo";
|
|
3064
3304
|
const isExpoLan = isExpo && (lanMode || isLanEnvEnabled());
|
|
@@ -3092,9 +3332,17 @@ portless
|
|
|
3092
3332
|
// baked-in pinging, making this env var ineffective. Expo handles its
|
|
3093
3333
|
// own LAN discovery natively.
|
|
3094
3334
|
...lanMode ? { PORTLESS_LAN: "1" } : {},
|
|
3335
|
+
...tailscaleUrl ? { PORTLESS_TAILSCALE_URL: tailscaleUrl } : {},
|
|
3095
3336
|
...caEnv
|
|
3096
3337
|
},
|
|
3097
3338
|
onCleanup: () => {
|
|
3339
|
+
try {
|
|
3340
|
+
unregisterTailscale({
|
|
3341
|
+
tailscaleHttpsPort,
|
|
3342
|
+
tailscaleFunnel: wantsFunnel || void 0
|
|
3343
|
+
});
|
|
3344
|
+
} catch {
|
|
3345
|
+
}
|
|
3098
3346
|
try {
|
|
3099
3347
|
store.removeRoute(hostname);
|
|
3100
3348
|
} catch {
|
|
@@ -3124,6 +3372,18 @@ function appPortFromEnv() {
|
|
|
3124
3372
|
}
|
|
3125
3373
|
return port;
|
|
3126
3374
|
}
|
|
3375
|
+
function applyTailscaleFlag(flag) {
|
|
3376
|
+
if (flag === "--tailscale") {
|
|
3377
|
+
process.env.PORTLESS_TAILSCALE = "1";
|
|
3378
|
+
return true;
|
|
3379
|
+
}
|
|
3380
|
+
if (flag === "--funnel") {
|
|
3381
|
+
process.env.PORTLESS_FUNNEL = "1";
|
|
3382
|
+
process.env.PORTLESS_TAILSCALE = "1";
|
|
3383
|
+
return true;
|
|
3384
|
+
}
|
|
3385
|
+
return false;
|
|
3386
|
+
}
|
|
3127
3387
|
function parseRunArgs(args) {
|
|
3128
3388
|
let force = false;
|
|
3129
3389
|
let appPort;
|
|
@@ -3180,9 +3440,12 @@ ${colors_default.bold("Examples:")}
|
|
|
3180
3440
|
process.exit(1);
|
|
3181
3441
|
}
|
|
3182
3442
|
name = args[i];
|
|
3443
|
+
} else if (applyTailscaleFlag(args[i])) {
|
|
3183
3444
|
} else {
|
|
3184
3445
|
console.error(colors_default.red(`Error: Unknown flag "${args[i]}".`));
|
|
3185
|
-
console.error(
|
|
3446
|
+
console.error(
|
|
3447
|
+
colors_default.blue("Known flags: --name, --force, --app-port, --tailscale, --funnel, --help")
|
|
3448
|
+
);
|
|
3186
3449
|
process.exit(1);
|
|
3187
3450
|
}
|
|
3188
3451
|
i++;
|
|
@@ -3203,9 +3466,10 @@ function parseAppArgs(args) {
|
|
|
3203
3466
|
} else if (args[i] === "--app-port") {
|
|
3204
3467
|
i++;
|
|
3205
3468
|
appPort = parseAppPort(args[i]);
|
|
3469
|
+
} else if (applyTailscaleFlag(args[i])) {
|
|
3206
3470
|
} else {
|
|
3207
3471
|
console.error(colors_default.red(`Error: Unknown flag "${args[i]}".`));
|
|
3208
|
-
console.error(colors_default.blue("Known flags: --force, --app-port"));
|
|
3472
|
+
console.error(colors_default.blue("Known flags: --force, --app-port, --tailscale, --funnel"));
|
|
3209
3473
|
process.exit(1);
|
|
3210
3474
|
}
|
|
3211
3475
|
i++;
|
|
@@ -3221,9 +3485,10 @@ function parseAppArgs(args) {
|
|
|
3221
3485
|
} else if (args[i] === "--app-port") {
|
|
3222
3486
|
i++;
|
|
3223
3487
|
appPort = parseAppPort(args[i]);
|
|
3488
|
+
} else if (applyTailscaleFlag(args[i])) {
|
|
3224
3489
|
} else {
|
|
3225
3490
|
console.error(colors_default.red(`Error: Unknown flag "${args[i]}".`));
|
|
3226
|
-
console.error(colors_default.blue("Known flags: --force, --app-port"));
|
|
3491
|
+
console.error(colors_default.blue("Known flags: --force, --app-port, --tailscale, --funnel"));
|
|
3227
3492
|
process.exit(1);
|
|
3228
3493
|
}
|
|
3229
3494
|
i++;
|
|
@@ -3268,6 +3533,8 @@ ${colors_default.bold("Examples:")}
|
|
|
3268
3533
|
portless run next dev # -> https://<project>.localhost
|
|
3269
3534
|
portless run next dev # in worktree -> https://<worktree>.<project>.localhost
|
|
3270
3535
|
portless get backend # -> https://backend.localhost
|
|
3536
|
+
portless myapp --tailscale next dev # -> also https://<node>.ts.net (tailnet)
|
|
3537
|
+
portless myapp --funnel next dev # -> also https://<node>.ts.net (public)
|
|
3271
3538
|
|
|
3272
3539
|
${colors_default.bold("Configuration (portless.json):")}
|
|
3273
3540
|
Optional. Portless works out of the box by running the "dev" script
|
|
@@ -3318,6 +3585,15 @@ ${colors_default.bold("LAN mode:")}
|
|
|
3318
3585
|
${colors_default.cyan("portless proxy start --lan --https")}
|
|
3319
3586
|
${colors_default.cyan("portless proxy start --lan --ip 192.168.1.42")}
|
|
3320
3587
|
|
|
3588
|
+
${colors_default.bold("Tailscale sharing:")}
|
|
3589
|
+
Use --tailscale to share your dev server with teammates on your tailnet.
|
|
3590
|
+
Each app is root-mounted on its own Tailscale HTTPS port (443, then 8443,
|
|
3591
|
+
8444, etc.) so no basePath configuration is needed.
|
|
3592
|
+
Use --funnel to expose your dev server to the public internet via
|
|
3593
|
+
Tailscale Funnel. Requires Tailscale CLI to be installed and connected.
|
|
3594
|
+
${colors_default.cyan("portless myapp --tailscale next dev")}
|
|
3595
|
+
${colors_default.cyan("portless myapp --funnel next dev")}
|
|
3596
|
+
|
|
3321
3597
|
${colors_default.bold("Options:")}
|
|
3322
3598
|
run [--name <name>] <cmd> Infer project name (or override with --name)
|
|
3323
3599
|
Adds worktree prefix in git worktrees
|
|
@@ -3334,6 +3610,8 @@ ${colors_default.bold("Options:")}
|
|
|
3334
3610
|
--tld <tld> Use a custom TLD instead of .localhost (e.g. test, dev)
|
|
3335
3611
|
--wildcard Allow unregistered subdomains to fall back to parent route
|
|
3336
3612
|
--app-port <number> Use a fixed port for the app (skip auto-assignment)
|
|
3613
|
+
--tailscale Share the app on your Tailscale network (tailnet)
|
|
3614
|
+
--funnel Share the app publicly via Tailscale Funnel
|
|
3337
3615
|
--force Kill the existing process and take over its route
|
|
3338
3616
|
--name <name> Use <name> as the app name (bypasses subcommand dispatch)
|
|
3339
3617
|
-- Stop flag parsing; everything after is passed to the child
|
|
@@ -3346,6 +3624,8 @@ ${colors_default.bold("Environment variables:")}
|
|
|
3346
3624
|
PORTLESS_TLD=<tld> Use a custom TLD (e.g. test, dev; default: localhost)
|
|
3347
3625
|
PORTLESS_WILDCARD=1 Allow unregistered subdomains to fall back to parent route
|
|
3348
3626
|
PORTLESS_SYNC_HOSTS=0 Disable auto-sync of ${HOSTS_DISPLAY} (on by default)
|
|
3627
|
+
PORTLESS_TAILSCALE=1 Share apps on your Tailscale network (same as --tailscale)
|
|
3628
|
+
PORTLESS_FUNNEL=1 Share apps publicly via Tailscale Funnel (same as --funnel)
|
|
3349
3629
|
PORTLESS_STATE_DIR=<path> Override the state directory
|
|
3350
3630
|
PORTLESS=0 Run command directly without proxy
|
|
3351
3631
|
|
|
@@ -3354,6 +3634,7 @@ ${colors_default.bold("Child process environment:")}
|
|
|
3354
3634
|
HOST Usually 127.0.0.1 (omitted for Expo in LAN mode)
|
|
3355
3635
|
PORTLESS_URL Public URL of the app (e.g. https://myapp.localhost)
|
|
3356
3636
|
PORTLESS_LAN Set to 1 when proxy is in LAN mode
|
|
3637
|
+
PORTLESS_TAILSCALE_URL Tailscale URL of the app (when --tailscale is active)
|
|
3357
3638
|
NODE_EXTRA_CA_CERTS Path to the portless CA (set when HTTPS is active)
|
|
3358
3639
|
|
|
3359
3640
|
${colors_default.bold("Safari / DNS:")}
|
|
@@ -3376,7 +3657,7 @@ ${colors_default.bold("Reserved names:")}
|
|
|
3376
3657
|
process.exit(0);
|
|
3377
3658
|
}
|
|
3378
3659
|
function printVersion() {
|
|
3379
|
-
console.log("0.
|
|
3660
|
+
console.log("0.12.0");
|
|
3380
3661
|
process.exit(0);
|
|
3381
3662
|
}
|
|
3382
3663
|
async function handleTrust() {
|
|
@@ -3397,7 +3678,7 @@ async function handleTrust() {
|
|
|
3397
3678
|
const isPermissionError = result.error?.includes("Permission denied") || result.error?.includes("EACCES");
|
|
3398
3679
|
if (isPermissionError && !isWindows && process.getuid?.() !== 0) {
|
|
3399
3680
|
console.log(colors_default.yellow("Trusting the CA requires elevated privileges. Requesting sudo..."));
|
|
3400
|
-
const sudoResult =
|
|
3681
|
+
const sudoResult = spawnSync3(
|
|
3401
3682
|
"sudo",
|
|
3402
3683
|
[
|
|
3403
3684
|
"env",
|
|
@@ -3453,6 +3734,16 @@ ${colors_default.bold("Options:")}
|
|
|
3453
3734
|
onWarning: (msg) => console.warn(colors_default.yellow(msg))
|
|
3454
3735
|
});
|
|
3455
3736
|
await stopProxy(store, port, tls2);
|
|
3737
|
+
const routesForClean = store.loadRoutesRaw();
|
|
3738
|
+
for (const route of routesForClean) {
|
|
3739
|
+
if (route.tailscaleHttpsPort) {
|
|
3740
|
+
try {
|
|
3741
|
+
unregisterTailscale(route);
|
|
3742
|
+
console.log(colors_default.green(`Removed tailscale serve on port ${route.tailscaleHttpsPort}.`));
|
|
3743
|
+
} catch {
|
|
3744
|
+
}
|
|
3745
|
+
}
|
|
3746
|
+
}
|
|
3456
3747
|
const stateDirs = collectStateDirsForCleanup();
|
|
3457
3748
|
for (const stateDir of stateDirs) {
|
|
3458
3749
|
const caPath = path8.join(stateDir, "ca.pem");
|
|
@@ -3481,7 +3772,7 @@ Try: sudo portless clean (Linux), or delete the certificate manually.`
|
|
|
3481
3772
|
console.log(
|
|
3482
3773
|
colors_default.yellow(`Updating ${HOSTS_DISPLAY} requires elevated privileges. Requesting sudo...`)
|
|
3483
3774
|
);
|
|
3484
|
-
const result =
|
|
3775
|
+
const result = spawnSync3(
|
|
3485
3776
|
"sudo",
|
|
3486
3777
|
["env", ...collectPortlessEnvArgs(), process.execPath, getEntryScript(), "clean"],
|
|
3487
3778
|
{
|
|
@@ -3532,6 +3823,17 @@ ${colors_default.bold("Options:")}
|
|
|
3532
3823
|
console.log("No orphaned routes found.");
|
|
3533
3824
|
return;
|
|
3534
3825
|
}
|
|
3826
|
+
for (const route of stale) {
|
|
3827
|
+
if (route.tailscaleHttpsPort) {
|
|
3828
|
+
try {
|
|
3829
|
+
unregisterTailscale(route);
|
|
3830
|
+
console.log(
|
|
3831
|
+
` ${route.hostname} - removed tailscale serve on port ${route.tailscaleHttpsPort}`
|
|
3832
|
+
);
|
|
3833
|
+
} catch {
|
|
3834
|
+
}
|
|
3835
|
+
}
|
|
3836
|
+
}
|
|
3535
3837
|
let killed = 0;
|
|
3536
3838
|
for (const route of stale) {
|
|
3537
3839
|
const pids = findPidsOnPort(route.port);
|
|
@@ -3709,7 +4011,7 @@ ${colors_default.bold("Auto-sync:")}
|
|
|
3709
4011
|
`Writing to ${HOSTS_DISPLAY} requires elevated privileges. Requesting sudo...`
|
|
3710
4012
|
)
|
|
3711
4013
|
);
|
|
3712
|
-
const result =
|
|
4014
|
+
const result = spawnSync3(
|
|
3713
4015
|
"sudo",
|
|
3714
4016
|
["env", ...collectPortlessEnvArgs(), process.execPath, getEntryScript(), "hosts", "clean"],
|
|
3715
4017
|
{
|
|
@@ -3762,7 +4064,7 @@ ${colors_default.bold("Usage: portless hosts <command>")}
|
|
|
3762
4064
|
console.log(
|
|
3763
4065
|
colors_default.yellow(`Writing to ${HOSTS_DISPLAY} requires elevated privileges. Requesting sudo...`)
|
|
3764
4066
|
);
|
|
3765
|
-
const result =
|
|
4067
|
+
const result = spawnSync3(
|
|
3766
4068
|
"sudo",
|
|
3767
4069
|
["env", ...collectPortlessEnvArgs(), process.execPath, getEntryScript(), "hosts", "sync"],
|
|
3768
4070
|
{
|
|
@@ -4053,7 +4355,7 @@ ${colors_default.bold("LAN mode (--lan):")}
|
|
|
4053
4355
|
if (!hasExplicitPort) {
|
|
4054
4356
|
console.log(colors_default.gray(`(To skip sudo, use an unprivileged port: ${fallbackCommand})`));
|
|
4055
4357
|
}
|
|
4056
|
-
const result =
|
|
4358
|
+
const result = spawnSync3("sudo", ["env", ...collectPortlessEnvArgs(), ...startArgs], {
|
|
4057
4359
|
stdio: "inherit",
|
|
4058
4360
|
timeout: SUDO_SPAWN_TIMEOUT_MS
|
|
4059
4361
|
});
|
|
@@ -4825,6 +5127,13 @@ async function main() {
|
|
|
4825
5127
|
process.env[INTERNAL_LAN_IP_ENV] = autoIpResult;
|
|
4826
5128
|
process.env.PORTLESS_LAN = "1";
|
|
4827
5129
|
}
|
|
5130
|
+
if (stripGlobalFlag("--tailscale", false)) {
|
|
5131
|
+
process.env.PORTLESS_TAILSCALE = "1";
|
|
5132
|
+
}
|
|
5133
|
+
if (stripGlobalFlag("--funnel", false)) {
|
|
5134
|
+
process.env.PORTLESS_FUNNEL = "1";
|
|
5135
|
+
process.env.PORTLESS_TAILSCALE = "1";
|
|
5136
|
+
}
|
|
4828
5137
|
const scriptResult = stripGlobalFlag("--script", true);
|
|
4829
5138
|
if (scriptResult === false) {
|
|
4830
5139
|
console.error(colors_default.red("Error: --script requires a script name."));
|
package/dist/index.d.ts
CHANGED
|
@@ -61,6 +61,9 @@ declare const FILE_MODE = 420;
|
|
|
61
61
|
declare const DIR_MODE = 493;
|
|
62
62
|
interface RouteMapping extends RouteInfo {
|
|
63
63
|
pid: number;
|
|
64
|
+
tailscaleUrl?: string;
|
|
65
|
+
tailscaleHttpsPort?: number;
|
|
66
|
+
tailscaleFunnel?: boolean;
|
|
64
67
|
}
|
|
65
68
|
/**
|
|
66
69
|
* Thrown when a route is already registered by a live process and --force was
|
|
@@ -119,6 +122,11 @@ declare class RouteStore {
|
|
|
119
122
|
* result. Returns the removed stale entries so the caller can act on them.
|
|
120
123
|
*/
|
|
121
124
|
pruneStaleRoutes(): RouteMapping[];
|
|
125
|
+
/**
|
|
126
|
+
* Update metadata on an existing route entry. Only provided fields are
|
|
127
|
+
* merged; the route must already exist (matched by hostname).
|
|
128
|
+
*/
|
|
129
|
+
updateRoute(hostname: string, fields: Partial<Pick<RouteMapping, "tailscaleUrl" | "tailscaleHttpsPort" | "tailscaleFunnel">>): void;
|
|
122
130
|
removeRoute(hostname: string): void;
|
|
123
131
|
}
|
|
124
132
|
|
package/dist/index.js
CHANGED