localpreview 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,182 @@
1
+ import { LOCALPREVIEW_PROTOCOL_VERSION, LOCALPREVIEW_RELAY_SNAPSHOT_VERSION, } from "@localpreview/protocol";
2
+ import { Context, Effect, Layer, Schedule } from "effect";
3
+ import { ControlPlaneError } from "./errors.js";
4
+ export class ControlPlaneClient extends Context.Service()("ControlPlaneClient") {
5
+ }
6
+ export const ControlPlaneClientLive = Layer.succeed(ControlPlaneClient)({
7
+ cleanAllSubdomains: (controlPlaneUrl, input) => cleanAllSubdomainsEffect(controlPlaneUrl, input),
8
+ cleanSubdomain: (controlPlaneUrl, input) => cleanSubdomainEffect(controlPlaneUrl, input),
9
+ closeTunnel: (controlPlaneUrl, tunnel) => closeTunnelEffect(controlPlaneUrl, tunnel).pipe(Effect.retry({
10
+ schedule: Schedule.recurs(2),
11
+ while: (error) => error.retryable === true,
12
+ })),
13
+ createTunnel: (controlPlaneUrl, body) => createTunnelEffect(controlPlaneUrl, body),
14
+ });
15
+ export const createTunnel = (controlPlaneUrl, body) => Effect.runPromise(createTunnelEffect(controlPlaneUrl, body));
16
+ export const closeTunnel = (controlPlaneUrl, tunnel) => Effect.runPromise(closeTunnelEffect(controlPlaneUrl, tunnel));
17
+ export const cleanSubdomain = (controlPlaneUrl, input) => Effect.runPromise(cleanSubdomainEffect(controlPlaneUrl, input));
18
+ export const cleanAllSubdomains = (controlPlaneUrl, input) => Effect.runPromise(cleanAllSubdomainsEffect(controlPlaneUrl, input));
19
+ const createTunnelEffect = (controlPlaneUrl, body) => Effect.promise(async () => {
20
+ let response;
21
+ try {
22
+ response = await fetch(new URL("/api/tunnels", controlPlaneUrl), {
23
+ body: JSON.stringify(body),
24
+ headers: {
25
+ "content-type": "application/json",
26
+ },
27
+ method: "POST",
28
+ });
29
+ }
30
+ catch {
31
+ throw new ControlPlaneError({
32
+ message: [
33
+ `Could not reach localpreview control-plane at ${controlPlaneUrl}.`,
34
+ 'Use "-l" or "--local" to target a local control-plane at http://localhost:3000.',
35
+ ].join("\n"),
36
+ retryable: false,
37
+ });
38
+ }
39
+ const json = await readJson(response, "Tunnel creation");
40
+ if (!response.ok) {
41
+ if (json.error?.code === "SUBDOMAIN_TAKEN") {
42
+ throw new ControlPlaneError({
43
+ message: `Subdomain "${json.error.subdomain ?? "requested"}" is already in use.`,
44
+ });
45
+ }
46
+ throw new ControlPlaneError({
47
+ message: json.error?.message ?? `Tunnel creation failed with HTTP ${response.status}.`,
48
+ });
49
+ }
50
+ return validateCreateTunnelResponse(json);
51
+ });
52
+ const closeTunnelEffect = (controlPlaneUrl, tunnel) => Effect.promise(async () => {
53
+ let response;
54
+ try {
55
+ response = await fetch(new URL(`/api/tunnels/${tunnel.tunnelId}`, controlPlaneUrl), {
56
+ headers: {
57
+ "x-localpreview-client-token": tunnel.clientToken,
58
+ },
59
+ method: "DELETE",
60
+ });
61
+ }
62
+ catch {
63
+ throw new ControlPlaneError({
64
+ message: `Could not clean up tunnel ${tunnel.tunnelId}.`,
65
+ retryable: true,
66
+ });
67
+ }
68
+ if (!response.ok) {
69
+ throw new ControlPlaneError({
70
+ message: `Tunnel cleanup failed with HTTP ${response.status}.`,
71
+ retryable: response.status >= 500,
72
+ });
73
+ }
74
+ });
75
+ const cleanSubdomainEffect = (controlPlaneUrl, input) => Effect.promise(async () => {
76
+ let response;
77
+ const url = new URL(`/api/subdomains/${encodeURIComponent(input.subdomain)}`, controlPlaneUrl);
78
+ if (input.force) {
79
+ url.searchParams.set("force", "1");
80
+ }
81
+ try {
82
+ response = await fetch(url, {
83
+ headers: {
84
+ "x-localpreview-admin-token": input.adminToken,
85
+ },
86
+ method: "DELETE",
87
+ });
88
+ }
89
+ catch {
90
+ throw new ControlPlaneError({
91
+ message: `Could not reach localpreview control-plane at ${controlPlaneUrl}.`,
92
+ retryable: false,
93
+ });
94
+ }
95
+ const json = await readJson(response, "Subdomain cleanup");
96
+ if (!response.ok) {
97
+ throw new ControlPlaneError({
98
+ message: json.error?.message ?? `Subdomain cleanup failed with HTTP ${response.status}.`,
99
+ });
100
+ }
101
+ return json;
102
+ });
103
+ const cleanAllSubdomainsEffect = (controlPlaneUrl, input) => Effect.promise(async () => {
104
+ let response;
105
+ const url = new URL("/api/subdomains", controlPlaneUrl);
106
+ if (input.force) {
107
+ url.searchParams.set("force", "1");
108
+ }
109
+ try {
110
+ response = await fetch(url, {
111
+ headers: {
112
+ "x-localpreview-admin-token": input.adminToken,
113
+ },
114
+ method: "DELETE",
115
+ });
116
+ }
117
+ catch {
118
+ throw new ControlPlaneError({
119
+ message: `Could not reach localpreview control-plane at ${controlPlaneUrl}.`,
120
+ retryable: false,
121
+ });
122
+ }
123
+ const json = await readJson(response, "Bulk subdomain cleanup");
124
+ if (!response.ok) {
125
+ if (json.error?.code === "TUNNEL_HOST_NOT_FOUND") {
126
+ throw new ControlPlaneError({
127
+ message: [
128
+ "The control-plane does not support bulk cleanup yet.",
129
+ "Deploy the updated control-plane, or use `localpreview clean <subdomain> --force` for a single tunnel.",
130
+ ].join(" "),
131
+ });
132
+ }
133
+ throw new ControlPlaneError({
134
+ message: json.error?.message ?? `Bulk subdomain cleanup failed with HTTP ${response.status}.`,
135
+ });
136
+ }
137
+ return json;
138
+ });
139
+ const requiredCreateTunnelStringFields = [
140
+ "tunnelId",
141
+ "subdomain",
142
+ "publicUrl",
143
+ "relayWsUrl",
144
+ "clientToken",
145
+ "expiresAt",
146
+ "protocolVersion",
147
+ "relaySnapshotVersion",
148
+ ];
149
+ const validateCreateTunnelResponse = (json) => {
150
+ for (const field of requiredCreateTunnelStringFields) {
151
+ if (typeof json[field] !== "string" || json[field].length === 0) {
152
+ throw new ControlPlaneError({
153
+ message: [
154
+ `Tunnel creation returned an invalid response; missing ${field}.`,
155
+ "This usually means the control-plane is running an older LocalPreview protocol.",
156
+ ].join(" "),
157
+ });
158
+ }
159
+ }
160
+ if (json.protocolVersion !== LOCALPREVIEW_PROTOCOL_VERSION ||
161
+ json.relaySnapshotVersion !== LOCALPREVIEW_RELAY_SNAPSHOT_VERSION) {
162
+ throw new ControlPlaneError({
163
+ message: [
164
+ "LocalPreview protocol mismatch:",
165
+ `expected protocol ${LOCALPREVIEW_PROTOCOL_VERSION}, got ${json.protocolVersion};`,
166
+ `expected relay snapshot ${LOCALPREVIEW_RELAY_SNAPSHOT_VERSION}, got ${json.relaySnapshotVersion}.`,
167
+ "Rebuild and promote a relay snapshot with pnpm relay:snapshot.",
168
+ ].join(" "),
169
+ });
170
+ }
171
+ return json;
172
+ };
173
+ const readJson = async (response, operation) => {
174
+ try {
175
+ return (await response.json());
176
+ }
177
+ catch {
178
+ throw new ControlPlaneError({
179
+ message: `${operation} failed with HTTP ${response.status}; control-plane returned an invalid JSON response.`,
180
+ });
181
+ }
182
+ };
@@ -0,0 +1,58 @@
1
+ declare const CliUsageError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
2
+ readonly _tag: "CliUsageError";
3
+ } & Readonly<A>;
4
+ export declare class CliUsageError extends CliUsageError_base<{
5
+ readonly message: string;
6
+ }> {
7
+ }
8
+ declare const CliConfigError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
9
+ readonly _tag: "CliConfigError";
10
+ } & Readonly<A>;
11
+ export declare class CliConfigError extends CliConfigError_base<{
12
+ readonly message: string;
13
+ }> {
14
+ }
15
+ declare const ControlPlaneError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
16
+ readonly _tag: "ControlPlaneError";
17
+ } & Readonly<A>;
18
+ export declare class ControlPlaneError extends ControlPlaneError_base<{
19
+ readonly message: string;
20
+ readonly retryable?: boolean;
21
+ }> {
22
+ }
23
+ declare const RelayConnectionError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
24
+ readonly _tag: "RelayConnectionError";
25
+ } & Readonly<A>;
26
+ export declare class RelayConnectionError extends RelayConnectionError_base<{
27
+ readonly message: string;
28
+ readonly retryable?: boolean;
29
+ }> {
30
+ }
31
+ declare const RelayProtocolError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
32
+ readonly _tag: "RelayProtocolError";
33
+ } & Readonly<A>;
34
+ export declare class RelayProtocolError extends RelayProtocolError_base<{
35
+ readonly message: string;
36
+ }> {
37
+ }
38
+ declare const LocalRequestError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
39
+ readonly _tag: "LocalRequestError";
40
+ } & Readonly<A>;
41
+ export declare class LocalRequestError extends LocalRequestError_base<{
42
+ readonly message: string;
43
+ readonly requestId: string;
44
+ }> {
45
+ }
46
+ declare const RequestLimitError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
47
+ readonly _tag: "RequestLimitError";
48
+ } & Readonly<A>;
49
+ export declare class RequestLimitError extends RequestLimitError_base<{
50
+ readonly limitBytes: number;
51
+ readonly message: string;
52
+ readonly requestId: string;
53
+ }> {
54
+ }
55
+ export type CliRuntimeError = CliConfigError | ControlPlaneError | RelayConnectionError | RelayProtocolError | LocalRequestError | RequestLimitError;
56
+ export declare const errorMessage: (error: unknown) => string;
57
+ export {};
58
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +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"}
package/dist/errors.js ADDED
@@ -0,0 +1,21 @@
1
+ import { Data } from "effect";
2
+ export class CliUsageError extends Data.TaggedError("CliUsageError") {
3
+ }
4
+ export class CliConfigError extends Data.TaggedError("CliConfigError") {
5
+ }
6
+ export class ControlPlaneError extends Data.TaggedError("ControlPlaneError") {
7
+ }
8
+ export class RelayConnectionError extends Data.TaggedError("RelayConnectionError") {
9
+ }
10
+ export class RelayProtocolError extends Data.TaggedError("RelayProtocolError") {
11
+ }
12
+ export class LocalRequestError extends Data.TaggedError("LocalRequestError") {
13
+ }
14
+ export class RequestLimitError extends Data.TaggedError("RequestLimitError") {
15
+ }
16
+ export const errorMessage = (error) => {
17
+ if (typeof error === "object" && error !== null && "message" in error) {
18
+ return String(error.message);
19
+ }
20
+ return "Unknown failure.";
21
+ };
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":""}
package/dist/index.js ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
3
+ import { Console, Effect } from "effect";
4
+ import { runCli } from "./command.js";
5
+ import { errorMessage } from "./errors.js";
6
+ const argv = process.argv.slice(2);
7
+ const program = runCli(argv).pipe(Effect.catch((error) => Console.error(errorMessage(error)).pipe(Effect.andThen(Effect.sync(() => {
8
+ process.exitCode =
9
+ typeof error === "object" &&
10
+ error !== null &&
11
+ "_tag" in error &&
12
+ error._tag === "CliUsageError"
13
+ ? 2
14
+ : 1;
15
+ })))));
16
+ NodeRuntime.runMain(program);
@@ -0,0 +1,15 @@
1
+ import { type HeaderPairs, type TunnelTarget } from "@localpreview/protocol";
2
+ import { Context, Effect, Layer } from "effect";
3
+ import type WebSocket from "ws";
4
+ import { CliConfig } from "./config.js";
5
+ import { RelayProtocolError } from "./errors.js";
6
+ export type LocalProxyShape = {
7
+ readonly handleMessage: (socket: WebSocket, target: TunnelTarget, input: string) => Effect.Effect<void, RelayProtocolError>;
8
+ };
9
+ declare const LocalProxy_base: Context.ServiceClass<LocalProxy, "LocalProxy", LocalProxyShape>;
10
+ export declare class LocalProxy extends LocalProxy_base {
11
+ }
12
+ export declare const LocalProxyLive: Layer.Layer<LocalProxy, never, CliConfig>;
13
+ export declare const filterLocalResponseHeaders: (headers: Headers) => HeaderPairs;
14
+ export {};
15
+ //# sourceMappingURL=local-proxy.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,214 @@
1
+ import { decodeServerRelayMessage, encodeRelayMessage, filterEndToEndHeaderPairs, } from "@localpreview/protocol";
2
+ import { Console, Context, Effect, Fiber, Layer, Ref } from "effect";
3
+ import { CliConfig } from "./config.js";
4
+ import { LocalRequestError, RelayProtocolError, RequestLimitError, errorMessage, } from "./errors.js";
5
+ export class LocalProxy extends Context.Service()("LocalProxy") {
6
+ }
7
+ export const LocalProxyLive = Layer.effect(LocalProxy)(Effect.gen(function* () {
8
+ const config = yield* CliConfig;
9
+ const requests = yield* Ref.make(new Map());
10
+ return {
11
+ handleMessage: (socket, target, input) => handleMessage(config, requests, socket, target, input),
12
+ };
13
+ }));
14
+ const handleMessage = (config, requests, socket, target, input) => Effect.gen(function* () {
15
+ const decoded = decodeServerRelayMessage(input);
16
+ if (!decoded.ok) {
17
+ return yield* Effect.fail(new RelayProtocolError({
18
+ message: `Received an invalid relay request message: ${errorMessage(decoded.error)}`,
19
+ }));
20
+ }
21
+ const message = decoded.message;
22
+ switch (message.type) {
23
+ case "request-start":
24
+ return yield* handleRequestStart(config, requests, socket, message);
25
+ case "request-chunk":
26
+ return yield* handleRequestChunk(config, requests, socket, message);
27
+ case "request-end":
28
+ return yield* handleRequestEnd(config, requests, socket, target, message);
29
+ case "cancel":
30
+ return yield* handleCancel(requests, message.requestId);
31
+ }
32
+ });
33
+ const handleRequestStart = (config, requests, socket, message) => Effect.gen(function* () {
34
+ const current = yield* Ref.get(requests);
35
+ if (current.size >= config.maxInFlightRequests) {
36
+ yield* send(socket, {
37
+ message: `Too many in-flight requests; limit is ${config.maxInFlightRequests}.`,
38
+ requestId: message.requestId,
39
+ type: "response-error",
40
+ });
41
+ return;
42
+ }
43
+ const next = new Map(current);
44
+ next.set(message.requestId, {
45
+ chunks: [],
46
+ headers: message.headers,
47
+ method: message.method,
48
+ path: message.path,
49
+ totalBytes: 0,
50
+ });
51
+ yield* Ref.set(requests, next);
52
+ yield* Console.log(`-> ${message.method} ${logPath(message.path)}`);
53
+ });
54
+ const handleRequestChunk = (config, requests, socket, message) => Effect.gen(function* () {
55
+ const current = yield* Ref.get(requests);
56
+ const request = current.get(message.requestId);
57
+ if (request === undefined) {
58
+ return;
59
+ }
60
+ const chunk = Buffer.from(message.chunkBase64, "base64");
61
+ const totalBytes = request.totalBytes + chunk.length;
62
+ if (totalBytes > config.requestBodyLimitBytes) {
63
+ const next = new Map(current);
64
+ next.delete(message.requestId);
65
+ yield* Ref.set(requests, next);
66
+ yield* sendRequestError(socket, message.requestId, {
67
+ limitBytes: config.requestBodyLimitBytes,
68
+ message: "Request body is too large.",
69
+ });
70
+ return;
71
+ }
72
+ const next = new Map(current);
73
+ next.set(message.requestId, {
74
+ ...request,
75
+ chunks: [...request.chunks, chunk],
76
+ totalBytes,
77
+ });
78
+ yield* Ref.set(requests, next);
79
+ });
80
+ const handleRequestEnd = (config, requests, socket, target, message) => Effect.gen(function* () {
81
+ const current = yield* Ref.get(requests);
82
+ const request = current.get(message.requestId);
83
+ if (request === undefined) {
84
+ yield* send(socket, {
85
+ message: "Request ended without a matching start message.",
86
+ requestId: message.requestId,
87
+ type: "response-error",
88
+ });
89
+ return;
90
+ }
91
+ const fiber = yield* proxyToLocalTarget(config, socket, target, message.requestId, request).pipe(Effect.catch((error) => send(socket, {
92
+ message: error.message,
93
+ requestId: message.requestId,
94
+ type: "response-error",
95
+ }).pipe(Effect.andThen(Console.log(`<- error ${logPath(request.path)} ${error.message}`)))), Effect.ensuring(Ref.update(requests, (map) => {
96
+ const next = new Map(map);
97
+ next.delete(message.requestId);
98
+ return next;
99
+ })), Effect.forkDetach);
100
+ const next = new Map(current);
101
+ next.set(message.requestId, { ...request, fiber });
102
+ yield* Ref.set(requests, next);
103
+ });
104
+ const handleCancel = (requests, requestId) => Effect.gen(function* () {
105
+ const current = yield* Ref.get(requests);
106
+ const request = current.get(requestId);
107
+ if (request?.fiber !== undefined) {
108
+ yield* Fiber.interrupt(request.fiber);
109
+ }
110
+ const next = new Map(current);
111
+ next.delete(requestId);
112
+ yield* Ref.set(requests, next);
113
+ });
114
+ const proxyToLocalTarget = (config, socket, target, requestId, request) => Effect.gen(function* () {
115
+ const startedAt = Date.now();
116
+ const targetUrl = new URL(request.path, `${target.protocol}://${target.hostname}:${target.port}`);
117
+ const headers = new Headers(filterEndToEndHeaderPairs(request.headers).map(([name, value]) => [
118
+ name,
119
+ value,
120
+ ]));
121
+ headers.set("accept-encoding", "identity");
122
+ headers.set("host", `${target.hostname}:${target.port}`);
123
+ const body = Buffer.concat(request.chunks);
124
+ const init = {
125
+ headers,
126
+ method: request.method,
127
+ };
128
+ if (request.method !== "GET" && request.method !== "HEAD") {
129
+ init.body = body;
130
+ }
131
+ const response = yield* Effect.promise(() => fetch(targetUrl, init)).pipe(timeoutFail(config.requestTimeoutMs, new LocalRequestError({
132
+ message: "Local request timed out.",
133
+ requestId,
134
+ })), Effect.mapError((error) => error instanceof LocalRequestError
135
+ ? error
136
+ : new LocalRequestError({
137
+ message: errorMessage(error),
138
+ requestId,
139
+ })));
140
+ const responseBody = yield* Effect.promise(() => response.arrayBuffer()).pipe(timeoutFail(config.requestTimeoutMs, new LocalRequestError({
141
+ message: "Local request timed out.",
142
+ requestId,
143
+ })), Effect.map((arrayBuffer) => Buffer.from(arrayBuffer)), Effect.mapError((error) => error instanceof LocalRequestError
144
+ ? error
145
+ : new LocalRequestError({
146
+ message: errorMessage(error),
147
+ requestId,
148
+ })));
149
+ if (responseBody.length > config.responseBodyLimitBytes) {
150
+ return yield* Effect.fail(new RequestLimitError({
151
+ limitBytes: config.responseBodyLimitBytes,
152
+ message: "Response body is too large.",
153
+ requestId,
154
+ }));
155
+ }
156
+ yield* send(socket, {
157
+ headers: filterLocalResponseHeaders(response.headers),
158
+ requestId,
159
+ status: response.status,
160
+ type: "response-start",
161
+ });
162
+ for (let offset = 0; offset < responseBody.length; offset += config.responseChunkSizeBytes) {
163
+ const chunk = responseBody.subarray(offset, offset + config.responseChunkSizeBytes);
164
+ yield* send(socket, {
165
+ chunkBase64: chunk.toString("base64"),
166
+ requestId,
167
+ type: "response-chunk",
168
+ });
169
+ }
170
+ yield* send(socket, {
171
+ requestId,
172
+ type: "response-end",
173
+ });
174
+ yield* Console.log(`<- ${response.status} ${logPath(request.path)} ${Date.now() - startedAt}ms`);
175
+ });
176
+ const sendRequestError = (socket, requestId, error) => send(socket, {
177
+ message: `${error.message} Limit is ${error.limitBytes} bytes.`,
178
+ requestId,
179
+ type: "response-error",
180
+ });
181
+ const send = (socket, message) => Effect.sync(() => {
182
+ if (socket.readyState === socket.OPEN) {
183
+ socket.send(encodeRelayMessage(message));
184
+ }
185
+ });
186
+ export const filterLocalResponseHeaders = (headers) => {
187
+ const setCookies = getSetCookies(headers);
188
+ const pairs = [];
189
+ for (const [name, value] of headers.entries()) {
190
+ const lowerName = name.toLowerCase();
191
+ if (lowerName !== "set-cookie" &&
192
+ lowerName !== "content-encoding" &&
193
+ lowerName !== "content-length") {
194
+ pairs.push([name, value]);
195
+ }
196
+ }
197
+ for (const cookie of setCookies) {
198
+ pairs.push(["set-cookie", cookie]);
199
+ }
200
+ return filterEndToEndHeaderPairs(pairs);
201
+ };
202
+ const getSetCookies = (headers) => {
203
+ const withSetCookie = headers;
204
+ return withSetCookie.getSetCookie?.() ?? [];
205
+ };
206
+ const logPath = (path) => {
207
+ try {
208
+ return new URL(path, "http://localpreview.local").pathname;
209
+ }
210
+ catch {
211
+ return path.split("?")[0] ?? path;
212
+ }
213
+ };
214
+ const timeoutFail = (millis, error) => (effect) => Effect.raceFirst(effect, Effect.sleep(`${millis} millis`).pipe(Effect.andThen(Effect.fail(error))));
@@ -0,0 +1,15 @@
1
+ import type { CreateTunnelResponse, TunnelTarget } from "@localpreview/protocol";
2
+ import { Context, Effect, Layer } from "effect";
3
+ import { CliConfig } from "./config.js";
4
+ import { RelayConnectionError, RelayProtocolError } from "./errors.js";
5
+ import { LocalProxy } from "./local-proxy.js";
6
+ export type RelayClientShape = {
7
+ readonly connectAndServe: (tunnel: CreateTunnelResponse, target: TunnelTarget) => Effect.Effect<void, RelayConnectionError | RelayProtocolError>;
8
+ };
9
+ declare const RelayClient_base: Context.ServiceClass<RelayClient, "RelayClient", RelayClientShape>;
10
+ export declare class RelayClient extends RelayClient_base {
11
+ }
12
+ export declare const RelayClientLive: Layer.Layer<RelayClient, never, LocalProxy | CliConfig>;
13
+ export declare const isRetryableCloseCode: (code: number) => boolean;
14
+ export {};
15
+ //# sourceMappingURL=relay-client.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,115 @@
1
+ import { Console, Context, Deferred, Effect, Layer, Schedule } from "effect";
2
+ import WebSocket from "ws";
3
+ import { CliConfig } from "./config.js";
4
+ import { RelayConnectionError, RelayProtocolError, errorMessage } from "./errors.js";
5
+ import { LocalProxy } from "./local-proxy.js";
6
+ export class RelayClient extends Context.Service()("RelayClient") {
7
+ }
8
+ export const RelayClientLive = Layer.effect(RelayClient)(Effect.gen(function* () {
9
+ const config = yield* CliConfig;
10
+ const localProxy = yield* LocalProxy;
11
+ return {
12
+ connectAndServe: (tunnel, target) => connectAndServe(config, localProxy, tunnel, target),
13
+ };
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) => {
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)));
19
+ }
20
+ return Effect.fail(error);
21
+ }));
22
+ const serveOnce = (config, localProxy, tunnel, target) => Effect.gen(function* () {
23
+ const socket = yield* openSocket(tunnel).pipe(Effect.retry({
24
+ schedule: Schedule.recurs(Math.ceil(config.relayConnectTimeoutMs / 250)),
25
+ }));
26
+ yield* Console.log("Connected to relay.");
27
+ const done = yield* Deferred.make();
28
+ const handleSignal = () => {
29
+ socket.close(1000, "Interrupted");
30
+ Effect.runFork(Deferred.succeed(done, undefined));
31
+ };
32
+ yield* Effect.scoped(Effect.gen(function* () {
33
+ yield* Effect.acquireRelease(Effect.sync(() => {
34
+ process.once("SIGINT", handleSignal);
35
+ process.once("SIGTERM", handleSignal);
36
+ socket.on("message", (data) => {
37
+ const effect = localProxy.handleMessage(socket, target, data.toString()).pipe(Effect.catch((error) => Effect.gen(function* () {
38
+ socket.close(1002, error.message);
39
+ yield* Deferred.fail(done, error);
40
+ })));
41
+ Effect.runFork(effect);
42
+ });
43
+ socket.on("error", (error) => {
44
+ Effect.runFork(Deferred.fail(done, new RelayConnectionError({
45
+ message: error.message,
46
+ })));
47
+ });
48
+ socket.on("close", (code, reason) => {
49
+ if (code === 1000 || code === 1001) {
50
+ Effect.runFork(Deferred.succeed(done, undefined));
51
+ return;
52
+ }
53
+ Effect.runFork(Deferred.fail(done, new RelayConnectionError({
54
+ message: `Relay connection closed (${code}): ${reason.toString()}`,
55
+ retryable: isRetryableCloseCode(code),
56
+ })));
57
+ });
58
+ }), () => Effect.sync(() => {
59
+ process.off("SIGINT", handleSignal);
60
+ process.off("SIGTERM", handleSignal);
61
+ if (socket.readyState === socket.OPEN || socket.readyState === socket.CONNECTING) {
62
+ socket.close(1000, "Closing");
63
+ }
64
+ }));
65
+ yield* Deferred.await(done);
66
+ }));
67
+ });
68
+ const openSocket = (tunnel) => Effect.callback((resume) => {
69
+ const relayUrl = new URL(tunnel.relayWsUrl);
70
+ relayUrl.searchParams.set("tunnelId", tunnel.tunnelId);
71
+ relayUrl.searchParams.set("token", tunnel.clientToken);
72
+ const socket = new WebSocket(relayUrl);
73
+ let opened = false;
74
+ const cleanup = () => {
75
+ socket.off("open", onOpen);
76
+ socket.off("error", onError);
77
+ socket.off("close", onClose);
78
+ };
79
+ const onOpen = () => {
80
+ opened = true;
81
+ cleanup();
82
+ resume(Effect.succeed(socket));
83
+ };
84
+ const onError = (error) => {
85
+ if (opened) {
86
+ return;
87
+ }
88
+ cleanup();
89
+ resume(Effect.fail(new RelayConnectionError({
90
+ message: error.message,
91
+ })));
92
+ };
93
+ const onClose = (code, reason) => {
94
+ if (opened) {
95
+ return;
96
+ }
97
+ cleanup();
98
+ resume(Effect.fail(new RelayConnectionError({
99
+ message: `Relay connection closed before open (${code}): ${reason.toString()}`,
100
+ retryable: isRetryableCloseCode(code),
101
+ })));
102
+ };
103
+ socket.once("open", onOpen);
104
+ socket.once("error", onError);
105
+ socket.once("close", onClose);
106
+ return Effect.sync(() => {
107
+ cleanup();
108
+ if (!opened) {
109
+ socket.close(1000, "Opening cancelled");
110
+ }
111
+ });
112
+ }).pipe(Effect.mapError((error) => new RelayConnectionError({
113
+ message: errorMessage(error),
114
+ })));
115
+ export const isRetryableCloseCode = (code) => code === 1006;