@dbx-tools/tunnel 0.6.120 → 0.6.124

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.
@@ -17,11 +17,10 @@
17
17
  * @module
18
18
  */
19
19
 
20
- import type { ChildProcess } from "node:child_process";
21
20
  import type { Interceptor, InterceptorContext } from "@dbx-tools/appkit";
22
21
  import { log, object } from "@dbx-tools/shared-core";
23
- import { installFrp, resolveFrpConfig, startFrp, writeFrpConfig } from "./frp.ts";
24
- import { installPortr, resolvePortrConfig, startPortr, writePortrConfig } from "./portr.ts";
22
+ import { installFrp, resolveFrpConfig, superviseFrp, writeFrpConfig } from "./frp.ts";
23
+ import { installPortr, resolvePortrConfig, supervisePortr, writePortrConfig } from "./portr.ts";
25
24
  import { startPathProxy } from "./path-proxy.ts";
26
25
 
27
26
  const logger = log.logger("tunnel:interceptor");
@@ -61,13 +60,49 @@ export interface TunnelInterceptorOptions {
61
60
  /** Public tunnel clients supported by the interceptor and CLI. */
62
61
  export type TunnelTransport = "portr" | "frp" | "both";
63
62
 
63
+ interface TunnelAuxiliary {
64
+ stop(): void;
65
+ }
66
+
67
+ interface TunnelRuntime {
68
+ startPortr(config: NonNullable<ReturnType<typeof resolvePortrConfig>>): Promise<TunnelAuxiliary>;
69
+ startFrp(
70
+ config: NonNullable<ReturnType<typeof resolveFrpConfig>>,
71
+ ctx: InterceptorContext,
72
+ ): Promise<TunnelAuxiliary>;
73
+ }
74
+
75
+ const defaultRuntime: TunnelRuntime = {
76
+ async startPortr(config) {
77
+ const portrEnv = await installPortr();
78
+ await writePortrConfig(config, portrEnv);
79
+ return supervisePortr(config, portrEnv);
80
+ },
81
+ async startFrp(config, ctx) {
82
+ const pathProxy =
83
+ config.stripPrefix && config.path !== "/"
84
+ ? await startPathProxy(config.targetPort, config.path)
85
+ : undefined;
86
+ if (pathProxy) {
87
+ config.targetPort = pathProxy.port;
88
+ ctx.onLifecycle("shutdown", () => void pathProxy.close());
89
+ }
90
+ const frpEnv = await installFrp();
91
+ const configPath = await writeFrpConfig(config, frpEnv);
92
+ const supervisor = superviseFrp(config, frpEnv, configPath);
93
+ return {
94
+ stop() {
95
+ supervisor.stop();
96
+ void pathProxy?.close();
97
+ },
98
+ };
99
+ },
100
+ };
101
+
64
102
  /** Resolve and validate the tunnel transport selector. */
65
103
  export function resolveTunnelTransport(transport?: string): TunnelTransport {
66
104
  const resolved =
67
- transport ??
68
- process.env.DBX_TOOLS_TUNNEL_TRANSPORT ??
69
- process.env.TUNNEL_TRANSPORT ??
70
- "portr";
105
+ transport ?? process.env.DBX_TOOLS_TUNNEL_TRANSPORT ?? process.env.TUNNEL_TRANSPORT ?? "portr";
71
106
  if (resolved === "portr" || resolved === "frp" || resolved === "both") return resolved;
72
107
  throw new TypeError(`invalid tunnel transport: ${resolved} (expected portr, frp, or both)`);
73
108
  }
@@ -92,7 +127,10 @@ function resolvePublicPort(port?: number): number {
92
127
  * interceptor: tunnelInterceptor(),
93
128
  * });
94
129
  */
95
- export function tunnelInterceptor(options: TunnelInterceptorOptions = {}): Interceptor {
130
+ export function tunnelInterceptor(
131
+ options: TunnelInterceptorOptions = {},
132
+ runtime: TunnelRuntime = defaultRuntime,
133
+ ): Interceptor {
96
134
  return async (ctx: InterceptorContext): Promise<void> => {
97
135
  if (ctx.env.databricksHost) {
98
136
  process.env.DATABRICKS_HOST ??= ctx.env.databricksHost;
@@ -100,7 +138,7 @@ export function tunnelInterceptor(options: TunnelInterceptorOptions = {}): Inter
100
138
 
101
139
  const port = resolvePublicPort(options.port);
102
140
  const transport = resolveTunnelTransport(options.transport);
103
- const children: ChildProcess[] = [];
141
+ const initializers: Array<Promise<TunnelAuxiliary>> = [];
104
142
  if (transport === "portr" || transport === "both") {
105
143
  const portrConfig = resolvePortrConfig({
106
144
  publicDomain: options.publicDomain,
@@ -108,9 +146,7 @@ export function tunnelInterceptor(options: TunnelInterceptorOptions = {}): Inter
108
146
  port,
109
147
  });
110
148
  if (portrConfig) {
111
- const portrEnv = await installPortr();
112
- await writePortrConfig(portrConfig, portrEnv);
113
- children.push(await startPortr(portrConfig, portrEnv));
149
+ initializers.push(runtime.startPortr(portrConfig));
114
150
  } else {
115
151
  logger.info("portr not configured (requires PORTR_TOKEN and TUNNEL_PUBLIC_DOMAIN)");
116
152
  }
@@ -128,26 +164,31 @@ export function tunnelInterceptor(options: TunnelInterceptorOptions = {}): Inter
128
164
  port,
129
165
  });
130
166
  if (frpConfig) {
131
- const pathProxy =
132
- frpConfig.stripPrefix && frpConfig.path !== "/"
133
- ? await startPathProxy(port, frpConfig.path)
134
- : undefined;
135
- if (pathProxy) {
136
- frpConfig.targetPort = pathProxy.port;
137
- ctx.onLifecycle("shutdown", () => void pathProxy.close());
138
- }
139
- const frpEnv = await installFrp();
140
- const configPath = await writeFrpConfig(frpConfig, frpEnv);
141
- children.push(startFrp(frpConfig, frpEnv, configPath));
167
+ initializers.push(runtime.startFrp(frpConfig, ctx));
142
168
  } else {
143
169
  logger.info("frp not configured (requires TUNNEL_PUBLIC_DOMAIN)");
144
170
  }
145
171
  }
146
- if (!children.length) return;
147
- for (const child of children) ctx.bindProcess(child);
148
- ctx.onLifecycle("shutdown", () => {
149
- for (const child of children) if (!child.killed) child.kill("SIGTERM");
172
+ if (!initializers.length) return;
173
+ const auxiliaries: TunnelAuxiliary[] = [];
174
+ let stopped = false;
175
+ const stop = () => {
176
+ stopped = true;
177
+ for (const auxiliary of auxiliaries) auxiliary.stop();
178
+ };
179
+ ctx.onTeardown(stop);
180
+ ctx.onLifecycle("shutdown", stop);
181
+ void Promise.allSettled(initializers).then((results) => {
182
+ for (const result of results) {
183
+ if (result.status === "rejected") {
184
+ logger.error("tunnel failed to start", { error: result.reason });
185
+ continue;
186
+ }
187
+ if (stopped) result.value.stop();
188
+ else auxiliaries.push(result.value);
189
+ }
190
+ if (!stopped && auxiliaries.length) logger.info(`tunnel bound: ${transport} -> :${port}`);
150
191
  });
151
- logger.info(`tunnel bound: ${transport} -> :${port}`);
192
+ logger.info(`tunnel initialization scheduled: ${transport} -> :${port}`);
152
193
  };
153
194
  }
package/src/plugin.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  ResourceType,
26
26
  } from "@databricks/appkit";
27
27
  import { plugin as appkitPlugin } from "@dbx-tools/appkit";
28
+ import { brand as appkitBrand } from "@dbx-tools/appkit";
28
29
  import {
29
30
  auth as passwordlessAuth,
30
31
  storage as authStorage,
@@ -356,7 +357,7 @@ export class AuthGatePlugin extends Plugin<AuthGateConfig> {
356
357
  } satisfies PluginManifest<"authGate">;
357
358
 
358
359
  private resolved!: ResolvedAuthGateConfig;
359
- private runtime?: PasswordlessAuthRuntime;
360
+ private readonly runtimes = new Map<string, PasswordlessAuthRuntime>();
360
361
 
361
362
  static getResourceRequirements(config: AuthGateConfig): ResourceRequirement[] {
362
363
  if (authStorage.resolveAuthStorageConfig(config).mode !== "lakebase") return [];
@@ -378,32 +379,40 @@ export class AuthGatePlugin extends Plugin<AuthGateConfig> {
378
379
 
379
380
  override async setup(): Promise<void> {
380
381
  this.resolved = resolveAuthGateConfig(this.config);
382
+ if (!this.config.brandName && !coreConfig.text("AUTH_BRAND_NAME", TUNNEL_CONFIG)) {
383
+ this.resolved.brandName = appkitBrand.getBrandContext().name;
384
+ }
381
385
 
382
386
  if (this.resolved.insecure) {
383
387
  logger.warn("insecure mode - the tunnel runs OPEN with no auth gate");
384
388
  } else {
385
389
  const lakebasePlugin = appkitPlugin.instance(this.context, lakebase);
386
- const storage = await authStorage.createAuthStorage(
387
- this.resolved,
388
- lakebasePlugin?.exports().pool,
389
- );
390
390
  const { key } = await signingKey(this.resolved.sessionCutoffMs);
391
- this.runtime = await passwordlessAuth.createPasswordlessAuth({
392
- storage,
393
- baseURL: authOrigin(this.resolved.publicDomain),
394
- basePath: "/api/email/auth",
395
- appName: this.resolved.brandName,
396
- secret: Buffer.from(key).toString("base64url"),
397
- sessionTtlSeconds: this.resolved.sessionTtlSeconds,
398
- sessionCutoffMs: this.resolved.sessionCutoffMs,
399
- logoutRedirectPath: this.resolved.logoutRedirectPath,
400
- codeTtlSeconds: this.resolved.codeTtlSeconds,
401
- maxAttempts: this.resolved.maxAttempts,
402
- authorizeIdentity: (email) => this.authorizeIdentity(email),
403
- sendCode: (email, code, options) => this.sendCode(email, code, options),
404
- subject: this.resolved.subject,
405
- message: this.resolved.message,
406
- });
391
+ const origins = this.resolved.publicDomains.length
392
+ ? this.resolved.publicDomains.map(authOrigin)
393
+ : [authOrigin(this.resolved.publicDomain)];
394
+ for (const origin of new Set(origins)) {
395
+ const storage = await authStorage.createAuthStorage(
396
+ this.resolved,
397
+ lakebasePlugin?.exports().pool,
398
+ );
399
+ this.runtimes.set(new URL(origin).host.toLowerCase(), await passwordlessAuth.createPasswordlessAuth({
400
+ storage,
401
+ baseURL: origin,
402
+ basePath: "/api/email/auth",
403
+ appName: this.resolved.brandName,
404
+ secret: Buffer.from(key).toString("base64url"),
405
+ sessionTtlSeconds: this.resolved.sessionTtlSeconds,
406
+ sessionCutoffMs: this.resolved.sessionCutoffMs,
407
+ logoutRedirectPath: this.resolved.logoutRedirectPath,
408
+ codeTtlSeconds: this.resolved.codeTtlSeconds,
409
+ maxAttempts: this.resolved.maxAttempts,
410
+ authorizeIdentity: (email) => this.authorizeIdentity(email),
411
+ sendCode: (email, code, options) => this.sendCode(email, code, options),
412
+ subject: this.resolved.subject,
413
+ message: this.resolved.message,
414
+ }));
415
+ }
407
416
  // `server()` is deferred, so it does not exist during this plugin's setup.
408
417
  // At `setup:complete` the Express app exists but has not injected plugin
409
418
  // routes or static handling yet, which is the one point the gate can mount
@@ -431,7 +440,8 @@ export class AuthGatePlugin extends Plugin<AuthGateConfig> {
431
440
  }
432
441
 
433
442
  async shutdown(): Promise<void> {
434
- await this.runtime?.close();
443
+ await Promise.all([...this.runtimes.values()].map((runtime) => runtime.close()));
444
+ this.runtimes.clear();
435
445
  }
436
446
 
437
447
  /**
@@ -467,16 +477,19 @@ export class AuthGatePlugin extends Plugin<AuthGateConfig> {
467
477
  }
468
478
 
469
479
  override exports(): AuthGateApi {
470
- const runtime = this.runtime;
480
+ const runtime = (headers?: Headers, request?: Request) => {
481
+ const host = (headers?.get("host") ?? (request ? new URL(request.url).host : "")).toLowerCase();
482
+ return this.runtimes.get(host) ?? this.runtimes.values().next().value;
483
+ };
471
484
  return {
472
- passkeysEnabled: runtime?.passkeysEnabled ?? false,
485
+ passkeysEnabled: [...this.runtimes.values()].some((entry) => entry.passkeysEnabled),
473
486
  handler: (request) =>
474
- runtime?.handler(request) ?? Promise.resolve(new Response("Not Found", { status: 404 })),
475
- session: (headers) => runtime?.session(headers) ?? Promise.resolve(undefined),
487
+ runtime(request.headers, request)?.handler(request) ?? Promise.resolve(new Response("Not Found", { status: 404 })),
488
+ session: (headers) => runtime(headers)?.session(headers) ?? Promise.resolve(undefined),
476
489
  status: (headers) =>
477
- runtime?.status(headers) ??
490
+ runtime(headers)?.status(headers) ??
478
491
  Promise.resolve({ authenticated: false, enabled: false, passkeysEnabled: false }),
479
- close: () => runtime?.close() ?? Promise.resolve(),
492
+ close: () => this.shutdown(),
480
493
  };
481
494
  }
482
495
  }
package/src/portr.ts CHANGED
@@ -18,6 +18,7 @@ import { promisify } from "node:util";
18
18
  import { bin, config } from "@dbx-tools/core";
19
19
  import { log } from "@dbx-tools/shared-core";
20
20
  import { TUNNEL_CONFIG } from "./_config.ts";
21
+ import { superviseProcessForever, type ProcessSupervisor } from "./supervisor.ts";
21
22
 
22
23
  const logger = log.logger("tunnel:portr");
23
24
  const PORTR_VERSION = "1.0.15-sse.2";
@@ -141,3 +142,11 @@ export async function startPortr(
141
142
  logger.info(`portr tunneling https://${config.subdomain}.${config.server} -> :${config.port}`);
142
143
  return spawn("portr", ["start"], { env: childEnv, stdio: "inherit" });
143
144
  }
145
+
146
+ export function supervisePortr(config: PortrConfig, childEnv: NodeJS.ProcessEnv): ProcessSupervisor {
147
+ return superviseProcessForever({
148
+ name: "portr",
149
+ logger,
150
+ start: () => startPortr(config, childEnv),
151
+ });
152
+ }
@@ -0,0 +1,89 @@
1
+ import type { ChildProcess } from "node:child_process";
2
+ import { async as asyncTools, type Logger } from "@dbx-tools/shared-core";
3
+
4
+ const STABLE_CONNECTION_MS = 60_000;
5
+
6
+ export interface ProcessSupervisor {
7
+ stop(): void;
8
+ }
9
+
10
+ type ProcessOutcome = {
11
+ code?: number | null;
12
+ signal?: NodeJS.Signals | null;
13
+ error?: unknown;
14
+ };
15
+
16
+ export function superviseProcessForever(options: {
17
+ name: string;
18
+ logger: Logger;
19
+ start: () => ChildProcess | Promise<ChildProcess>;
20
+ retryDelaysMs?: readonly number[];
21
+ }): ProcessSupervisor {
22
+ const controller = new AbortController();
23
+ let child: ChildProcess | undefined;
24
+
25
+ const stop = () => {
26
+ if (controller.signal.aborted) return;
27
+ controller.abort();
28
+ if (child && !child.killed) child.kill("SIGTERM");
29
+ process.off("exit", onProcessExit);
30
+ };
31
+ const onProcessExit = () => {
32
+ if (child && !child.killed) child.kill("SIGTERM");
33
+ };
34
+ process.once("exit", onProcessExit);
35
+
36
+ const run = async () => {
37
+ let failures = 0;
38
+ while (!controller.signal.aborted) {
39
+ const startedAt = Date.now();
40
+ let outcome: ProcessOutcome;
41
+ try {
42
+ child = await options.start();
43
+ outcome = await processOutcome(child, controller.signal);
44
+ } catch (error) {
45
+ outcome = { error };
46
+ } finally {
47
+ child = undefined;
48
+ }
49
+ if (controller.signal.aborted) return;
50
+ if (Date.now() - startedAt >= STABLE_CONNECTION_MS) failures = 0;
51
+ const delayMs = asyncTools.boundedRetryDelay(failures++, options.retryDelaysMs);
52
+ options.logger.warn(`${options.name} stopped; retrying`, { ...outcome, delayMs });
53
+ try {
54
+ await asyncTools.sleep(delayMs, controller.signal);
55
+ } catch {
56
+ return;
57
+ }
58
+ }
59
+ };
60
+
61
+ void run().catch((error) => options.logger.error(`${options.name} supervisor failed`, { error }));
62
+ return {
63
+ stop,
64
+ };
65
+ }
66
+
67
+ function processOutcome(child: ChildProcess, signal: AbortSignal): Promise<ProcessOutcome> {
68
+ return new Promise((resolve) => {
69
+ const finish = (outcome: ProcessOutcome) => {
70
+ child.off("exit", onExit);
71
+ child.off("error", onError);
72
+ signal.removeEventListener("abort", onAbort);
73
+ resolve(outcome);
74
+ };
75
+ const onExit = (code: number | null, exitSignal: NodeJS.Signals | null) =>
76
+ finish({ code, signal: exitSignal });
77
+ const onError = (error: Error) => {
78
+ if (!child.killed) child.kill("SIGTERM");
79
+ finish({ error });
80
+ };
81
+ const onAbort = () => {
82
+ if (!child.killed) child.kill("SIGTERM");
83
+ finish({ signal: "SIGTERM" });
84
+ };
85
+ child.once("exit", onExit);
86
+ child.once("error", onError);
87
+ signal.addEventListener("abort", onAbort, { once: true });
88
+ });
89
+ }