Skip to content

Commit 64d7b4f

Browse files
committed
feat: add ngrok support for tunneling in DevCommand and update environment variables
1 parent f28f691 commit 64d7b4f

9 files changed

Lines changed: 130 additions & 34 deletions

File tree

packages/common/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,5 +63,10 @@
6363
"arkormx": {
6464
"optional": true
6565
}
66+
},
67+
"inlinedDependencies": {
68+
"clear-router": "2.9.3",
69+
"dayjs": "1.11.20",
70+
"kanun": "1.2.0"
6671
}
6772
}

packages/common/src/EnvLoader.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,14 @@ export class EnvLoader {
2727
this.loaded = true
2828

2929
try {
30-
loadEnvFile({ quiet: true })
30+
loadEnvFile({
31+
quiet: true,
32+
// `ark dev` is a long-lived parent process. Its restarted server
33+
// children inherit the parent's original dotenv values, so let
34+
// each fresh child replace those cached values from the current
35+
// file. Normal runtimes retain dotenv's shell-first precedence.
36+
override: process.env.ARKSTACK_ENV_RELOAD === 'true',
37+
})
3138
} catch {
3239
/** No .env file (or dotenv unavailable); use process.env as-is. */
3340
}
@@ -79,4 +86,4 @@ export class EnvLoader {
7986
/**
8087
* Shared environment loader backing {@link env}.
8188
*/
82-
export const envLoader = new EnvLoader()
89+
export const envLoader = new EnvLoader()

packages/common/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export interface EnvRegistry {
2828
APP_HOST: string
2929
APP_PORT: number
3030
APP_DEBUG: boolean
31+
APP_SECURE: boolean
3132
APP_TIMEZONE: string
3233
APP_LOCALE: typeof locales[number]
3334
APP_FALLBACK_LOCALE: typeof locales[number]

packages/console/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
"clear-router": "catalog:"
5252
},
5353
"dependencies": {
54+
"@ngrok/ngrok": "^1.7.0",
5455
"@arkstack/common": "workspace:^",
5556
"@arkstack/contract": "workspace:^",
5657
"@h3ravel/musket": "catalog:",

packages/console/src/commands/DevCommand.ts

Lines changed: 45 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { dirname, join } from 'node:path'
22

33
import { Arkstack } from '@arkstack/contract'
4+
import { env } from '@arkstack/common'
45
import { Command } from '@h3ravel/musket'
56
import { createRequire } from 'node:module'
67
import { readFileSync } from 'node:fs'
78
import { spawn } from 'node:child_process'
9+
import ngrok, { type Listener } from '@ngrok/ngrok'
810

911
export interface DevServerOptions {
1012
/**
@@ -35,6 +37,22 @@ export class DevCommand extends Command {
3537
const vars = DevCommand.devServerEnv(this.options())
3638
const rootDir = Arkstack.rootDir()
3739
const bin = DevCommand.resolveTsdownBin(rootDir)
40+
let tunnel: Listener | undefined
41+
42+
if (this.option?.('tunnel')) {
43+
tunnel = await ngrok.forward({
44+
addr: Number(env('APP_PORT', env('PORT', 3000))),
45+
authtoken: env('NGROK_AUTHTOKEN'),
46+
domain: env('NGROK_DOMAIN'),
47+
})
48+
49+
const url = tunnel.url()
50+
51+
if (url) {
52+
vars.TUNNEL_URL = url
53+
console.log(`Traffic has been tunnelled to ${url}`)
54+
}
55+
}
3856

3957
// Run tsdown directly with node when we can resolve its bin — this avoids
4058
// the extra `pnpm exec` wrapper process. Fall back to `pnpm exec tsdown`
@@ -46,27 +64,31 @@ export class DevCommand extends Command {
4664
['exec', 'tsdown', '--log-level', 'silent'],
4765
]
4866

49-
await new Promise<void>((resolve, reject) => {
50-
const child = spawn(command, args, {
51-
cwd: rootDir,
52-
stdio: 'inherit',
53-
env: Object.assign(process.env, vars),
54-
})
55-
56-
child.on('error', (error) => {
57-
reject(error)
58-
})
59-
60-
child.on('exit', (code) => {
61-
if (code === 0 || code === null) {
62-
resolve()
63-
64-
return
65-
}
66-
67-
reject(new Error(`tsdown exited with code ${code}`))
67+
try {
68+
await new Promise<void>((resolve, reject) => {
69+
const child = spawn(command, args, {
70+
cwd: rootDir,
71+
stdio: 'inherit',
72+
env: Object.assign({}, process.env, vars),
73+
})
74+
75+
child.on('error', (error) => {
76+
reject(error)
77+
})
78+
79+
child.on('exit', (code) => {
80+
if (code === 0 || code === null) {
81+
resolve()
82+
83+
return
84+
}
85+
86+
reject(new Error(`tsdown exited with code ${code}`))
87+
})
6888
})
69-
})
89+
} finally {
90+
await tunnel?.close()
91+
}
7092
}
7193

7294
/**
@@ -97,7 +119,8 @@ export class DevCommand extends Command {
97119
*
98120
* The dev server binds `127.0.0.1` by default so it is local-only; `--host`
99121
* switches it to `0.0.0.0` to expose it on the local network. `--secure` flags
100-
* the driver to serve HTTPS, and `--tunnel` enables the Ngrok tunnel.
122+
* the driver to serve HTTPS. The command itself owns the Ngrok tunnel so
123+
* watcher-driven application restarts cannot replace its public URL.
101124
*
102125
* @param options
103126
* @returns
@@ -108,10 +131,7 @@ export class DevCommand extends Command {
108131
const vars: Record<string, string> = {
109132
NODE_ENV: 'development',
110133
APP_HOST: options.host ? '0.0.0.0' : '127.0.0.1',
111-
}
112-
113-
if (options.tunnel) {
114-
vars.TUNNEL = 'true'
134+
ARKSTACK_ENV_RELOAD: 'true',
115135
}
116136

117137
if (options.secure) {

packages/console/tests/dev-command.test.ts

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ vi.mock('node:child_process', () => {
99
}
1010
})
1111

12+
vi.mock('@ngrok/ngrok', () => ({
13+
default: {
14+
forward: vi.fn(),
15+
},
16+
}))
17+
1218
const makeChild = () => new EventEmitter() as EventEmitter & {
1319
on: (event: string, listener: (...args: any[]) => void) => EventEmitter;
1420
}
@@ -38,6 +44,7 @@ describe('DevCommand', () => {
3844
...process.env,
3945
NODE_ENV: 'development',
4046
APP_HOST: '127.0.0.1',
47+
ARKSTACK_ENV_RELOAD: 'true',
4148
}
4249
},
4350
)
@@ -81,6 +88,39 @@ describe('DevCommand', () => {
8188
await expect(promise).rejects.toThrow('tsdown exited with code 1')
8289
})
8390

91+
it('keeps the tunnel in the dev command and passes its URL to the watcher', async () => {
92+
const { spawn } = await import('node:child_process')
93+
const { default: ngrok } = await import('@ngrok/ngrok')
94+
const { DevCommand } = await import('../src/commands/DevCommand')
95+
const close = vi.fn().mockResolvedValue(undefined)
96+
const child = makeChild()
97+
98+
vi.mocked(ngrok.forward).mockResolvedValueOnce({
99+
url: () => 'https://stable.ngrok.app',
100+
close,
101+
} as any)
102+
vi.mocked(spawn).mockReturnValueOnce(child as any)
103+
104+
const promise = DevCommand.prototype.handle.call({
105+
options: () => ({ tunnel: true }),
106+
option: () => true,
107+
})
108+
await vi.waitFor(() => expect(spawn).toHaveBeenCalled())
109+
child.emit('exit', 0)
110+
111+
await expect(promise).resolves.toBeUndefined()
112+
expect(spawn).toHaveBeenLastCalledWith(
113+
expect.any(String),
114+
expect.any(Array),
115+
expect.objectContaining({
116+
env: expect.objectContaining({
117+
TUNNEL_URL: 'https://stable.ngrok.app',
118+
}),
119+
}),
120+
)
121+
expect(close).toHaveBeenCalledOnce()
122+
})
123+
84124
it('resolveTsdownBin resolves tsdown from the workspace', async () => {
85125
const { DevCommand } = await import('../src/commands/DevCommand')
86126

@@ -97,6 +137,7 @@ describe('devServerEnv', () => {
97137

98138
expect(vars.NODE_ENV).toBe('development')
99139
expect(vars.APP_HOST).toBe('127.0.0.1')
140+
expect(vars.ARKSTACK_ENV_RELOAD).toBe('true')
100141
expect(vars.TUNNEL).toBeUndefined()
101142
expect(vars.APP_SECURE).toBeUndefined()
102143
})
@@ -107,11 +148,11 @@ describe('devServerEnv', () => {
107148
expect(DevCommand.devServerEnv({ host: true }).APP_HOST).toBe('0.0.0.0')
108149
})
109150

110-
it('--secure flags HTTPS and --tunnel enables Ngrok', async () => {
151+
it('--secure flags HTTPS while the command manages Ngrok separately', async () => {
111152
const { DevCommand } = await import('../src/commands/DevCommand')
112153

113154
expect(DevCommand.devServerEnv({ secure: true }).APP_SECURE).toBe('true')
114-
expect(DevCommand.devServerEnv({ tunnel: true }).TUNNEL).toBe('true')
155+
expect(DevCommand.devServerEnv({ tunnel: true }).TUNNEL).toBeUndefined()
115156
})
116157

117158
it('combines all flags', async () => {
@@ -120,7 +161,7 @@ describe('devServerEnv', () => {
120161
expect(DevCommand.devServerEnv({ host: true, secure: true, tunnel: true })).toEqual({
121162
NODE_ENV: 'development',
122163
APP_HOST: '0.0.0.0',
123-
TUNNEL: 'true',
164+
ARKSTACK_ENV_RELOAD: 'true',
124165
APP_SECURE: 'true',
125166
})
126167
})

packages/driver-express/src/index.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,12 +143,21 @@ export class ExpressDriver extends ArkstackKitDriver<Express, Handler> {
143143
const host = env('APP_HOST', env('HOST', '0.0.0.0'))
144144
const secure = env('APP_SECURE', false) === true
145145
const tunneled = env('TUNNEL', false)
146+
const tunnelUrl = env('TUNNEL_URL')
146147
const scheme = secure ? 'https' : 'http'
147148

148149
const onListen = async () => {
149150
let log = startupLogLines(scheme, host, port)
150151

151-
if (tunneled === true) {
152+
if (tunnelUrl) {
153+
log = log.concat(Logger.log([
154+
['Traffic has been tunnelled to', 'white'],
155+
[tunnelUrl, 'green']
156+
], ' ', false))
157+
158+
this.tunnel_url = tunnelUrl
159+
globalThis.tunnelUrl = () => tunnelUrl
160+
} else if (tunneled === true) {
152161
const listener = await ngrok.forward({
153162
addr: port,
154163
authtoken: env('NGROK_AUTHTOKEN'),
@@ -159,7 +168,7 @@ export class ExpressDriver extends ArkstackKitDriver<Express, Handler> {
159168

160169
if (url) {
161170
log = log.concat(Logger.log([
162-
['Trafic has been tunnelled to', 'white'],
171+
['Traffic has been tunnelled to', 'white'],
163172
[url, 'green']
164173
], ' ', false))
165174

packages/driver-h3/src/index.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ export class H3Driver extends ArkstackKitDriver<H3, H3Middleware> {
150150
const host = env('APP_HOST', env('HOST', '0.0.0.0'))
151151
const secure = env('APP_SECURE', false) === true
152152
const tunneled = env('TUNNEL', false)
153+
const tunnelUrl = env('TUNNEL_URL')
153154
const scheme = secure ? 'https' : 'http'
154155

155156
// Dev HTTPS: serve with an in-memory self-signed certificate.
@@ -164,7 +165,15 @@ export class H3Driver extends ArkstackKitDriver<H3, H3Middleware> {
164165

165166
let log = startupLogLines(scheme, host, port)
166167

167-
if (tunneled === true) {
168+
if (tunnelUrl) {
169+
log = log.concat(Logger.log([
170+
['Traffic has been tunnelled to', 'white'],
171+
[tunnelUrl, 'green']
172+
], ' ', false))
173+
174+
this.tunnel_url = tunnelUrl
175+
globalThis.tunnelUrl = () => tunnelUrl
176+
} else if (tunneled === true) {
168177
const listener = await ngrok.forward({
169178
addr: port,
170179
authtoken: env('NGROK_AUTHTOKEN'),
@@ -175,7 +184,7 @@ export class H3Driver extends ArkstackKitDriver<H3, H3Middleware> {
175184

176185
if (url) {
177186
log = log.concat(Logger.log([
178-
['Trafic has been tunnelled to', 'white'],
187+
['Traffic has been tunnelled to', 'white'],
179188
[url, 'green']
180189
], ' ', false))
181190

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)