localpreview 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +114 -0
- package/dist/args.d.ts +3 -0
- package/dist/args.d.ts.map +1 -0
- package/dist/args.js +8 -0
- package/dist/command.d.ts +5 -0
- package/dist/command.d.ts.map +1 -0
- package/dist/command.js +298 -0
- package/dist/config.d.ts +30 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +31 -0
- package/dist/control-plane.d.ts +59 -0
- package/dist/control-plane.d.ts.map +1 -0
- package/dist/control-plane.js +182 -0
- package/dist/errors.d.ts +58 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +21 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/local-proxy.d.ts +15 -0
- package/dist/local-proxy.d.ts.map +1 -0
- package/dist/local-proxy.js +214 -0
- package/dist/relay-client.d.ts +15 -0
- package/dist/relay-client.d.ts.map +1 -0
- package/dist/relay-client.js +115 -0
- package/dist/ui.d.ts +38 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +109 -0
- package/package.json +34 -0
package/README.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# localpreview
|
|
2
|
+
|
|
3
|
+
Node CLI for opening an ephemeral LocalPreview session from a local development
|
|
4
|
+
server to a public URL.
|
|
5
|
+
|
|
6
|
+
## Usage
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
localpreview connect 3000
|
|
10
|
+
localpreview connect https://localhost:3000
|
|
11
|
+
localpreview connect 3000 --name proyecto
|
|
12
|
+
localpreview connect 3000 -l
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`localpreview <target>` is still accepted as a deprecated compatibility alias and
|
|
16
|
+
prints a warning. New usage should prefer `localpreview connect <target>`.
|
|
17
|
+
|
|
18
|
+
Targets are parsed by `@localpreview/protocol`:
|
|
19
|
+
|
|
20
|
+
- `4000` means `http://127.0.0.1:4000`.
|
|
21
|
+
- URLs must use `http` or `https`.
|
|
22
|
+
- URL targets must include an explicit port.
|
|
23
|
+
|
|
24
|
+
## Runtime Flow
|
|
25
|
+
|
|
26
|
+
1. The CLI validates the target and optional `--name`.
|
|
27
|
+
2. It calls the control-plane `POST /api/tunnels`.
|
|
28
|
+
3. The control-plane returns the public URL, relay WebSocket URL, tunnel id, and
|
|
29
|
+
client token.
|
|
30
|
+
4. The CLI connects to the relay over WebSocket.
|
|
31
|
+
5. Browser requests flow through:
|
|
32
|
+
|
|
33
|
+
```txt
|
|
34
|
+
browser -> control-plane -> relay -> CLI WebSocket -> local target
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
6. When the process exits or receives `SIGINT`/`SIGTERM`, the CLI closes the
|
|
38
|
+
relay connection and sends `DELETE /api/tunnels/:id` as best-effort cleanup.
|
|
39
|
+
|
|
40
|
+
## Implementation Shape
|
|
41
|
+
|
|
42
|
+
The package is structured as small Effect-backed services:
|
|
43
|
+
|
|
44
|
+
- `src/command.ts`: command parsing, legacy alias handling, and top-level flow.
|
|
45
|
+
- `src/config.ts`: runtime defaults and environment-derived limits.
|
|
46
|
+
- `src/control-plane.ts`: tunnel create/delete HTTP adapter.
|
|
47
|
+
- `src/relay-client.ts`: WebSocket lifecycle, signals, and relay event handling.
|
|
48
|
+
- `src/local-proxy.ts`: relay message handling and local HTTP forwarding.
|
|
49
|
+
- `src/errors.ts`: typed CLI failure model.
|
|
50
|
+
|
|
51
|
+
`ws` remains the low-level WebSocket adapter. Promise/event APIs are kept at the
|
|
52
|
+
edges; lifecycle, cleanup, concurrency, config, and typed failures are modeled in
|
|
53
|
+
Effect.
|
|
54
|
+
|
|
55
|
+
## Defaults and Limits
|
|
56
|
+
|
|
57
|
+
- Control-plane URL precedence:
|
|
58
|
+
`-l|--local` > `https://localpreview.dev`
|
|
59
|
+
- Request body limit: `10 MB`
|
|
60
|
+
- Response body limit: `50 MB`
|
|
61
|
+
- Local request timeout: `30 seconds`
|
|
62
|
+
- Max in-flight requests: `100`
|
|
63
|
+
- Response chunks sent to relay: `64 KiB`
|
|
64
|
+
- Initial relay connect retry budget: `10 seconds`
|
|
65
|
+
|
|
66
|
+
Optional environment overrides:
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
LOCALPREVIEW_REQUEST_BODY_LIMIT_BYTES=10485760
|
|
70
|
+
LOCALPREVIEW_RESPONSE_BODY_LIMIT_BYTES=52428800
|
|
71
|
+
LOCALPREVIEW_REQUEST_TIMEOUT_MS=30000
|
|
72
|
+
LOCALPREVIEW_MAX_IN_FLIGHT_REQUESTS=100
|
|
73
|
+
LOCALPREVIEW_RESPONSE_CHUNK_SIZE_BYTES=65536
|
|
74
|
+
LOCALPREVIEW_RELAY_CONNECT_TIMEOUT_MS=10000
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Development
|
|
78
|
+
|
|
79
|
+
From the repo root:
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
pnpm --filter localpreview test
|
|
83
|
+
pnpm --filter localpreview typecheck
|
|
84
|
+
pnpm --filter localpreview build
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
To make the CLI available globally during local development:
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
pnpm --filter localpreview build
|
|
91
|
+
cd packages/cli
|
|
92
|
+
pnpm link --global
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Then verify it from any directory:
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
which localpreview
|
|
99
|
+
localpreview connect 3000 -l
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
If pnpm reports that the global bin directory is missing, run `pnpm setup`, then
|
|
103
|
+
restart the terminal so `PNPM_HOME` is added to `PATH`.
|
|
104
|
+
|
|
105
|
+
The global command points at `dist/index.js`, so rebuild after changing CLI
|
|
106
|
+
source files.
|
|
107
|
+
|
|
108
|
+
For a local end-to-end run, start the relay and control-plane first:
|
|
109
|
+
|
|
110
|
+
```sh
|
|
111
|
+
pnpm dev:relay
|
|
112
|
+
pnpm dev:web
|
|
113
|
+
pnpm localpreview connect 3000 -l
|
|
114
|
+
```
|
package/dist/args.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"args.d.ts","sourceRoot":"","sources":["../src/args.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,gBAAgB,GAAI,MAAM,aAAa,CAAC,MAAM,CAAC,KAAG,aAAa,CAAC,MAAM,CAC1C,CAAC;AAE1C,eAAO,MAAM,aAAa,GAAI,MAAM,aAAa,CAAC,MAAM,CAAC,EAAE,MAAM,MAAM,KAAG,MAAM,GAAG,SAQlF,CAAC"}
|
package/dist/args.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { CliUsageError, type CliRuntimeError } from "./errors.js";
|
|
3
|
+
export declare const runCli: (argv: ReadonlyArray<string>) => Effect.Effect<void, CliRuntimeError | CliUsageError>;
|
|
4
|
+
export declare const normalizeCliArgs: (argv: ReadonlyArray<string>) => ReadonlyArray<string>;
|
|
5
|
+
//# sourceMappingURL=command.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"command.d.ts","sourceRoot":"","sources":["../src/command.ts"],"names":[],"mappings":"AAKA,OAAO,EAAW,MAAM,EAAiB,MAAM,QAAQ,CAAC;AAOxD,OAAO,EAAE,aAAa,EAAgB,KAAK,eAAe,EAAE,MAAM,aAAa,CAAC;AA6BhF,eAAO,MAAM,MAAM,GACjB,MAAM,aAAa,CAAC,MAAM,CAAC,KAC1B,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,eAAe,GAAG,aAAa,CAkBrD,CAAC;AAEF,eAAO,MAAM,gBAAgB,GAAI,MAAM,aAAa,CAAC,MAAM,CAAC,KAAG,aAAa,CAAC,MAAM,CAC1C,CAAC"}
|
package/dist/command.js
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { parseTarget, validateRequestedSubdomain, } from "@localpreview/protocol";
|
|
2
|
+
import { Console, Effect, Layer, Option } from "effect";
|
|
3
|
+
import { CliConfig, CliConfigLive, LOCAL_CONTROL_PLANE_URL } from "./config.js";
|
|
4
|
+
import { ControlPlaneClient, ControlPlaneClientLive, } from "./control-plane.js";
|
|
5
|
+
import { CliUsageError, errorMessage } from "./errors.js";
|
|
6
|
+
import { LocalProxyLive } from "./local-proxy.js";
|
|
7
|
+
import { RelayClient, RelayClientLive } from "./relay-client.js";
|
|
8
|
+
export const runCli = (argv) => {
|
|
9
|
+
const normalized = normalizeCliArgs(argv);
|
|
10
|
+
if (isHelpInvocation(normalized)) {
|
|
11
|
+
return printHelp();
|
|
12
|
+
}
|
|
13
|
+
if (isVersionInvocation(normalized)) {
|
|
14
|
+
return Console.log("0.0.0");
|
|
15
|
+
}
|
|
16
|
+
const parsed = parseCommand(normalized);
|
|
17
|
+
if (!parsed.ok) {
|
|
18
|
+
return Effect.fail(new CliUsageError({ message: parsed.message }));
|
|
19
|
+
}
|
|
20
|
+
return runSubcommand(parsed.command);
|
|
21
|
+
};
|
|
22
|
+
export const normalizeCliArgs = (argv) => argv[0] === "--" ? argv.slice(1) : argv;
|
|
23
|
+
const isHelpInvocation = (argv) => argv.length === 0 || argv.includes("--help") || argv.includes("-h");
|
|
24
|
+
const isVersionInvocation = (argv) => argv.includes("--version");
|
|
25
|
+
const isLegacyConnectInvocation = (argv) => {
|
|
26
|
+
const first = argv[0];
|
|
27
|
+
if (first === undefined) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
return first !== "connect" && !first.startsWith("-");
|
|
31
|
+
};
|
|
32
|
+
const printHelp = () => Console.log([
|
|
33
|
+
"Usage: localpreview connect <port|target-url> [--name subdomain] [-l|--local]",
|
|
34
|
+
" localpreview clean <subdomain> [--force] [-l|--local] [--admin-token token]",
|
|
35
|
+
" localpreview clean --all --force [-l|--local] [--admin-token token]",
|
|
36
|
+
"",
|
|
37
|
+
"Examples:",
|
|
38
|
+
" localpreview connect 3000",
|
|
39
|
+
" localpreview connect https://localhost:3000 --name proyecto",
|
|
40
|
+
" localpreview connect 3000 -l",
|
|
41
|
+
" localpreview clean proyecto --force",
|
|
42
|
+
" localpreview clean --all --force",
|
|
43
|
+
].join("\n"));
|
|
44
|
+
const parseCommand = (argv) => {
|
|
45
|
+
if (argv[0] === "clean") {
|
|
46
|
+
const parsed = parseCleanArgs(argv.slice(1));
|
|
47
|
+
if (!parsed.ok) {
|
|
48
|
+
return parsed;
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
command: {
|
|
52
|
+
config: parsed.config,
|
|
53
|
+
kind: "clean",
|
|
54
|
+
},
|
|
55
|
+
ok: true,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const legacy = isLegacyConnectInvocation(argv);
|
|
59
|
+
const args = legacy ? argv : argv[0] === "connect" ? argv.slice(1) : argv;
|
|
60
|
+
const parsed = parseConnectArgs(args);
|
|
61
|
+
if (!parsed.ok) {
|
|
62
|
+
return parsed;
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
command: {
|
|
66
|
+
config: parsed.config,
|
|
67
|
+
kind: "connect",
|
|
68
|
+
legacy,
|
|
69
|
+
},
|
|
70
|
+
ok: true,
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
const parseConnectArgs = (argv) => {
|
|
74
|
+
const rest = [...argv];
|
|
75
|
+
const target = rest.shift();
|
|
76
|
+
if (target === undefined || target.startsWith("-")) {
|
|
77
|
+
return {
|
|
78
|
+
message: "Usage: localpreview connect <port|target-url> [--name subdomain] [-l|--local]",
|
|
79
|
+
ok: false,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
let controlPlane;
|
|
83
|
+
let requestedName;
|
|
84
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
85
|
+
const arg = rest[index];
|
|
86
|
+
if (arg === "--name") {
|
|
87
|
+
const value = readRequiredOptionValue(rest, index, "--name");
|
|
88
|
+
if (!value.ok) {
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
requestedName = value.value;
|
|
92
|
+
index += 1;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (arg === "-l" || arg === "--local") {
|
|
96
|
+
controlPlane = LOCAL_CONTROL_PLANE_URL;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
message: `Unknown option: ${arg}`,
|
|
101
|
+
ok: false,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
config: {
|
|
106
|
+
controlPlane: toOption(controlPlane),
|
|
107
|
+
requestedName: toOption(requestedName),
|
|
108
|
+
target,
|
|
109
|
+
},
|
|
110
|
+
ok: true,
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
const parseCleanArgs = (argv) => {
|
|
114
|
+
const rest = [...argv];
|
|
115
|
+
let adminToken;
|
|
116
|
+
let all = false;
|
|
117
|
+
let controlPlane;
|
|
118
|
+
let force = false;
|
|
119
|
+
let subdomain;
|
|
120
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
121
|
+
const arg = rest[index];
|
|
122
|
+
if (arg === "--admin-token") {
|
|
123
|
+
const value = readRequiredOptionValue(rest, index, "--admin-token");
|
|
124
|
+
if (!value.ok) {
|
|
125
|
+
return value;
|
|
126
|
+
}
|
|
127
|
+
adminToken = value.value;
|
|
128
|
+
index += 1;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (arg === "--all") {
|
|
132
|
+
all = true;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (arg === "--force") {
|
|
136
|
+
force = true;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (arg === "-l" || arg === "--local") {
|
|
140
|
+
controlPlane = LOCAL_CONTROL_PLANE_URL;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (arg !== undefined && !arg.startsWith("-") && subdomain === undefined) {
|
|
144
|
+
subdomain = arg;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (arg !== undefined && !arg.startsWith("-")) {
|
|
148
|
+
return {
|
|
149
|
+
message: `Unexpected argument: ${arg}`,
|
|
150
|
+
ok: false,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
message: `Unknown option: ${arg}`,
|
|
155
|
+
ok: false,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
if (all && subdomain !== undefined) {
|
|
159
|
+
return {
|
|
160
|
+
message: "Use either `localpreview clean <subdomain>` or `localpreview clean --all --force`, not both.",
|
|
161
|
+
ok: false,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
if (!all && subdomain === undefined) {
|
|
165
|
+
return {
|
|
166
|
+
message: "Usage: localpreview clean <subdomain> [--force] [-l|--local] [--admin-token token]\n localpreview clean --all --force [-l|--local] [--admin-token token]",
|
|
167
|
+
ok: false,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
config: {
|
|
172
|
+
all,
|
|
173
|
+
adminToken: toOption(adminToken),
|
|
174
|
+
controlPlane: toOption(controlPlane),
|
|
175
|
+
force,
|
|
176
|
+
subdomain: toOption(subdomain),
|
|
177
|
+
},
|
|
178
|
+
ok: true,
|
|
179
|
+
};
|
|
180
|
+
};
|
|
181
|
+
const readRequiredOptionValue = (argv, index, option) => {
|
|
182
|
+
const value = argv[index + 1];
|
|
183
|
+
if (value === undefined || value.startsWith("-")) {
|
|
184
|
+
return {
|
|
185
|
+
message: `Missing value for ${option}.`,
|
|
186
|
+
ok: false,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
ok: true,
|
|
191
|
+
value,
|
|
192
|
+
};
|
|
193
|
+
};
|
|
194
|
+
const toOption = (value) => value === undefined ? Option.none() : Option.some(value);
|
|
195
|
+
const runSubcommand = (command) => {
|
|
196
|
+
const controlPlane = Option.getOrUndefined(command.config.controlPlane);
|
|
197
|
+
const effect = command.kind === "connect"
|
|
198
|
+
? runConnect(command.config, command.legacy)
|
|
199
|
+
: runClean(command.config);
|
|
200
|
+
return effect.pipe(Effect.provide(makeCommandLayer(makeConfigInput(controlPlane))));
|
|
201
|
+
};
|
|
202
|
+
const makeConfigInput = (controlPlaneUrl) => controlPlaneUrl === undefined ? {} : { controlPlaneUrl };
|
|
203
|
+
const makeCommandLayer = (input) => {
|
|
204
|
+
const ConfigLive = CliConfigLive(input);
|
|
205
|
+
const ProxyLive = LocalProxyLive.pipe(Layer.provide(ConfigLive));
|
|
206
|
+
const RelayLive = RelayClientLive.pipe(Layer.provide(Layer.mergeAll(ConfigLive, ProxyLive)));
|
|
207
|
+
return Layer.mergeAll(ConfigLive, ControlPlaneClientLive, ProxyLive, RelayLive);
|
|
208
|
+
};
|
|
209
|
+
const runConnect = (config, legacy) => Effect.scoped(Effect.gen(function* () {
|
|
210
|
+
if (legacy) {
|
|
211
|
+
yield* Console.error("Warning: `localpreview <target>` is deprecated. Use `localpreview connect <target>`.");
|
|
212
|
+
}
|
|
213
|
+
const target = parseTarget(config.target);
|
|
214
|
+
if (!target.ok) {
|
|
215
|
+
return yield* Effect.fail(new CliUsageError({ message: target.message }));
|
|
216
|
+
}
|
|
217
|
+
const requestedSubdomain = yield* validateRequestedName(config.requestedName);
|
|
218
|
+
const controlPlane = yield* ControlPlaneClient;
|
|
219
|
+
const relay = yield* RelayClient;
|
|
220
|
+
const cliConfig = yield* CliConfig;
|
|
221
|
+
const tunnel = yield* Effect.acquireRelease(controlPlane.createTunnel(cliConfig.controlPlaneUrl, requestedSubdomain === undefined ? {} : { requestedSubdomain }), (tunnel) => closeTunnelBestEffort(controlPlane, cliConfig.controlPlaneUrl, tunnel));
|
|
222
|
+
yield* Console.log(`Tunnel ready: ${tunnel.publicUrl}`);
|
|
223
|
+
yield* Console.log(`Forwarding to ${target.target.protocol}://${target.target.hostname}:${target.target.port}`);
|
|
224
|
+
yield* relay.connectAndServe(tunnel, target.target);
|
|
225
|
+
}));
|
|
226
|
+
const validateRequestedName = (value) => Option.match(value, {
|
|
227
|
+
onNone: () => Effect.succeed(undefined),
|
|
228
|
+
onSome: (requestedName) => {
|
|
229
|
+
const validation = validateRequestedSubdomain(requestedName);
|
|
230
|
+
if (!validation.valid) {
|
|
231
|
+
return Effect.fail(new CliUsageError({
|
|
232
|
+
message: `Subdomain "${requestedName}" is not available: ${validation.reason}.`,
|
|
233
|
+
}));
|
|
234
|
+
}
|
|
235
|
+
return Effect.succeed(validation.subdomain);
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
const runClean = (config) => Effect.gen(function* () {
|
|
239
|
+
const subdomain = Option.getOrUndefined(config.subdomain);
|
|
240
|
+
if (config.all && !config.force) {
|
|
241
|
+
return yield* Effect.fail(new CliUsageError({
|
|
242
|
+
message: "Bulk cleanup requires --force.",
|
|
243
|
+
}));
|
|
244
|
+
}
|
|
245
|
+
const controlPlane = yield* ControlPlaneClient;
|
|
246
|
+
const cliConfig = yield* CliConfig;
|
|
247
|
+
const adminToken = Option.getOrUndefined(config.adminToken) ?? cliConfig.cleanupAdminToken;
|
|
248
|
+
if (adminToken === undefined || adminToken.length === 0) {
|
|
249
|
+
return yield* Effect.fail(new CliUsageError({
|
|
250
|
+
message: "Subdomain cleanup requires an admin token. Set LOCALPREVIEW_CLEANUP_TOKEN or pass --admin-token.",
|
|
251
|
+
}));
|
|
252
|
+
}
|
|
253
|
+
if (config.all) {
|
|
254
|
+
const result = yield* controlPlane.cleanAllSubdomains(cliConfig.controlPlaneUrl, {
|
|
255
|
+
adminToken,
|
|
256
|
+
force: config.force,
|
|
257
|
+
});
|
|
258
|
+
if (!result.cleaned) {
|
|
259
|
+
yield* Console.log("No subdomains were active.");
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const untracked = result.untrackedRelays;
|
|
263
|
+
const untrackedSuffix = untracked === undefined || untracked.total === 0
|
|
264
|
+
? ""
|
|
265
|
+
: ` Found ${untracked.total} untracked relays/sandboxes.`;
|
|
266
|
+
yield* Console.log(`Cleaned ${result.total} subdomains. Stopped ${result.stopped} relays/sandboxes.` +
|
|
267
|
+
(result.failedToStop === 0 ? "" : ` Failed to stop ${result.failedToStop}.`) +
|
|
268
|
+
untrackedSuffix);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (subdomain === undefined) {
|
|
272
|
+
return yield* Effect.fail(new CliUsageError({
|
|
273
|
+
message: "Usage: localpreview clean <subdomain> [--force] [-l|--local] [--admin-token token]",
|
|
274
|
+
}));
|
|
275
|
+
}
|
|
276
|
+
const validation = validateRequestedSubdomain(subdomain);
|
|
277
|
+
if (!validation.valid) {
|
|
278
|
+
return yield* Effect.fail(new CliUsageError({
|
|
279
|
+
message: `Subdomain "${subdomain}" is not available: ${validation.reason}.`,
|
|
280
|
+
}));
|
|
281
|
+
}
|
|
282
|
+
const result = yield* controlPlane.cleanSubdomain(cliConfig.controlPlaneUrl, {
|
|
283
|
+
adminToken,
|
|
284
|
+
force: config.force,
|
|
285
|
+
subdomain: validation.subdomain,
|
|
286
|
+
});
|
|
287
|
+
if (!result.cleaned) {
|
|
288
|
+
yield* Console.log(`Subdomain "${result.subdomain}" was already clean.`);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const suffix = result.tunnelId === undefined
|
|
292
|
+
? ""
|
|
293
|
+
: ` Tunnel ${result.tunnelId} was ${result.relayStopped ? "stopped" : "removed from the store"}.`;
|
|
294
|
+
yield* Console.log(`Subdomain "${result.subdomain}" cleaned.${suffix}`);
|
|
295
|
+
});
|
|
296
|
+
const closeTunnelBestEffort = (controlPlane, controlPlaneUrl, tunnel) => controlPlane
|
|
297
|
+
.closeTunnel(controlPlaneUrl, tunnel)
|
|
298
|
+
.pipe(Effect.catch((error) => Console.error(`Warning: ${errorMessage(error)}`)));
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Context, Effect, Layer } from "effect";
|
|
2
|
+
export type CliConfigShape = {
|
|
3
|
+
readonly cleanupAdminToken: string | undefined;
|
|
4
|
+
readonly controlPlaneUrl: string;
|
|
5
|
+
readonly maxInFlightRequests: number;
|
|
6
|
+
readonly relayConnectTimeoutMs: number;
|
|
7
|
+
readonly requestBodyLimitBytes: number;
|
|
8
|
+
readonly requestTimeoutMs: number;
|
|
9
|
+
readonly responseBodyLimitBytes: number;
|
|
10
|
+
readonly responseChunkSizeBytes: number;
|
|
11
|
+
};
|
|
12
|
+
declare const CliConfig_base: Context.ServiceClass<CliConfig, "CliConfig", CliConfigShape>;
|
|
13
|
+
export declare class CliConfig extends CliConfig_base {
|
|
14
|
+
}
|
|
15
|
+
export declare const PUBLIC_CONTROL_PLANE_URL = "https://localpreview.dev";
|
|
16
|
+
export declare const LOCAL_CONTROL_PLANE_URL = "http://localhost:3000";
|
|
17
|
+
export declare const makeCliConfig: (input: {
|
|
18
|
+
readonly controlPlaneUrl?: string;
|
|
19
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
20
|
+
}) => CliConfigShape;
|
|
21
|
+
export declare const CliConfigLive: (input: {
|
|
22
|
+
readonly controlPlaneUrl?: string;
|
|
23
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
24
|
+
}) => Layer.Layer<CliConfig, never, never>;
|
|
25
|
+
export declare const readCliConfig: (input: {
|
|
26
|
+
readonly controlPlaneUrl?: string;
|
|
27
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
28
|
+
}) => Effect.Effect<CliConfigShape>;
|
|
29
|
+
export {};
|
|
30
|
+
//# sourceMappingURL=config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,QAAQ,CAAC;AAEhD,MAAM,MAAM,cAAc,GAAG;IAC3B,QAAQ,CAAC,iBAAiB,EAAE,MAAM,GAAG,SAAS,CAAC;IAC/C,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,mBAAmB,EAAE,MAAM,CAAC;IACrC,QAAQ,CAAC,qBAAqB,EAAE,MAAM,CAAC;IACvC,QAAQ,CAAC,qBAAqB,EAAE,MAAM,CAAC;IACvC,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,QAAQ,CAAC,sBAAsB,EAAE,MAAM,CAAC;IACxC,QAAQ,CAAC,sBAAsB,EAAE,MAAM,CAAC;CACzC,CAAC;;AAEF,qBAAa,SAAU,SAAQ,cAAyD;CAAG;AAE3F,eAAO,MAAM,wBAAwB,6BAA6B,CAAC;AACnE,eAAO,MAAM,uBAAuB,0BAA0B,CAAC;AAE/D,eAAO,MAAM,aAAa,GAAI,OAAO;IACnC,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;IAClC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;CAClC,KAAG,cAoBH,CAAC;AAEF,eAAO,MAAM,aAAa,GAAI,OAAO;IACnC,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;IAClC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;CAClC,yCAAmD,CAAC;AAWrD,eAAO,MAAM,aAAa,GAAI,OAAO;IACnC,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;IAClC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;CAClC,KAAG,MAAM,CAAC,MAAM,CAAC,cAAc,CAA4C,CAAC"}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { LOCALPREVIEW_PUBLIC_ORIGIN } from "@localpreview/protocol";
|
|
2
|
+
import { Context, Effect, Layer } from "effect";
|
|
3
|
+
export class CliConfig extends Context.Service()("CliConfig") {
|
|
4
|
+
}
|
|
5
|
+
export const PUBLIC_CONTROL_PLANE_URL = LOCALPREVIEW_PUBLIC_ORIGIN;
|
|
6
|
+
export const LOCAL_CONTROL_PLANE_URL = "http://localhost:3000";
|
|
7
|
+
export const makeCliConfig = (input) => {
|
|
8
|
+
const env = input.env ?? process.env;
|
|
9
|
+
const controlPlaneUrl = input.controlPlaneUrl ?? PUBLIC_CONTROL_PLANE_URL;
|
|
10
|
+
const cleanupAdminToken = env.LOCALPREVIEW_CLEANUP_TOKEN ??
|
|
11
|
+
(controlPlaneUrl === LOCAL_CONTROL_PLANE_URL ? "local-dev-cleanup-token" : undefined);
|
|
12
|
+
return {
|
|
13
|
+
cleanupAdminToken,
|
|
14
|
+
controlPlaneUrl,
|
|
15
|
+
maxInFlightRequests: readNumber(env.LOCALPREVIEW_MAX_IN_FLIGHT_REQUESTS, 100),
|
|
16
|
+
relayConnectTimeoutMs: readNumber(env.LOCALPREVIEW_RELAY_CONNECT_TIMEOUT_MS, 10_000),
|
|
17
|
+
requestBodyLimitBytes: readNumber(env.LOCALPREVIEW_REQUEST_BODY_LIMIT_BYTES, 10 * 1024 * 1024),
|
|
18
|
+
requestTimeoutMs: readNumber(env.LOCALPREVIEW_REQUEST_TIMEOUT_MS, 30_000),
|
|
19
|
+
responseBodyLimitBytes: readNumber(env.LOCALPREVIEW_RESPONSE_BODY_LIMIT_BYTES, 50 * 1024 * 1024),
|
|
20
|
+
responseChunkSizeBytes: readNumber(env.LOCALPREVIEW_RESPONSE_CHUNK_SIZE_BYTES, 64 * 1024),
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
export const CliConfigLive = (input) => Layer.succeed(CliConfig, makeCliConfig(input));
|
|
24
|
+
const readNumber = (value, fallback) => {
|
|
25
|
+
if (value === undefined) {
|
|
26
|
+
return fallback;
|
|
27
|
+
}
|
|
28
|
+
const parsed = Number(value);
|
|
29
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
30
|
+
};
|
|
31
|
+
export const readCliConfig = (input) => Effect.sync(() => makeCliConfig(input));
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type CreateTunnelResponse } from "@localpreview/protocol";
|
|
2
|
+
import { Context, Effect, Layer } from "effect";
|
|
3
|
+
import { ControlPlaneError } from "./errors.js";
|
|
4
|
+
export type ControlPlaneClientShape = {
|
|
5
|
+
readonly cleanAllSubdomains: (controlPlaneUrl: string, input: {
|
|
6
|
+
readonly adminToken: string;
|
|
7
|
+
readonly force: boolean;
|
|
8
|
+
}) => Effect.Effect<CleanAllSubdomainsResponse, ControlPlaneError>;
|
|
9
|
+
readonly cleanSubdomain: (controlPlaneUrl: string, input: {
|
|
10
|
+
readonly adminToken: string;
|
|
11
|
+
readonly force: boolean;
|
|
12
|
+
readonly subdomain: string;
|
|
13
|
+
}) => Effect.Effect<CleanSubdomainResponse, ControlPlaneError>;
|
|
14
|
+
readonly closeTunnel: (controlPlaneUrl: string, tunnel: Pick<CreateTunnelResponse, "clientToken" | "tunnelId">) => Effect.Effect<void, ControlPlaneError>;
|
|
15
|
+
readonly createTunnel: (controlPlaneUrl: string, body: {
|
|
16
|
+
readonly requestedSubdomain?: string;
|
|
17
|
+
}) => Effect.Effect<CreateTunnelResponse, ControlPlaneError>;
|
|
18
|
+
};
|
|
19
|
+
export type CleanSubdomainResponse = {
|
|
20
|
+
readonly cleaned: boolean;
|
|
21
|
+
readonly relayStopped: boolean;
|
|
22
|
+
readonly subdomain: string;
|
|
23
|
+
readonly tunnelId?: string;
|
|
24
|
+
};
|
|
25
|
+
export type CleanAllSubdomainsResponse = {
|
|
26
|
+
readonly cleaned: boolean;
|
|
27
|
+
readonly failedToStop: number;
|
|
28
|
+
readonly results: ReadonlyArray<CleanSubdomainResponse>;
|
|
29
|
+
readonly stopped: number;
|
|
30
|
+
readonly total: number;
|
|
31
|
+
readonly untrackedRelays?: {
|
|
32
|
+
readonly failedToStop: number;
|
|
33
|
+
readonly results: ReadonlyArray<{
|
|
34
|
+
readonly relayStopped: boolean;
|
|
35
|
+
readonly sandboxId: string;
|
|
36
|
+
}>;
|
|
37
|
+
readonly stopped: number;
|
|
38
|
+
readonly total: number;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
declare const ControlPlaneClient_base: Context.ServiceClass<ControlPlaneClient, "ControlPlaneClient", ControlPlaneClientShape>;
|
|
42
|
+
export declare class ControlPlaneClient extends ControlPlaneClient_base {
|
|
43
|
+
}
|
|
44
|
+
export declare const ControlPlaneClientLive: Layer.Layer<ControlPlaneClient, never, never>;
|
|
45
|
+
export declare const createTunnel: (controlPlaneUrl: string, body: {
|
|
46
|
+
readonly requestedSubdomain?: string;
|
|
47
|
+
}) => Promise<CreateTunnelResponse>;
|
|
48
|
+
export declare const closeTunnel: (controlPlaneUrl: string, tunnel: Pick<CreateTunnelResponse, "clientToken" | "tunnelId">) => Promise<void>;
|
|
49
|
+
export declare const cleanSubdomain: (controlPlaneUrl: string, input: {
|
|
50
|
+
readonly adminToken: string;
|
|
51
|
+
readonly force: boolean;
|
|
52
|
+
readonly subdomain: string;
|
|
53
|
+
}) => Promise<CleanSubdomainResponse>;
|
|
54
|
+
export declare const cleanAllSubdomains: (controlPlaneUrl: string, input: {
|
|
55
|
+
readonly adminToken: string;
|
|
56
|
+
readonly force: boolean;
|
|
57
|
+
}) => Promise<CleanAllSubdomainsResponse>;
|
|
58
|
+
export {};
|
|
59
|
+
//# sourceMappingURL=control-plane.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"control-plane.d.ts","sourceRoot":"","sources":["../src/control-plane.ts"],"names":[],"mappings":"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"}
|