From 4c5bcf5eb5ad2d7ec635c6a058afd96b3abd6c62 Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Thu, 13 Aug 2026 09:07:23 +0100 Subject: [PATCH] fix: keep dev tunnel stable across restarts --- package.json | 1 - packages/common/src/dev-server.ts | 98 +++++++++++++++++++++ packages/common/src/index.ts | 1 + packages/console/src/commands/DevCommand.ts | 1 - pnpm-lock.yaml | 32 ------- templates/express-inertia/package.json | 3 +- templates/express-inertia/tsdown.config.ts | 26 +++--- templates/express/package.json | 1 - templates/express/tsdown.config.ts | 26 +++--- templates/h3/package.json | 1 - templates/h3/tsdown.config.ts | 25 +++--- 11 files changed, 130 insertions(+), 85 deletions(-) create mode 100644 packages/common/src/dev-server.ts diff --git a/package.json b/package.json index 8553c521..df1388e1 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,6 @@ "@eslint/js": "^10.0.1", "@eslint/markdown": "^8.0.1", "@faker-js/faker": "^10.4.0", - "@rollup/plugin-run": "^3.1.0", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/multer": "^2.1.0", diff --git a/packages/common/src/dev-server.ts b/packages/common/src/dev-server.ts new file mode 100644 index 00000000..ad5e1db9 --- /dev/null +++ b/packages/common/src/dev-server.ts @@ -0,0 +1,98 @@ +import { fork, type ChildProcess, type ForkOptions } from 'node:child_process' +import { access, stat } from 'node:fs/promises' +import path from 'node:path' +import { nodeEnv } from './system' + +interface OutputChunk { + facadeModuleId?: string | null; + isEntry?: boolean; +} + +interface OutputOptions { + dir?: string; + file?: string; +} + +export interface DevServerRunnerOptions extends ForkOptions { + input: string; + args?: readonly string[]; +} + +/** + * Run the emitted server entry after a watched build has completely settled. + * + * This runner does not replace the live process immediately in `writeBundle`. + * Tsdown can still finalize or replace its output after that hook, creating a + * short window where Node cannot resolve the entry. + */ +export const devServer = (options: DevServerRunnerOptions) => { + if (nodeEnv() !== 'dev' || process.env.CLI_BUILD === 'true') { + return { name: 'arkstack-dev-server-disabled' } + } + + let child: ChildProcess | undefined + let restart = Promise.resolve() + const { + input: sourceInput, + args: runnerArgs = [], + ...forkOptions + } = options + const input = path.resolve(sourceInput) + const args = [...runnerArgs] + + const waitUntilStable = async (entry: string): Promise => { + let previousSize = -1 + + for (let attempt = 0; attempt < 50; attempt++) { + try { + await access(entry) + const size = (await stat(entry)).size + + if (size > 0 && size === previousSize) return + previousSize = size + } catch { + previousSize = -1 + } + + await new Promise(resolve => setTimeout(resolve, 10)) + } + + throw new Error(`The emitted server entry was not ready: ${entry}`) + } + + const stopChild = async (): Promise => { + if (!child || child.exitCode !== null) return + + const exited = new Promise(resolve => child?.once('exit', () => resolve())) + child.kill() + await exited + } + + return { + name: 'arkstack-dev-server', + writeBundle(output: OutputOptions, bundle: Record) { + const fileName = Object.keys(bundle).find(name => { + const chunk = bundle[name] + + return chunk?.isEntry && path.resolve(chunk.facadeModuleId ?? '') === input + }) + + if (!fileName) throw new Error('Could not find the emitted server entry') + + const directory = output.dir ?? path.dirname(output.file ?? '') + const entry = path.join(directory, fileName) + + restart = restart.then(async () => { + // Yield past `writeBundle` so tsdown can finish swapping its + // staged output into place before we inspect or execute it. + await new Promise(resolve => setTimeout(resolve, 0)) + await waitUntilStable(entry) + await stopChild() + child = fork(entry, args, forkOptions) + }) + }, + async closeWatcher() { + await stopChild() + }, + } +} diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 5d72852d..2ccf8aef 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -16,3 +16,4 @@ export * from './utils/helpers' export * from './EnvLoader' export * from './ConfigLoader' export * from './tls' +export * from './dev-server' diff --git a/packages/console/src/commands/DevCommand.ts b/packages/console/src/commands/DevCommand.ts index e49af87d..7be5770a 100644 --- a/packages/console/src/commands/DevCommand.ts +++ b/packages/console/src/commands/DevCommand.ts @@ -50,7 +50,6 @@ export class DevCommand extends Command { if (url) { vars.TUNNEL_URL = url - console.log(`Traffic has been tunnelled to ${url}`) } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5ea558b..861cfdd3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,9 +111,6 @@ importers: '@faker-js/faker': specifier: ^10.4.0 version: 10.4.0 - '@rollup/plugin-run': - specifier: ^3.1.0 - version: 3.1.0(rollup@4.57.1) '@types/cors': specifier: ^2.8.19 version: 2.8.19 @@ -853,9 +850,6 @@ importers: '@faker-js/faker': specifier: ^10.4.0 version: 10.4.0 - '@rollup/plugin-run': - specifier: ^3.1.0 - version: 3.1.0(rollup@4.57.1) '@types/cors': specifier: ^2.8.19 version: 2.8.19 @@ -1004,9 +998,6 @@ importers: '@faker-js/faker': specifier: ^10.4.0 version: 10.4.0 - '@rollup/plugin-run': - specifier: ^3.1.0 - version: 3.1.0(rollup@4.57.1) '@types/cors': specifier: ^2.8.19 version: 2.8.19 @@ -1146,9 +1137,6 @@ importers: '@faker-js/faker': specifier: ^10.4.0 version: 10.4.0 - '@rollup/plugin-run': - specifier: ^3.1.0 - version: 3.1.0(rollup@4.57.1) '@types/node': specifier: ^25.6.2 version: 25.6.2 @@ -2835,15 +2823,6 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - '@rollup/plugin-run@3.1.0': - resolution: {integrity: sha512-k2daijcVA8RAG1PXUFtIAOmb9ifiMv6Kth3Y9OhZ8/W+j8eTgZkVsOmBQD11HaeY1rYqRb0aLjX4e2V9bpS01Q==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^2.0.0||^3.0.0||^4.0.0 - peerDependenciesMeta: - rollup: - optional: true - '@rollup/rollup-android-arm-eabi@4.57.1': resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} cpu: [arm] @@ -3434,9 +3413,6 @@ packages: '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} - '@types/node@14.18.30': - resolution: {integrity: sha512-8OEyg4oc/CqN5+LbInKNLA8MfbGzbC+k8lVPePXazuwEVrVeQ9gwMDX00HJwWbC7syc1FWRU6Mow0Lm+mibHAQ==} - '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} @@ -8904,12 +8880,6 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} - '@rollup/plugin-run@3.1.0(rollup@4.57.1)': - dependencies: - '@types/node': 14.18.30 - optionalDependencies: - rollup: 4.57.1 - '@rollup/rollup-android-arm-eabi@4.57.1': optional: true @@ -9554,8 +9524,6 @@ snapshots: '@types/node@12.20.55': {} - '@types/node@14.18.30': {} - '@types/node@18.19.130': dependencies: undici-types: 5.26.5 diff --git a/templates/express-inertia/package.json b/templates/express-inertia/package.json index 655756a7..ed69a17f 100644 --- a/templates/express-inertia/package.json +++ b/templates/express-inertia/package.json @@ -53,7 +53,6 @@ "@eslint/js": "^10.0.1", "@eslint/markdown": "^8.0.1", "@faker-js/faker": "^10.4.0", - "@rollup/plugin-run": "^3.1.0", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/node": "^25.6.2", @@ -71,4 +70,4 @@ "unrun": "^0.3.0", "vitest": "^4.1.5" } -} \ No newline at end of file +} diff --git a/templates/express-inertia/tsdown.config.ts b/templates/express-inertia/tsdown.config.ts index d083c4f7..6c5f28cb 100644 --- a/templates/express-inertia/tsdown.config.ts +++ b/templates/express-inertia/tsdown.config.ts @@ -1,10 +1,9 @@ -import { nodeEnv, outputDir } from '@arkstack/common' +import { devServer, nodeEnv, outputDir } from '@arkstack/common' import { readFileSync, writeFileSync } from 'node:fs' import { Arkstack } from '@arkstack/contract' import { defineConfig } from 'tsdown' import path from 'node:path' -import run from '@rollup/plugin-run' const env = nodeEnv() const dist = path.relative(Arkstack.rootDir(), outputDir()) @@ -23,20 +22,15 @@ export default defineConfig([ skipNodeModulesBundle: true, }, watch: env === 'dev' && process.env.CLI_BUILD !== 'true' ? ['.env', '.env.*', 'src', 'tsconfig.json'] : false, - plugins: - env === 'dev' && process.env.CLI_BUILD !== 'true' - ? [ - run({ - env: Object.assign({}, process.env, { - NODE_ENV: env, - }), - execArgv: ['-r', 'source-map-support/register'], - allowRestarts: true, - input: path.join(Arkstack.rootDir(), 'src/server.ts'), - }) - ] - : [ - ], + plugins: [ + devServer({ + env: Object.assign({}, process.env, { + NODE_ENV: env, + }), + execArgv: ['-r', 'source-map-support/register'], + input: path.join(Arkstack.rootDir(), 'src/server.ts'), + }) + ], outExtensions: () => { return { js: '.js', diff --git a/templates/express/package.json b/templates/express/package.json index 4cd8d30b..6f34f38f 100644 --- a/templates/express/package.json +++ b/templates/express/package.json @@ -48,7 +48,6 @@ "@eslint/js": "^10.0.1", "@eslint/markdown": "^8.0.1", "@faker-js/faker": "^10.4.0", - "@rollup/plugin-run": "^3.1.0", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/node": "^25.6.2", diff --git a/templates/express/tsdown.config.ts b/templates/express/tsdown.config.ts index 04cfdcc8..db7c33af 100644 --- a/templates/express/tsdown.config.ts +++ b/templates/express/tsdown.config.ts @@ -1,10 +1,9 @@ -import { nodeEnv, outputDir } from '@arkstack/common' +import { devServer, nodeEnv, outputDir } from '@arkstack/common' import { readFileSync, writeFileSync } from 'node:fs' import { Arkstack } from '@arkstack/contract' import { defineConfig } from 'tsdown' import path from 'node:path' -import run from '@rollup/plugin-run' const env = nodeEnv() const dist = path.relative(Arkstack.rootDir(), outputDir()) @@ -23,20 +22,15 @@ export default defineConfig([ skipNodeModulesBundle: true, }, watch: env === 'dev' && process.env.CLI_BUILD !== 'true' ? ['.env', '.env.*', 'src', 'tsconfig.json'] : false, - plugins: - env === 'dev' && process.env.CLI_BUILD !== 'true' - ? [ - run({ - env: Object.assign({}, process.env, { - NODE_ENV: env, - }), - execArgv: ['-r', 'source-map-support/register'], - allowRestarts: true, - input: path.join(Arkstack.rootDir(), 'src/server.ts'), - }) - ] - : [ - ], + plugins: [ + devServer({ + env: Object.assign({}, process.env, { + NODE_ENV: env, + }), + execArgv: ['-r', 'source-map-support/register'], + input: path.join(Arkstack.rootDir(), 'src/server.ts'), + }) + ], outExtensions: () => { return { js: '.js', diff --git a/templates/h3/package.json b/templates/h3/package.json index e49ba9b8..1cf2f3a9 100644 --- a/templates/h3/package.json +++ b/templates/h3/package.json @@ -48,7 +48,6 @@ "@eslint/js": "^10.0.1", "@eslint/markdown": "^8.0.1", "@faker-js/faker": "^10.4.0", - "@rollup/plugin-run": "^3.1.0", "@types/node": "^25.6.2", "@types/pg": "^8.16.0", "eslint": "^10.3.0", diff --git a/templates/h3/tsdown.config.ts b/templates/h3/tsdown.config.ts index 5b513488..292b1827 100644 --- a/templates/h3/tsdown.config.ts +++ b/templates/h3/tsdown.config.ts @@ -1,10 +1,9 @@ -import { nodeEnv, outputDir } from '@arkstack/common' +import { devServer, nodeEnv, outputDir } from '@arkstack/common' import { readFileSync, writeFileSync } from 'node:fs' import { Arkstack } from '@arkstack/contract' import { defineConfig } from 'tsdown' import path from 'node:path' -import run from '@rollup/plugin-run' const env = nodeEnv() const dist = path.relative(Arkstack.rootDir(), outputDir()) @@ -27,19 +26,15 @@ export default defineConfig([ skipNodeModulesBundle: true, }, watch: env === 'dev' && process.env.CLI_BUILD !== 'true' ? ['.env', '.env.*', 'src', 'tsconfig.json'] : false, - plugins: - env === 'dev' && process.env.CLI_BUILD !== 'true' - ? [ - run({ - env: Object.assign({}, process.env, { - NODE_ENV: env, - }), - execArgv: ['-r', 'source-map-support/register'], - allowRestarts: true, - input: path.join(Arkstack.rootDir(), 'src/server.ts'), - }), - ] - : [], + plugins: [ + devServer({ + env: Object.assign({}, process.env, { + NODE_ENV: env, + }), + execArgv: ['-r', 'source-map-support/register'], + input: path.join(Arkstack.rootDir(), 'src/server.ts'), + }), + ], outExtensions: () => { return { js: '.js',