@ngrok/nextjs 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ishan Jain
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,184 @@
1
- # Temporary Holding Version
1
+ # @ngrok/nextjs
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Run `next dev` with a public ngrok URL, in one command.
4
+ ```
5
+ npx @ngrok/nextjs dev
6
+ ```
7
+
8
+ Built on [`@ngrok/ngrok`](https://www.npmjs.com/package/@ngrok/ngrok), ngrok's official
9
+ JavaScript SDK — a native module, and nothing to install besides this package.
10
+
11
+ ## Setup
12
+
13
+ 1. Sign up at [ngrok.com](https://ngrok.com) and grab an authtoken from
14
+ [the dashboard](https://dashboard.ngrok.com/authtokens).
15
+ 2. Put it in `.env.local` in your Next.js project root:
16
+ ```
17
+ NGROK_AUTHTOKEN=your_token_here
18
+ ```
19
+ 3. Run it:
20
+ ```
21
+ npx @ngrok/nextjs dev
22
+ ```
23
+
24
+ You'll get a public URL bound to your ngrok account's dev domain.
25
+
26
+ ## What it does
27
+
28
+ - Spawns `next dev` and opens an ngrok tunnel to it as one process, so stopping it
29
+ (`Ctrl+C`) cleanly tears down both — including `next dev`'s own child processes.
30
+ - Defaults to your account's dev domain (`abc123.ngrok-free.dev`).
31
+ - Syncs the tunnel URL into `.env.local` under `NEXTAUTH_URL` and `NEXT_PUBLIC_APP_URL` by
32
+ default, so auth libraries and client code pick it up automatically.
33
+ - Prints a QR code so you can open the tunnel on your phone. Disable with `NGROK_QR=false`.
34
+ - Prints a one-time setup checklist on first run per project, reminding you to register the
35
+ URL with your OAuth/webhook providers.
36
+
37
+ ## Config
38
+
39
+ Optional `ngrok.config.ts` (or `.js`/`.mjs`) in your project root.
40
+
41
+ ```ts
42
+ export default {
43
+ url: "https://your-reserved-domain.ngrok.app", // omit for the account's default dev domain
44
+ env: { url: ["NEXTAUTH_URL", "NEXT_PUBLIC_APP_URL"] },
45
+ trafficPolicy: `
46
+ on_http_request:
47
+ - actions:
48
+ - type: basic-auth
49
+ config:
50
+ credentials:
51
+ - "user:password123"
52
+ `,
53
+ };
54
+ ```
55
+
56
+ | Field | Type | Description |
57
+ |---|---|---|
58
+ | `url` | `string?` | Public endpoint URL, with scheme. |
59
+ | `pooling` | `boolean?` | Opt in to ngrok endpoint pooling so multiple endpoints can intentionally share one `url` with load-balanced routing. Required when two endpoints would otherwise collide on the same URL. |
60
+ | `trafficPolicy` | `string?` | A raw [ngrok Traffic Policy](https://ngrok.com/docs/traffic-policy/) document (YAML or JSON) — the mechanism for auth, IP restrictions, header manipulation, webhook verification, and more. |
61
+ | `env.url` | `string[]?` | Which env vars in `.env.local` get this endpoint's URL. |
62
+ | `binding` | `"public" \| "internal" \| "kubernetes"?` | Ingress configuration. |
63
+ | `endpoints` | `EndpointConfig[]?` | multi-endpoint case — front more than one local service from a single command. Supersedes the root-level fields above entirely. Each entry also takes `upstream` (a port number or raw address string) alongside `url`/`pooling`/`trafficPolicy`/`env`/`binding`. |
64
+
65
+ ### Personal overrides
66
+
67
+ Two ways to override the shared config without editing a committed file:
68
+
69
+ - `NGROK_DOMAIN` env var (bare domain or full URL)
70
+ - `ngrok.config.local.ts` — same shape as `ngrok.config.ts`, gitignore it
71
+
72
+ Resolution order: `NGROK_DOMAIN` → `ngrok.config.local.ts` → `ngrok.config.ts` → account
73
+ default dev domain.
74
+
75
+ ### Multiple endpoints
76
+
77
+ ```ts
78
+ export default {
79
+ endpoints: [
80
+ { upstream: 3000, url: "https://app.mycompany.ngrok.app" },
81
+ { upstream: 4000, url: "https://api.mycompany.ngrok.app" },
82
+ ],
83
+ };
84
+ ```
85
+
86
+ Each endpoint needs its own distinct `url`, or `pooling: true` on every endpoint sharing one.
87
+
88
+ ## Examples
89
+
90
+ ### Use your own domain instead of the default dev domain
91
+
92
+ Requires a domain reserved on your ngrok account. Three ways to point at it, in order of precedence:
93
+
94
+ **Env var** — quickest, no file needed:
95
+ ```
96
+ NGROK_DOMAIN=myapp.ngrok.app npx @ngrok/nextjs dev
97
+ ```
98
+ Or put it in `.env.local`. Accepts a bare domain (auto-prefixed to `https://`) or a full URL.
99
+
100
+ **`ngrok.config.local.ts`** — gitignored, personal-only, persists across runs without
101
+ re-exporting an env var every time:
102
+ ```ts
103
+ export default {
104
+ url: "https://myapp.ngrok.app",
105
+ };
106
+ ```
107
+
108
+ **`ngrok.config.ts`** — committed, shared with the team. Only makes sense here if the domain
109
+ is meant for whoever's running the tool at the time, not concurrently by multiple people — see
110
+ [Personal overrides](#personal-overrides) for why.
111
+ ```ts
112
+ export default {
113
+ url: "https://myapp.ngrok.app",
114
+ };
115
+ ```
116
+
117
+ ### Traffic Policy examples
118
+
119
+ `trafficPolicy` is a raw [ngrok Traffic Policy](https://ngrok.com/docs/traffic-policy/)
120
+ document (YAML or JSON) in `ngrok.config.ts`, applied to the endpoint as-is. A few common ones:
121
+
122
+ **Basic auth**:
123
+ ```ts
124
+ export default {
125
+ trafficPolicy: `
126
+ on_http_request:
127
+ - actions:
128
+ - type: basic-auth
129
+ config:
130
+ credentials:
131
+ - "user:password123"
132
+ `,
133
+ };
134
+ ```
135
+
136
+ **IP allowlist**:
137
+ ```ts
138
+ export default {
139
+ trafficPolicy: `
140
+ on_http_request:
141
+ - actions:
142
+ - type: restrict-ips
143
+ config:
144
+ enforce: true
145
+ allow:
146
+ - "203.0.113.0/24"
147
+ `,
148
+ };
149
+ ```
150
+
151
+ **Webhook signature verification**:
152
+ ```ts
153
+ export default {
154
+ trafficPolicy: `
155
+ on_http_request:
156
+ - actions:
157
+ - type: verify-webhook
158
+ config:
159
+ provider: stripe
160
+ secret: "whsec_..."
161
+ `,
162
+ };
163
+ ```
164
+
165
+ ## Requirements
166
+
167
+ - Node.js 18+
168
+ - An ngrok account and authtoken.
169
+
170
+ ## Development
171
+
172
+ ```
173
+ pnpm install
174
+ pnpm run build # compiles src/ -> dist/
175
+ pnpm test # runs the unit test suite
176
+ ```
177
+
178
+ See [`examples/test-app`](examples/test-app) for a working Next.js app to test against, and
179
+ [`DESIGN.md`](DESIGN.md) for the design rationale and everything confirmed empirically along
180
+ the way.
181
+
182
+ ## License
183
+
184
+ [MIT](LICENSE)
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { resolve } from "node:path";
4
+ import { config as loadDotenv } from "dotenv";
5
+ import qrcode from "qrcode-terminal";
6
+ import { loadConfig } from "./config.js";
7
+ import { syncEnvLocal } from "./env-sync.js";
8
+ import { isFirstRunForProject } from "./first-run.js";
9
+ import { openTunnels } from "./tunnels.js";
10
+ async function main() {
11
+ const cwd = process.cwd();
12
+ // .env.local is where NGROK_AUTHTOKEN/NGROK_DOMAIN are expected to live
13
+ // (see DESIGN.md "Config") — Next.js loads it for the `next dev` child
14
+ // process automatically, but that doesn't put it in *this* process's env,
15
+ // so without this, NGROK_AUTHTOKEN silently "isn't found" even when it's
16
+ // sitting right there in the file. Doesn't override already-exported
17
+ // shell env vars.
18
+ loadDotenv({ path: resolve(cwd, ".env.local"), quiet: true });
19
+ const port = Number(process.env.PORT ?? 3000);
20
+ const config = await loadConfig(cwd, port);
21
+ // NGROK_DOMAIN wins over config so a personal reserved domain never has to
22
+ // be hardcoded into a file — see DESIGN.md "Config" for why that matters
23
+ // once more than one person shares an ngrok account. Only applies to the
24
+ // endpoint next dev itself is running on. `url` requires a scheme (see
25
+ // tunnels.ts) — default to https:// if the env var is a bare domain.
26
+ if (process.env.NGROK_DOMAIN) {
27
+ const primaryEntry = config.endpoints.find((e) => e.upstream === port);
28
+ if (primaryEntry) {
29
+ primaryEntry.url = process.env.NGROK_DOMAIN.includes("://")
30
+ ? process.env.NGROK_DOMAIN
31
+ : `https://${process.env.NGROK_DOMAIN}`;
32
+ }
33
+ }
34
+ if (!process.env.NGROK_AUTHTOKEN) {
35
+ console.error("\nNo NGROK_AUTHTOKEN found.\n" +
36
+ "Get a free one at https://dashboard.ngrok.com/authtokens, then run:\n" +
37
+ " NGROK_AUTHTOKEN=<token> npx @ngrok/nextjs dev\n");
38
+ process.exit(1);
39
+ }
40
+ const next = spawn("npx", ["next", "dev", "--port", String(port)], {
41
+ stdio: "inherit",
42
+ shell: process.platform === "win32",
43
+ detached: process.platform !== "win32",
44
+ });
45
+ const killNext = () => {
46
+ if (next.exitCode !== null || next.pid === undefined)
47
+ return;
48
+ if (process.platform === "win32") {
49
+ spawn("taskkill", ["/pid", String(next.pid), "/T", "/F"]);
50
+ }
51
+ else {
52
+ // next dev spawns its own child processes (Turbopack workers, etc.);
53
+ // killing just `next.pid` leaves those running and holding the port.
54
+ // The negative pid targets the whole process group instead.
55
+ try {
56
+ process.kill(-next.pid, "SIGTERM");
57
+ }
58
+ catch {
59
+ // group may already be gone
60
+ }
61
+ }
62
+ };
63
+ let tunnels;
64
+ try {
65
+ tunnels = await openTunnels(config.endpoints);
66
+ }
67
+ catch (err) {
68
+ // next dev is already running at this point — if the tunnel setup fails
69
+ // (e.g. bad config, domain collision), it must not be left orphaned.
70
+ killNext();
71
+ throw err;
72
+ }
73
+ for (const endpoint of tunnels.endpoints) {
74
+ console.log(`\n Public (${endpoint.upstream}): ${endpoint.url}\n`);
75
+ syncEnvLocal(cwd, endpoint.envVars, endpoint.url);
76
+ }
77
+ // QR code only for the primary endpoint (the one next dev is actually
78
+ // running on) — printing one per endpoint would just be noise. Only
79
+ // matches when upstream is the plain port number next dev runs on; if the
80
+ // primary entry's upstream is written as a string instead, it won't be
81
+ // detected as primary — an accepted edge case, not worth parsing for.
82
+ const primary = tunnels.endpoints.find((e) => e.upstream === port) ?? tunnels.endpoints[0];
83
+ if (primary && process.env.NGROK_QR !== "false") {
84
+ qrcode.generate(primary.url, { small: true });
85
+ }
86
+ if (primary && isFirstRunForProject(cwd)) {
87
+ console.log(" First run for this project — register these URLs once with your OAuth/webhook\n" +
88
+ " providers. They won't change again as long as you keep using these domains:\n" +
89
+ tunnels.endpoints.map((e) => ` ${e.url}`).join("\n") +
90
+ "\n");
91
+ }
92
+ const shutdown = async () => {
93
+ await tunnels.close();
94
+ killNext();
95
+ process.exit(0);
96
+ };
97
+ process.on("SIGINT", shutdown);
98
+ process.on("SIGTERM", shutdown);
99
+ next.on("exit", (code) => {
100
+ tunnels.close().finally(() => process.exit(code ?? 0));
101
+ });
102
+ }
103
+ main().catch((err) => {
104
+ console.error(err);
105
+ process.exit(1);
106
+ });
@@ -0,0 +1,23 @@
1
+ export interface HttpEndpointOptions {
2
+ url?: string;
3
+ pooling?: boolean;
4
+ trafficPolicy?: string;
5
+ binding?: "public" | "internal" | "kubernetes";
6
+ }
7
+ export interface EndpointConfig extends HttpEndpointOptions {
8
+ upstream: number | string;
9
+ env?: {
10
+ url?: string[];
11
+ };
12
+ }
13
+ export interface NgrokNextConfig extends HttpEndpointOptions {
14
+ env?: {
15
+ url?: string[];
16
+ };
17
+ endpoints?: EndpointConfig[];
18
+ }
19
+ interface ResolvedConfig {
20
+ endpoints: EndpointConfig[];
21
+ }
22
+ export declare function loadConfig(cwd: string, defaultPort: number): Promise<ResolvedConfig>;
23
+ export {};
package/dist/config.js ADDED
@@ -0,0 +1,34 @@
1
+ import { existsSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { createJiti } from "jiti";
4
+ // Only applied to the synthesized single-endpoint case (no explicit
5
+ // `endpoints` array in the user's config) — a user-specified multi-endpoint
6
+ // entry that omits `env` stays un-synced on purpose, since e.g. a secondary
7
+ // API-server endpoint usually shouldn't claim NEXTAUTH_URL.
8
+ const DEFAULT_ENV_VARS = ["NEXTAUTH_URL", "NEXT_PUBLIC_APP_URL"];
9
+ // Checked in this order: personal override first, then shared/committed config.
10
+ // See DESIGN.md "Config" — ngrok.config.local.ts must never be committed.
11
+ const CONFIG_FILENAMES = [
12
+ "ngrok.config.local.ts",
13
+ "ngrok.config.local.js",
14
+ "ngrok.config.local.mjs",
15
+ "ngrok.config.ts",
16
+ "ngrok.config.js",
17
+ "ngrok.config.mjs",
18
+ ];
19
+ export async function loadConfig(cwd, defaultPort) {
20
+ const jiti = createJiti(import.meta.url);
21
+ for (const filename of CONFIG_FILENAMES) {
22
+ const path = resolve(cwd, filename);
23
+ if (!existsSync(path))
24
+ continue;
25
+ const config = (await jiti.import(path, { default: true }));
26
+ const { env, endpoints, ...httpOptions } = config;
27
+ return {
28
+ endpoints: endpoints ?? [
29
+ { upstream: defaultPort, env: { url: env?.url ?? DEFAULT_ENV_VARS }, ...httpOptions },
30
+ ],
31
+ };
32
+ }
33
+ return { endpoints: [{ upstream: defaultPort, env: { url: DEFAULT_ENV_VARS } }] };
34
+ }
@@ -0,0 +1 @@
1
+ export declare function syncEnvLocal(cwd: string, keys: string[], url: string): void;
@@ -0,0 +1,26 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ // Updates (or appends) the given keys in .env.local with the tunnel URL,
4
+ // preserving every other line in the file untouched.
5
+ export function syncEnvLocal(cwd, keys, url) {
6
+ if (keys.length === 0)
7
+ return;
8
+ const path = resolve(cwd, ".env.local");
9
+ const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
10
+ const lines = existing.length > 0 ? existing.split("\n") : [];
11
+ const seen = new Set();
12
+ const updated = lines.map((line) => {
13
+ const match = line.match(/^([A-Za-z0-9_]+)=/);
14
+ if (match && keys.includes(match[1])) {
15
+ seen.add(match[1]);
16
+ return `${match[1]}=${url}`;
17
+ }
18
+ return line;
19
+ });
20
+ for (const key of keys) {
21
+ if (!seen.has(key))
22
+ updated.push(`${key}=${url}`);
23
+ }
24
+ const output = updated.join("\n").replace(/\n*$/, "\n");
25
+ writeFileSync(path, output);
26
+ }
@@ -0,0 +1 @@
1
+ export declare function isFirstRunForProject(cwd: string): boolean;
@@ -0,0 +1,15 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ // Marker lives under node_modules so it never needs its own gitignore entry —
4
+ // node_modules is already universally ignored.
5
+ function markerPath(cwd) {
6
+ return resolve(cwd, "node_modules/.cache/ngrok-nextjs/first-run");
7
+ }
8
+ export function isFirstRunForProject(cwd) {
9
+ const path = markerPath(cwd);
10
+ if (existsSync(path))
11
+ return false;
12
+ mkdirSync(dirname(path), { recursive: true });
13
+ writeFileSync(path, "");
14
+ return true;
15
+ }
@@ -0,0 +1,27 @@
1
+ import type { EndpointConfig } from "./config.js";
2
+ export interface OpenedEndpoint {
3
+ upstream: number | string;
4
+ url: string;
5
+ envVars: string[];
6
+ }
7
+ export interface Tunnels {
8
+ endpoints: OpenedEndpoint[];
9
+ close(): Promise<void>;
10
+ }
11
+ export type ParsedEndpoint = {
12
+ kind: "default";
13
+ } | {
14
+ kind: "http";
15
+ hostname: string;
16
+ scheme: "HTTP" | "HTTPS";
17
+ } | {
18
+ kind: "tls";
19
+ hostname: string;
20
+ } | {
21
+ kind: "tcp";
22
+ remoteAddr: string;
23
+ };
24
+ export declare function parseEndpointUrl(url: string | undefined): ParsedEndpoint;
25
+ export declare function forwardAddr(upstream: number | string): string;
26
+ export declare function validateUrlGroups(endpoints: EndpointConfig[]): void;
27
+ export declare function openTunnels(endpoints: EndpointConfig[]): Promise<Tunnels>;
@@ -0,0 +1,101 @@
1
+ import ngrok from "@ngrok/ngrok";
2
+ // Pure — no SDK calls — so the scheme-inference rules can be unit tested
3
+ // without a live session. The endpoint type (HTTP/TCP/TLS) and its
4
+ // domain-equivalent setting aren't separate config fields — both come from
5
+ // `url`'s own scheme, since that's already how everyone writes URLs.
6
+ export function parseEndpointUrl(url) {
7
+ if (!url)
8
+ return { kind: "default" };
9
+ let parsed;
10
+ try {
11
+ parsed = new URL(url);
12
+ }
13
+ catch {
14
+ throw new Error(`Endpoint url "${url}" must include a scheme (http://, https://, tcp://, or ` +
15
+ "tls://) so the endpoint type and domain can be inferred from it.");
16
+ }
17
+ const proto = parsed.protocol.replace(":", "");
18
+ switch (proto) {
19
+ case "http":
20
+ case "https":
21
+ return { kind: "http", hostname: parsed.hostname, scheme: proto.toUpperCase() };
22
+ case "tls":
23
+ return { kind: "tls", hostname: parsed.hostname };
24
+ case "tcp":
25
+ // TCP addresses are host:port, not a bare domain — e.g. "2.tcp.ngrok.io:21746".
26
+ return { kind: "tcp", remoteAddr: parsed.host };
27
+ default:
28
+ throw new Error(`Endpoint url "${url}" has unsupported proto "${proto}:" — expected http, https, tcp, or tls.`);
29
+ }
30
+ }
31
+ // tcp/tls are genuinely different SDK builders from http, with different
32
+ // address-setting methods — this is the only place that matters.
33
+ function buildListener(session, entry) {
34
+ const parsed = parseEndpointUrl(entry.url);
35
+ const builder = parsed.kind === "default"
36
+ ? session.httpEndpoint()
37
+ : parsed.kind === "http"
38
+ ? session.httpEndpoint().domain(parsed.hostname).scheme(parsed.scheme)
39
+ : parsed.kind === "tls"
40
+ ? session.tlsEndpoint().domain(parsed.hostname)
41
+ : session.tcpEndpoint().remoteAddr(parsed.remoteAddr);
42
+ if (entry.pooling)
43
+ builder.poolingEnabled(true);
44
+ if (entry.trafficPolicy)
45
+ builder.trafficPolicy(entry.trafficPolicy);
46
+ if (entry.binding)
47
+ builder.binding(entry.binding);
48
+ return builder;
49
+ }
50
+ export function forwardAddr(upstream) {
51
+ return typeof upstream === "number" ? `http://localhost:${upstream}` : upstream;
52
+ }
53
+ // ngrok does not reject or warn when two listeners in the same session end
54
+ // up on the same public url — it silently lets the most recently opened
55
+ // listener win, and the other becomes unreachable with zero indication of
56
+ // why. Confirmed empirically, isolated from this wrapper's own code (a raw
57
+ // SDK script showed both listenAndForward() calls resolving successfully
58
+ // with identical URLs, no exception either time). An endpoint with no
59
+ // explicit `url` falls back to the account's default dev domain, so two
60
+ // url-less endpoints collide there just as surely as two hardcoding the
61
+ // same string. Either case is fine *if* every endpoint sharing that url
62
+ // opts into `pooling: true` (ngrok's real mechanism for intentionally
63
+ // sharing an endpoint with load-balanced routing, confirmed live — repeated
64
+ // requests alternated between two different local backends) — otherwise
65
+ // it's the silent-failure footgun, so fail fast instead.
66
+ export function validateUrlGroups(endpoints) {
67
+ const groups = new Map();
68
+ for (const entry of endpoints) {
69
+ const key = entry.url ?? "";
70
+ groups.set(key, [...(groups.get(key) ?? []), entry]);
71
+ }
72
+ for (const [url, group] of groups) {
73
+ if (group.length <= 1)
74
+ continue;
75
+ if (group.every((e) => e.pooling))
76
+ continue;
77
+ const label = url || "the account's default dev domain";
78
+ const upstreams = group.map((e) => e.upstream).join(", ");
79
+ throw new Error(`Upstreams ${upstreams} would all share ${label}. Without endpoint pooling, only the ` +
80
+ "most recently opened listener actually receives traffic — the rest go silently " +
81
+ 'unreachable. Set "pooling: true" on each of these endpoints to share it ' +
82
+ "intentionally, or give each its own url.");
83
+ }
84
+ }
85
+ // Always goes through a single explicit Session, even for the common
86
+ // one-endpoint case — this is what lets every option apply uniformly
87
+ // regardless of how many endpoints are configured or what proto each uses.
88
+ export async function openTunnels(endpoints) {
89
+ validateUrlGroups(endpoints);
90
+ const session = await new ngrok.SessionBuilder().authtokenFromEnv().connect();
91
+ const opened = [];
92
+ for (const entry of endpoints) {
93
+ const builder = buildListener(session, entry);
94
+ const listener = await builder.listenAndForward(forwardAddr(entry.upstream));
95
+ const url = listener.url();
96
+ if (!url)
97
+ throw new Error(`ngrok listener for upstream ${entry.upstream} returned no URL`);
98
+ opened.push({ upstream: entry.upstream, url, envVars: entry.env?.url ?? [] });
99
+ }
100
+ return { endpoints: opened, close: () => session.close() };
101
+ }
package/package.json CHANGED
@@ -1,6 +1,33 @@
1
1
  {
2
2
  "name": "@ngrok/nextjs",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.1",
4
+ "description": "Run next dev with a public ngrok URL, no separate binary or config required.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "ngrok-nextjs": "./dist/cli.js"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc",
15
+ "dev": "tsc --watch",
16
+ "prepare": "npm run build",
17
+ "test": "tsc -p tsconfig.test.json && node --test 'dist-test/**/*.test.js'"
18
+ },
19
+ "dependencies": {
20
+ "@ngrok/ngrok": "^1.7.0",
21
+ "dotenv": "17.4.2",
22
+ "jiti": "2.7.0",
23
+ "qrcode-terminal": "^0.12.0"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^26.1.2",
27
+ "@types/qrcode-terminal": "^0.12.2",
28
+ "typescript": "^7.0.2"
29
+ },
30
+ "engines": {
31
+ "node": ">=18"
32
+ }
33
+ }