localpreview 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,6 +26,7 @@ export type ResolvedRoute = {
26
26
  readonly kind: "capture";
27
27
  readonly target: TunnelTarget;
28
28
  } | {
29
+ readonly capturePath: ParsedCapturePath;
29
30
  readonly kind: "invalid-capture";
30
31
  readonly message: string;
31
32
  } | {
@@ -1 +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"}
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,WAAW,EAAE,iBAAiB,CAAC;IACxC,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,aA0BF,CAAC"}
@@ -59,6 +59,7 @@ export const resolveRoute = (requestPath, primaryTarget, captures) => {
59
59
  const capture = findCapture(captures, parsed.hostname, parsed.port);
60
60
  if (capture === undefined) {
61
61
  return {
62
+ capturePath: parsed,
62
63
  kind: "invalid-capture",
63
64
  message: `Capture route is not allowlisted: ${parsed.hostname}:${parsed.port}.`,
64
65
  };
@@ -1,8 +1,12 @@
1
1
  import type { CaptureTarget } from "@localpreview/protocol";
2
2
  import { encodeCaptureHostname } from "./capture-route.js";
3
3
  export { encodeCaptureHostname };
4
+ type CaptureShimPrimaryTarget = {
5
+ readonly hostname: string;
6
+ readonly port: number;
7
+ };
4
8
  /** Generates the inline browser shim injected into proxied HTML responses. */
5
- export declare const buildCaptureShimScript: (captures: ReadonlyArray<CaptureTarget>) => string;
9
+ export declare const buildCaptureShimScript: (primary: CaptureShimPrimaryTarget, captures: ReadonlyArray<CaptureTarget>) => string;
6
10
  /** Injects the capture shim into an HTML document string. */
7
11
  export declare const injectCaptureShim: (html: string, shimScript: string) => string;
8
12
  /** Returns true when the response looks like HTML suitable for shim injection. */
@@ -1 +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"}
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,KAAK,wBAAwB,GAAG;IAC9B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF,8EAA8E;AAC9E,eAAO,MAAM,sBAAsB,GACjC,SAAS,wBAAwB,EACjC,UAAU,aAAa,CAAC,aAAa,CAAC,KACrC,MAWF,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"}
@@ -1,15 +1,16 @@
1
1
  import { CAPTURE_PATH_PREFIX, encodeCaptureHostname } from "./capture-route.js";
2
2
  export { encodeCaptureHostname };
3
3
  /** Generates the inline browser shim injected into proxied HTML responses. */
4
- export const buildCaptureShimScript = (captures) => {
4
+ export const buildCaptureShimScript = (primary, captures) => {
5
5
  const config = JSON.stringify({
6
6
  captures: captures.map((capture) => ({
7
7
  hostname: capture.hostname,
8
8
  port: capture.port,
9
9
  })),
10
10
  prefix: CAPTURE_PATH_PREFIX,
11
+ primary,
11
12
  });
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
+ return `(function(){var cfg=${config};var caps=cfg.captures;var primary=cfg.primary;var prefix=cfg.prefix;var eventsPath="/__localpreview/events";var reported={};function normHost(h){return h==="::1"?"[::1]":h.toLowerCase();}function isLoopbackHost(h){var lower=normHost(h);if(lower==="localhost"||lower==="127.0.0.1"||lower==="[::1]")return true;return lower.endsWith(".localhost");}function loopbackGroup(h){var lower=normHost(h);if(lower==="localhost"||lower==="127.0.0.1"||lower==="[::1]")return "__loopback__";return lower;}function sameLoopbackPort(host,port,otherHost,otherPort){return port===otherPort&&loopbackGroup(host)===loopbackGroup(otherHost);}function dedupeKey(proto,host,port){var group=loopbackGroup(host);return proto+"://"+(group==="__loopback__"?group:normHost(host))+":"+port;}function urlPort(url){return url.port?Number(url.port):(url.protocol==="https:"||url.protocol==="wss:"?443:80);}function matchesCapture(url){var host=normHost(url.hostname);var port=urlPort(url);for(var i=0;i<caps.length;i++){var cap=caps[i];if(sameLoopbackPort(host,port,cap.hostname,cap.port))return true;}return false;}function isPrimaryPort(url){return sameLoopbackPort(url.hostname,urlPort(url),primary.hostname,primary.port);}function isAllowlisted(url){return matchesCapture(url)||isPrimaryPort(url);}function reportMissing(url,transport,method){try{if(!isLoopbackHost(url.hostname))return;if(isAllowlisted(url))return;var proto=url.protocol.replace(":","");if(proto!=="http"&&proto!=="https"&&proto!=="ws"&&proto!=="wss")return;var host=normHost(url.hostname);var port=urlPort(url);var key=dedupeKey(proto,host,port);if(reported[key])return;reported[key]=true;var payload={protocol:proto,hostname:host,port:port,transport:transport};if(typeof method==="string"&&method){payload.method=method;}var body=JSON.stringify({type:"missing-capture",payload:payload});if(typeof navigator!=="undefined"&&navigator.sendBeacon){navigator.sendBeacon(eventsPath,body);}else{fetch(eventsPath,{method:"POST",body:body,keepalive:true});}}catch(e){}}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.");}function fetchMethod(input,init){if(init&&typeof init==="object"&&typeof init.method==="string")return init.method.toUpperCase();if(typeof Request!=="undefined"&&input instanceof Request)return input.method.toUpperCase();return "GET";}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 parsed=new URL(url,window.location.href);reportMissing(parsed,"fetch",fetchMethod(input,init));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 parsed=new URL(url,window.location.href);reportMissing(parsed,"xhr",typeof method==="string"?method.toUpperCase():undefined);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);reportMissing(parsed,"websocket");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);reportMissing(parsed,"eventsource");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
  };
14
15
  /** Injects the capture shim into an HTML document string. */
15
16
  export const injectCaptureShim = (html, shimScript) => {
package/dist/cli-ui.d.ts CHANGED
@@ -6,8 +6,22 @@ export declare const formatCliError: (message: string) => string;
6
6
  export declare const formatStatus: (message: string) => string;
7
7
  export declare const formatRelayReconnect: (message: string) => string;
8
8
  export declare const formatRequestOutbound: (method: string, path: string) => string;
9
+ export declare const formatCapturedRequestOutbound: (method: string, targetUrl: string) => string;
9
10
  export declare const formatRequestInbound: (status: number, path: string, durationMs: number) => string;
11
+ export declare const formatCapturedRequestInbound: (status: number, targetUrl: string, durationMs: number) => string;
12
+ export declare const formatMissingCaptureWarning: (options: {
13
+ readonly origin: string;
14
+ readonly method?: string;
15
+ readonly suggestedCommand: string;
16
+ readonly transport?: string;
17
+ }) => string;
10
18
  export declare const formatRequestError: (path: string, message: string) => string;
19
+ export declare const formatInvalidCaptureRouteError: (options: {
20
+ readonly hostname: string;
21
+ readonly method: string;
22
+ readonly path: string;
23
+ readonly port: number;
24
+ }) => string;
11
25
  export declare const removedAdminTokenFlagMessage: () => string;
12
26
  export declare const adminTokenRequiredMessage: () => string;
13
27
  export type HelpRenderOptions = {
@@ -1 +1 @@
1
- {"version":3,"file":"cli-ui.d.ts","sourceRoot":"","sources":["../src/cli-ui.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,mBAAmB,EAAkB,MAAM,wBAAwB,CAAC;AAIlF,eAAO,MAAM,eAAe,6BAA6B,CAAC;AAE1D,eAAO,MAAM,kBAAkB,GAAI,MAAK,MAAM,CAAC,UAAwB,KAAG,OAGzE,CAAC;AAEF,eAAO,MAAM,aAAa,GAAI,SAAS,MAAM,KAAG,MAQ/C,CAAC;AAEF,eAAO,MAAM,cAAc,GAAI,SAAS,MAAM,KAAG,MAQhD,CAAC;AAEF,eAAO,MAAM,YAAY,GAAI,SAAS,MAAM,KAAG,MAM9C,CAAC;AAEF,eAAO,MAAM,oBAAoB,GAAI,SAAS,MAAM,KAAG,MACT,CAAC;AAE/C,eAAO,MAAM,qBAAqB,GAAI,QAAQ,MAAM,EAAE,MAAM,MAAM,KAAG,MASpE,CAAC;AAEF,eAAO,MAAM,oBAAoB,GAC/B,QAAQ,MAAM,EACd,MAAM,MAAM,EACZ,YAAY,MAAM,KACjB,MAWF,CAAC;AAEF,eAAO,MAAM,kBAAkB,GAAI,MAAM,MAAM,EAAE,SAAS,MAAM,KAAG,MASlE,CAAC;AAKF,eAAO,MAAM,4BAA4B,QAAO,MAA0C,CAAC;AAE3F,eAAO,MAAM,yBAAyB,QAAO,MACkD,CAAC;AAEhG,MAAM,MAAM,iBAAiB,GAAG;IAC9B,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;CAClC,CAAC;AAiDF,eAAO,MAAM,gBAAgB,GAAI,UAAS,iBAAsB,KAAG,MASlE,CAAC;AAEF,eAAO,MAAM,iBAAiB,QAAO,MAuBjC,CAAC;AAEL,eAAO,MAAM,eAAe,GAAI,UAAS,iBAAsB,KAAG,MAmCjE,CAAC;AAEF,eAAO,MAAM,cAAc,GAAI,UAAS,iBAAsB,KAAG,MAiChE,CAAC;AAaF,eAAO,MAAM,uBAAuB,GAAI,UAAU,mBAAmB,KAAG,MA0DvE,CAAC;AAwCF,eAAO,MAAM,gBAAgB,GAAI,aAAa,MAAM,GAAG,SAAS,KAAG,MA0BlE,CAAC"}
1
+ {"version":3,"file":"cli-ui.d.ts","sourceRoot":"","sources":["../src/cli-ui.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,mBAAmB,EAAkB,MAAM,wBAAwB,CAAC;AAIlF,eAAO,MAAM,eAAe,6BAA6B,CAAC;AAE1D,eAAO,MAAM,kBAAkB,GAAI,MAAK,MAAM,CAAC,UAAwB,KAAG,OAGzE,CAAC;AAEF,eAAO,MAAM,aAAa,GAAI,SAAS,MAAM,KAAG,MAQ/C,CAAC;AAEF,eAAO,MAAM,cAAc,GAAI,SAAS,MAAM,KAAG,MAQhD,CAAC;AAEF,eAAO,MAAM,YAAY,GAAI,SAAS,MAAM,KAAG,MAM9C,CAAC;AAEF,eAAO,MAAM,oBAAoB,GAAI,SAAS,MAAM,KAAG,MACT,CAAC;AAE/C,eAAO,MAAM,qBAAqB,GAAI,QAAQ,MAAM,EAAE,MAAM,MAAM,KAAG,MASpE,CAAC;AAEF,eAAO,MAAM,6BAA6B,GAAI,QAAQ,MAAM,EAAE,WAAW,MAAM,KAAG,MAUjF,CAAC;AAEF,eAAO,MAAM,oBAAoB,GAAI,QAAQ,MAAM,EAAE,MAAM,MAAM,EAAE,YAAY,MAAM,KAAG,MAgBvF,CAAC;AAEF,eAAO,MAAM,4BAA4B,GACvC,QAAQ,MAAM,EACd,WAAW,MAAM,EACjB,YAAY,MAAM,KACjB,MAiBF,CAAC;AAIF,eAAO,MAAM,2BAA2B,GAAI,SAAS;IACnD,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B,KAAG,MAiBH,CAAC;AAEF,eAAO,MAAM,kBAAkB,GAAI,MAAM,MAAM,EAAE,SAAS,MAAM,KAAG,MASlE,CAAC;AAEF,eAAO,MAAM,8BAA8B,GAAI,SAAS;IACtD,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB,KAAG,MAaH,CAAC;AAKF,eAAO,MAAM,4BAA4B,QAAO,MAA0C,CAAC;AAE3F,eAAO,MAAM,yBAAyB,QAAO,MACkD,CAAC;AAEhG,MAAM,MAAM,iBAAiB,GAAG;IAC9B,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;CAClC,CAAC;AAiDF,eAAO,MAAM,gBAAgB,GAAI,UAAS,iBAAsB,KAAG,MASlE,CAAC;AAEF,eAAO,MAAM,iBAAiB,QAAO,MAuBjC,CAAC;AAEL,eAAO,MAAM,eAAe,GAAI,UAAS,iBAAsB,KAAG,MA+BjE,CAAC;AAEF,eAAO,MAAM,cAAc,GAAI,UAAS,iBAAsB,KAAG,MA+BhE,CAAC;AAcF,eAAO,MAAM,uBAAuB,GAAI,UAAU,mBAAmB,KAAG,MAkEvE,CAAC;AAyCF,eAAO,MAAM,gBAAgB,GAAI,aAAa,MAAM,GAAG,SAAS,KAAG,MA0BlE,CAAC"}
package/dist/cli-ui.js CHANGED
@@ -35,15 +35,58 @@ export const formatRequestOutbound = (method, path) => {
35
35
  }
36
36
  return `${pc.dim(arrow)} ${pc.bold(method)} ${path}`;
37
37
  };
38
+ export const formatCapturedRequestOutbound = (method, targetUrl) => {
39
+ const arrow = "→";
40
+ const label = "CAPTURED";
41
+ const line = `${arrow} ${label} ${method} ${targetUrl}`;
42
+ if (!pc.isColorSupported) {
43
+ return line;
44
+ }
45
+ return `${pc.dim(arrow)} ${formatCaptureBadge(label)} ${pc.bold(method)} ${targetUrl}`;
46
+ };
38
47
  export const formatRequestInbound = (status, path, durationMs) => {
39
48
  const arrow = "←";
40
49
  const line = `${arrow} ${status} ${path} ${durationMs}ms`;
41
50
  if (!pc.isColorSupported) {
42
51
  return line;
43
52
  }
44
- const statusText = status >= 500 ? pc.red(String(status)) : status >= 400 ? pc.yellow(String(status)) : pc.green(String(status));
53
+ const statusText = status >= 500
54
+ ? pc.red(String(status))
55
+ : status >= 400
56
+ ? pc.yellow(String(status))
57
+ : pc.green(String(status));
45
58
  return `${pc.dim(arrow)} ${statusText} ${path} ${pc.dim(`${durationMs}ms`)}`;
46
59
  };
60
+ export const formatCapturedRequestInbound = (status, targetUrl, durationMs) => {
61
+ const arrow = "←";
62
+ const label = "CAPTURED";
63
+ const line = `${arrow} ${label} ${status} ${targetUrl} ${durationMs}ms`;
64
+ if (!pc.isColorSupported) {
65
+ return line;
66
+ }
67
+ const statusText = status >= 500
68
+ ? pc.red(String(status))
69
+ : status >= 400
70
+ ? pc.yellow(String(status))
71
+ : pc.green(String(status));
72
+ return `${pc.dim(arrow)} ${formatCaptureBadge(label)} ${statusText} ${targetUrl} ${pc.dim(`${durationMs}ms`)}`;
73
+ };
74
+ const formatCaptureBadge = (label) => pc.yellow(pc.dim(` ${label} `));
75
+ export const formatMissingCaptureWarning = (options) => {
76
+ const transportNote = options.transport === "websocket" || options.transport === "eventsource"
77
+ ? " WebSocket/EventSource may still fail in preview."
78
+ : "";
79
+ const arrow = "←";
80
+ const requestLabel = options.method ?? options.transport ?? "request";
81
+ const lines = [
82
+ `${arrow} ${requestLabel} ${options.origin}`,
83
+ ` Browser tried to reach uncaptured loopback backend ${options.origin}. Restart with: ${options.suggestedCommand}.${transportNote}`,
84
+ ];
85
+ if (!pc.isColorSupported) {
86
+ return lines.join("\n");
87
+ }
88
+ return lines.map((line) => pc.red(line)).join("\n");
89
+ };
47
90
  export const formatRequestError = (path, message) => {
48
91
  const arrow = "←";
49
92
  const line = `${arrow} error ${path} ${message}`;
@@ -52,6 +95,18 @@ export const formatRequestError = (path, message) => {
52
95
  }
53
96
  return `${pc.dim(arrow)} ${pc.red("error")} ${path} ${pc.red(message)}`;
54
97
  };
98
+ export const formatInvalidCaptureRouteError = (options) => {
99
+ const arrow = "←";
100
+ const origin = `${options.hostname}:${options.port}`;
101
+ const lines = [
102
+ `${arrow} ${options.method} ${options.path}`,
103
+ ` Browser tried to reach blocked loopback backend ${origin}. Allow it with --capture ${origin}.`,
104
+ ];
105
+ if (!pc.isColorSupported) {
106
+ return lines.join("\n");
107
+ }
108
+ return lines.map((line) => pc.red(line)).join("\n");
109
+ };
55
110
  const REMOVED_ADMIN_TOKEN_FLAG_MESSAGE = "The --admin-token flag was removed. Set the LOCALPREVIEW_ADMIN_TOKEN environment variable instead.";
56
111
  export const removedAdminTokenFlagMessage = () => REMOVED_ADMIN_TOKEN_FLAG_MESSAGE;
57
112
  export const adminTokenRequiredMessage = () => `Admin commands require ${ADMIN_TOKEN_ENV}. Export it in your shell, then rerun the command.`;
@@ -126,11 +181,7 @@ export const renderConnectHelp = () => joinLines([
126
181
  export const renderCleanHelp = (options = {}) => {
127
182
  const env = options.env ?? process.env;
128
183
  if (!hasAdminTokenInEnv(env)) {
129
- return joinLines([
130
- renderGlobalHelp(options),
131
- "",
132
- adminTokenRequiredMessage(),
133
- ]);
184
+ return joinLines([renderGlobalHelp(options), "", adminTokenRequiredMessage()]);
134
185
  }
135
186
  return joinLines([
136
187
  heading("localpreview clean — admin subdomain cleanup"),
@@ -160,11 +211,7 @@ export const renderCleanHelp = (options = {}) => {
160
211
  export const renderListHelp = (options = {}) => {
161
212
  const env = options.env ?? process.env;
162
213
  if (!hasAdminTokenInEnv(env)) {
163
- return joinLines([
164
- renderGlobalHelp(options),
165
- "",
166
- adminTokenRequiredMessage(),
167
- ]);
214
+ return joinLines([renderGlobalHelp(options), "", adminTokenRequiredMessage()]);
168
215
  }
169
216
  return joinLines([
170
217
  heading("localpreview list — inspect tunnel inventory"),
@@ -191,6 +238,7 @@ export const renderListHelp = (options = {}) => {
191
238
  };
192
239
  const LIST_COLUMNS = [
193
240
  "kind",
241
+ "state",
194
242
  "subdomain",
195
243
  "tunnel",
196
244
  "age",
@@ -242,6 +290,7 @@ export const formatListTunnelsOutput = (response) => {
242
290
  };
243
291
  const formatListTunnelRow = (item) => [
244
292
  formatListItemKind(item.kind),
293
+ item.status ?? "-",
245
294
  item.subdomain ?? "-",
246
295
  formatShortId(item.tunnelId),
247
296
  formatCompactAge(item.activeForMs),
package/dist/command.d.ts CHANGED
@@ -2,4 +2,5 @@ import { Effect } from "effect";
2
2
  import { CliUsageError, type CliRuntimeError } from "./errors.js";
3
3
  export declare const runCli: (argv: ReadonlyArray<string>) => Effect.Effect<void, CliRuntimeError | CliUsageError>;
4
4
  export declare const normalizeCliArgs: (argv: ReadonlyArray<string>) => ReadonlyArray<string>;
5
+ export declare const buildConnectOriginalArgv: (argv: ReadonlyArray<string>, legacy: boolean) => ReadonlyArray<string>;
5
6
  //# sourceMappingURL=command.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"command.d.ts","sourceRoot":"","sources":["../src/command.ts"],"names":[],"mappings":"AAQA,OAAO,EAAW,MAAM,EAAiB,MAAM,QAAQ,CAAC;AAkBxD,OAAO,EAAE,aAAa,EAAgB,KAAK,eAAe,EAAE,MAAM,aAAa,CAAC;AAwChF,eAAO,MAAM,MAAM,GACjB,MAAM,aAAa,CAAC,MAAM,CAAC,KAC1B,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,eAAe,GAAG,aAAa,CAmBrD,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;AAuBxD,OAAO,EAAE,aAAa,EAAgB,KAAK,eAAe,EAAE,MAAM,aAAa,CAAC;AAyChF,eAAO,MAAM,MAAM,GACjB,MAAM,aAAa,CAAC,MAAM,CAAC,KAC1B,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,eAAe,GAAG,aAAa,CAmBrD,CAAC;AAEF,eAAO,MAAM,gBAAgB,GAAI,MAAM,aAAa,CAAC,MAAM,CAAC,KAAG,aAAa,CAAC,MAAM,CAC1C,CAAC;AAE1C,eAAO,MAAM,wBAAwB,GACnC,MAAM,aAAa,CAAC,MAAM,CAAC,EAC3B,QAAQ,OAAO,KACd,aAAa,CAAC,MAAM,CAAmE,CAAC"}
package/dist/command.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { formatCaptureOrigin, parseCaptureHostPort, parseTarget, validateRequestedSubdomain, } from "@localpreview/protocol";
2
2
  import { Console, Effect, Layer, Option } from "effect";
3
- import { adminTokenRequiredMessage, formatStatus, formatWarning, formatListTunnelsOutput, renderCleanHelp, renderConnectHelp, renderGlobalHelp, renderListHelp, removedAdminTokenFlagMessage, } from "./cli-ui.js";
4
- import { CliConfig, CliConfigLive, LOCAL_CONTROL_PLANE_URL, normalizeControlPlaneUrl } from "./config.js";
3
+ import { adminTokenRequiredMessage, formatWarning, formatListTunnelsOutput, renderCleanHelp, renderConnectHelp, renderGlobalHelp, renderListHelp, removedAdminTokenFlagMessage, } from "./cli-ui.js";
4
+ import { CliConfig, CliConfigLive, LOCAL_CONTROL_PLANE_PUBLIC_HOST, LOCAL_CONTROL_PLANE_URL, normalizeControlPlaneUrl, } from "./config.js";
5
5
  import { ControlPlaneClient, ControlPlaneClientLive, } from "./control-plane.js";
6
6
  import { CliUsageError, errorMessage } from "./errors.js";
7
7
  import { LocalProxyLive } from "./local-proxy.js";
@@ -20,9 +20,10 @@ export const runCli = (argv) => {
20
20
  if (!parsed.ok) {
21
21
  return Effect.fail(new CliUsageError({ message: parsed.message }));
22
22
  }
23
- return runSubcommand(parsed.command);
23
+ return runSubcommand(parsed.command, normalized);
24
24
  };
25
25
  export const normalizeCliArgs = (argv) => argv[0] === "--" ? argv.slice(1) : argv;
26
+ export const buildConnectOriginalArgv = (argv, legacy) => (legacy ? argv : argv[0] === "connect" ? argv.slice(1) : argv);
26
27
  const hasHelpFlag = (argv) => argv.includes("--help") || argv.includes("-h");
27
28
  const resolveHelpKind = (argv) => {
28
29
  if (argv.length === 0) {
@@ -177,6 +178,7 @@ const parseConnectArgs = (argv) => {
177
178
  config: {
178
179
  captures,
179
180
  controlPlane: toOption(controlPlane),
181
+ localMode: usedLocalControlPlane,
180
182
  requestedName: toOption(requestedName),
181
183
  target,
182
184
  },
@@ -439,10 +441,10 @@ const readRequiredOptionValue = (argv, index, option) => {
439
441
  };
440
442
  };
441
443
  const toOption = (value) => value === undefined ? Option.none() : Option.some(value);
442
- const runSubcommand = (command) => {
444
+ const runSubcommand = (command, originalArgv) => {
443
445
  const controlPlane = Option.getOrUndefined(command.config.controlPlane);
444
446
  const effect = command.kind === "connect"
445
- ? runConnect(command.config, command.legacy)
447
+ ? runConnect(command.config, command.legacy, originalArgv)
446
448
  : command.kind === "clean"
447
449
  ? runClean(command.config)
448
450
  : runList(command.config);
@@ -455,7 +457,7 @@ const makeCommandLayer = (input) => {
455
457
  const RelayLive = RelayClientLive.pipe(Layer.provide(Layer.mergeAll(ConfigLive, ProxyLive)));
456
458
  return Layer.mergeAll(ConfigLive, ControlPlaneClientLive, ProxyLive, RelayLive);
457
459
  };
458
- const runConnect = (config, legacy) => Effect.scoped(Effect.gen(function* () {
460
+ const runConnect = (config, legacy, originalArgv) => Effect.scoped(Effect.gen(function* () {
459
461
  if (legacy) {
460
462
  yield* Console.error(formatWarning("`localpreview <target>` is deprecated. Use `localpreview connect <target>`."));
461
463
  }
@@ -467,14 +469,18 @@ const runConnect = (config, legacy) => Effect.scoped(Effect.gen(function* () {
467
469
  const controlPlane = yield* ControlPlaneClient;
468
470
  const relay = yield* RelayClient;
469
471
  const cliConfig = yield* CliConfig;
470
- const tunnel = yield* Effect.acquireRelease(controlPlane.createTunnel(cliConfig.controlPlaneUrl, requestedSubdomain === undefined ? {} : { requestedSubdomain }), (tunnel) => closeTunnelBestEffort(controlPlane, cliConfig.controlPlaneUrl, tunnel));
471
- yield* Console.log(formatStatus(`Tunnel ready: ${tunnel.publicUrl}`));
472
- yield* Console.log(formatStatus(`Forwarding to ${target.target.protocol}://${target.target.hostname}:${target.target.port}`));
472
+ const tunnel = yield* Effect.acquireRelease(controlPlane.createTunnel(cliConfig.controlPlaneUrl, {
473
+ ...(config.localMode ? { publicHost: LOCAL_CONTROL_PLANE_PUBLIC_HOST } : {}),
474
+ ...(requestedSubdomain === undefined ? {} : { requestedSubdomain }),
475
+ }), (tunnel) => closeTunnelBestEffort(controlPlane, cliConfig.controlPlaneUrl, tunnel));
473
476
  if (config.captures.length > 0) {
474
477
  const origins = config.captures.map((capture) => formatCaptureOrigin(capture)).join(", ");
475
478
  yield* Console.error(formatWarning(`Captured local backends (${origins}) are exposed through this preview URL. Anyone with the link can reach them.`));
476
479
  }
477
- yield* relay.connectAndServe(tunnel, target.target, config.captures);
480
+ yield* relay.connectAndServe(tunnel, target.target, config.captures, {
481
+ legacy,
482
+ originalArgv: buildConnectOriginalArgv(originalArgv, legacy),
483
+ });
478
484
  }));
479
485
  const validateRequestedName = (value) => Option.match(value, {
480
486
  onNone: () => Effect.succeed(undefined),
package/dist/config.d.ts CHANGED
@@ -4,6 +4,7 @@ export type CliConfigShape = {
4
4
  readonly controlPlaneUrl: string;
5
5
  readonly maxInFlightRequests: number;
6
6
  readonly relayConnectTimeoutMs: number;
7
+ readonly relayReadyTimeoutMs: number;
7
8
  readonly requestBodyLimitBytes: number;
8
9
  readonly requestTimeoutMs: number;
9
10
  readonly responseBodyLimitBytes: number;
@@ -13,7 +14,8 @@ declare const CliConfig_base: Context.ServiceClass<CliConfig, "CliConfig", CliCo
13
14
  export declare class CliConfig extends CliConfig_base {
14
15
  }
15
16
  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 LOCAL_CONTROL_PLANE_URL = "http://127.0.0.1:3000";
18
+ export declare const LOCAL_CONTROL_PLANE_PUBLIC_HOST = "localhost:3000";
17
19
  export type NormalizeControlPlaneUrlResult = {
18
20
  readonly ok: true;
19
21
  readonly url: string;
@@ -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,UAAU,EAAE,MAAM,GAAG,SAAS,CAAC;IACxC,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,cAkBH,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,UAAU,EAAE,MAAM,GAAG,SAAS,CAAC;IACxC,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,mBAAmB,EAAE,MAAM,CAAC;IACrC,QAAQ,CAAC,qBAAqB,EAAE,MAAM,CAAC;IACvC,QAAQ,CAAC,mBAAmB,EAAE,MAAM,CAAC;IACrC,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;AAC/D,eAAO,MAAM,+BAA+B,mBAAmB,CAAC;AAEhE,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,cAmBH,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
@@ -3,7 +3,8 @@ import { Context, Effect, Layer } from "effect";
3
3
  export class CliConfig extends Context.Service()("CliConfig") {
4
4
  }
5
5
  export const PUBLIC_CONTROL_PLANE_URL = LOCALPREVIEW_PUBLIC_ORIGIN;
6
- export const LOCAL_CONTROL_PLANE_URL = "http://localhost:3000";
6
+ export const LOCAL_CONTROL_PLANE_URL = "http://127.0.0.1:3000";
7
+ export const LOCAL_CONTROL_PLANE_PUBLIC_HOST = "localhost:3000";
7
8
  /** Normalizes and validates a control-plane base URL from `--control-plane`. */
8
9
  export const normalizeControlPlaneUrl = (value) => {
9
10
  let parsed;
@@ -84,6 +85,7 @@ export const makeCliConfig = (input) => {
84
85
  controlPlaneUrl,
85
86
  maxInFlightRequests: readNumber(env.LOCALPREVIEW_MAX_IN_FLIGHT_REQUESTS, 100),
86
87
  relayConnectTimeoutMs: readNumber(env.LOCALPREVIEW_RELAY_CONNECT_TIMEOUT_MS, 10_000),
88
+ relayReadyTimeoutMs: readNumber(env.LOCALPREVIEW_RELAY_READY_TIMEOUT_MS, 60_000),
87
89
  requestBodyLimitBytes: readNumber(env.LOCALPREVIEW_REQUEST_BODY_LIMIT_BYTES, 10 * 1024 * 1024),
88
90
  requestTimeoutMs: readNumber(env.LOCALPREVIEW_REQUEST_TIMEOUT_MS, 30_000),
89
91
  responseBodyLimitBytes: readNumber(env.LOCALPREVIEW_RESPONSE_BODY_LIMIT_BYTES, 50 * 1024 * 1024),
@@ -1,4 +1,4 @@
1
- import { type CreateTunnelResponse, type ListTunnelsResponse } from "@localpreview/protocol";
1
+ import { type CreateTunnelRequest, type CreateTunnelResponse, type ListTunnelsResponse } from "@localpreview/protocol";
2
2
  import { Context, Effect, Layer } from "effect";
3
3
  import { ControlPlaneError } from "./errors.js";
4
4
  export type ControlPlaneClientShape = {
@@ -12,9 +12,7 @@ export type ControlPlaneClientShape = {
12
12
  readonly subdomain: string;
13
13
  }) => Effect.Effect<CleanSubdomainResponse, ControlPlaneError>;
14
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>;
15
+ readonly createTunnel: (controlPlaneUrl: string, body: CreateTunnelRequest) => Effect.Effect<CreateTunnelResponse, ControlPlaneError>;
18
16
  readonly listTunnels: (controlPlaneUrl: string, input: {
19
17
  readonly adminToken: string;
20
18
  readonly limit: number;
@@ -47,9 +45,7 @@ declare const ControlPlaneClient_base: Context.ServiceClass<ControlPlaneClient,
47
45
  export declare class ControlPlaneClient extends ControlPlaneClient_base {
48
46
  }
49
47
  export declare const ControlPlaneClientLive: Layer.Layer<ControlPlaneClient, never, never>;
50
- export declare const createTunnel: (controlPlaneUrl: string, body: {
51
- readonly requestedSubdomain?: string;
52
- }) => Promise<CreateTunnelResponse>;
48
+ export declare const createTunnel: (controlPlaneUrl: string, body: CreateTunnelRequest) => Promise<CreateTunnelResponse>;
53
49
  export declare const closeTunnel: (controlPlaneUrl: string, tunnel: Pick<CreateTunnelResponse, "clientToken" | "tunnelId">) => Promise<void>;
54
50
  export declare const cleanSubdomain: (controlPlaneUrl: string, input: {
55
51
  readonly adminToken: string;
@@ -1 +1 @@
1
- {"version":3,"file":"control-plane.d.ts","sourceRoot":"","sources":["../src/control-plane.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"control-plane.d.ts","sourceRoot":"","sources":["../src/control-plane.ts"],"names":[],"mappings":"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"}
@@ -1,5 +1,6 @@
1
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
+ import { LOCAL_CONTROL_PLANE_URL } from "./config.js";
3
4
  import { ControlPlaneError } from "./errors.js";
4
5
  export class ControlPlaneClient extends Context.Service()("ControlPlaneClient") {
5
6
  }
@@ -20,8 +21,9 @@ export const cleanAllSubdomains = (controlPlaneUrl, input) => Effect.runPromise(
20
21
  export const listTunnels = (controlPlaneUrl, input) => Effect.runPromise(listTunnelsEffect(controlPlaneUrl, input));
21
22
  const createTunnelEffect = (controlPlaneUrl, body) => Effect.promise(async () => {
22
23
  let response;
24
+ const createTunnelUrl = new URL("/api/tunnels", controlPlaneUrl);
23
25
  try {
24
- response = await fetch(new URL("/api/tunnels", controlPlaneUrl), {
26
+ response = await fetch(createTunnelUrl, {
25
27
  body: JSON.stringify(body),
26
28
  headers: {
27
29
  "content-type": "application/json",
@@ -33,7 +35,7 @@ const createTunnelEffect = (controlPlaneUrl, body) => Effect.promise(async () =>
33
35
  throw new ControlPlaneError({
34
36
  message: [
35
37
  `Could not reach localpreview control-plane at ${controlPlaneUrl}.`,
36
- 'Use "-l" or "--local" as shorthand for --control-plane http://localhost:3000.',
38
+ `Use "-l" or "--local" as shorthand for --control-plane ${LOCAL_CONTROL_PLANE_URL}.`,
37
39
  ].join("\n"),
38
40
  retryable: false,
39
41
  });
package/dist/errors.d.ts CHANGED
@@ -39,6 +39,10 @@ declare const LocalRequestError_base: new <A extends Record<string, any> = {}>(a
39
39
  readonly _tag: "LocalRequestError";
40
40
  } & Readonly<A>;
41
41
  export declare class LocalRequestError extends LocalRequestError_base<{
42
+ readonly invalidCapture?: {
43
+ readonly hostname: string;
44
+ readonly port: number;
45
+ };
42
46
  readonly message: string;
43
47
  readonly requestId: string;
44
48
  }> {
@@ -1 +1 @@
1
- {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":";;;AAEA,qBAAa,aAAc,SAAQ,mBAAkC;IACnE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B,CAAC;CAAG;;;;AAEL,qBAAa,cAAe,SAAQ,oBAAmC;IACrE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B,CAAC;CAAG;;;;AAEL,qBAAa,iBAAkB,SAAQ,uBAAsC;IAC3E,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC;CAC9B,CAAC;CAAG;;;;AAEL,qBAAa,oBAAqB,SAAQ,0BAAyC;IACjF,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC;CAC9B,CAAC;CAAG;;;;AAEL,qBAAa,kBAAmB,SAAQ,wBAAuC;IAC7E,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B,CAAC;CAAG;;;;AAEL,qBAAa,iBAAkB,SAAQ,uBAAsC;IAC3E,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B,CAAC;CAAG;;;;AAEL,qBAAa,iBAAkB,SAAQ,uBAAsC;IAC3E,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B,CAAC;CAAG;AAEL,MAAM,MAAM,eAAe,GACvB,cAAc,GACd,iBAAiB,GACjB,oBAAoB,GACpB,kBAAkB,GAClB,iBAAiB,GACjB,iBAAiB,CAAC;AAEtB,eAAO,MAAM,YAAY,GAAI,OAAO,OAAO,KAAG,MAM7C,CAAC"}
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":";;;AAEA,qBAAa,aAAc,SAAQ,mBAAkC;IACnE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B,CAAC;CAAG;;;;AAEL,qBAAa,cAAe,SAAQ,oBAAmC;IACrE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B,CAAC;CAAG;;;;AAEL,qBAAa,iBAAkB,SAAQ,uBAAsC;IAC3E,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC;CAC9B,CAAC;CAAG;;;;AAEL,qBAAa,oBAAqB,SAAQ,0BAAyC;IACjF,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC;CAC9B,CAAC;CAAG;;;;AAEL,qBAAa,kBAAmB,SAAQ,wBAAuC;IAC7E,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B,CAAC;CAAG;;;;AAEL,qBAAa,iBAAkB,SAAQ,uBAAsC;IAC3E,QAAQ,CAAC,cAAc,CAAC,EAAE;QACxB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;QAC1B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;KACvB,CAAC;IACF,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B,CAAC;CAAG;;;;AAEL,qBAAa,iBAAkB,SAAQ,uBAAsC;IAC3E,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B,CAAC;CAAG;AAEL,MAAM,MAAM,eAAe,GACvB,cAAc,GACd,iBAAiB,GACjB,oBAAoB,GACpB,kBAAkB,GAClB,iBAAiB,GACjB,iBAAiB,CAAC;AAEtB,eAAO,MAAM,YAAY,GAAI,OAAO,OAAO,KAAG,MAM7C,CAAC"}
@@ -5,6 +5,9 @@ import { CliConfig } from "./config.js";
5
5
  import { RelayProtocolError } from "./errors.js";
6
6
  export type ProxySession = {
7
7
  readonly captures: ReadonlyArray<CaptureTarget>;
8
+ readonly legacy: boolean;
9
+ readonly originalArgv: ReadonlyArray<string>;
10
+ readonly reportedMissingCaptures: Set<string>;
8
11
  readonly target: TunnelTarget;
9
12
  };
10
13
  export type LocalProxyShape = {
@@ -1 +1 @@
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;AAchC,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;AAgVF,eAAO,MAAM,uBAAuB,GAAI,QAAQ,MAAM,EAAE,YAAY,MAAM,KAAG,MAqC5E,CAAC;AA0BF,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;AA8BhC,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,OAAO,CAAC;IACzB,QAAQ,CAAC,YAAY,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC7C,QAAQ,CAAC,uBAAuB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC9C,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;AA6aF,eAAO,MAAM,uBAAuB,GAAI,QAAQ,MAAM,EAAE,YAAY,MAAM,KAAG,MAqC5E,CAAC;AA0CF,eAAO,MAAM,0BAA0B,GAAI,SAAS,OAAO,KAAG,WAqB7D,CAAC"}
@@ -2,7 +2,8 @@ import { decodeServerRelayMessage, encodeRelayMessage, filterEndToEndHeaderPairs
2
2
  import { Console, Context, Effect, Fiber, Layer, Ref } from "effect";
3
3
  import { buildCaptureCookiePathPrefix, resolveRoute, } from "./capture-route.js";
4
4
  import { buildCaptureShimScript, buildFrontendOrigin, injectCaptureShim, isHtmlResponse, stripContentSecurityPolicy, } from "./capture-shim.js";
5
- import { formatRequestError, formatRequestInbound, formatRequestOutbound } from "./cli-ui.js";
5
+ import { formatCapturedRequestInbound, formatCapturedRequestOutbound, formatInvalidCaptureRouteError, formatMissingCaptureWarning, formatRequestError, formatRequestInbound, formatRequestOutbound, } from "./cli-ui.js";
6
+ import { buildSuggestedCaptureCommand, formatMissingCaptureOrigin, isLocalPreviewEventsPath, missingCaptureDedupeKey, parseMissingCaptureEvent, shouldReportMissingCapture, } from "./missing-capture.js";
6
7
  import { CliConfig } from "./config.js";
7
8
  import { LocalRequestError, RelayProtocolError, RequestLimitError, errorMessage, } from "./errors.js";
8
9
  export class LocalProxy extends Context.Service()("LocalProxy") {
@@ -24,7 +25,7 @@ const handleMessage = (config, requests, socket, session, input) => Effect.gen(f
24
25
  const message = decoded.message;
25
26
  switch (message.type) {
26
27
  case "request-start":
27
- return yield* handleRequestStart(config, requests, socket, message);
28
+ return yield* handleRequestStart(config, requests, socket, session, message);
28
29
  case "request-chunk":
29
30
  return yield* handleRequestChunk(config, requests, socket, message);
30
31
  case "request-end":
@@ -33,7 +34,7 @@ const handleMessage = (config, requests, socket, session, input) => Effect.gen(f
33
34
  return yield* handleCancel(requests, message.requestId);
34
35
  }
35
36
  });
36
- const handleRequestStart = (config, requests, socket, message) => Effect.gen(function* () {
37
+ const handleRequestStart = (config, requests, socket, session, message) => Effect.gen(function* () {
37
38
  const current = yield* Ref.get(requests);
38
39
  if (current.size >= config.maxInFlightRequests) {
39
40
  yield* send(socket, {
@@ -52,7 +53,16 @@ const handleRequestStart = (config, requests, socket, message) => Effect.gen(fun
52
53
  totalBytes: 0,
53
54
  });
54
55
  yield* Ref.set(requests, next);
55
- yield* Console.log(formatRequestOutbound(message.method, logPath(message.path)));
56
+ if (shouldLogOutboundRequest(message.method, message.path)) {
57
+ const route = resolveRoute(message.path, session.target, session.captures);
58
+ const displayPath = route.kind === "capture"
59
+ ? formatCaptureTargetUrl(route.capturePath)
60
+ : logPath(message.path);
61
+ const line = route.kind === "capture"
62
+ ? formatCapturedRequestOutbound(message.method, displayPath)
63
+ : formatRequestOutbound(message.method, displayPath);
64
+ yield* Console.log(line);
65
+ }
56
66
  });
57
67
  const handleRequestChunk = (config, requests, socket, message) => Effect.gen(function* () {
58
68
  const current = yield* Ref.get(requests);
@@ -95,7 +105,7 @@ const handleRequestEnd = (config, requests, socket, session, message) => Effect.
95
105
  message: error.message,
96
106
  requestId: message.requestId,
97
107
  type: "response-error",
98
- }).pipe(Effect.andThen(Console.log(formatRequestError(logPath(request.path), error.message))))), Effect.ensuring(Ref.update(requests, (map) => {
108
+ }).pipe(Effect.andThen(Console.log(formatProxyRequestError(request, error))))), Effect.ensuring(Ref.update(requests, (map) => {
99
109
  const next = new Map(map);
100
110
  next.delete(message.requestId);
101
111
  return next;
@@ -115,9 +125,18 @@ const handleCancel = (requests, requestId) => Effect.gen(function* () {
115
125
  yield* Ref.set(requests, next);
116
126
  });
117
127
  const proxyRequest = (config, socket, session, requestId, request) => Effect.gen(function* () {
128
+ if (request.method === "POST" && isLocalPreviewEventsPath(request.path)) {
129
+ yield* handleMissingCaptureEvent(session, request);
130
+ yield* sendEventsResponse(socket, requestId);
131
+ return;
132
+ }
118
133
  const route = resolveRoute(request.path, session.target, session.captures);
119
134
  if (route.kind === "invalid-capture") {
120
135
  return yield* Effect.fail(new LocalRequestError({
136
+ invalidCapture: {
137
+ hostname: route.capturePath.hostname,
138
+ port: route.capturePath.port,
139
+ },
121
140
  message: route.message,
122
141
  requestId,
123
142
  }));
@@ -170,12 +189,10 @@ const proxyRequest = (config, socket, session, requestId, request) => Effect.gen
170
189
  }));
171
190
  }
172
191
  const contentType = response.headers.get("content-type");
173
- const shouldInjectShim = route.kind === "primary" &&
174
- session.captures.length > 0 &&
175
- isHtmlResponse(contentType);
192
+ const shouldInjectShim = route.kind === "primary" && isHtmlResponse(contentType);
176
193
  if (shouldInjectShim) {
177
194
  const html = responseBody.toString("utf8");
178
- const shimScript = buildCaptureShimScript(session.captures);
195
+ const shimScript = buildCaptureShimScript(session.target, session.captures);
179
196
  responseBody = Buffer.from(injectCaptureShim(html, shimScript), "utf8");
180
197
  }
181
198
  let responseHeaders = filterLocalResponseHeaders(response.headers);
@@ -203,7 +220,54 @@ const proxyRequest = (config, socket, session, requestId, request) => Effect.gen
203
220
  requestId,
204
221
  type: "response-end",
205
222
  });
206
- yield* Console.log(formatRequestInbound(response.status, logPath(request.path), Date.now() - startedAt));
223
+ const displayPath = route.kind === "capture" ? formatCaptureTargetUrl(route.capturePath) : logPath(request.path);
224
+ const line = route.kind === "capture"
225
+ ? formatCapturedRequestInbound(response.status, displayPath, Date.now() - startedAt)
226
+ : formatRequestInbound(response.status, displayPath, Date.now() - startedAt);
227
+ yield* Console.log(line);
228
+ });
229
+ const handleMissingCaptureEvent = (session, request) => Effect.gen(function* () {
230
+ const bodyText = Buffer.concat(request.chunks).toString("utf8");
231
+ if (bodyText.length === 0) {
232
+ return;
233
+ }
234
+ let parsedBody;
235
+ try {
236
+ parsedBody = JSON.parse(bodyText);
237
+ }
238
+ catch {
239
+ return;
240
+ }
241
+ const event = parseMissingCaptureEvent(parsedBody);
242
+ if (event === undefined ||
243
+ !shouldReportMissingCapture(event, session.target, session.captures)) {
244
+ return;
245
+ }
246
+ const dedupeKey = missingCaptureDedupeKey(event.payload.protocol, event.payload.hostname, event.payload.port);
247
+ if (session.reportedMissingCaptures.has(dedupeKey)) {
248
+ return;
249
+ }
250
+ session.reportedMissingCaptures.add(dedupeKey);
251
+ const origin = formatMissingCaptureOrigin(event.payload.protocol, event.payload.hostname, event.payload.port);
252
+ const suggestedCommand = buildSuggestedCaptureCommand(session.originalArgv, session.legacy, session.captures, event.payload.hostname, event.payload.port);
253
+ yield* Console.error(formatMissingCaptureWarning({
254
+ ...(event.payload.method === undefined ? {} : { method: event.payload.method }),
255
+ origin,
256
+ suggestedCommand,
257
+ transport: event.payload.transport,
258
+ }));
259
+ });
260
+ const sendEventsResponse = (socket, requestId) => Effect.gen(function* () {
261
+ yield* send(socket, {
262
+ headers: [],
263
+ requestId,
264
+ status: 204,
265
+ type: "response-start",
266
+ });
267
+ yield* send(socket, {
268
+ requestId,
269
+ type: "response-end",
270
+ });
207
271
  });
208
272
  const rewriteCaptureResponseHeaders = (headers, capturePath) => {
209
273
  const pathPrefix = buildCaptureCookiePathPrefix(capturePath);
@@ -250,6 +314,17 @@ const sendRequestError = (socket, requestId, error) => send(socket, {
250
314
  requestId,
251
315
  type: "response-error",
252
316
  });
317
+ const formatProxyRequestError = (request, error) => {
318
+ if (error instanceof LocalRequestError && error.invalidCapture !== undefined) {
319
+ return formatInvalidCaptureRouteError({
320
+ hostname: error.invalidCapture.hostname,
321
+ method: request.method,
322
+ path: request.path,
323
+ port: error.invalidCapture.port,
324
+ });
325
+ }
326
+ return formatRequestError(logPath(request.path), error.message);
327
+ };
253
328
  const send = (socket, message) => Effect.sync(() => {
254
329
  if (socket.readyState === socket.OPEN) {
255
330
  socket.send(encodeRelayMessage(message));
@@ -283,4 +358,6 @@ const logPath = (path) => {
283
358
  return path.split("?")[0] ?? path;
284
359
  }
285
360
  };
361
+ const formatCaptureTargetUrl = (capturePath) => `${capturePath.protocol}://${capturePath.hostname}:${capturePath.port}${capturePath.path}`;
362
+ const shouldLogOutboundRequest = (method, path) => !(method === "POST" && isLocalPreviewEventsPath(path));
286
363
  const timeoutFail = (millis, error) => (effect) => Effect.raceFirst(effect, Effect.sleep(`${millis} millis`).pipe(Effect.andThen(Effect.fail(error))));
@@ -0,0 +1,44 @@
1
+ import { type CaptureTarget, type TunnelTarget } from "@localpreview/protocol";
2
+ export declare const LOCALPREVIEW_EVENTS_PATH = "/__localpreview/events";
3
+ export type MissingCaptureTransport = "fetch" | "xhr" | "websocket" | "eventsource";
4
+ export type MissingCaptureProtocol = "http" | "https" | "ws" | "wss";
5
+ export type MissingCaptureEvent = {
6
+ readonly type: "missing-capture";
7
+ readonly payload: {
8
+ readonly protocol: "http" | "https" | "ws" | "wss";
9
+ readonly hostname: string;
10
+ readonly method?: string;
11
+ readonly port: number;
12
+ readonly transport: MissingCaptureTransport;
13
+ };
14
+ };
15
+ export type MissingCaptureEventInput = {
16
+ readonly hostname: string;
17
+ readonly method?: string;
18
+ readonly port: number;
19
+ readonly protocol: string;
20
+ readonly transport: string;
21
+ };
22
+ /** Canonical loopback group for localhost / 127.0.0.1 / [::1] equivalence. */
23
+ export declare const loopbackGroup: (hostname: string) => string;
24
+ /** Returns true when two loopback endpoints share host equivalence and port. */
25
+ export declare const sameLoopbackEndpoint: (leftHost: string, leftPort: number, rightHost: string, rightPort: number) => boolean;
26
+ /** Returns true when the endpoint matches the primary tunnel target port. */
27
+ export declare const isPrimaryTargetPort: (primaryTarget: Pick<TunnelTarget, "hostname" | "port">, hostname: string, port: number) => boolean;
28
+ /** Returns true when the endpoint is already allowlisted via --capture. */
29
+ export declare const isCapturedEndpoint: (captures: ReadonlyArray<CaptureTarget>, hostname: string, port: number) => boolean;
30
+ /** Formats a missing-capture origin as protocol://hostname:port. */
31
+ export declare const formatMissingCaptureOrigin: (protocol: MissingCaptureEvent["payload"]["protocol"], hostname: string, port: number) => string;
32
+ /** Dedupe key for one missing-capture report per protocol://hostname:port. */
33
+ export declare const missingCaptureDedupeKey: (protocol: MissingCaptureEvent["payload"]["protocol"], hostname: string, port: number) => string;
34
+ /** Returns true when the request targets the internal events route. */
35
+ export declare const isLocalPreviewEventsPath: (requestPath: string) => boolean;
36
+ /** Strictly validates a parsed missing-capture event body. */
37
+ export declare const parseMissingCaptureEvent: (body: unknown) => MissingCaptureEvent | undefined;
38
+ /** Builds a restart command preserving the original argv and appending --capture. */
39
+ export declare const buildSuggestedCaptureCommand: (originalArgv: ReadonlyArray<string>, legacy: boolean, captures: ReadonlyArray<CaptureTarget>, hostname: string, port: number) => string;
40
+ /** Returns true when a missing-capture event should produce a CLI warning. */
41
+ export declare const shouldReportMissingCapture: (event: MissingCaptureEvent, primaryTarget: Pick<TunnelTarget, "hostname" | "port">, captures: ReadonlyArray<CaptureTarget>) => boolean;
42
+ /** Formats a capture target for inclusion in warning text. */
43
+ export declare const formatMissingCaptureTarget: (capture: CaptureTarget) => string;
44
+ //# sourceMappingURL=missing-capture.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"missing-capture.d.ts","sourceRoot":"","sources":["../src/missing-capture.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,128 @@
1
+ import { formatCaptureOrigin, isLoopbackHost, normalizeLoopbackHostname, } from "@localpreview/protocol";
2
+ export const LOCALPREVIEW_EVENTS_PATH = "/__localpreview/events";
3
+ const MISSING_CAPTURE_PROTOCOLS = new Set(["http", "https", "ws", "wss"]);
4
+ const MISSING_CAPTURE_TRANSPORTS = new Set(["fetch", "xhr", "websocket", "eventsource"]);
5
+ const isMissingCaptureProtocol = (value) => MISSING_CAPTURE_PROTOCOLS.has(value);
6
+ const isMissingCaptureTransport = (value) => MISSING_CAPTURE_TRANSPORTS.has(value);
7
+ /** Canonical loopback group for localhost / 127.0.0.1 / [::1] equivalence. */
8
+ export const loopbackGroup = (hostname) => {
9
+ const normalized = normalizeLoopbackHostname(hostname);
10
+ if (normalized === "localhost" ||
11
+ normalized === "127.0.0.1" ||
12
+ normalized === "[::1]") {
13
+ return "__loopback__";
14
+ }
15
+ return normalized;
16
+ };
17
+ /** Returns true when two loopback endpoints share host equivalence and port. */
18
+ export const sameLoopbackEndpoint = (leftHost, leftPort, rightHost, rightPort) => leftPort === rightPort && loopbackGroup(leftHost) === loopbackGroup(rightHost);
19
+ /** Returns true when the endpoint matches the primary tunnel target port. */
20
+ export const isPrimaryTargetPort = (primaryTarget, hostname, port) => sameLoopbackEndpoint(primaryTarget.hostname, primaryTarget.port, hostname, port);
21
+ /** Returns true when the endpoint is already allowlisted via --capture. */
22
+ export const isCapturedEndpoint = (captures, hostname, port) => captures.some((capture) => sameLoopbackEndpoint(capture.hostname, capture.port, hostname, port));
23
+ /** Formats a missing-capture origin as protocol://hostname:port. */
24
+ export const formatMissingCaptureOrigin = (protocol, hostname, port) => `${protocol}://${hostname}:${port}`;
25
+ /** Dedupe key for one missing-capture report per protocol://hostname:port. */
26
+ export const missingCaptureDedupeKey = (protocol, hostname, port) => {
27
+ const group = loopbackGroup(hostname);
28
+ const hostKey = group === "__loopback__" ? group : normalizeLoopbackHostname(hostname);
29
+ return `${protocol}://${hostKey}:${port}`;
30
+ };
31
+ /** Returns true when the request targets the internal events route. */
32
+ export const isLocalPreviewEventsPath = (requestPath) => {
33
+ const pathname = requestPath.split("?")[0] ?? requestPath;
34
+ return pathname === LOCALPREVIEW_EVENTS_PATH;
35
+ };
36
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
37
+ const normalizeHttpMethod = (value) => {
38
+ if (typeof value !== "string") {
39
+ return undefined;
40
+ }
41
+ const method = value.trim().toUpperCase();
42
+ return /^[!#$%&'*+\-.^_`|~0-9A-Z]+$/.test(method) ? method : undefined;
43
+ };
44
+ /** Strictly validates a parsed missing-capture event body. */
45
+ export const parseMissingCaptureEvent = (body) => {
46
+ if (!isRecord(body) || body.type !== "missing-capture" || !isRecord(body.payload)) {
47
+ return undefined;
48
+ }
49
+ const { payload } = body;
50
+ if (typeof payload.protocol !== "string" ||
51
+ typeof payload.hostname !== "string" ||
52
+ typeof payload.port !== "number" ||
53
+ typeof payload.transport !== "string") {
54
+ return undefined;
55
+ }
56
+ if (!isMissingCaptureProtocol(payload.protocol) ||
57
+ !isMissingCaptureTransport(payload.transport) ||
58
+ !Number.isInteger(payload.port) ||
59
+ payload.port < 1 ||
60
+ payload.port > 65_535 ||
61
+ !isLoopbackHost(payload.hostname)) {
62
+ return undefined;
63
+ }
64
+ const method = payload.method === undefined ? undefined : normalizeHttpMethod(payload.method);
65
+ if (payload.method !== undefined && method === undefined) {
66
+ return undefined;
67
+ }
68
+ return {
69
+ type: "missing-capture",
70
+ payload: {
71
+ hostname: normalizeLoopbackHostname(payload.hostname),
72
+ ...(method === undefined ? {} : { method }),
73
+ port: payload.port,
74
+ protocol: payload.protocol,
75
+ transport: payload.transport,
76
+ },
77
+ };
78
+ };
79
+ const formatCaptureFlagValue = (hostname, port) => hostname.includes(":") ? `[${hostname}]:${port}` : `${hostname}:${port}`;
80
+ const captureFlagExists = (argv, captures, hostname, port) => {
81
+ if (isCapturedEndpoint(captures, hostname, port)) {
82
+ return true;
83
+ }
84
+ for (let index = 0; index < argv.length; index += 1) {
85
+ const arg = argv[index];
86
+ if (arg !== "--capture") {
87
+ continue;
88
+ }
89
+ const value = argv[index + 1];
90
+ if (value === undefined) {
91
+ continue;
92
+ }
93
+ const separator = value.lastIndexOf(":");
94
+ if (separator <= 0) {
95
+ continue;
96
+ }
97
+ const captureHost = value.startsWith("[")
98
+ ? value.slice(0, value.indexOf("]") + 1)
99
+ : value.slice(0, separator);
100
+ const capturePort = Number(value.slice(separator + 1));
101
+ if (sameLoopbackEndpoint(captureHost, capturePort, hostname, port)) {
102
+ return true;
103
+ }
104
+ }
105
+ return false;
106
+ };
107
+ /** Builds a restart command preserving the original argv and appending --capture. */
108
+ export const buildSuggestedCaptureCommand = (originalArgv, legacy, captures, hostname, port) => {
109
+ const nextArgv = [...originalArgv];
110
+ if (!captureFlagExists(nextArgv, captures, hostname, port)) {
111
+ nextArgv.push("--capture", formatCaptureFlagValue(hostname, port));
112
+ }
113
+ const commandPrefix = legacy ? "localpreview" : "localpreview connect";
114
+ return `${commandPrefix} ${nextArgv.join(" ")}`.trim();
115
+ };
116
+ /** Returns true when a missing-capture event should produce a CLI warning. */
117
+ export const shouldReportMissingCapture = (event, primaryTarget, captures) => {
118
+ const { hostname, port } = event.payload;
119
+ if (isPrimaryTargetPort(primaryTarget, hostname, port)) {
120
+ return false;
121
+ }
122
+ if (isCapturedEndpoint(captures, hostname, port)) {
123
+ return false;
124
+ }
125
+ return true;
126
+ };
127
+ /** Formats a capture target for inclusion in warning text. */
128
+ export const formatMissingCaptureTarget = (capture) => formatCaptureOrigin(capture);
@@ -1,15 +1,24 @@
1
- import type { CaptureTarget, CreateTunnelResponse, TunnelTarget } from "@localpreview/protocol";
2
- import { Context, Effect, Layer } from "effect";
1
+ import { type CaptureTarget, type CreateTunnelResponse, type TunnelTarget } from "@localpreview/protocol";
2
+ import { Context, Deferred, 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, captures: ReadonlyArray<CaptureTarget>) => Effect.Effect<void, RelayConnectionError | RelayProtocolError>;
7
+ readonly connectAndServe: (tunnel: CreateTunnelResponse, target: TunnelTarget, captures: ReadonlyArray<CaptureTarget>, sessionMetadata: RelaySessionMetadata) => Effect.Effect<void, RelayConnectionError | RelayProtocolError>;
8
+ };
9
+ export type RelaySessionMetadata = {
10
+ readonly legacy: boolean;
11
+ readonly originalArgv: ReadonlyArray<string>;
8
12
  };
9
13
  declare const RelayClient_base: Context.ServiceClass<RelayClient, "RelayClient", RelayClientShape>;
10
14
  export declare class RelayClient extends RelayClient_base {
11
15
  }
12
16
  export declare const RelayClientLive: Layer.Layer<RelayClient, never, CliConfig | LocalProxy>;
17
+ export declare const waitForTunnelReady: (input: {
18
+ readonly ready: Deferred.Deferred<void, RelayConnectionError>;
19
+ readonly terminatedBeforeReady: Deferred.Deferred<void, RelayConnectionError>;
20
+ readonly timeoutMs: number;
21
+ }) => Effect.Effect<void, RelayConnectionError>;
13
22
  export declare const isRetryableCloseCode: (code: number) => boolean;
14
23
  export {};
15
24
  //# sourceMappingURL=relay-client.d.ts.map
@@ -1 +1 @@
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;AAG7E,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"}
1
+ {"version":3,"file":"relay-client.d.ts","sourceRoot":"","sources":["../src/relay-client.ts"],"names":[],"mappings":"AAAA,OAAO,EAA4B,KAAK,aAAa,EAAE,KAAK,oBAAoB,EAAE,KAAK,YAAY,EAAE,MAAM,wBAAwB,CAAC;AACpI,OAAO,EAAW,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAY,MAAM,QAAQ,CAAC;AAG7E,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,EACtC,eAAe,EAAE,oBAAoB,KAClC,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,oBAAoB,GAAG,kBAAkB,CAAC,CAAC;CACrE,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,QAAQ,CAAC,YAAY,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;CAC9C,CAAC;;AAEF,qBAAa,WAAY,SAAQ,gBAA+D;CAAG;AAEnG,eAAO,MAAM,eAAe,yDAU3B,CAAC;AA0OF,eAAO,MAAM,kBAAkB,GAAI,OAAO;IACxC,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC,QAAQ,CAAC,IAAI,EAAE,oBAAoB,CAAC,CAAC;IAC9D,QAAQ,CAAC,qBAAqB,EAAE,QAAQ,CAAC,QAAQ,CAAC,IAAI,EAAE,oBAAoB,CAAC,CAAC;IAC9E,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B,KAAG,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,oBAAoB,CAczC,CAAC;AAEJ,eAAO,MAAM,oBAAoB,GAAI,MAAM,MAAM,KAAG,OAAwB,CAAC"}
@@ -1,3 +1,4 @@
1
+ import { decodeServerRelayMessage } from "@localpreview/protocol";
1
2
  import { Console, Context, Deferred, Effect, Layer, Schedule } from "effect";
2
3
  import WebSocket from "ws";
3
4
  import { formatRelayReconnect, formatStatus } from "./cli-ui.js";
@@ -10,23 +11,32 @@ export const RelayClientLive = Layer.effect(RelayClient)(Effect.gen(function* ()
10
11
  const config = yield* CliConfig;
11
12
  const localProxy = yield* LocalProxy;
12
13
  return {
13
- connectAndServe: (tunnel, target, captures) => connectAndServe(config, localProxy, tunnel, target, captures),
14
+ connectAndServe: (tunnel, target, captures, sessionMetadata) => connectAndServe(config, localProxy, tunnel, target, captures, sessionMetadata),
14
15
  };
15
16
  }));
16
- const connectAndServe = (config, localProxy, tunnel, target, captures) => serveWithReconnect(config, localProxy, tunnel, target, captures);
17
- const serveWithReconnect = (config, localProxy, tunnel, target, captures) => serveOnce(config, localProxy, tunnel, target, captures).pipe(Effect.catch((error) => {
17
+ const connectAndServe = (config, localProxy, tunnel, target, captures, sessionMetadata) => serveWithReconnect(config, localProxy, tunnel, target, captures, sessionMetadata);
18
+ const serveWithReconnect = (config, localProxy, tunnel, target, captures, sessionMetadata) => serveOnce(config, localProxy, tunnel, target, captures, sessionMetadata).pipe(Effect.catch((error) => {
18
19
  if (error instanceof RelayConnectionError && error.retryable === true) {
19
- return Console.error(formatRelayReconnect(error.message)).pipe(Effect.andThen(Effect.sleep("1 second")), Effect.andThen(serveWithReconnect(config, localProxy, tunnel, target, captures)));
20
+ return Console.error(formatRelayReconnect(error.message)).pipe(Effect.andThen(Effect.sleep("1 second")), Effect.andThen(serveWithReconnect(config, localProxy, tunnel, target, captures, sessionMetadata)));
20
21
  }
21
22
  return Effect.fail(error);
22
23
  }));
23
- const serveOnce = (config, localProxy, tunnel, target, captures) => Effect.gen(function* () {
24
+ const serveOnce = (config, localProxy, tunnel, target, captures, sessionMetadata) => Effect.gen(function* () {
24
25
  const socket = yield* openSocket(tunnel).pipe(Effect.retry({
25
26
  schedule: Schedule.recurs(Math.ceil(config.relayConnectTimeoutMs / 250)),
26
27
  }));
27
28
  yield* Console.log(formatStatus("Connected to relay."));
28
- const session = { captures, target };
29
+ const session = {
30
+ captures,
31
+ legacy: sessionMetadata.legacy,
32
+ originalArgv: sessionMetadata.originalArgv,
33
+ reportedMissingCaptures: new Set(),
34
+ target,
35
+ };
29
36
  const done = yield* Deferred.make();
37
+ const ready = yield* Deferred.make();
38
+ const terminatedBeforeReady = yield* Deferred.make();
39
+ let tunnelReady = false;
30
40
  const handleSignal = () => {
31
41
  socket.close(1000, "Interrupted");
32
42
  Effect.runFork(Deferred.succeed(done, undefined));
@@ -36,6 +46,14 @@ const serveOnce = (config, localProxy, tunnel, target, captures) => Effect.gen(f
36
46
  process.once("SIGINT", handleSignal);
37
47
  process.once("SIGTERM", handleSignal);
38
48
  socket.on("message", (data) => {
49
+ const decoded = decodeServerRelayMessage(data.toString());
50
+ if (decoded.ok &&
51
+ decoded.message.type === "tunnel-ready" &&
52
+ decoded.message.tunnelId === tunnel.tunnelId) {
53
+ tunnelReady = true;
54
+ Effect.runFork(Deferred.succeed(ready, undefined));
55
+ return;
56
+ }
39
57
  const effect = localProxy.handleMessage(socket, session, data.toString()).pipe(Effect.catch((error) => Effect.gen(function* () {
40
58
  socket.close(1002, error.message);
41
59
  yield* Deferred.fail(done, error);
@@ -43,19 +61,23 @@ const serveOnce = (config, localProxy, tunnel, target, captures) => Effect.gen(f
43
61
  Effect.runFork(effect);
44
62
  });
45
63
  socket.on("error", (error) => {
46
- Effect.runFork(Deferred.fail(done, new RelayConnectionError({
64
+ const relayError = new RelayConnectionError({
47
65
  message: error.message,
48
- })));
66
+ });
67
+ Effect.runFork(Deferred.fail(done, relayError).pipe(Effect.andThen(Deferred.fail(terminatedBeforeReady, relayError))));
49
68
  });
50
69
  socket.on("close", (code, reason) => {
70
+ const relayError = new RelayConnectionError({
71
+ message: tunnelReady
72
+ ? `Relay connection closed (${code}): ${reason.toString()}`
73
+ : `Relay connection closed before tunnel-ready (${code}): ${reason.toString()}`,
74
+ retryable: isRetryableCloseCode(code),
75
+ });
51
76
  if (code === 1000 || code === 1001) {
52
- Effect.runFork(Deferred.succeed(done, undefined));
77
+ Effect.runFork(Deferred.fail(terminatedBeforeReady, relayError).pipe(Effect.andThen(Deferred.succeed(done, undefined))));
53
78
  return;
54
79
  }
55
- Effect.runFork(Deferred.fail(done, new RelayConnectionError({
56
- message: `Relay connection closed (${code}): ${reason.toString()}`,
57
- retryable: isRetryableCloseCode(code),
58
- })));
80
+ Effect.runFork(Deferred.fail(done, relayError).pipe(Effect.andThen(Deferred.fail(terminatedBeforeReady, relayError))));
59
81
  });
60
82
  }), () => Effect.sync(() => {
61
83
  process.off("SIGINT", handleSignal);
@@ -64,6 +86,13 @@ const serveOnce = (config, localProxy, tunnel, target, captures) => Effect.gen(f
64
86
  socket.close(1000, "Closing");
65
87
  }
66
88
  }));
89
+ yield* waitForTunnelReady({
90
+ ready,
91
+ terminatedBeforeReady,
92
+ timeoutMs: config.relayReadyTimeoutMs,
93
+ });
94
+ yield* Console.log(formatStatus(`Tunnel ready: ${tunnel.publicUrl}`));
95
+ yield* Console.log(formatStatus(`Forwarding to ${target.protocol}://${target.hostname}:${target.port}`));
67
96
  yield* Deferred.await(done);
68
97
  }));
69
98
  });
@@ -114,4 +143,10 @@ const openSocket = (tunnel) => Effect.callback((resume) => {
114
143
  }).pipe(Effect.mapError((error) => new RelayConnectionError({
115
144
  message: errorMessage(error),
116
145
  })));
146
+ export const waitForTunnelReady = (input) => Effect.raceFirst(Deferred.await(input.ready), Deferred.await(input.terminatedBeforeReady)).pipe(Effect.timeoutOrElse({
147
+ duration: `${input.timeoutMs} millis`,
148
+ orElse: () => Effect.fail(new RelayConnectionError({
149
+ message: "Timed out waiting for relay tunnel-ready acknowledgement.",
150
+ })),
151
+ }));
117
152
  export const isRetryableCloseCode = (code) => code === 1006;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "localpreview",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "bin": {
5
5
  "localpreview": "./dist/index.js"
6
6
  },
@@ -15,7 +15,7 @@
15
15
  "effect": "beta",
16
16
  "picocolors": "^1.1.1",
17
17
  "ws": "latest",
18
- "@localpreview/protocol": "0.2.2"
18
+ "@localpreview/protocol": "0.2.3"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@effect/vitest": "beta",