localpreview 0.1.0 → 0.2.1

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.
@@ -0,0 +1,38 @@
1
+ import { type CaptureTarget, type TunnelTarget } from "@localpreview/protocol";
2
+ export declare const CAPTURE_PATH_PREFIX = "/__localpreview/capture";
3
+ export type ParsedCapturePath = {
4
+ readonly hostname: string;
5
+ readonly path: string;
6
+ readonly port: number;
7
+ readonly protocol: TunnelTarget["protocol"];
8
+ };
9
+ type ParseCapturePathResult = {
10
+ readonly ok: true;
11
+ readonly parsed: ParsedCapturePath;
12
+ } | {
13
+ readonly ok: false;
14
+ };
15
+ /**
16
+ * Parses an internal capture route:
17
+ * `/__localpreview/capture/<protocol>/<host>/<port>/<original-path>`.
18
+ */
19
+ export declare const parseCapturePath: (requestPath: string) => ParseCapturePathResult;
20
+ /** Builds the internal capture path prefix for cookie isolation. */
21
+ export declare const buildCaptureCookiePathPrefix: (parsed: ParsedCapturePath) => string;
22
+ /** Encodes a hostname for use in capture path segments. */
23
+ export declare const encodeCaptureHostname: (hostname: string) => string;
24
+ export type ResolvedRoute = {
25
+ readonly capturePath: ParsedCapturePath;
26
+ readonly kind: "capture";
27
+ readonly target: TunnelTarget;
28
+ } | {
29
+ readonly kind: "invalid-capture";
30
+ readonly message: string;
31
+ } | {
32
+ readonly kind: "primary";
33
+ readonly target: TunnelTarget;
34
+ };
35
+ /** Resolves a proxied request to either the primary target or a captured backend. */
36
+ export declare const resolveRoute: (requestPath: string, primaryTarget: TunnelTarget, captures: ReadonlyArray<CaptureTarget>) => ResolvedRoute;
37
+ export {};
38
+ //# sourceMappingURL=capture-route.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"capture-route.d.ts","sourceRoot":"","sources":["../src/capture-route.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,aAAa,EAClB,KAAK,YAAY,EAClB,MAAM,wBAAwB,CAAC;AAEhC,eAAO,MAAM,mBAAmB,4BAA4B,CAAC;AAE7D,MAAM,MAAM,iBAAiB,GAAG;IAC9B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAC,UAAU,CAAC,CAAC;CAC7C,CAAC;AAEF,KAAK,sBAAsB,GACvB;IACE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAClB,QAAQ,CAAC,MAAM,EAAE,iBAAiB,CAAC;CACpC,GACD;IACE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;CACpB,CAAC;AAEN;;;GAGG;AACH,eAAO,MAAM,gBAAgB,GAAI,aAAa,MAAM,KAAG,sBA+CtD,CAAC;AAEF,oEAAoE;AACpE,eAAO,MAAM,4BAA4B,GAAI,QAAQ,iBAAiB,KAAG,MAC6B,CAAC;AAEvG,2DAA2D;AAC3D,eAAO,MAAM,qBAAqB,GAAI,UAAU,MAAM,KAAG,MACA,CAAC;AAE1D,MAAM,MAAM,aAAa,GACrB;IACE,QAAQ,CAAC,WAAW,EAAE,iBAAiB,CAAC;IACxC,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAC;CAC/B,GACD;IACE,QAAQ,CAAC,IAAI,EAAE,iBAAiB,CAAC;IACjC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B,GACD;IACE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAC;CAC/B,CAAC;AAEN,qFAAqF;AACrF,eAAO,MAAM,YAAY,GACvB,aAAa,MAAM,EACnB,eAAe,YAAY,EAC3B,UAAU,aAAa,CAAC,aAAa,CAAC,KACrC,aAyBF,CAAC"}
@@ -0,0 +1,91 @@
1
+ import { captureToTunnelTarget, findCapture, normalizeLoopbackHostname, } from "@localpreview/protocol";
2
+ export const CAPTURE_PATH_PREFIX = "/__localpreview/capture";
3
+ /**
4
+ * Parses an internal capture route:
5
+ * `/__localpreview/capture/<protocol>/<host>/<port>/<original-path>`.
6
+ */
7
+ export const parseCapturePath = (requestPath) => {
8
+ const pathname = splitPathname(requestPath);
9
+ if (!pathname.startsWith(`${CAPTURE_PATH_PREFIX}/`)) {
10
+ return { ok: false };
11
+ }
12
+ const suffix = pathname.slice(CAPTURE_PATH_PREFIX.length + 1);
13
+ const segments = suffix.split("/");
14
+ if (segments.length < 4) {
15
+ return { ok: false };
16
+ }
17
+ const [protocolText, encodedHost, portText, ...rest] = segments;
18
+ const protocol = parseProtocol(protocolText);
19
+ if (protocol === undefined) {
20
+ return { ok: false };
21
+ }
22
+ const port = Number(portText);
23
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) {
24
+ return { ok: false };
25
+ }
26
+ let hostname;
27
+ try {
28
+ hostname = normalizeLoopbackHostname(decodeURIComponent(encodedHost ?? ""));
29
+ }
30
+ catch {
31
+ return { ok: false };
32
+ }
33
+ const pathSuffix = rest.length === 0 ? "/" : `/${rest.join("/")}`;
34
+ const query = splitQuery(requestPath);
35
+ return {
36
+ ok: true,
37
+ parsed: {
38
+ hostname,
39
+ path: query === undefined ? pathSuffix : `${pathSuffix}?${query}`,
40
+ port,
41
+ protocol,
42
+ },
43
+ };
44
+ };
45
+ /** Builds the internal capture path prefix for cookie isolation. */
46
+ export const buildCaptureCookiePathPrefix = (parsed) => `${CAPTURE_PATH_PREFIX}/${parsed.protocol}/${encodeCaptureHostname(parsed.hostname)}/${parsed.port}`;
47
+ /** Encodes a hostname for use in capture path segments. */
48
+ export const encodeCaptureHostname = (hostname) => encodeURIComponent(normalizeLoopbackHostname(hostname));
49
+ /** Resolves a proxied request to either the primary target or a captured backend. */
50
+ export const resolveRoute = (requestPath, primaryTarget, captures) => {
51
+ const parsedCapture = parseCapturePath(requestPath);
52
+ if (!parsedCapture.ok) {
53
+ return {
54
+ kind: "primary",
55
+ target: primaryTarget,
56
+ };
57
+ }
58
+ const { parsed } = parsedCapture;
59
+ const capture = findCapture(captures, parsed.hostname, parsed.port);
60
+ if (capture === undefined) {
61
+ return {
62
+ kind: "invalid-capture",
63
+ message: `Capture route is not allowlisted: ${parsed.hostname}:${parsed.port}.`,
64
+ };
65
+ }
66
+ return {
67
+ capturePath: parsed,
68
+ kind: "capture",
69
+ target: captureToTunnelTarget(capture, parsed.protocol),
70
+ };
71
+ };
72
+ const parseProtocol = (value) => {
73
+ if (value === "http" || value === "https") {
74
+ return value;
75
+ }
76
+ return undefined;
77
+ };
78
+ const splitPathname = (requestPath) => {
79
+ const questionMark = requestPath.indexOf("?");
80
+ if (questionMark === -1) {
81
+ return requestPath;
82
+ }
83
+ return requestPath.slice(0, questionMark);
84
+ };
85
+ const splitQuery = (requestPath) => {
86
+ const questionMark = requestPath.indexOf("?");
87
+ if (questionMark === -1) {
88
+ return undefined;
89
+ }
90
+ return requestPath.slice(questionMark + 1);
91
+ };
@@ -0,0 +1,18 @@
1
+ import type { CaptureTarget } from "@localpreview/protocol";
2
+ import { encodeCaptureHostname } from "./capture-route.js";
3
+ export { encodeCaptureHostname };
4
+ /** Generates the inline browser shim injected into proxied HTML responses. */
5
+ export declare const buildCaptureShimScript: (captures: ReadonlyArray<CaptureTarget>) => string;
6
+ /** Injects the capture shim into an HTML document string. */
7
+ export declare const injectCaptureShim: (html: string, shimScript: string) => string;
8
+ /** Returns true when the response looks like HTML suitable for shim injection. */
9
+ export declare const isHtmlResponse: (contentType: string | null) => boolean;
10
+ /** Strips CSP headers that would block the injected shim. */
11
+ export declare const stripContentSecurityPolicy: (headers: ReadonlyArray<readonly [string, string]>) => Array<readonly [string, string]>;
12
+ /** Builds the canonical local frontend origin used for Origin/Referer simulation. */
13
+ export declare const buildFrontendOrigin: (target: {
14
+ readonly hostname: string;
15
+ readonly port: number;
16
+ readonly protocol: "http" | "https";
17
+ }) => string;
18
+ //# sourceMappingURL=capture-shim.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"capture-shim.d.ts","sourceRoot":"","sources":["../src/capture-shim.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAC;AAC5D,OAAO,EAAuB,qBAAqB,EAAE,MAAM,oBAAoB,CAAC;AAEhF,OAAO,EAAE,qBAAqB,EAAE,CAAC;AAEjC,8EAA8E;AAC9E,eAAO,MAAM,sBAAsB,GAAI,UAAU,aAAa,CAAC,aAAa,CAAC,KAAG,MAU/E,CAAC;AAEF,6DAA6D;AAC7D,eAAO,MAAM,iBAAiB,GAAI,MAAM,MAAM,EAAE,YAAY,MAAM,KAAG,MAcpE,CAAC;AAEF,kFAAkF;AAClF,eAAO,MAAM,cAAc,GAAI,aAAa,MAAM,GAAG,IAAI,KAAG,OACa,CAAC;AAE1E,6DAA6D;AAC7D,eAAO,MAAM,0BAA0B,GACrC,SAAS,aAAa,CAAC,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,KAChD,KAAK,CAAC,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,CAI9B,CAAC;AAEL,qFAAqF;AACrF,eAAO,MAAM,mBAAmB,GAAI,QAAQ;IAC1C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC;CACrC,KAAG,MAAkE,CAAC"}
@@ -0,0 +1,34 @@
1
+ import { CAPTURE_PATH_PREFIX, encodeCaptureHostname } from "./capture-route.js";
2
+ export { encodeCaptureHostname };
3
+ /** Generates the inline browser shim injected into proxied HTML responses. */
4
+ export const buildCaptureShimScript = (captures) => {
5
+ const config = JSON.stringify({
6
+ captures: captures.map((capture) => ({
7
+ hostname: capture.hostname,
8
+ port: capture.port,
9
+ })),
10
+ prefix: CAPTURE_PATH_PREFIX,
11
+ });
12
+ return `(function(){var cfg=${config};var caps=cfg.captures;var prefix=cfg.prefix;function normHost(h){return h==="::1"?"[::1]":h.toLowerCase();}function matchesCapture(url){var host=normHost(url.hostname);var port=url.port?Number(url.port):(url.protocol==="https:"?443:80);for(var i=0;i<caps.length;i++){var cap=caps[i];if(normHost(cap.hostname)===host&&cap.port===port){return true;}}return false;}function rewriteUrl(input){var url=new URL(input,window.location.href);if(!matchesCapture(url)){return input;}var proto=url.protocol.replace(":","");var host=encodeURIComponent(normHost(url.hostname));var path=url.pathname+url.search;return prefix+"/"+proto+"/"+host+"/"+url.port+path;}function warnUnsupported(api,url){console.error("[localpreview] "+api+" to captured local backend is not supported in v1: "+url+". Use fetch/XHR or run localpreview connect for that service directly.");}var origFetch=window.fetch;window.fetch=function(input,init){try{var url=typeof input==="string"?input:input instanceof URL?input.href:input.url;if(typeof url==="string"&&/^https?:\\/\\//.test(url)){var rewritten=rewriteUrl(url);if(rewritten!==url){return origFetch.call(this,rewritten,init);}}}catch(e){}return origFetch.call(this,input,init);};if(typeof XMLHttpRequest!=="undefined"){var origOpen=XMLHttpRequest.prototype.open;XMLHttpRequest.prototype.open=function(method,url){try{if(typeof url==="string"&&/^https?:\\/\\//.test(url)){var rewritten=rewriteUrl(url);if(rewritten!==url){return origOpen.call(this,method,rewritten);}}}catch(e){}return origOpen.apply(this,arguments);};}if(typeof window.WebSocket!=="undefined"){var OrigWebSocket=window.WebSocket;window.WebSocket=function(url,protocols){try{var parsed=new URL(url,window.location.href);if(matchesCapture(parsed)){warnUnsupported("WebSocket",url);throw new Error("LocalPreview does not proxy WebSocket connections to captured backends in v1.");}}catch(e){if(e instanceof Error&&e.message.indexOf("LocalPreview")!==-1){throw e;}}return protocols===undefined?new OrigWebSocket(url):new OrigWebSocket(url,protocols);};if(OrigWebSocket.prototype){window.WebSocket.prototype=OrigWebSocket.prototype;}}if(typeof window.EventSource!=="undefined"){var OrigEventSource=window.EventSource;window.EventSource=function(url,init){try{var parsed=new URL(url,window.location.href);if(matchesCapture(parsed)){warnUnsupported("EventSource",url);throw new Error("LocalPreview does not proxy EventSource connections to captured backends in v1.");}}catch(e){if(e instanceof Error&&e.message.indexOf("LocalPreview")!==-1){throw e;}}return init===undefined?new OrigEventSource(url):new OrigEventSource(url,init);};if(OrigEventSource.prototype){window.EventSource.prototype=OrigEventSource.prototype;}}})();`;
13
+ };
14
+ /** Injects the capture shim into an HTML document string. */
15
+ export const injectCaptureShim = (html, shimScript) => {
16
+ const scriptTag = `<script>${shimScript}</script>`;
17
+ if (html.includes("</head>")) {
18
+ return html.replace("</head>", `${scriptTag}</head>`);
19
+ }
20
+ const bodyMatch = html.match(/<body[^>]*>/i);
21
+ if (bodyMatch !== null) {
22
+ return html.replace(bodyMatch[0], `${bodyMatch[0]}${scriptTag}`);
23
+ }
24
+ return `${scriptTag}${html}`;
25
+ };
26
+ /** Returns true when the response looks like HTML suitable for shim injection. */
27
+ export const isHtmlResponse = (contentType) => contentType !== null && contentType.toLowerCase().includes("text/html");
28
+ /** Strips CSP headers that would block the injected shim. */
29
+ export const stripContentSecurityPolicy = (headers) => headers.filter(([name]) => {
30
+ const lower = name.toLowerCase();
31
+ return lower !== "content-security-policy" && lower !== "content-security-policy-report-only";
32
+ });
33
+ /** Builds the canonical local frontend origin used for Origin/Referer simulation. */
34
+ export const buildFrontendOrigin = (target) => `${target.protocol}://${target.hostname}:${target.port}`;
@@ -1 +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"}
1
+ {"version":3,"file":"command.d.ts","sourceRoot":"","sources":["../src/command.ts"],"names":[],"mappings":"AAQA,OAAO,EAAW,MAAM,EAAiB,MAAM,QAAQ,CAAC;AAOxD,OAAO,EAAE,aAAa,EAAgB,KAAK,eAAe,EAAE,MAAM,aAAa,CAAC;AA8BhF,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 CHANGED
@@ -1,6 +1,6 @@
1
- import { parseTarget, validateRequestedSubdomain, } from "@localpreview/protocol";
1
+ import { formatCaptureOrigin, parseCaptureHostPort, parseTarget, validateRequestedSubdomain, } from "@localpreview/protocol";
2
2
  import { Console, Effect, Layer, Option } from "effect";
3
- import { CliConfig, CliConfigLive, LOCAL_CONTROL_PLANE_URL } from "./config.js";
3
+ import { CliConfig, CliConfigLive, LOCAL_CONTROL_PLANE_URL, normalizeControlPlaneUrl } from "./config.js";
4
4
  import { ControlPlaneClient, ControlPlaneClientLive, } from "./control-plane.js";
5
5
  import { CliUsageError, errorMessage } from "./errors.js";
6
6
  import { LocalProxyLive } from "./local-proxy.js";
@@ -30,16 +30,19 @@ const isLegacyConnectInvocation = (argv) => {
30
30
  return first !== "connect" && !first.startsWith("-");
31
31
  };
32
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]",
33
+ "Usage: localpreview connect <port|target-url> [--name subdomain] [--capture host:port] [-l|--local] [--control-plane url]",
34
+ " localpreview clean <subdomain> [--force] [-l|--local] [--control-plane url] [--admin-token token]",
35
+ " localpreview clean --all --force [-l|--local] [--control-plane url] [--admin-token token]",
36
36
  "",
37
37
  "Examples:",
38
38
  " localpreview connect 3000",
39
+ " localpreview connect 5173 --capture localhost:4000",
39
40
  " localpreview connect https://localhost:3000 --name proyecto",
40
41
  " localpreview connect 3000 -l",
42
+ " localpreview connect 3000 --control-plane https://staging.localpreview.dev",
41
43
  " localpreview clean proyecto --force",
42
44
  " localpreview clean --all --force",
45
+ " localpreview clean demo --control-plane https://staging.localpreview.dev --admin-token ...",
43
46
  ].join("\n"));
44
47
  const parseCommand = (argv) => {
45
48
  if (argv[0] === "clean") {
@@ -75,14 +78,20 @@ const parseConnectArgs = (argv) => {
75
78
  const target = rest.shift();
76
79
  if (target === undefined || target.startsWith("-")) {
77
80
  return {
78
- message: "Usage: localpreview connect <port|target-url> [--name subdomain] [-l|--local]",
81
+ message: "Usage: localpreview connect <port|target-url> [--name subdomain] [--capture host:port] [-l|--local] [--control-plane url]",
79
82
  ok: false,
80
83
  };
81
84
  }
82
85
  let controlPlane;
83
86
  let requestedName;
87
+ let usedLocalControlPlane = false;
88
+ let usedControlPlaneFlag = false;
89
+ const captures = [];
84
90
  for (let index = 0; index < rest.length; index += 1) {
85
91
  const arg = rest[index];
92
+ if (arg === undefined) {
93
+ continue;
94
+ }
86
95
  if (arg === "--name") {
87
96
  const value = readRequiredOptionValue(rest, index, "--name");
88
97
  if (!value.ok) {
@@ -92,8 +101,38 @@ const parseConnectArgs = (argv) => {
92
101
  index += 1;
93
102
  continue;
94
103
  }
95
- if (arg === "-l" || arg === "--local") {
96
- controlPlane = LOCAL_CONTROL_PLANE_URL;
104
+ if (arg === "--capture") {
105
+ const value = readRequiredOptionValue(rest, index, "--capture");
106
+ if (!value.ok) {
107
+ return value;
108
+ }
109
+ const parsedCapture = parseCaptureHostPort(value.value);
110
+ if (!parsedCapture.ok) {
111
+ return {
112
+ message: parsedCapture.message,
113
+ ok: false,
114
+ };
115
+ }
116
+ captures.push(parsedCapture.capture);
117
+ index += 1;
118
+ continue;
119
+ }
120
+ const controlPlaneFlag = readControlPlaneFlag(rest, index, arg, {
121
+ usedControlPlaneFlag,
122
+ usedLocalControlPlane,
123
+ });
124
+ if (!controlPlaneFlag.ok) {
125
+ return controlPlaneFlag;
126
+ }
127
+ if (controlPlaneFlag.handled) {
128
+ if (controlPlaneFlag.usedLocalControlPlane) {
129
+ usedLocalControlPlane = true;
130
+ }
131
+ if (controlPlaneFlag.usedControlPlaneFlag) {
132
+ usedControlPlaneFlag = true;
133
+ }
134
+ controlPlane = controlPlaneFlag.controlPlane ?? controlPlane;
135
+ index = controlPlaneFlag.nextIndex;
97
136
  continue;
98
137
  }
99
138
  return {
@@ -103,6 +142,7 @@ const parseConnectArgs = (argv) => {
103
142
  }
104
143
  return {
105
144
  config: {
145
+ captures,
106
146
  controlPlane: toOption(controlPlane),
107
147
  requestedName: toOption(requestedName),
108
148
  target,
@@ -117,8 +157,13 @@ const parseCleanArgs = (argv) => {
117
157
  let controlPlane;
118
158
  let force = false;
119
159
  let subdomain;
160
+ let usedLocalControlPlane = false;
161
+ let usedControlPlaneFlag = false;
120
162
  for (let index = 0; index < rest.length; index += 1) {
121
163
  const arg = rest[index];
164
+ if (arg === undefined) {
165
+ continue;
166
+ }
122
167
  if (arg === "--admin-token") {
123
168
  const value = readRequiredOptionValue(rest, index, "--admin-token");
124
169
  if (!value.ok) {
@@ -136,8 +181,22 @@ const parseCleanArgs = (argv) => {
136
181
  force = true;
137
182
  continue;
138
183
  }
139
- if (arg === "-l" || arg === "--local") {
140
- controlPlane = LOCAL_CONTROL_PLANE_URL;
184
+ const controlPlaneFlag = readControlPlaneFlag(rest, index, arg, {
185
+ usedControlPlaneFlag,
186
+ usedLocalControlPlane,
187
+ });
188
+ if (!controlPlaneFlag.ok) {
189
+ return controlPlaneFlag;
190
+ }
191
+ if (controlPlaneFlag.handled) {
192
+ if (controlPlaneFlag.usedLocalControlPlane) {
193
+ usedLocalControlPlane = true;
194
+ }
195
+ if (controlPlaneFlag.usedControlPlaneFlag) {
196
+ usedControlPlaneFlag = true;
197
+ }
198
+ controlPlane = controlPlaneFlag.controlPlane ?? controlPlane;
199
+ index = controlPlaneFlag.nextIndex;
141
200
  continue;
142
201
  }
143
202
  if (arg !== undefined && !arg.startsWith("-") && subdomain === undefined) {
@@ -163,7 +222,7 @@ const parseCleanArgs = (argv) => {
163
222
  }
164
223
  if (!all && subdomain === undefined) {
165
224
  return {
166
- message: "Usage: localpreview clean <subdomain> [--force] [-l|--local] [--admin-token token]\n localpreview clean --all --force [-l|--local] [--admin-token token]",
225
+ message: "Usage: localpreview clean <subdomain> [--force] [-l|--local] [--control-plane url] [--admin-token token]\n localpreview clean --all --force [-l|--local] [--control-plane url] [--admin-token token]",
167
226
  ok: false,
168
227
  };
169
228
  }
@@ -178,6 +237,53 @@ const parseCleanArgs = (argv) => {
178
237
  ok: true,
179
238
  };
180
239
  };
240
+ const readControlPlaneFlag = (argv, index, arg, state) => {
241
+ if (arg === "-l" || arg === "--local") {
242
+ if (state.usedControlPlaneFlag) {
243
+ return {
244
+ message: "Use either -l/--local or --control-plane, not both.",
245
+ ok: false,
246
+ };
247
+ }
248
+ return {
249
+ controlPlane: LOCAL_CONTROL_PLANE_URL,
250
+ handled: true,
251
+ nextIndex: index,
252
+ ok: true,
253
+ usedLocalControlPlane: true,
254
+ };
255
+ }
256
+ if (arg === "--control-plane") {
257
+ if (state.usedLocalControlPlane) {
258
+ return {
259
+ message: "Use either -l/--local or --control-plane, not both.",
260
+ ok: false,
261
+ };
262
+ }
263
+ const value = readRequiredOptionValue(argv, index, "--control-plane");
264
+ if (!value.ok) {
265
+ return value;
266
+ }
267
+ const normalized = normalizeControlPlaneUrl(value.value);
268
+ if (!normalized.ok) {
269
+ return {
270
+ message: normalized.message,
271
+ ok: false,
272
+ };
273
+ }
274
+ return {
275
+ controlPlane: normalized.url,
276
+ handled: true,
277
+ nextIndex: index + 1,
278
+ ok: true,
279
+ usedControlPlaneFlag: true,
280
+ };
281
+ }
282
+ return {
283
+ handled: false,
284
+ ok: true,
285
+ };
286
+ };
181
287
  const readRequiredOptionValue = (argv, index, option) => {
182
288
  const value = argv[index + 1];
183
289
  if (value === undefined || value.startsWith("-")) {
@@ -221,7 +327,11 @@ const runConnect = (config, legacy) => Effect.scoped(Effect.gen(function* () {
221
327
  const tunnel = yield* Effect.acquireRelease(controlPlane.createTunnel(cliConfig.controlPlaneUrl, requestedSubdomain === undefined ? {} : { requestedSubdomain }), (tunnel) => closeTunnelBestEffort(controlPlane, cliConfig.controlPlaneUrl, tunnel));
222
328
  yield* Console.log(`Tunnel ready: ${tunnel.publicUrl}`);
223
329
  yield* Console.log(`Forwarding to ${target.target.protocol}://${target.target.hostname}:${target.target.port}`);
224
- yield* relay.connectAndServe(tunnel, target.target);
330
+ if (config.captures.length > 0) {
331
+ const origins = config.captures.map((capture) => formatCaptureOrigin(capture)).join(", ");
332
+ yield* Console.error(`Warning: captured local backends (${origins}) are exposed through this preview URL. Anyone with the link can reach them.`);
333
+ }
334
+ yield* relay.connectAndServe(tunnel, target.target, config.captures);
225
335
  }));
226
336
  const validateRequestedName = (value) => Option.match(value, {
227
337
  onNone: () => Effect.succeed(undefined),
@@ -270,7 +380,7 @@ const runClean = (config) => Effect.gen(function* () {
270
380
  }
271
381
  if (subdomain === undefined) {
272
382
  return yield* Effect.fail(new CliUsageError({
273
- message: "Usage: localpreview clean <subdomain> [--force] [-l|--local] [--admin-token token]",
383
+ message: "Usage: localpreview clean <subdomain> [--force] [-l|--local] [--control-plane url] [--admin-token token]",
274
384
  }));
275
385
  }
276
386
  const validation = validateRequestedSubdomain(subdomain);
package/dist/config.d.ts CHANGED
@@ -14,6 +14,15 @@ export declare class CliConfig extends CliConfig_base {
14
14
  }
15
15
  export declare const PUBLIC_CONTROL_PLANE_URL = "https://localpreview.dev";
16
16
  export declare const LOCAL_CONTROL_PLANE_URL = "http://localhost:3000";
17
+ export type NormalizeControlPlaneUrlResult = {
18
+ readonly ok: true;
19
+ readonly url: string;
20
+ } | {
21
+ readonly message: string;
22
+ readonly ok: false;
23
+ };
24
+ /** Normalizes and validates a control-plane base URL from `--control-plane`. */
25
+ export declare const normalizeControlPlaneUrl: (value: string) => NormalizeControlPlaneUrlResult;
17
26
  export declare const makeCliConfig: (input: {
18
27
  readonly controlPlaneUrl?: string;
19
28
  readonly env?: NodeJS.ProcessEnv;
@@ -1 +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"}
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,MAAM,MAAM,8BAA8B,GACtC;IACE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAClB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB,GACD;IACE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;CACpB,CAAC;AAEN,gFAAgF;AAChF,eAAO,MAAM,wBAAwB,GAAI,OAAO,MAAM,KAAG,8BA4DxD,CAAC;AA2BF,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 CHANGED
@@ -4,6 +4,77 @@ export class CliConfig extends Context.Service()("CliConfig") {
4
4
  }
5
5
  export const PUBLIC_CONTROL_PLANE_URL = LOCALPREVIEW_PUBLIC_ORIGIN;
6
6
  export const LOCAL_CONTROL_PLANE_URL = "http://localhost:3000";
7
+ /** Normalizes and validates a control-plane base URL from `--control-plane`. */
8
+ export const normalizeControlPlaneUrl = (value) => {
9
+ let parsed;
10
+ try {
11
+ parsed = new URL(value);
12
+ }
13
+ catch {
14
+ return {
15
+ message: "Control plane URL must be a valid URL.",
16
+ ok: false,
17
+ };
18
+ }
19
+ if (parsed.username.length > 0 || parsed.password.length > 0) {
20
+ return {
21
+ message: "Control plane URL must not include credentials.",
22
+ ok: false,
23
+ };
24
+ }
25
+ if (parsed.search.length > 0 || parsed.hash.length > 0) {
26
+ return {
27
+ message: "Control plane URL must not include query parameters or fragments.",
28
+ ok: false,
29
+ };
30
+ }
31
+ if (parsed.pathname !== "/" && parsed.pathname.length > 0) {
32
+ return {
33
+ message: "Control plane URL must not include a path.",
34
+ ok: false,
35
+ };
36
+ }
37
+ const hostname = parsed.hostname.toLowerCase();
38
+ if (parsed.protocol === "https:") {
39
+ return {
40
+ ok: true,
41
+ url: `${parsed.protocol}//${parsed.host}`,
42
+ };
43
+ }
44
+ if (parsed.protocol === "http:" && isLocalControlPlaneHostname(hostname)) {
45
+ return {
46
+ ok: true,
47
+ url: `${parsed.protocol}//${parsed.host}`,
48
+ };
49
+ }
50
+ if (parsed.protocol === "http:") {
51
+ return {
52
+ message: "Remote control plane URLs must use HTTPS.",
53
+ ok: false,
54
+ };
55
+ }
56
+ return {
57
+ message: "Control plane URL must use HTTP or HTTPS.",
58
+ ok: false,
59
+ };
60
+ };
61
+ const isLocalControlPlaneHostname = (hostname) => hostname === "localhost" || hostname.endsWith(".localhost") || isIpv4LoopbackAddress(hostname);
62
+ const isIpv4LoopbackAddress = (hostname) => {
63
+ const parts = hostname.split(".");
64
+ if (parts.length !== 4) {
65
+ return false;
66
+ }
67
+ for (const part of parts) {
68
+ if (!/^\d{1,3}$/.test(part)) {
69
+ return false;
70
+ }
71
+ const octet = Number(part);
72
+ if (!Number.isInteger(octet) || octet < 0 || octet > 255) {
73
+ return false;
74
+ }
75
+ }
76
+ return Number(parts[0]) === 127;
77
+ };
7
78
  export const makeCliConfig = (input) => {
8
79
  const env = input.env ?? process.env;
9
80
  const controlPlaneUrl = input.controlPlaneUrl ?? PUBLIC_CONTROL_PLANE_URL;
@@ -1 +1 @@
1
- {"version":3,"file":"control-plane.d.ts","sourceRoot":"","sources":["../src/control-plane.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,oBAAoB,EAC1B,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAY,MAAM,QAAQ,CAAC;AAC1D,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAEhD,MAAM,MAAM,uBAAuB,GAAG;IACpC,QAAQ,CAAC,kBAAkB,EAAE,CAC3B,eAAe,EAAE,MAAM,EACvB,KAAK,EAAE;QACL,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;QAC5B,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;KACzB,KACE,MAAM,CAAC,MAAM,CAAC,0BAA0B,EAAE,iBAAiB,CAAC,CAAC;IAClE,QAAQ,CAAC,cAAc,EAAE,CACvB,eAAe,EAAE,MAAM,EACvB,KAAK,EAAE;QACL,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;QAC5B,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;QACxB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;KAC5B,KACE,MAAM,CAAC,MAAM,CAAC,sBAAsB,EAAE,iBAAiB,CAAC,CAAC;IAC9D,QAAQ,CAAC,WAAW,EAAE,CACpB,eAAe,EAAE,MAAM,EACvB,MAAM,EAAE,IAAI,CAAC,oBAAoB,EAAE,aAAa,GAAG,UAAU,CAAC,KAC3D,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,iBAAiB,CAAC,CAAC;IAC5C,QAAQ,CAAC,YAAY,EAAE,CACrB,eAAe,EAAE,MAAM,EACvB,IAAI,EAAE;QACJ,QAAQ,CAAC,kBAAkB,CAAC,EAAE,MAAM,CAAC;KACtC,KACE,MAAM,CAAC,MAAM,CAAC,oBAAoB,EAAE,iBAAiB,CAAC,CAAC;CAC7D,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAAG;IACnC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;IAC/B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC5B,CAAC;AAEF,MAAM,MAAM,0BAA0B,GAAG;IACvC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC,sBAAsB,CAAC,CAAC;IACxD,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,eAAe,CAAC,EAAE;QACzB,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;QAC9B,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC;YAC9B,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;YAC/B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;SAC5B,CAAC,CAAC;QACH,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;QACzB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;KACxB,CAAC;CACH,CAAC;;AAEF,qBAAa,kBAAmB,SAAQ,uBAGf;CAAG;AAE5B,eAAO,MAAM,sBAAsB,+CAWjC,CAAC;AAEH,eAAO,MAAM,YAAY,GACvB,iBAAiB,MAAM,EACvB,MAAM;IACJ,QAAQ,CAAC,kBAAkB,CAAC,EAAE,MAAM,CAAC;CACtC,KACA,OAAO,CAAC,oBAAoB,CAAiE,CAAC;AAEjG,eAAO,MAAM,WAAW,GACtB,iBAAiB,MAAM,EACvB,QAAQ,IAAI,CAAC,oBAAoB,EAAE,aAAa,GAAG,UAAU,CAAC,KAC7D,OAAO,CAAC,IAAI,CAAkE,CAAC;AAElF,eAAO,MAAM,cAAc,GACzB,iBAAiB,MAAM,EACvB,OAAO;IACL,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B,KACA,OAAO,CAAC,sBAAsB,CACgC,CAAC;AAElE,eAAO,MAAM,kBAAkB,GAC7B,iBAAiB,MAAM,EACvB,OAAO;IACL,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;CACzB,KACA,OAAO,CAAC,0BAA0B,CACgC,CAAC"}
1
+ {"version":3,"file":"control-plane.d.ts","sourceRoot":"","sources":["../src/control-plane.ts"],"names":[],"mappings":"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"}
@@ -1,4 +1,4 @@
1
- import { LOCALPREVIEW_PROTOCOL_VERSION, LOCALPREVIEW_RELAY_SNAPSHOT_VERSION, } from "@localpreview/protocol";
1
+ import { LOCALPREVIEW_ADMIN_TOKEN_HEADER, LOCALPREVIEW_ERROR_CODES, LOCALPREVIEW_PROTOCOL_VERSION, LOCALPREVIEW_RELAY_SNAPSHOT_VERSION, } from "@localpreview/protocol";
2
2
  import { Context, Effect, Layer, Schedule } from "effect";
3
3
  import { ControlPlaneError } from "./errors.js";
4
4
  export class ControlPlaneClient extends Context.Service()("ControlPlaneClient") {
@@ -38,7 +38,7 @@ const createTunnelEffect = (controlPlaneUrl, body) => Effect.promise(async () =>
38
38
  }
39
39
  const json = await readJson(response, "Tunnel creation");
40
40
  if (!response.ok) {
41
- if (json.error?.code === "SUBDOMAIN_TAKEN") {
41
+ if (json.error?.code === LOCALPREVIEW_ERROR_CODES.SUBDOMAIN_TAKEN) {
42
42
  throw new ControlPlaneError({
43
43
  message: `Subdomain "${json.error.subdomain ?? "requested"}" is already in use.`,
44
44
  });
@@ -81,7 +81,7 @@ const cleanSubdomainEffect = (controlPlaneUrl, input) => Effect.promise(async ()
81
81
  try {
82
82
  response = await fetch(url, {
83
83
  headers: {
84
- "x-localpreview-admin-token": input.adminToken,
84
+ [LOCALPREVIEW_ADMIN_TOKEN_HEADER]: input.adminToken,
85
85
  },
86
86
  method: "DELETE",
87
87
  });
@@ -109,7 +109,7 @@ const cleanAllSubdomainsEffect = (controlPlaneUrl, input) => Effect.promise(asyn
109
109
  try {
110
110
  response = await fetch(url, {
111
111
  headers: {
112
- "x-localpreview-admin-token": input.adminToken,
112
+ [LOCALPREVIEW_ADMIN_TOKEN_HEADER]: input.adminToken,
113
113
  },
114
114
  method: "DELETE",
115
115
  });
@@ -1,15 +1,20 @@
1
- import { type HeaderPairs, type TunnelTarget } from "@localpreview/protocol";
1
+ import { type CaptureTarget, type HeaderPairs, type TunnelTarget } from "@localpreview/protocol";
2
2
  import { Context, Effect, Layer } from "effect";
3
3
  import type WebSocket from "ws";
4
4
  import { CliConfig } from "./config.js";
5
5
  import { RelayProtocolError } from "./errors.js";
6
+ export type ProxySession = {
7
+ readonly captures: ReadonlyArray<CaptureTarget>;
8
+ readonly target: TunnelTarget;
9
+ };
6
10
  export type LocalProxyShape = {
7
- readonly handleMessage: (socket: WebSocket, target: TunnelTarget, input: string) => Effect.Effect<void, RelayProtocolError>;
11
+ readonly handleMessage: (socket: WebSocket, session: ProxySession, input: string) => Effect.Effect<void, RelayProtocolError>;
8
12
  };
9
13
  declare const LocalProxy_base: Context.ServiceClass<LocalProxy, "LocalProxy", LocalProxyShape>;
10
14
  export declare class LocalProxy extends LocalProxy_base {
11
15
  }
12
16
  export declare const LocalProxyLive: Layer.Layer<LocalProxy, never, CliConfig>;
17
+ export declare const rewriteCaptureSetCookie: (cookie: string, pathPrefix: string) => string;
13
18
  export declare const filterLocalResponseHeaders: (headers: Headers) => HeaderPairs;
14
19
  export {};
15
20
  //# sourceMappingURL=local-proxy.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"local-proxy.d.ts","sourceRoot":"","sources":["../src/local-proxy.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,WAAW,EAEhB,KAAK,YAAY,EAClB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAW,OAAO,EAAE,MAAM,EAAS,KAAK,EAAO,MAAM,QAAQ,CAAC;AACrE,OAAO,KAAK,SAAS,MAAM,IAAI,CAAC;AAChC,OAAO,EAAE,SAAS,EAAuB,MAAM,aAAa,CAAC;AAC7D,OAAO,EAEL,kBAAkB,EAGnB,MAAM,aAAa,CAAC;AAWrB,MAAM,MAAM,eAAe,GAAG;IAC5B,QAAQ,CAAC,aAAa,EAAE,CACtB,MAAM,EAAE,SAAS,EACjB,MAAM,EAAE,YAAY,EACpB,KAAK,EAAE,MAAM,KACV,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,kBAAkB,CAAC,CAAC;CAC9C,CAAC;;AAEF,qBAAa,UAAW,SAAQ,eAA4D;CAAG;AAE/F,eAAO,MAAM,cAAc,2CAU1B,CAAC;AAySF,eAAO,MAAM,0BAA0B,GAAI,SAAS,OAAO,KAAG,WAqB7D,CAAC"}
1
+ {"version":3,"file":"local-proxy.d.ts","sourceRoot":"","sources":["../src/local-proxy.ts"],"names":[],"mappings":"AAAA,OAAO,EAKL,KAAK,aAAa,EAClB,KAAK,WAAW,EAEhB,KAAK,YAAY,EAClB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAW,OAAO,EAAE,MAAM,EAAS,KAAK,EAAO,MAAM,QAAQ,CAAC;AACrE,OAAO,KAAK,SAAS,MAAM,IAAI,CAAC;AAahC,OAAO,EAAE,SAAS,EAAuB,MAAM,aAAa,CAAC;AAC7D,OAAO,EAEL,kBAAkB,EAGnB,MAAM,aAAa,CAAC;AAWrB,MAAM,MAAM,YAAY,GAAG;IACzB,QAAQ,CAAC,QAAQ,EAAE,aAAa,CAAC,aAAa,CAAC,CAAC;IAChD,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAC;CAC/B,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC5B,QAAQ,CAAC,aAAa,EAAE,CACtB,MAAM,EAAE,SAAS,EACjB,OAAO,EAAE,YAAY,EACrB,KAAK,EAAE,MAAM,KACV,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,kBAAkB,CAAC,CAAC;CAC9C,CAAC;;AAEF,qBAAa,UAAW,SAAQ,eAA4D;CAAG;AAE/F,eAAO,MAAM,cAAc,2CAU1B,CAAC;AA4UF,eAAO,MAAM,uBAAuB,GAAI,QAAQ,MAAM,EAAE,YAAY,MAAM,KAAG,MAqC5E,CAAC;AA0BF,eAAO,MAAM,0BAA0B,GAAI,SAAS,OAAO,KAAG,WAqB7D,CAAC"}
@@ -1,5 +1,7 @@
1
- import { decodeServerRelayMessage, encodeRelayMessage, filterEndToEndHeaderPairs, } from "@localpreview/protocol";
1
+ import { decodeServerRelayMessage, encodeRelayMessage, filterEndToEndHeaderPairs, isLoopbackHost, } from "@localpreview/protocol";
2
2
  import { Console, Context, Effect, Fiber, Layer, Ref } from "effect";
3
+ import { buildCaptureCookiePathPrefix, resolveRoute, } from "./capture-route.js";
4
+ import { buildCaptureShimScript, buildFrontendOrigin, injectCaptureShim, isHtmlResponse, stripContentSecurityPolicy, } from "./capture-shim.js";
3
5
  import { CliConfig } from "./config.js";
4
6
  import { LocalRequestError, RelayProtocolError, RequestLimitError, errorMessage, } from "./errors.js";
5
7
  export class LocalProxy extends Context.Service()("LocalProxy") {
@@ -8,10 +10,10 @@ export const LocalProxyLive = Layer.effect(LocalProxy)(Effect.gen(function* () {
8
10
  const config = yield* CliConfig;
9
11
  const requests = yield* Ref.make(new Map());
10
12
  return {
11
- handleMessage: (socket, target, input) => handleMessage(config, requests, socket, target, input),
13
+ handleMessage: (socket, session, input) => handleMessage(config, requests, socket, session, input),
12
14
  };
13
15
  }));
14
- const handleMessage = (config, requests, socket, target, input) => Effect.gen(function* () {
16
+ const handleMessage = (config, requests, socket, session, input) => Effect.gen(function* () {
15
17
  const decoded = decodeServerRelayMessage(input);
16
18
  if (!decoded.ok) {
17
19
  return yield* Effect.fail(new RelayProtocolError({
@@ -25,7 +27,7 @@ const handleMessage = (config, requests, socket, target, input) => Effect.gen(fu
25
27
  case "request-chunk":
26
28
  return yield* handleRequestChunk(config, requests, socket, message);
27
29
  case "request-end":
28
- return yield* handleRequestEnd(config, requests, socket, target, message);
30
+ return yield* handleRequestEnd(config, requests, socket, session, message);
29
31
  case "cancel":
30
32
  return yield* handleCancel(requests, message.requestId);
31
33
  }
@@ -77,7 +79,7 @@ const handleRequestChunk = (config, requests, socket, message) => Effect.gen(fun
77
79
  });
78
80
  yield* Ref.set(requests, next);
79
81
  });
80
- const handleRequestEnd = (config, requests, socket, target, message) => Effect.gen(function* () {
82
+ const handleRequestEnd = (config, requests, socket, session, message) => Effect.gen(function* () {
81
83
  const current = yield* Ref.get(requests);
82
84
  const request = current.get(message.requestId);
83
85
  if (request === undefined) {
@@ -88,7 +90,7 @@ const handleRequestEnd = (config, requests, socket, target, message) => Effect.g
88
90
  });
89
91
  return;
90
92
  }
91
- const fiber = yield* proxyToLocalTarget(config, socket, target, message.requestId, request).pipe(Effect.catch((error) => send(socket, {
93
+ const fiber = yield* proxyRequest(config, socket, session, message.requestId, request).pipe(Effect.catch((error) => send(socket, {
92
94
  message: error.message,
93
95
  requestId: message.requestId,
94
96
  type: "response-error",
@@ -111,15 +113,28 @@ const handleCancel = (requests, requestId) => Effect.gen(function* () {
111
113
  next.delete(requestId);
112
114
  yield* Ref.set(requests, next);
113
115
  });
114
- const proxyToLocalTarget = (config, socket, target, requestId, request) => Effect.gen(function* () {
116
+ const proxyRequest = (config, socket, session, requestId, request) => Effect.gen(function* () {
117
+ const route = resolveRoute(request.path, session.target, session.captures);
118
+ if (route.kind === "invalid-capture") {
119
+ return yield* Effect.fail(new LocalRequestError({
120
+ message: route.message,
121
+ requestId,
122
+ }));
123
+ }
115
124
  const startedAt = Date.now();
116
- const targetUrl = new URL(request.path, `${target.protocol}://${target.hostname}:${target.port}`);
125
+ const upstreamPath = route.kind === "capture" ? route.capturePath.path : request.path;
126
+ const targetUrl = new URL(upstreamPath, `${route.target.protocol}://${route.target.hostname}:${route.target.port}`);
117
127
  const headers = new Headers(filterEndToEndHeaderPairs(request.headers).map(([name, value]) => [
118
128
  name,
119
129
  value,
120
130
  ]));
121
131
  headers.set("accept-encoding", "identity");
122
- headers.set("host", `${target.hostname}:${target.port}`);
132
+ headers.set("host", `${route.target.hostname}:${route.target.port}`);
133
+ if (route.kind === "capture") {
134
+ const frontendOrigin = buildFrontendOrigin(session.target);
135
+ headers.set("origin", frontendOrigin);
136
+ headers.set("referer", `${frontendOrigin}/`);
137
+ }
123
138
  const body = Buffer.concat(request.chunks);
124
139
  const init = {
125
140
  headers,
@@ -137,7 +152,7 @@ const proxyToLocalTarget = (config, socket, target, requestId, request) => Effec
137
152
  message: errorMessage(error),
138
153
  requestId,
139
154
  })));
140
- const responseBody = yield* Effect.promise(() => response.arrayBuffer()).pipe(timeoutFail(config.requestTimeoutMs, new LocalRequestError({
155
+ let responseBody = yield* Effect.promise(() => response.arrayBuffer()).pipe(timeoutFail(config.requestTimeoutMs, new LocalRequestError({
141
156
  message: "Local request timed out.",
142
157
  requestId,
143
158
  })), Effect.map((arrayBuffer) => Buffer.from(arrayBuffer)), Effect.mapError((error) => error instanceof LocalRequestError
@@ -153,8 +168,24 @@ const proxyToLocalTarget = (config, socket, target, requestId, request) => Effec
153
168
  requestId,
154
169
  }));
155
170
  }
171
+ const contentType = response.headers.get("content-type");
172
+ const shouldInjectShim = route.kind === "primary" &&
173
+ session.captures.length > 0 &&
174
+ isHtmlResponse(contentType);
175
+ if (shouldInjectShim) {
176
+ const html = responseBody.toString("utf8");
177
+ const shimScript = buildCaptureShimScript(session.captures);
178
+ responseBody = Buffer.from(injectCaptureShim(html, shimScript), "utf8");
179
+ }
180
+ let responseHeaders = filterLocalResponseHeaders(response.headers);
181
+ if (route.kind === "capture") {
182
+ responseHeaders = rewriteCaptureResponseHeaders(responseHeaders, route.capturePath);
183
+ }
184
+ if (shouldInjectShim) {
185
+ responseHeaders = stripContentSecurityPolicy(responseHeaders);
186
+ }
156
187
  yield* send(socket, {
157
- headers: filterLocalResponseHeaders(response.headers),
188
+ headers: responseHeaders,
158
189
  requestId,
159
190
  status: response.status,
160
191
  type: "response-start",
@@ -173,6 +204,46 @@ const proxyToLocalTarget = (config, socket, target, requestId, request) => Effec
173
204
  });
174
205
  yield* Console.log(`<- ${response.status} ${logPath(request.path)} ${Date.now() - startedAt}ms`);
175
206
  });
207
+ const rewriteCaptureResponseHeaders = (headers, capturePath) => {
208
+ const pathPrefix = buildCaptureCookiePathPrefix(capturePath);
209
+ const next = [];
210
+ for (const [name, value] of headers) {
211
+ if (name.toLowerCase() === "set-cookie") {
212
+ next.push(["set-cookie", rewriteCaptureSetCookie(value, pathPrefix)]);
213
+ continue;
214
+ }
215
+ next.push([name, value]);
216
+ }
217
+ return next;
218
+ };
219
+ export const rewriteCaptureSetCookie = (cookie, pathPrefix) => {
220
+ const parts = cookie.split(";").map((part) => part.trim());
221
+ const nameValue = parts[0];
222
+ if (nameValue === undefined) {
223
+ return cookie;
224
+ }
225
+ const preserved = [];
226
+ let pathValue;
227
+ for (const attribute of parts.slice(1)) {
228
+ const lower = attribute.toLowerCase();
229
+ if (lower.startsWith("domain=")) {
230
+ const domain = attribute.slice("domain=".length).trim().replace(/^\./, "");
231
+ if (isLoopbackHost(domain) || domain === "localhost") {
232
+ continue;
233
+ }
234
+ }
235
+ if (lower.startsWith("path=")) {
236
+ pathValue = attribute.slice("path=".length).trim();
237
+ continue;
238
+ }
239
+ preserved.push(attribute);
240
+ }
241
+ const originalPath = pathValue ?? "/";
242
+ const isolatedPath = originalPath === "/"
243
+ ? `${pathPrefix}/`
244
+ : `${pathPrefix}${originalPath.startsWith("/") ? originalPath : `/${originalPath}`}`;
245
+ return [nameValue, `Path=${isolatedPath}`, ...preserved].join("; ");
246
+ };
176
247
  const sendRequestError = (socket, requestId, error) => send(socket, {
177
248
  message: `${error.message} Limit is ${error.limitBytes} bytes.`,
178
249
  requestId,
@@ -1,10 +1,10 @@
1
- import type { CreateTunnelResponse, TunnelTarget } from "@localpreview/protocol";
1
+ import type { CaptureTarget, CreateTunnelResponse, TunnelTarget } from "@localpreview/protocol";
2
2
  import { Context, Effect, Layer } from "effect";
3
3
  import { CliConfig } from "./config.js";
4
4
  import { RelayConnectionError, RelayProtocolError } from "./errors.js";
5
5
  import { LocalProxy } from "./local-proxy.js";
6
6
  export type RelayClientShape = {
7
- readonly connectAndServe: (tunnel: CreateTunnelResponse, target: TunnelTarget) => Effect.Effect<void, RelayConnectionError | RelayProtocolError>;
7
+ readonly connectAndServe: (tunnel: CreateTunnelResponse, target: TunnelTarget, captures: ReadonlyArray<CaptureTarget>) => Effect.Effect<void, RelayConnectionError | RelayProtocolError>;
8
8
  };
9
9
  declare const RelayClient_base: Context.ServiceClass<RelayClient, "RelayClient", RelayClientShape>;
10
10
  export declare class RelayClient extends RelayClient_base {
@@ -1 +1 @@
1
- {"version":3,"file":"relay-client.d.ts","sourceRoot":"","sources":["../src/relay-client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AACjF,OAAO,EAAW,OAAO,EAAY,MAAM,EAAE,KAAK,EAAY,MAAM,QAAQ,CAAC;AAE7E,OAAO,EAAE,SAAS,EAAuB,MAAM,aAAa,CAAC;AAC7D,OAAO,EAAE,oBAAoB,EAAE,kBAAkB,EAAgB,MAAM,aAAa,CAAC;AACrF,OAAO,EAAE,UAAU,EAAwB,MAAM,kBAAkB,CAAC;AAEpE,MAAM,MAAM,gBAAgB,GAAG;IAC7B,QAAQ,CAAC,eAAe,EAAE,CACxB,MAAM,EAAE,oBAAoB,EAC5B,MAAM,EAAE,YAAY,KACjB,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,oBAAoB,GAAG,kBAAkB,CAAC,CAAC;CACrE,CAAC;;AAEF,qBAAa,WAAY,SAAQ,gBAA+D;CAAG;AAEnG,eAAO,MAAM,eAAe,yDAS3B,CAAC;AAyLF,eAAO,MAAM,oBAAoB,GAAI,MAAM,MAAM,KAAG,OAAwB,CAAC"}
1
+ {"version":3,"file":"relay-client.d.ts","sourceRoot":"","sources":["../src/relay-client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,oBAAoB,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAChG,OAAO,EAAW,OAAO,EAAY,MAAM,EAAE,KAAK,EAAY,MAAM,QAAQ,CAAC;AAE7E,OAAO,EAAE,SAAS,EAAuB,MAAM,aAAa,CAAC;AAC7D,OAAO,EAAE,oBAAoB,EAAE,kBAAkB,EAAgB,MAAM,aAAa,CAAC;AACrF,OAAO,EAAE,UAAU,EAAwB,MAAM,kBAAkB,CAAC;AAEpE,MAAM,MAAM,gBAAgB,GAAG;IAC7B,QAAQ,CAAC,eAAe,EAAE,CACxB,MAAM,EAAE,oBAAoB,EAC5B,MAAM,EAAE,YAAY,EACpB,QAAQ,EAAE,aAAa,CAAC,aAAa,CAAC,KACnC,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,oBAAoB,GAAG,kBAAkB,CAAC,CAAC;CACrE,CAAC;;AAEF,qBAAa,WAAY,SAAQ,gBAA+D;CAAG;AAEnG,eAAO,MAAM,eAAe,yDAU3B,CAAC;AA6LF,eAAO,MAAM,oBAAoB,GAAI,MAAM,MAAM,KAAG,OAAwB,CAAC"}
@@ -9,21 +9,22 @@ export const RelayClientLive = Layer.effect(RelayClient)(Effect.gen(function* ()
9
9
  const config = yield* CliConfig;
10
10
  const localProxy = yield* LocalProxy;
11
11
  return {
12
- connectAndServe: (tunnel, target) => connectAndServe(config, localProxy, tunnel, target),
12
+ connectAndServe: (tunnel, target, captures) => connectAndServe(config, localProxy, tunnel, target, captures),
13
13
  };
14
14
  }));
15
- const connectAndServe = (config, localProxy, tunnel, target) => serveWithReconnect(config, localProxy, tunnel, target);
16
- const serveWithReconnect = (config, localProxy, tunnel, target) => serveOnce(config, localProxy, tunnel, target).pipe(Effect.catch((error) => {
15
+ const connectAndServe = (config, localProxy, tunnel, target, captures) => serveWithReconnect(config, localProxy, tunnel, target, captures);
16
+ const serveWithReconnect = (config, localProxy, tunnel, target, captures) => serveOnce(config, localProxy, tunnel, target, captures).pipe(Effect.catch((error) => {
17
17
  if (error instanceof RelayConnectionError && error.retryable === true) {
18
- return Console.error(`${error.message}; reconnecting...`).pipe(Effect.andThen(Effect.sleep("1 second")), Effect.andThen(serveWithReconnect(config, localProxy, tunnel, target)));
18
+ return Console.error(`${error.message}; reconnecting...`).pipe(Effect.andThen(Effect.sleep("1 second")), Effect.andThen(serveWithReconnect(config, localProxy, tunnel, target, captures)));
19
19
  }
20
20
  return Effect.fail(error);
21
21
  }));
22
- const serveOnce = (config, localProxy, tunnel, target) => Effect.gen(function* () {
22
+ const serveOnce = (config, localProxy, tunnel, target, captures) => Effect.gen(function* () {
23
23
  const socket = yield* openSocket(tunnel).pipe(Effect.retry({
24
24
  schedule: Schedule.recurs(Math.ceil(config.relayConnectTimeoutMs / 250)),
25
25
  }));
26
26
  yield* Console.log("Connected to relay.");
27
+ const session = { captures, target };
27
28
  const done = yield* Deferred.make();
28
29
  const handleSignal = () => {
29
30
  socket.close(1000, "Interrupted");
@@ -34,7 +35,7 @@ const serveOnce = (config, localProxy, tunnel, target) => Effect.gen(function* (
34
35
  process.once("SIGINT", handleSignal);
35
36
  process.once("SIGTERM", handleSignal);
36
37
  socket.on("message", (data) => {
37
- const effect = localProxy.handleMessage(socket, target, data.toString()).pipe(Effect.catch((error) => Effect.gen(function* () {
38
+ const effect = localProxy.handleMessage(socket, session, data.toString()).pipe(Effect.catch((error) => Effect.gen(function* () {
38
39
  socket.close(1002, error.message);
39
40
  yield* Deferred.fail(done, error);
40
41
  })));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "localpreview",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "bin": {
5
5
  "localpreview": "./dist/index.js"
6
6
  },
@@ -10,25 +10,27 @@
10
10
  "README.md"
11
11
  ],
12
12
  "types": "./dist/index.d.ts",
13
- "dependencies": {
14
- "@effect/platform-node": "beta",
15
- "effect": "beta",
16
- "ws": "latest",
17
- "@localpreview/protocol": "0.1.0"
18
- },
19
- "devDependencies": {
20
- "@effect/vitest": "beta",
21
- "@types/node": "latest",
22
- "@types/ws": "latest",
23
- "tsx": "latest",
24
- "typescript": "latest",
25
- "vitest": "latest"
26
- },
27
13
  "scripts": {
28
14
  "build": "tsc -p tsconfig.build.json",
29
15
  "dev": "tsx src/index.ts",
30
16
  "lint": "oxlint .",
17
+ "prepack": "pnpm build",
18
+ "prepublishOnly": "pnpm test && pnpm typecheck && pnpm build",
31
19
  "test": "vitest run --passWithNoTests",
32
20
  "typecheck": "tsc -p tsconfig.json --noEmit"
21
+ },
22
+ "dependencies": {
23
+ "@effect/platform-node": "beta",
24
+ "@localpreview/protocol": "^0.2.0",
25
+ "effect": "beta",
26
+ "ws": "latest"
27
+ },
28
+ "devDependencies": {
29
+ "@effect/vitest": "catalog:",
30
+ "@types/node": "catalog:",
31
+ "@types/ws": "catalog:",
32
+ "tsx": "catalog:",
33
+ "typescript": "catalog:",
34
+ "vitest": "catalog:"
33
35
  }
34
- }
36
+ }