@absolutejs/absolute 0.20.0-beta.164 → 0.20.0-beta.165

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,12 @@ This file is generated by `absolute-changelog` from the entries in
6
6
  `changelog/`. Edit an entry, not this file — and add new ones under
7
7
  `changelog/unreleased/`.
8
8
 
9
+ ## 0.20.0-beta.165 — 2026-11-05
10
+
11
+ ### Added
12
+
13
+ - **Expose a server-side tunnel relay entry point with exact callback route restrictions and optional hosting liveness endpoint**
14
+
9
15
  ## 0.20.0-beta.164 — 2026-11-02
10
16
 
11
17
  ### Fixed
package/changelog.json CHANGED
@@ -2,6 +2,16 @@
2
2
  "contract": 1,
3
3
  "name": "@absolutejs/absolute",
4
4
  "releases": [
5
+ {
6
+ "changes": [
7
+ {
8
+ "kind": "added",
9
+ "summary": "Expose a server-side tunnel relay entry point with exact callback route restrictions and optional hosting liveness endpoint"
10
+ }
11
+ ],
12
+ "date": "2026-11-05",
13
+ "version": "0.20.0-beta.165"
14
+ },
5
15
  {
6
16
  "changes": [
7
17
  {
@@ -290,10 +300,10 @@
290
300
  {
291
301
  "changes": [
292
302
  {
293
- "detail": "Elysia 2.0.0-rc.0 renamed every lifecycle hook to its `on` form (request → onRequest, beforeHandle → onBeforeHandle, afterHandle → onAfterHandle, afterResponse → onAfterResponse, error → onError, parse → onParse, transform → onTransform) and removed setup/cleanup in favour of onStart/onStop. The framework now builds, tests and ships against Elysia 2.0.0-rc.0 and the matching @elysia plugins, and requires `elysia ^2.0.0-rc.0`, so it also accepts later release candidates and 2.0.0. It moves with the packages that made the same change: @absolutejs/auth 0.101 and @absolutejs/sync 3, plus pwa, sync-capacitor and sync-expo releases that accept sync 3.",
303
+ "detail": "Elysia 2.0.0-rc.0 renamed every lifecycle hook to its `on` form (request \u2192 onRequest, beforeHandle \u2192 onBeforeHandle, afterHandle \u2192 onAfterHandle, afterResponse \u2192 onAfterResponse, error \u2192 onError, parse \u2192 onParse, transform \u2192 onTransform) and removed setup/cleanup in favour of onStart/onStop. The framework now builds, tests and ships against Elysia 2.0.0-rc.0 and the matching @elysia plugins, and requires `elysia ^2.0.0-rc.0`, so it also accepts later release candidates and 2.0.0. It moves with the packages that made the same change: @absolutejs/auth 0.101 and @absolutejs/sync 3, plus pwa, sync-capacitor and sync-expo releases that accept sync 3.",
294
304
  "kind": "breaking",
295
305
  "migration": {
296
- "instruction": "Upgrade `elysia` and every `@elysia/*` package to 2.0.0-rc.0 or later, `@absolutejs/auth` to 0.101 or later and `@absolutejs/sync` to 3. Then rename lifecycle hooks in your server: request → onRequest, beforeHandle → onBeforeHandle, afterHandle → onAfterHandle, afterResponse → onAfterResponse, error → onError, parse → onParse, transform → onTransform; replace setup/cleanup with onStart/onStop. The codemod at absolutejs/scripts codemods/elysia-rc-hooks.ts does the renames (run it before upgrading Elysia).",
306
+ "instruction": "Upgrade `elysia` and every `@elysia/*` package to 2.0.0-rc.0 or later, `@absolutejs/auth` to 0.101 or later and `@absolutejs/sync` to 3. Then rename lifecycle hooks in your server: request \u2192 onRequest, beforeHandle \u2192 onBeforeHandle, afterHandle \u2192 onAfterHandle, afterResponse \u2192 onAfterResponse, error \u2192 onError, parse \u2192 onParse, transform \u2192 onTransform; replace setup/cleanup with onStart/onStop. The codemod at absolutejs/scripts codemods/elysia-rc-hooks.ts does the renames (run it before upgrading Elysia).",
297
307
  "manual": true
298
308
  },
299
309
  "summary": "Requires Elysia 2.0.0-rc.0, @absolutejs/auth 0.101 and @absolutejs/sync 3",
@@ -314,7 +324,7 @@
314
324
  "summary": "Browser test recordings and reports no longer restart absolute dev"
315
325
  },
316
326
  {
317
- "detail": "An app that imports one of its own workspace packages (`\"@acme/core\": \"workspace:*\"`) did not run under `absolute dev`. With Bun's isolated linker, the default for a workspace, the dev build imported the package from the framework's own folder, could not find it, and the server crash-looped. With the hoisted linker the page rendered but never hydrated: modules outside the app's folder were addressed as `/@src/../…`, which a browser collapses before asking, so they 404ed. Now a package that resolves to source outside node_modules is the app's own code: served and transpiled like the app's files rather than vendored, its folder watched, and an edit to it hot-reloads in place (server and browser) instead of restarting the server or reloading the page. Packages are read from the app's folder, so any dependency only the app declares also works under the isolated linker, and the dev server serves files outside the app folder (a workspace package, a node_modules hoisted to the workspace root) through `/@src/@up/…`. A `dev.watchDirs` entry or a symlink written to work around this is no longer needed.",
327
+ "detail": "An app that imports one of its own workspace packages (`\"@acme/core\": \"workspace:*\"`) did not run under `absolute dev`. With Bun's isolated linker, the default for a workspace, the dev build imported the package from the framework's own folder, could not find it, and the server crash-looped. With the hoisted linker the page rendered but never hydrated: modules outside the app's folder were addressed as `/@src/../\u2026`, which a browser collapses before asking, so they 404ed. Now a package that resolves to source outside node_modules is the app's own code: served and transpiled like the app's files rather than vendored, its folder watched, and an edit to it hot-reloads in place (server and browser) instead of restarting the server or reloading the page. Packages are read from the app's folder, so any dependency only the app declares also works under the isolated linker, and the dev server serves files outside the app folder (a workspace package, a node_modules hoisted to the workspace root) through `/@src/@up/\u2026`. A `dev.watchDirs` entry or a symlink written to work around this is no longer needed.",
318
328
  "kind": "fixed",
319
329
  "summary": "`absolute dev` runs apps that import their own workspace packages, under Bun's isolated or hoisted linker, with hot reload"
320
330
  },
@@ -353,7 +363,7 @@
353
363
  {
354
364
  "detail": "Production builds bundled React into its vendor files without setting `process.env.NODE_ENV`, so React picked its development build: `react-dom_client.js` was 903 KB, and React ran its development checks in every production page. Production builds now set `process.env.NODE_ENV` to `\"production\"`: 400 KB, 81 KB gzipped. Only whitespace is minified; renaming identifiers or minifying syntax across the split vendor chunks makes Bun emit an export named `__INVALID__REF__`, which stopped compiled executables from hydrating. `absolute dev` is unchanged; Fast Refresh needs the development build.",
355
365
  "kind": "fixed",
356
- "summary": "Production builds ship React's production build (react-dom 903 KB → 400 KB, no development checks)"
366
+ "summary": "Production builds ship React's production build (react-dom 903 KB \u2192 400 KB, no development checks)"
357
367
  },
358
368
  {
359
369
  "detail": "React Fast Refresh updates the components it registered. Editing a module that registers none (a class component, or a `memo`/`forwardRef` wrapper the transpiler does not register) re-ran only that module, and its importer kept rendering the old one, so the edit never showed until a reload. The update now also re-imports the module's importers up to the nearest one that registers a component, so the edit appears and the rest of the page keeps its state.",
@@ -527,7 +537,7 @@
527
537
  "summary": "The dev server starts when a server module re-exports with inline `type` specifiers"
528
538
  },
529
539
  {
530
- "detail": "In 0.20.0-beta.129 the published package bundles the dev bootstrap separately from the code that reacts to file changes, and each bundle kept its own copy of the hot runtime's state. The file-change side never saw what the bootstrap had installed, so every server edit was reported as not hot-managed and restarted the server, which is the behaviour backend HMR was meant to remove. The state now lives on `globalThis`, so every bundle shares one runtime. A server edit now updates in place in the published package as well, at about 50–250ms for a handler and about 150–300ms for a plugin or the server entry on a large app.",
540
+ "detail": "In 0.20.0-beta.129 the published package bundles the dev bootstrap separately from the code that reacts to file changes, and each bundle kept its own copy of the hot runtime's state. The file-change side never saw what the bootstrap had installed, so every server edit was reported as not hot-managed and restarted the server, which is the behaviour backend HMR was meant to remove. The state now lives on `globalThis`, so every bundle shares one runtime. A server edit now updates in place in the published package as well, at about 50\u2013250ms for a handler and about 150\u2013300ms for a plugin or the server entry on a large app.",
531
541
  "kind": "fixed",
532
542
  "summary": "Server edits update in place in the published package instead of restarting"
533
543
  },
@@ -548,7 +558,7 @@
548
558
  {
549
559
  "changes": [
550
560
  {
551
- "detail": "Editing a server module in development used to restart the whole server: a second or more of downtime, every open page dropped, every connection, pool, queue worker and in-memory cache rebuilt. Server modules are now updated in place, statement by statement. A save re-runs only the top-level statements whose own text changed, those that use something that changed, and the importer statements that call a changed export; everything else (database pools, workers, caches, `let` state) is kept as it was. React pages and components the server imports for SSR are updated the same way, so the next request renders the edit. A handler edit typically lands in 1–50ms with nothing else re-run, and exactly one update is applied per save. What a replaced statement started is stopped before its replacement runs: intervals, timeouts and timer chains (cron libraries included), immediates, `process` listeners, extra `Bun.serve` servers (a fixed port is handed over), workers and `onHotDispose` callbacks. Work started by requests and WebSocket events is never stopped. Rebuilding the Elysia app swaps it onto the running server and runs the old app's `cleanup` and the new app's `setup` hooks, as a stop followed by a listen would. An edit that fails to parse or throws while running keeps the previous version serving, and everything it started is rolled back; the fix applies normally. Set `ABSOLUTE_BACKEND_HMR=0` to restart on server edits as before. See docs/BACKEND_HMR.md.",
561
+ "detail": "Editing a server module in development used to restart the whole server: a second or more of downtime, every open page dropped, every connection, pool, queue worker and in-memory cache rebuilt. Server modules are now updated in place, statement by statement. A save re-runs only the top-level statements whose own text changed, those that use something that changed, and the importer statements that call a changed export; everything else (database pools, workers, caches, `let` state) is kept as it was. React pages and components the server imports for SSR are updated the same way, so the next request renders the edit. A handler edit typically lands in 1\u201350ms with nothing else re-run, and exactly one update is applied per save. What a replaced statement started is stopped before its replacement runs: intervals, timeouts and timer chains (cron libraries included), immediates, `process` listeners, extra `Bun.serve` servers (a fixed port is handed over), workers and `onHotDispose` callbacks. Work started by requests and WebSocket events is never stopped. Rebuilding the Elysia app swaps it onto the running server and runs the old app's `cleanup` and the new app's `setup` hooks, as a stop followed by a listen would. An edit that fails to parse or throws while running keeps the previous version serving, and everything it started is rolled back; the fix applies normally. Set `ABSOLUTE_BACKEND_HMR=0` to restart on server edits as before. See docs/BACKEND_HMR.md.",
552
562
  "kind": "added",
553
563
  "summary": "Server code edits in development update the running server in place instead of restarting it",
554
564
  "symbols": [
@@ -592,7 +602,7 @@
592
602
  {
593
603
  "changes": [
594
604
  {
595
- "detail": "When a page failed its on-demand build in development, the handler threw `Asset \"<Page>\" not found in manifest.` and left the response to whichever error handler answered first. An app with its own `onError` (most real apps) showed its generic error page, so the actual cause — a missing import, a syntax error — was only in the terminal. Page handlers for Vue, React, Svelte, Angular and Ember now return a Build Error page themselves, with the build's own error message, and the page reloads by itself once a rebuild lands or the dev server comes back. AbsoluteJS's fallback error page for missing assets shows the same build error.",
605
+ "detail": "When a page failed its on-demand build in development, the handler threw `Asset \"<Page>\" not found in manifest.` and left the response to whichever error handler answered first. An app with its own `onError` (most real apps) showed its generic error page, so the actual cause \u2014 a missing import, a syntax error \u2014 was only in the terminal. Page handlers for Vue, React, Svelte, Angular and Ember now return a Build Error page themselves, with the build's own error message, and the page reloads by itself once a rebuild lands or the dev server comes back. AbsoluteJS's fallback error page for missing assets shows the same build error.",
596
606
  "kind": "fixed",
597
607
  "summary": "A page that fails to build in development shows the real build error and reloads itself once fixed",
598
608
  "symbols": [
@@ -601,7 +611,7 @@
601
611
  ]
602
612
  },
603
613
  {
604
- "detail": "Any changed file no HMR pipeline handled restarted the dev server, including documentation and test files the server never runs. Each restart dropped every open page for seconds and rebuilt them from nothing. Now documents (`.md`, `.txt`, …) and test files never restart it; code modules restart it only when the running server process has loaded them (a module nothing has imported yet loads fresh on first use); config such as `.env`, `package.json` and `tsconfig.json` still does.",
614
+ "detail": "Any changed file no HMR pipeline handled restarted the dev server, including documentation and test files the server never runs. Each restart dropped every open page for seconds and rebuilt them from nothing. Now documents (`.md`, `.txt`, \u2026) and test files never restart it; code modules restart it only when the running server process has loaded them (a module nothing has imported yet loads fresh on first use); config such as `.env`, `package.json` and `tsconfig.json` still does.",
605
615
  "kind": "fixed",
606
616
  "summary": "Editing docs, tests or code the server never loaded no longer restarts the dev server"
607
617
  }
@@ -639,7 +649,7 @@
639
649
  {
640
650
  "changes": [
641
651
  {
642
- "detail": "The Vue build copies every `.ts` helper a page reaches into its generated tree, but it found them with a regex that only matched `import … from`. A helper reached only through a re-export (`export { x } from './y'` or `export * from './y'`) was never copied, so the build failed with `Could not resolve: \"./y\"` and every page above it answered 500 in dev. Dependencies are now read with Bun's import scanner, which sees imports, dynamic imports and all three re-export forms; a regex that also covers re-exports is the fallback for source that does not parse.",
652
+ "detail": "The Vue build copies every `.ts` helper a page reaches into its generated tree, but it found them with a regex that only matched `import \u2026 from`. A helper reached only through a re-export (`export { x } from './y'` or `export * from './y'`) was never copied, so the build failed with `Could not resolve: \"./y\"` and every page above it answered 500 in dev. Dependencies are now read with Bun's import scanner, which sees imports, dynamic imports and all three re-export forms; a regex that also covers re-exports is the fallback for source that does not parse.",
643
653
  "kind": "fixed",
644
654
  "summary": "Vue pages that reach a helper through a re-export build again"
645
655
  }
@@ -1057,7 +1067,7 @@
1057
1067
  {
1058
1068
  "changes": [
1059
1069
  {
1060
- "detail": "The provider-neutral release command now installs the exact Capacitor AAB-derived APK set on the managed emulator, takes both network transports offline, and requires an AbsoluteJS-owned native readiness signal across a cold launch and process relaunch. Bun workspace-hoisted Capacitor executables and package manifests are resolved only through the application’s governing workspace. Reports and telemetry retain only provider, artifact identity, outcome, and bounded timings.",
1070
+ "detail": "The provider-neutral release command now installs the exact Capacitor AAB-derived APK set on the managed emulator, takes both network transports offline, and requires an AbsoluteJS-owned native readiness signal across a cold launch and process relaunch. Bun workspace-hoisted Capacitor executables and package manifests are resolved only through the application\u2019s governing workspace. Reports and telemetry retain only provider, artifact identity, outcome, and bounded timings.",
1061
1071
  "kind": "added",
1062
1072
  "summary": "Capacitor Android releases gain installed offline AAB acceptance"
1063
1073
  }
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- // .angular-partial-tmp-3SfpvV/src/core/streamingSlotRegistrar.ts
2
+ // .angular-partial-tmp-IooXgx/src/core/streamingSlotRegistrar.ts
3
3
  var STREAMING_SLOT_REGISTRAR_KEY = Symbol.for("absolutejs.streamingSlotRegistrar");
4
4
  var STREAMING_SLOT_WARNING_STORAGE_KEY = Symbol.for("absolutejs.streamingSlotWarningController");
5
5
  var STREAMING_SLOT_COLLECTION_STORAGE_KEY = Symbol.for("absolutejs.streamingSlotCollectionController");
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- // .angular-partial-tmp-3SfpvV/src/core/streamingSlotRegistrar.ts
2
+ // .angular-partial-tmp-IooXgx/src/core/streamingSlotRegistrar.ts
3
3
  var STREAMING_SLOT_REGISTRAR_KEY = Symbol.for("absolutejs.streamingSlotRegistrar");
4
4
  var STREAMING_SLOT_WARNING_STORAGE_KEY = Symbol.for("absolutejs.streamingSlotWarningController");
5
5
  var STREAMING_SLOT_COLLECTION_STORAGE_KEY = Symbol.for("absolutejs.streamingSlotCollectionController");
@@ -46,7 +46,7 @@ var warnMissingStreamingSlotCollector = (primitiveName) => {
46
46
  getWarningController()?.maybeWarn(primitiveName);
47
47
  };
48
48
 
49
- // .angular-partial-tmp-3SfpvV/src/core/streamingSlotRegistry.ts
49
+ // .angular-partial-tmp-IooXgx/src/core/streamingSlotRegistry.ts
50
50
  var STREAMING_SLOT_STORAGE_KEY = Symbol.for("absolutejs.streamingSlotAsyncLocalStorage");
51
51
  var isObjectRecord2 = (value) => Boolean(value) && typeof value === "object";
52
52
  var isAsyncLocalStorage = (value) => isObjectRecord2(value) && ("getStore" in value) && typeof value.getStore === "function" && ("run" in value) && typeof value.run === "function";
package/dist/cli/index.js CHANGED
@@ -233,7 +233,7 @@ if (command === "dev") {
233
233
  setupMkcert();
234
234
  } else if (command === "tunnel-relay") {
235
235
  sendTelemetryEvent("cli:command", { command });
236
- const { tunnelRelay } = await import("./tunnelRelay-19ay0fxn.js");
236
+ const { tunnelRelay } = await import("./tunnelRelay-23zsvsfm.js");
237
237
  tunnelRelay();
238
238
  } else {
239
239
  const message = command ? `Unknown command: ${command}` : "No command specified";
@@ -7,6 +7,16 @@ import {
7
7
  } from "./index-hg54jyts.js";
8
8
 
9
9
  // src/dev/tunnel/relay.ts
10
+ var parseTunnelPublicRoutes = (value) => {
11
+ const routes = JSON.parse(value);
12
+ if (!Array.isArray(routes))
13
+ throw new Error("Tunnel public routes must be an array");
14
+ return routes.map((route) => {
15
+ if (!route || typeof route !== "object" || !("path" in route) || !("method" in route) || typeof route.path !== "string" || typeof route.method !== "string" || !/^[A-Z]+$/.test(route.method) || !/^\/(?:[A-Za-z0-9_.~-]+\/?)*$/.test(route.path) || new URL(route.path, "https://relay.invalid").pathname !== route.path || route.path === TUNNEL_CONTROL_PATH)
16
+ throw new Error("Tunnel public routes require an exact canonical path and uppercase method");
17
+ return { method: route.method, path: route.path };
18
+ });
19
+ };
10
20
  var DEFAULT_RELAY_PORT = 8787;
11
21
  var DEFAULT_REQUEST_TIMEOUT_MS = 30000;
12
22
  var headersToObject = (headers) => {
@@ -17,6 +27,9 @@ var headersToObject = (headers) => {
17
27
  return out;
18
28
  };
19
29
  var startTunnelRelay = (options) => {
30
+ if (options.healthPath !== undefined)
31
+ parseTunnelPublicRoutes(JSON.stringify([{ method: "GET", path: options.healthPath }]));
32
+ const publicRoutes = options.publicRoutes === undefined ? undefined : parseTunnelPublicRoutes(JSON.stringify(options.publicRoutes));
20
33
  const port = options.port ?? (Number(process.env.PORT) || DEFAULT_RELAY_PORT);
21
34
  const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
22
35
  let client = null;
@@ -104,6 +117,8 @@ var startTunnelRelay = (options) => {
104
117
  },
105
118
  async fetch(request, srv) {
106
119
  const url = new URL(request.url);
120
+ if (options.healthPath && url.pathname === options.healthPath && request.method === "GET")
121
+ return new Response(null, { status: 204 });
107
122
  if (url.pathname === TUNNEL_CONTROL_PATH) {
108
123
  if (url.searchParams.get("token") !== options.token) {
109
124
  return new Response("Forbidden", { status: 403 });
@@ -113,6 +128,8 @@ var startTunnelRelay = (options) => {
113
128
  });
114
129
  return upgraded ? undefined : new Response("Upgrade failed", { status: 426 });
115
130
  }
131
+ if (publicRoutes && (isWebSocketUpgrade(request) || !publicRoutes.some((route) => route.path === url.pathname && route.method === request.method)))
132
+ return new Response("Not found", { status: 404 });
116
133
  if (!client) {
117
134
  return new Response("Tunnel offline: no dev client connected.", { status: 503 });
118
135
  }
@@ -169,8 +186,11 @@ var tunnelRelay = () => {
169
186
  console.error("[tunnel-relay] ABSOLUTE_TUNNEL_TOKEN is required.");
170
187
  process.exit(1);
171
188
  }
189
+ const routes = process.env.ABSOLUTE_TUNNEL_PUBLIC_ROUTES;
172
190
  startTunnelRelay({
191
+ healthPath: process.env.ABSOLUTE_TUNNEL_HEALTH_PATH,
173
192
  port: Number(process.env.PORT) || undefined,
193
+ publicRoutes: routes === undefined ? undefined : parseTunnelPublicRoutes(routes),
174
194
  publicUrl: process.env.ABSOLUTE_TUNNEL_PUBLIC_URL,
175
195
  token
176
196
  });
@@ -4,6 +4,7 @@
4
4
  * it with `dev: { tunnel: { relay, token } }`. Reads:
5
5
  * - PORT (App Platform injects this; default 8787)
6
6
  * - ABSOLUTE_TUNNEL_TOKEN (required shared secret)
7
+ * - ABSOLUTE_TUNNEL_PUBLIC_ROUTES (optional JSON array of exact {path, method})
7
8
  * - ABSOLUTE_TUNNEL_PUBLIC_URL (optional; the relay's public base URL)
8
9
  */
9
10
  export declare const tunnelRelay: () => void;
@@ -1,4 +1,16 @@
1
+ export type TunnelPublicRoute = {
2
+ /** Exact, canonical pathname. Queries do not affect route matching. */
3
+ path: string;
4
+ /** Exact uppercase HTTP method. */
5
+ method: string;
6
+ };
1
7
  type RelayOptions = {
8
+ /** Optional relay process liveness endpoint; does not assert that the local client is online. */
9
+ healthPath?: string;
10
+ /** Restrict public forwarding to these HTTP routes. An empty list denies all.
11
+ * When supplied, public WebSockets are disabled. The authenticated control
12
+ * channel remains available. Omit to preserve full development tunneling. */
13
+ publicRoutes?: readonly TunnelPublicRoute[];
2
14
  /** Port the relay listens on (App Platform injects PORT). */
3
15
  port?: number;
4
16
  /** Shared secret the dev client must present (?token=). */
@@ -19,6 +31,10 @@ type PublicSocketData = {
19
31
  headers: Record<string, string>;
20
32
  };
21
33
  type SocketData = ControlSocketData | PublicSocketData;
34
+ export declare const parseTunnelPublicRoutes: (value: string) => {
35
+ method: string;
36
+ path: string;
37
+ }[];
22
38
  /**
23
39
  * Start the public reverse-tunnel relay. Holds one dev-client control socket
24
40
  * and forwards every other inbound HTTP request — and public WebSocket — down
@@ -0,0 +1,2 @@
1
+ /** Server-side reverse-tunnel relay for isolated callback deployments. */
2
+ export { startTunnelRelay, parseTunnelPublicRoutes, type TunnelPublicRoute } from './dev/tunnel/relay';
package/dist/tunnel.js ADDED
@@ -0,0 +1,193 @@
1
+ // @bun
2
+ // src/dev/tunnel/protocol.ts
3
+ var TUNNEL_CONTROL_PATH = "/__abs_tunnel/control";
4
+ var TUNNEL_FORWARDED_HOST_HEADER = "x-absolute-tunnel-host";
5
+ var decodeTunnelMessage = (raw) => {
6
+ try {
7
+ const parsed = JSON.parse(raw);
8
+ return parsed;
9
+ } catch {
10
+ return null;
11
+ }
12
+ };
13
+ var encodeTunnelMessage = (message) => JSON.stringify(message);
14
+
15
+ // src/dev/tunnel/relay.ts
16
+ var parseTunnelPublicRoutes = (value) => {
17
+ const routes = JSON.parse(value);
18
+ if (!Array.isArray(routes))
19
+ throw new Error("Tunnel public routes must be an array");
20
+ return routes.map((route) => {
21
+ if (!route || typeof route !== "object" || !("path" in route) || !("method" in route) || typeof route.path !== "string" || typeof route.method !== "string" || !/^[A-Z]+$/.test(route.method) || !/^\/(?:[A-Za-z0-9_.~-]+\/?)*$/.test(route.path) || new URL(route.path, "https://relay.invalid").pathname !== route.path || route.path === TUNNEL_CONTROL_PATH)
22
+ throw new Error("Tunnel public routes require an exact canonical path and uppercase method");
23
+ return { method: route.method, path: route.path };
24
+ });
25
+ };
26
+ var DEFAULT_RELAY_PORT = 8787;
27
+ var DEFAULT_REQUEST_TIMEOUT_MS = 30000;
28
+ var headersToObject = (headers) => {
29
+ const out = {};
30
+ headers.forEach((value, key) => {
31
+ out[key] = value;
32
+ });
33
+ return out;
34
+ };
35
+ var startTunnelRelay = (options) => {
36
+ if (options.healthPath !== undefined)
37
+ parseTunnelPublicRoutes(JSON.stringify([{ method: "GET", path: options.healthPath }]));
38
+ const publicRoutes = options.publicRoutes === undefined ? undefined : parseTunnelPublicRoutes(JSON.stringify(options.publicRoutes));
39
+ const port = options.port ?? (Number(process.env.PORT) || DEFAULT_RELAY_PORT);
40
+ const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
41
+ let client = null;
42
+ const pending = new Map;
43
+ const publicSockets = new Map;
44
+ const resolvePublicUrl = (request) => {
45
+ if (options.publicUrl)
46
+ return options.publicUrl.replace(/\/$/, "");
47
+ const url = new URL(request.url);
48
+ const host = request.headers.get("x-forwarded-host") ?? url.host;
49
+ const proto = request.headers.get("x-forwarded-proto") ?? url.protocol.replace(":", "");
50
+ return `${proto}://${host}`;
51
+ };
52
+ const isWebSocketUpgrade = (request) => request.headers.get("upgrade")?.toLowerCase() === "websocket";
53
+ const server = Bun.serve({
54
+ port,
55
+ websocket: {
56
+ close(ws) {
57
+ if (ws.data.control) {
58
+ if (client === ws)
59
+ client = null;
60
+ return;
61
+ }
62
+ publicSockets.delete(ws.data.id);
63
+ client?.send(encodeTunnelMessage({ id: ws.data.id, type: "ws_close" }));
64
+ },
65
+ message(ws, raw) {
66
+ if (!ws.data.control) {
67
+ const binary = typeof raw !== "string";
68
+ const bytes = typeof raw === "string" ? Buffer.from(raw, "utf8") : Buffer.from(raw);
69
+ client?.send(encodeTunnelMessage({
70
+ binary,
71
+ dataBase64: bytes.toString("base64"),
72
+ id: ws.data.id,
73
+ type: "ws_data"
74
+ }));
75
+ return;
76
+ }
77
+ const message = decodeTunnelMessage(typeof raw === "string" ? raw : raw.toString());
78
+ if (!message)
79
+ return;
80
+ switch (message.type) {
81
+ case "ping":
82
+ ws.send(encodeTunnelMessage({ type: "pong" }));
83
+ break;
84
+ case "response":
85
+ case "error":
86
+ pending.get(message.id)?.(message);
87
+ break;
88
+ case "ws_open_ack":
89
+ if (!message.ok)
90
+ publicSockets.get(message.id)?.close();
91
+ break;
92
+ case "ws_data": {
93
+ const target = publicSockets.get(message.id);
94
+ const bytes = Buffer.from(message.dataBase64, "base64");
95
+ target?.send(message.binary ? bytes : bytes.toString("utf8"));
96
+ break;
97
+ }
98
+ case "ws_close":
99
+ publicSockets.get(message.id)?.close(message.code, message.reason);
100
+ publicSockets.delete(message.id);
101
+ break;
102
+ default:
103
+ break;
104
+ }
105
+ },
106
+ open(ws) {
107
+ if (ws.data.control) {
108
+ client = ws;
109
+ ws.send(encodeTunnelMessage({
110
+ publicUrl: options.publicUrl ?? "",
111
+ type: "ready"
112
+ }));
113
+ return;
114
+ }
115
+ publicSockets.set(ws.data.id, ws);
116
+ client?.send(encodeTunnelMessage({
117
+ headers: ws.data.headers,
118
+ id: ws.data.id,
119
+ type: "ws_open",
120
+ url: ws.data.url
121
+ }));
122
+ }
123
+ },
124
+ async fetch(request, srv) {
125
+ const url = new URL(request.url);
126
+ if (options.healthPath && url.pathname === options.healthPath && request.method === "GET")
127
+ return new Response(null, { status: 204 });
128
+ if (url.pathname === TUNNEL_CONTROL_PATH) {
129
+ if (url.searchParams.get("token") !== options.token) {
130
+ return new Response("Forbidden", { status: 403 });
131
+ }
132
+ const upgraded = srv.upgrade(request, {
133
+ data: { control: true }
134
+ });
135
+ return upgraded ? undefined : new Response("Upgrade failed", { status: 426 });
136
+ }
137
+ if (publicRoutes && (isWebSocketUpgrade(request) || !publicRoutes.some((route) => route.path === url.pathname && route.method === request.method)))
138
+ return new Response("Not found", { status: 404 });
139
+ if (!client) {
140
+ return new Response("Tunnel offline: no dev client connected.", { status: 503 });
141
+ }
142
+ if (isWebSocketUpgrade(request)) {
143
+ const id = crypto.randomUUID();
144
+ const upgraded = srv.upgrade(request, {
145
+ data: {
146
+ control: false,
147
+ headers: headersToObject(request.headers),
148
+ id,
149
+ url: url.pathname + url.search
150
+ }
151
+ });
152
+ return upgraded ? undefined : new Response("Upgrade failed", { status: 426 });
153
+ }
154
+ const id = crypto.randomUUID();
155
+ const bodyBytes = ["GET", "HEAD"].includes(request.method) ? null : new Uint8Array(await request.arrayBuffer());
156
+ const headers = headersToObject(request.headers);
157
+ headers[TUNNEL_FORWARDED_HOST_HEADER] = resolvePublicUrl(request);
158
+ const message = {
159
+ headers,
160
+ id,
161
+ method: request.method,
162
+ type: "request",
163
+ url: url.pathname + url.search,
164
+ ...bodyBytes && bodyBytes.length > 0 ? { bodyBase64: Buffer.from(bodyBytes).toString("base64") } : {}
165
+ };
166
+ const responsePromise = new Promise((resolve) => {
167
+ pending.set(id, resolve);
168
+ });
169
+ client.send(encodeTunnelMessage(message));
170
+ const timeout = new Promise((resolve) => setTimeout(() => resolve({ id, message: "timeout", type: "error" }), requestTimeoutMs));
171
+ const result = await Promise.race([responsePromise, timeout]);
172
+ pending.delete(id);
173
+ if (result.type === "error") {
174
+ return new Response(`Tunnel error: ${result.message}`, {
175
+ status: 504
176
+ });
177
+ }
178
+ if (result.type !== "response") {
179
+ return new Response("Tunnel protocol error", { status: 502 });
180
+ }
181
+ return new Response(result.bodyBase64 ? Buffer.from(result.bodyBase64, "base64") : null, { headers: result.headers, status: result.status });
182
+ }
183
+ });
184
+ console.info(`[tunnel-relay] listening on :${server.port} (control ${TUNNEL_CONTROL_PATH})`);
185
+ return server;
186
+ };
187
+ export {
188
+ parseTunnelPublicRoutes,
189
+ startTunnelRelay
190
+ };
191
+
192
+ //# debugId=03C0B3D966BB1DD164756E2164756E21
193
+ //# sourceMappingURL=tunnel.js.map
@@ -0,0 +1,11 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/dev/tunnel/protocol.ts", "../src/dev/tunnel/relay.ts"],
4
+ "sourcesContent": [
5
+ "/**\n * Reverse-tunnel wire protocol (pure Bun, no third-party deps).\n *\n * A dev machine behind NAT cannot accept inbound connections, so the dev\n * `client` dials OUT to a public `relay` over a single control WebSocket. The\n * relay receives public HTTP requests and forwards each one down that socket;\n * the client replays it against the local app and sends the response back.\n *\n * Stage 1 covers HTTP request/response (webhooks). WebSocket passthrough for\n * telephony media streams is layered on later with additional message types.\n */\n\n/** Control-channel path the client connects to on the relay. */\nexport const TUNNEL_CONTROL_PATH = '/__abs_tunnel/control';\n\n/** Header the relay strips/sets; lets the app know its public origin. */\nexport const TUNNEL_FORWARDED_HOST_HEADER = 'x-absolute-tunnel-host';\n\n/** A forwarded HTTP request (relay → client). Body is base64 (binary-safe). */\nexport type TunnelRequestMessage = {\n\ttype: 'request';\n\tid: string;\n\tmethod: string;\n\t/** Path + query, e.g. `/v1/sms/intake?x=1`. */\n\turl: string;\n\theaders: Record<string, string>;\n\tbodyBase64?: string;\n};\n\n/** The client's response to a forwarded request (client → relay). */\nexport type TunnelResponseMessage = {\n\ttype: 'response';\n\tid: string;\n\tstatus: number;\n\theaders: Record<string, string>;\n\tbodyBase64?: string;\n};\n\n/** Client could not produce a response (local app down, fetch threw). */\nexport type TunnelErrorMessage = {\n\ttype: 'error';\n\tid: string;\n\tmessage: string;\n};\n\n/** Sent by the relay right after a successful auth handshake. */\nexport type TunnelReadyMessage = {\n\ttype: 'ready';\n\t/** Public base URL the relay is reachable at, e.g. `https://x.app`. */\n\tpublicUrl: string;\n};\n\n// --- WebSocket passthrough (Stage 2) -----------------------------------------\n// A public WebSocket (e.g. a Twilio Media Stream) is tunneled by id over the\n// same control channel: the relay asks the client to open a matching local WS,\n// then both sides forward frames tagged with that id until either end closes.\n\n/** A public WS connection opened; client should dial the local app (relay → client). */\nexport type TunnelWsOpenMessage = {\n\ttype: 'ws_open';\n\tid: string;\n\t/** Path + query of the upgraded request. */\n\turl: string;\n\theaders: Record<string, string>;\n};\n\n/** Result of the client opening the local WS (client → relay). */\nexport type TunnelWsOpenAckMessage = {\n\ttype: 'ws_open_ack';\n\tid: string;\n\tok: boolean;\n\terror?: string;\n};\n\n/** One WS frame, either direction. Binary frames are base64. */\nexport type TunnelWsDataMessage = {\n\ttype: 'ws_data';\n\tid: string;\n\tdataBase64: string;\n\tbinary: boolean;\n};\n\n/** A WS closed, either direction. */\nexport type TunnelWsCloseMessage = {\n\ttype: 'ws_close';\n\tid: string;\n\tcode?: number;\n\treason?: string;\n};\n\n/** App-level keepalive (either direction). */\nexport type TunnelPingMessage = { type: 'ping' };\nexport type TunnelPongMessage = { type: 'pong' };\n\nexport type TunnelClientMessage =\n\t| TunnelResponseMessage\n\t| TunnelErrorMessage\n\t| TunnelWsOpenAckMessage\n\t| TunnelWsDataMessage\n\t| TunnelWsCloseMessage\n\t| TunnelPongMessage\n\t| TunnelPingMessage;\nexport type TunnelServerMessage =\n\t| TunnelRequestMessage\n\t| TunnelReadyMessage\n\t| TunnelWsOpenMessage\n\t| TunnelWsDataMessage\n\t| TunnelWsCloseMessage\n\t| TunnelPingMessage\n\t| TunnelPongMessage;\n\nexport const decodeTunnelMessage = (raw: string) => {\n\ttry {\n\t\tconst parsed: TunnelClientMessage | TunnelServerMessage =\n\t\t\tJSON.parse(raw);\n\n\t\treturn parsed;\n\t} catch {\n\t\treturn null;\n\t}\n};\nexport const encodeTunnelMessage = (\n\tmessage: TunnelClientMessage | TunnelServerMessage\n) => JSON.stringify(message);\n",
6
+ "import type { ServerWebSocket } from 'bun';\nimport {\n\tdecodeTunnelMessage,\n\tencodeTunnelMessage,\n\tTUNNEL_CONTROL_PATH,\n\tTUNNEL_FORWARDED_HOST_HEADER,\n\ttype TunnelClientMessage,\n\ttype TunnelRequestMessage\n} from './protocol';\n\nexport type TunnelPublicRoute = {\n\t/** Exact, canonical pathname. Queries do not affect route matching. */\n\tpath: string;\n\t/** Exact uppercase HTTP method. */\n\tmethod: string;\n};\n\ntype RelayOptions = {\n\t/** Optional relay process liveness endpoint; does not assert that the local client is online. */\n\thealthPath?: string;\n\t/** Restrict public forwarding to these HTTP routes. An empty list denies all.\n\t * When supplied, public WebSockets are disabled. The authenticated control\n\t * channel remains available. Omit to preserve full development tunneling. */\n\tpublicRoutes?: readonly TunnelPublicRoute[];\n\t/** Port the relay listens on (App Platform injects PORT). */\n\tport?: number;\n\t/** Shared secret the dev client must present (?token=). */\n\ttoken: string;\n\t/** Public base URL the relay is reachable at. Falls back to the request\n\t * origin when omitted (App Platform: set APP_URL or pass explicitly). */\n\tpublicUrl?: string;\n\t/** How long to wait for the dev client to answer a forwarded request. */\n\trequestTimeoutMs?: number;\n};\n\ntype ControlSocketData = { control: true };\ntype PublicSocketData = {\n\tcontrol: false;\n\tid: string;\n\turl: string;\n\theaders: Record<string, string>;\n};\ntype SocketData = ControlSocketData | PublicSocketData;\n\nexport const parseTunnelPublicRoutes = (value: string) => {\n\tconst routes: unknown = JSON.parse(value);\n\tif (!Array.isArray(routes))\n\t\tthrow new Error('Tunnel public routes must be an array');\n\n\treturn routes.map((route: unknown) => {\n\t\tif (\n\t\t\t!route ||\n\t\t\ttypeof route !== 'object' ||\n\t\t\t!('path' in route) ||\n\t\t\t!('method' in route) ||\n\t\t\ttypeof route.path !== 'string' ||\n\t\t\ttypeof route.method !== 'string' ||\n\t\t\t!/^[A-Z]+$/.test(route.method) ||\n\t\t\t!/^\\/(?:[A-Za-z0-9_.~-]+\\/?)*$/.test(route.path) ||\n\t\t\tnew URL(route.path, 'https://relay.invalid').pathname !==\n\t\t\t\troute.path ||\n\t\t\troute.path === TUNNEL_CONTROL_PATH\n\t\t)\n\t\t\tthrow new Error(\n\t\t\t\t'Tunnel public routes require an exact canonical path and uppercase method'\n\t\t\t);\n\n\t\treturn { method: route.method, path: route.path };\n\t});\n};\n\nconst DEFAULT_RELAY_PORT = 8787;\nconst DEFAULT_REQUEST_TIMEOUT_MS = 30_000;\n\nconst headersToObject = (headers: Headers) => {\n\tconst out: Record<string, string> = {};\n\theaders.forEach((value, key) => {\n\t\tout[key] = value;\n\t});\n\n\treturn out;\n};\n\n/**\n * Start the public reverse-tunnel relay. Holds one dev-client control socket\n * and forwards every other inbound HTTP request — and public WebSocket — down\n * it. Single-tenant: the shared `token` gates the control channel.\n */\nexport const startTunnelRelay = (options: RelayOptions) => {\n\tif (options.healthPath !== undefined)\n\t\tparseTunnelPublicRoutes(\n\t\t\tJSON.stringify([{ method: 'GET', path: options.healthPath }])\n\t\t);\n\tconst publicRoutes =\n\t\toptions.publicRoutes === undefined\n\t\t\t? undefined\n\t\t\t: parseTunnelPublicRoutes(JSON.stringify(options.publicRoutes));\n\tconst port =\n\t\toptions.port ?? (Number(process.env.PORT) || DEFAULT_RELAY_PORT);\n\tconst requestTimeoutMs =\n\t\toptions.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;\n\n\tlet client: ServerWebSocket<SocketData> | null = null;\n\tconst pending = new Map<string, (message: TunnelClientMessage) => void>();\n\tconst publicSockets = new Map<string, ServerWebSocket<SocketData>>();\n\n\tconst resolvePublicUrl = (request: Request) => {\n\t\tif (options.publicUrl) return options.publicUrl.replace(/\\/$/, '');\n\t\tconst url = new URL(request.url);\n\t\tconst host = request.headers.get('x-forwarded-host') ?? url.host;\n\t\tconst proto =\n\t\t\trequest.headers.get('x-forwarded-proto') ??\n\t\t\turl.protocol.replace(':', '');\n\n\t\treturn `${proto}://${host}`;\n\t};\n\n\tconst isWebSocketUpgrade = (request: Request) =>\n\t\trequest.headers.get('upgrade')?.toLowerCase() === 'websocket';\n\n\tconst server = Bun.serve<SocketData>({\n\t\tport,\n\t\twebsocket: {\n\t\t\tclose(ws) {\n\t\t\t\tif (ws.data.control) {\n\t\t\t\t\tif (client === ws) client = null;\n\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tpublicSockets.delete(ws.data.id);\n\t\t\t\tclient?.send(\n\t\t\t\t\tencodeTunnelMessage({ id: ws.data.id, type: 'ws_close' })\n\t\t\t\t);\n\t\t\t},\n\t\t\tmessage(ws, raw) {\n\t\t\t\t// Public socket: forward the raw frame to the dev client.\n\t\t\t\tif (!ws.data.control) {\n\t\t\t\t\tconst binary = typeof raw !== 'string';\n\t\t\t\t\tconst bytes =\n\t\t\t\t\t\ttypeof raw === 'string'\n\t\t\t\t\t\t\t? Buffer.from(raw, 'utf8')\n\t\t\t\t\t\t\t: Buffer.from(raw);\n\t\t\t\t\tclient?.send(\n\t\t\t\t\t\tencodeTunnelMessage({\n\t\t\t\t\t\t\tbinary,\n\t\t\t\t\t\t\tdataBase64: bytes.toString('base64'),\n\t\t\t\t\t\t\tid: ws.data.id,\n\t\t\t\t\t\t\ttype: 'ws_data'\n\t\t\t\t\t\t})\n\t\t\t\t\t);\n\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\t// Control socket: messages from the dev client.\n\t\t\t\tconst message = decodeTunnelMessage(\n\t\t\t\t\ttypeof raw === 'string' ? raw : raw.toString()\n\t\t\t\t);\n\t\t\t\tif (!message) return;\n\t\t\t\tswitch (message.type) {\n\t\t\t\t\tcase 'ping':\n\t\t\t\t\t\tws.send(encodeTunnelMessage({ type: 'pong' }));\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcase 'response':\n\t\t\t\t\tcase 'error':\n\t\t\t\t\t\tpending.get(message.id)?.(message);\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcase 'ws_open_ack':\n\t\t\t\t\t\tif (!message.ok) publicSockets.get(message.id)?.close();\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcase 'ws_data': {\n\t\t\t\t\t\tconst target = publicSockets.get(message.id);\n\t\t\t\t\t\tconst bytes = Buffer.from(message.dataBase64, 'base64');\n\t\t\t\t\t\ttarget?.send(\n\t\t\t\t\t\t\tmessage.binary ? bytes : bytes.toString('utf8')\n\t\t\t\t\t\t);\n\t\t\t\t\t\tbreak;\n\t\t\t\t\t}\n\t\t\t\t\tcase 'ws_close':\n\t\t\t\t\t\tpublicSockets\n\t\t\t\t\t\t\t.get(message.id)\n\t\t\t\t\t\t\t?.close(message.code, message.reason);\n\t\t\t\t\t\tpublicSockets.delete(message.id);\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tdefault:\n\t\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t},\n\t\t\topen(ws) {\n\t\t\t\tif (ws.data.control) {\n\t\t\t\t\t// Single-tenant: a new client replaces any stale one.\n\t\t\t\t\tclient = ws;\n\t\t\t\t\tws.send(\n\t\t\t\t\t\tencodeTunnelMessage({\n\t\t\t\t\t\t\tpublicUrl: options.publicUrl ?? '',\n\t\t\t\t\t\t\ttype: 'ready'\n\t\t\t\t\t\t})\n\t\t\t\t\t);\n\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\t// Public WS: register it and ask the dev client to open the local peer.\n\t\t\t\tpublicSockets.set(ws.data.id, ws);\n\t\t\t\tclient?.send(\n\t\t\t\t\tencodeTunnelMessage({\n\t\t\t\t\t\theaders: ws.data.headers,\n\t\t\t\t\t\tid: ws.data.id,\n\t\t\t\t\t\ttype: 'ws_open',\n\t\t\t\t\t\turl: ws.data.url\n\t\t\t\t\t})\n\t\t\t\t);\n\t\t\t}\n\t\t},\n\t\tasync fetch(request, srv) {\n\t\t\tconst url = new URL(request.url);\n\n\t\t\tif (\n\t\t\t\toptions.healthPath &&\n\t\t\t\turl.pathname === options.healthPath &&\n\t\t\t\trequest.method === 'GET'\n\t\t\t)\n\t\t\t\treturn new Response(null, { status: 204 });\n\n\t\t\t// Dev client connects here to open the control channel.\n\t\t\tif (url.pathname === TUNNEL_CONTROL_PATH) {\n\t\t\t\tif (url.searchParams.get('token') !== options.token) {\n\t\t\t\t\treturn new Response('Forbidden', { status: 403 });\n\t\t\t\t}\n\t\t\t\tconst upgraded = srv.upgrade(request, {\n\t\t\t\t\tdata: { control: true }\n\t\t\t\t});\n\n\t\t\t\treturn upgraded\n\t\t\t\t\t? undefined\n\t\t\t\t\t: new Response('Upgrade failed', { status: 426 });\n\t\t\t}\n\n\t\t\tif (\n\t\t\t\tpublicRoutes &&\n\t\t\t\t(isWebSocketUpgrade(request) ||\n\t\t\t\t\t!publicRoutes.some(\n\t\t\t\t\t\t(route) =>\n\t\t\t\t\t\t\troute.path === url.pathname &&\n\t\t\t\t\t\t\troute.method === request.method\n\t\t\t\t\t))\n\t\t\t)\n\t\t\t\treturn new Response('Not found', { status: 404 });\n\n\t\t\tif (!client) {\n\t\t\t\treturn new Response(\n\t\t\t\t\t'Tunnel offline: no dev client connected.',\n\t\t\t\t\t{ status: 503 }\n\t\t\t\t);\n\t\t\t}\n\n\t\t\t// Public WebSocket (e.g. Twilio Media Stream) → tunnel it by id.\n\t\t\tif (isWebSocketUpgrade(request)) {\n\t\t\t\tconst id = crypto.randomUUID();\n\t\t\t\tconst upgraded = srv.upgrade(request, {\n\t\t\t\t\tdata: {\n\t\t\t\t\t\tcontrol: false,\n\t\t\t\t\t\theaders: headersToObject(request.headers),\n\t\t\t\t\t\tid,\n\t\t\t\t\t\turl: url.pathname + url.search\n\t\t\t\t\t}\n\t\t\t\t});\n\n\t\t\t\treturn upgraded\n\t\t\t\t\t? undefined\n\t\t\t\t\t: new Response('Upgrade failed', { status: 426 });\n\t\t\t}\n\n\t\t\t// Public HTTP request → forward and await the dev client's response.\n\t\t\tconst id = crypto.randomUUID();\n\t\t\tconst bodyBytes = ['GET', 'HEAD'].includes(request.method)\n\t\t\t\t? null\n\t\t\t\t: new Uint8Array(await request.arrayBuffer());\n\t\t\tconst headers = headersToObject(request.headers);\n\t\t\theaders[TUNNEL_FORWARDED_HOST_HEADER] = resolvePublicUrl(request);\n\n\t\t\tconst message: TunnelRequestMessage = {\n\t\t\t\theaders,\n\t\t\t\tid,\n\t\t\t\tmethod: request.method,\n\t\t\t\ttype: 'request',\n\t\t\t\turl: url.pathname + url.search,\n\t\t\t\t...(bodyBytes && bodyBytes.length > 0\n\t\t\t\t\t? { bodyBase64: Buffer.from(bodyBytes).toString('base64') }\n\t\t\t\t\t: {})\n\t\t\t};\n\n\t\t\tconst responsePromise = new Promise<TunnelClientMessage>(\n\t\t\t\t(resolve) => {\n\t\t\t\t\tpending.set(id, resolve);\n\t\t\t\t}\n\t\t\t);\n\t\t\tclient.send(encodeTunnelMessage(message));\n\n\t\t\tconst timeout = new Promise<TunnelClientMessage>((resolve) =>\n\t\t\t\tsetTimeout(\n\t\t\t\t\t() => resolve({ id, message: 'timeout', type: 'error' }),\n\t\t\t\t\trequestTimeoutMs\n\t\t\t\t)\n\t\t\t);\n\t\t\tconst result = await Promise.race([responsePromise, timeout]);\n\t\t\tpending.delete(id);\n\n\t\t\tif (result.type === 'error') {\n\t\t\t\treturn new Response(`Tunnel error: ${result.message}`, {\n\t\t\t\t\tstatus: 504\n\t\t\t\t});\n\t\t\t}\n\t\t\tif (result.type !== 'response') {\n\t\t\t\treturn new Response('Tunnel protocol error', { status: 502 });\n\t\t\t}\n\n\t\t\treturn new Response(\n\t\t\t\tresult.bodyBase64\n\t\t\t\t\t? Buffer.from(result.bodyBase64, 'base64')\n\t\t\t\t\t: null,\n\t\t\t\t{ headers: result.headers, status: result.status }\n\t\t\t);\n\t\t}\n\t});\n\n\tconsole.info(\n\t\t`[tunnel-relay] listening on :${server.port} (control ${TUNNEL_CONTROL_PATH})`\n\t);\n\n\treturn server;\n};\n"
7
+ ],
8
+ "mappings": "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",
9
+ "debugId": "03C0B3D966BB1DD164756E2164756E21",
10
+ "names": []
11
+ }
package/package.json CHANGED
@@ -278,6 +278,10 @@
278
278
  "import": "./dist/svelte/server.js",
279
279
  "types": "./dist/src/svelte/server.d.ts"
280
280
  },
281
+ "./tunnel": {
282
+ "import": "./dist/tunnel.js",
283
+ "types": "./dist/src/tunnel.d.ts"
284
+ },
281
285
  "./vue": {
282
286
  "browser": "./dist/vue/browser.js",
283
287
  "import": "./dist/vue/index.js",
@@ -553,6 +557,9 @@
553
557
  "svelte/*": [
554
558
  "dist/src/svelte/*"
555
559
  ],
560
+ "tunnel": [
561
+ "dist/src/tunnel.d.ts"
562
+ ],
556
563
  "vue": [
557
564
  "dist/src/vue/index.d.ts"
558
565
  ],
@@ -561,7 +568,7 @@
561
568
  ]
562
569
  }
563
570
  },
564
- "version": "0.20.0-beta.164",
571
+ "version": "0.20.0-beta.165",
565
572
  "workspaces": [
566
573
  "tests/fixtures/*",
567
574
  "tests/fixtures/_packages/*"