neonctl 2.23.1 → 2.24.0

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.
@@ -0,0 +1,67 @@
1
+ import { NEON_ENV_VAR_KEYS } from '@neondatabase/env';
2
+ import { log } from '../log.js';
3
+ import { resolveNeonEnvVars } from '../dev/env.js';
4
+ import { mergeEnvFile, resolveEnvFilePath } from '../env_file.js';
5
+ import { branchIdFromProps, fillSingleProject } from '../utils/enrichers.js';
6
+ export const command = 'env';
7
+ export const describe = "Manage a branch's Neon env variables locally";
8
+ export const builder = (argv) => argv
9
+ .usage('$0 env <sub-command> [options]')
10
+ .options({
11
+ 'project-id': { describe: 'Project ID', type: 'string' },
12
+ branch: { describe: 'Branch ID or name', type: 'string' },
13
+ })
14
+ .middleware(fillSingleProject)
15
+ .command('pull', "Write the branch's Neon env variables to a local .env file", (yargs) => yargs
16
+ .usage('$0 env pull [options]')
17
+ .options({
18
+ file: {
19
+ describe: 'Target .env file to write. Defaults to an existing .env, ' +
20
+ 'otherwise .env.local. Only Neon variables are updated; other ' +
21
+ 'lines are preserved.',
22
+ type: 'string',
23
+ },
24
+ })
25
+ .example('$0 env pull', "Write the linked branch's Neon vars into .env.local (or .env if present)")
26
+ .example('$0 env pull --branch preview --file .env.preview', 'Pull a specific branch into a specific file'), (args) => pull(args))
27
+ .demandCommand(1);
28
+ export const handler = (args) => args;
29
+ /** Every OS-level env var name `@neondatabase/env` can emit, used only for reporting. */
30
+ const NEON_VAR_NAMES = Object.values(NEON_ENV_VAR_KEYS).flatMap((group) => Object.values(group));
31
+ export const pull = async (props) => {
32
+ const cwd = props.cwd ?? process.cwd();
33
+ const branchId = await branchIdFromProps(props);
34
+ // Reuse `neon dev`'s tiered resolver (neon.ts policy -> plan gate -> fetchEnv, else
35
+ // pullConfig -> fetchEnv). Unlike dev, an unresolved context or failure is surfaced —
36
+ // `env pull` is an explicit action, so it should error rather than write nothing.
37
+ const vars = await resolveNeonEnvVars({
38
+ cwd,
39
+ projectId: props.projectId,
40
+ branchId,
41
+ ...(props.apiKey ? { apiKey: props.apiKey } : {}),
42
+ ...(props.runtimeApi ? { api: props.runtimeApi } : {}),
43
+ });
44
+ const neonVars = pickNeonVars(vars);
45
+ if (Object.keys(neonVars).length === 0) {
46
+ log.info('No Neon env variables to pull for this branch (no DATABASE_URL or ' +
47
+ 'enabled Auth / Data API).');
48
+ return;
49
+ }
50
+ const targetPath = resolveEnvFilePath(cwd, props.file);
51
+ const { written } = mergeEnvFile(targetPath, neonVars);
52
+ log.info('Pulled %d Neon variable%s into %s: %s', written.length, written.length === 1 ? '' : 's', targetPath, written.join(', '));
53
+ };
54
+ /**
55
+ * Keep only the recognized Neon variables from the resolved set, so a stray inherited
56
+ * value never lands in the user's `.env` file. (Today `resolveNeonEnvVars` only emits Neon
57
+ * vars, but filtering keeps the contract explicit and future-proof.)
58
+ */
59
+ const pickNeonVars = (vars) => {
60
+ const out = {};
61
+ for (const name of NEON_VAR_NAMES) {
62
+ const value = vars[name];
63
+ if (value !== undefined)
64
+ out[name] = value;
65
+ }
66
+ return out;
67
+ };
package/commands/index.js CHANGED
@@ -17,6 +17,10 @@ import * as init from './init.js';
17
17
  import * as dataApi from './data_api.js';
18
18
  import * as neonAuth from './neon_auth.js';
19
19
  import * as functions from './functions.js';
20
+ import * as dev from './dev.js';
21
+ import * as config from './config.js';
22
+ import * as deploy from './deploy.js';
23
+ import * as env from './env.js';
20
24
  export default [
21
25
  auth,
22
26
  users,
@@ -37,4 +41,8 @@ export default [
37
41
  init,
38
42
  dataApi,
39
43
  functions,
44
+ dev,
45
+ config,
46
+ deploy,
47
+ env,
40
48
  ];
package/commands/link.js CHANGED
@@ -303,19 +303,21 @@ const promptOrgFromList = async (orgs) => {
303
303
  };
304
304
  const promptProjectChoice = async (projects, suggestedName) => {
305
305
  const choices = [
306
+ { title: '+ Create new project…', value: CREATE_NEW_SENTINEL },
306
307
  ...projects.map((project) => ({
307
308
  title: `${project.name} (${project.id})`,
308
309
  value: project.id,
309
310
  })),
310
- { title: '+ Create new project', value: CREATE_NEW_SENTINEL },
311
311
  ];
312
+ // Create sits at the top, so default to the first existing project (index 1) when there
313
+ // is one; with no projects to show, the create option (index 0) is the only choice.
312
314
  const { selection } = await prompts({
313
315
  onState: onPromptState,
314
316
  type: 'select',
315
317
  name: 'selection',
316
318
  message: 'Which project would you like to link?',
317
319
  choices,
318
- initial: choices.length === 1 ? 0 : 0,
320
+ initial: projects.length > 0 ? 1 : 0,
319
321
  });
320
322
  if (selection === CREATE_NEW_SENTINEL) {
321
323
  return { type: 'create', suggestedName };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Render a TTL in whole seconds back to the canonical `neon.ts` duration string (e.g.
3
+ * `604800` -> `"7d"`), falling back to seconds when no clean unit boundary matches. Mirrors
4
+ * the formatter `@neondatabase/config` uses when it emits a TTL, so `config status` shows
5
+ * the same value a user would write in `neon.ts`.
6
+ */
7
+ export const formatDurationSeconds = (totalSeconds) => {
8
+ const units = [
9
+ ['w', 7 * 24 * 60 * 60],
10
+ ['d', 24 * 60 * 60],
11
+ ['h', 60 * 60],
12
+ ['m', 60],
13
+ ];
14
+ for (const [unit, perUnit] of units) {
15
+ if (totalSeconds % perUnit === 0)
16
+ return `${totalSeconds / perUnit}${unit}`;
17
+ }
18
+ return `${totalSeconds}s`;
19
+ };
20
+ /**
21
+ * Project a resolved branch config (plus the separately-pulled preview state, since
22
+ * functions/buckets don't live on the closure) into a {@link NeonConfigView}.
23
+ *
24
+ * - Service toggles are surfaced as `true` only when enabled (disabled is the absence of
25
+ * the key, exactly as in `neon.ts`).
26
+ * - `ttlSeconds` is rendered back to a duration string (`7d`).
27
+ * - The `branch` section is the JSON-able part of what would otherwise be the `branch`
28
+ * closure: `parent` / `ttl` / `protected` / `postgres.computeSettings`.
29
+ * - `branch` and `preview` are omitted entirely when they would be empty.
30
+ */
31
+ export const toNeonConfigView = (resolved, preview) => {
32
+ const view = {};
33
+ if (resolved.authEnabled)
34
+ view.auth = true;
35
+ if (resolved.dataApiEnabled)
36
+ view.dataApi = true;
37
+ const previewView = toPreviewView(preview);
38
+ if (previewView)
39
+ view.preview = previewView;
40
+ const branch = {};
41
+ if (resolved.parent !== undefined)
42
+ branch.parent = resolved.parent;
43
+ if (resolved.ttlSeconds !== undefined)
44
+ branch.ttl = formatDurationSeconds(resolved.ttlSeconds);
45
+ if (resolved.protected !== undefined)
46
+ branch.protected = resolved.protected;
47
+ if (resolved.postgres?.computeSettings)
48
+ branch.postgres = resolved.postgres;
49
+ if (Object.keys(branch).length > 0)
50
+ view.branch = branch;
51
+ return view;
52
+ };
53
+ const toPreviewView = (preview) => {
54
+ if (!preview)
55
+ return undefined;
56
+ const out = {};
57
+ if (preview.aiGatewayEnabled)
58
+ out.aiGateway = true;
59
+ if (preview.functions.length > 0) {
60
+ out.functions = Object.fromEntries(preview.functions.map((fn) => [fn.slug, { name: fn.name }]));
61
+ }
62
+ if (preview.buckets.length > 0) {
63
+ out.buckets = Object.fromEntries(preview.buckets.map((b) => [b.name, { access: b.access }]));
64
+ }
65
+ return Object.keys(out).length > 0 ? out : undefined;
66
+ };
package/dev/env.js ADDED
@@ -0,0 +1,183 @@
1
+ import { loadConfigFromFile, } from '@neondatabase/config';
2
+ import { plan, pullConfig, } from '@neondatabase/config-runtime';
3
+ import { fetchEnv, toEntries } from '@neondatabase/env';
4
+ import { log } from '../log.js';
5
+ /**
6
+ * Thrown when a `neon.ts` policy declares a branch-level resource (Neon Auth,
7
+ * Data API, a bucket, the AI Gateway) that the linked remote branch does not
8
+ * have yet. Unlike every other failure in {@link resolveDevEnv} — which degrades
9
+ * to "run without injection" — this is a hard stop: the user's intent (a policy)
10
+ * cannot be honored, and silently dropping the secret would be more confusing
11
+ * than refusing to start. The fix is to provision the resource first.
12
+ */
13
+ export class DevEnvMismatchError extends Error {
14
+ constructor() {
15
+ super(...arguments);
16
+ this.name = 'DevEnvMismatchError';
17
+ }
18
+ }
19
+ /**
20
+ * Signals that no project/branch context could be resolved, so there is nothing to
21
+ * resolve env from. `resolveDevEnv` degrades on this (dev runs without injection);
22
+ * `env pull` surfaces it (an explicit pull needs a branch).
23
+ */
24
+ export class MissingBranchContextError extends Error {
25
+ constructor() {
26
+ super(...arguments);
27
+ this.name = 'MissingBranchContextError';
28
+ }
29
+ }
30
+ /**
31
+ * Resolve the branch's Neon env vars (pooled / direct `DATABASE_URL`, plus Auth /
32
+ * Data API when enabled) into a `{ KEY: value }` map. Shared by `neon dev` (which
33
+ * injects them) and `neon env pull` (which writes them to a `.env` file).
34
+ *
35
+ * Tiered:
36
+ *
37
+ * 1. a `neon.ts` policy is found -> the policy is the source of truth. We first
38
+ * check it against the branch's live state (`plan`); if it declares a resource
39
+ * the branch is missing, we stop with a {@link DevEnvMismatchError} pointing at
40
+ * `neonctl deploy`. Otherwise `fetchEnv` evaluates the policy.
41
+ * 2. no `neon.ts`, but a project + branch are known -> `pullConfig` reads the
42
+ * branch's live state (incl. Auth / Data API enablement) into a config, then
43
+ * `fetchEnv` resolves what is actually enabled.
44
+ * 3. otherwise -> throw {@link MissingBranchContextError}.
45
+ *
46
+ * Unlike {@link resolveDevEnv}, this never swallows errors — callers decide how to
47
+ * handle them.
48
+ */
49
+ export const resolveNeonEnvVars = async (ctx) => {
50
+ const config = await loadNeonConfig(ctx.cwd);
51
+ if (config) {
52
+ if (!ctx.projectId || !ctx.branchId) {
53
+ throw new MissingBranchContextError('Found a neon.ts but could not resolve the project/branch. ' +
54
+ 'Run `neonctl link` and `neonctl checkout <branch>`, or pass ' +
55
+ '--project-id / --branch.');
56
+ }
57
+ // Resolve env from the policy with its `preview.functions` removed. Functions carry no
58
+ // branch-level secrets — their env comes from the local `neon.ts` `functions.<slug>.env`,
59
+ // layered per-function by the dev server — so env resolution never needs the functions
60
+ // API. Probing it (via `plan`/`fetchEnv`) only adds a failure mode: an undeployed
61
+ // function, or a project where the Functions Preview isn't enabled, would error and sink
62
+ // ALL injection (including DATABASE_URL). Stripping functions keeps env resolution honest
63
+ // while leaving buckets / AI Gateway / Auth / Data API fully checked — those DO carry
64
+ // secrets, so a declared-but-missing one still hard-stops (see assertPolicyMatchesBranch).
65
+ const envConfig = withoutPreviewFunctions(config);
66
+ await assertPolicyMatchesBranch(envConfig, ctx);
67
+ return await fetchAndProject(envConfig, ctx);
68
+ }
69
+ if (ctx.projectId && ctx.branchId) {
70
+ const pulled = await pullConfig({
71
+ projectId: ctx.projectId,
72
+ branchId: ctx.branchId,
73
+ ...(ctx.apiKey ? { apiKey: ctx.apiKey } : {}),
74
+ ...(ctx.api ? { api: ctx.api } : {}),
75
+ });
76
+ // `pulled.config` is already a `Config` (static auth/dataApi toggles + a branch
77
+ // tuning closure), so it feeds straight into fetchEnv — no wrapping needed.
78
+ return await fetchAndProject(pulled.config, ctx);
79
+ }
80
+ throw new MissingBranchContextError('No project/branch context found. Link a branch (`neonctl link` / ' +
81
+ '`neonctl checkout`) or pass --project-id and --branch.');
82
+ };
83
+ /**
84
+ * `neon dev`'s env resolver: {@link resolveNeonEnvVars} with graceful degradation.
85
+ * A missing branch context or any failure (no Neon account, no `.neon`, no network)
86
+ * logs a warning and returns `{}` so the function still runs locally; only a
87
+ * {@link DevEnvMismatchError} (policy declares a resource the branch lacks) is
88
+ * re-thrown for the caller to surface.
89
+ */
90
+ export const resolveDevEnv = async (ctx) => {
91
+ try {
92
+ return await resolveNeonEnvVars(ctx);
93
+ }
94
+ catch (err) {
95
+ if (err instanceof DevEnvMismatchError)
96
+ throw err;
97
+ if (err instanceof MissingBranchContextError) {
98
+ log.debug('dev: %s; skipping env injection', err.message);
99
+ return {};
100
+ }
101
+ log.warning('Could not inject Neon env vars; the function will run without them: %s', err instanceof Error ? err.message : String(err));
102
+ return {};
103
+ }
104
+ };
105
+ /**
106
+ * Return the policy with its `preview.functions` removed, so the env path never enumerates
107
+ * functions against the Neon API. Functions are local-source-bundled and produce no
108
+ * branch-level secrets, so they are irrelevant to env resolution; probing them only risks
109
+ * failing the whole resolve (undeployed function, or Functions Preview disabled on the
110
+ * project). Buckets / AI Gateway and the top-level Auth / Data API toggles are preserved —
111
+ * they DO carry env, so they must still be checked and resolved. Returns the config
112
+ * unchanged when it declares no functions.
113
+ */
114
+ const withoutPreviewFunctions = (config) => {
115
+ const preview = config.preview;
116
+ if (!preview?.functions)
117
+ return config;
118
+ const previewWithoutFunctions = { ...preview };
119
+ delete previewWithoutFunctions.functions;
120
+ return { ...config, preview: previewWithoutFunctions };
121
+ };
122
+ /**
123
+ * Tier-1 guard. Dry-run the policy against the branch's live state and stop if
124
+ * it declares a branch-level resource the branch is missing. Built on `plan` so
125
+ * it covers every present and future provisionable resource for free: any
126
+ * `create` action is a resource `neonctl deploy` would provision.
127
+ *
128
+ * Called with functions already stripped (see {@link withoutPreviewFunctions}), so the
129
+ * `plan` probe never enumerates the functions API — an undeployed function, or a project
130
+ * without the Functions Preview, must never block local dev or sink env injection.
131
+ */
132
+ const assertPolicyMatchesBranch = async (config, ctx) => {
133
+ const result = await plan(config, {
134
+ projectId: ctx.projectId,
135
+ branchId: ctx.branchId,
136
+ ...(ctx.apiKey ? { apiKey: ctx.apiKey } : {}),
137
+ ...(ctx.api ? { api: ctx.api } : {}),
138
+ });
139
+ const missing = result.applied.filter(isMissingResource);
140
+ if (missing.length === 0)
141
+ return;
142
+ const names = missing.map((change) => change.identifier).join(', ');
143
+ throw new DevEnvMismatchError(`Your neon.ts declares ${names} for branch ${ctx.branchId}, but the branch ` +
144
+ 'does not have it yet, so the matching env vars cannot be injected. ' +
145
+ 'Provision it first with `neonctl deploy` (or `neonctl config apply`), ' +
146
+ 'then re-run `neonctl dev`.');
147
+ };
148
+ /**
149
+ * A planned change that provisions a branch-level resource the branch lacks: a
150
+ * `create` on a service (Neon Auth, Data API, a bucket, the AI Gateway). Branch
151
+ * setting drift (`update`) and `noop`s are ignored — they don't block local dev
152
+ * — and functions are excluded (see {@link assertPolicyMatchesBranch}).
153
+ */
154
+ const isMissingResource = (change) => change.kind === 'service' &&
155
+ change.action === 'create' &&
156
+ !change.identifier.startsWith('function:');
157
+ const fetchAndProject = async (config, ctx) => {
158
+ const env = await fetchEnv(config, {
159
+ projectId: ctx.projectId,
160
+ branchId: ctx.branchId,
161
+ ...(ctx.apiKey ? { apiKey: ctx.apiKey } : {}),
162
+ ...(ctx.api ? { api: ctx.api } : {}),
163
+ });
164
+ return toEntries(env);
165
+ };
166
+ /**
167
+ * Load a `neon.ts` policy if one exists on the path from `cwd` up to the repo
168
+ * root. Returns `null` when there is none (the common "no config" case), and
169
+ * surfaces real load errors (e.g. a syntax error in an existing file).
170
+ */
171
+ const loadNeonConfig = async (cwd) => {
172
+ try {
173
+ const { config } = await loadConfigFromFile({ cwd });
174
+ return config;
175
+ }
176
+ catch (err) {
177
+ const message = err instanceof Error ? err.message : String(err);
178
+ if (/Could not find a Neon config file/i.test(message)) {
179
+ return null;
180
+ }
181
+ throw err;
182
+ }
183
+ };
@@ -0,0 +1,72 @@
1
+ import { dirname, isAbsolute, resolve } from 'node:path';
2
+ import { existsSync } from 'node:fs';
3
+ import { loadConfigFromFile, resolveConfig, } from '@neondatabase/config';
4
+ /**
5
+ * Load `neon.ts` (if any) and resolve the list of functions it declares into
6
+ * {@link PlannedFunction}s for `neon dev` to serve. Returns `null` when there is no
7
+ * `neon.ts` on the path from `cwd` up to the repo root — the caller turns that into a
8
+ * "no --source and no neon.ts" error.
9
+ *
10
+ * `branchName` is used only to evaluate a policy that switches on `branch.name`; the
11
+ * function list is otherwise branch-independent, so a placeholder is fine when unknown.
12
+ */
13
+ export const resolveFunctionsFromConfig = async (cwd, branchName) => {
14
+ const loaded = await loadNeonConfig(cwd);
15
+ if (!loaded)
16
+ return null;
17
+ const { config, configDir, configPath } = loaded;
18
+ const resolved = resolveConfig(config, {
19
+ name: branchName ?? 'local',
20
+ exists: branchName !== undefined,
21
+ });
22
+ const functions = resolved.preview?.functions ?? [];
23
+ const planned = functions.map((fn) => {
24
+ const source = isAbsolute(fn.source)
25
+ ? fn.source
26
+ : resolve(configDir, fn.source);
27
+ if (!existsSync(source)) {
28
+ throw new Error(`Function "${fn.slug}" points at a source that does not exist: ${source} ` +
29
+ `(from neon.ts "${fn.source}"). Fix the source path and re-run.`);
30
+ }
31
+ return {
32
+ slug: fn.slug,
33
+ name: fn.name,
34
+ source,
35
+ portless: fn.dev?.portless === true,
36
+ ...(devPort(fn.dev) !== undefined
37
+ ? { port: devPort(fn.dev) }
38
+ : {}),
39
+ env: { ...fn.env },
40
+ };
41
+ });
42
+ return { configPath, functions: planned };
43
+ };
44
+ /**
45
+ * Read the `port` off a {@link FunctionDevConfig}. The discriminated union guarantees a
46
+ * `port` is present whenever `portless` is true, so this is `undefined` only for the
47
+ * non-portless, port-omitted case (the supervisor then searches for a free port).
48
+ */
49
+ const devPort = (dev) => dev?.port;
50
+ /**
51
+ * Load a `neon.ts` policy if one exists, returning the loaded config, the resolved path to
52
+ * the config file (used by the dev server to watch it), and the directory it lives in (used
53
+ * to resolve each function's relative `source`). Returns `null` when no config file is
54
+ * found; surfaces real load errors (e.g. a syntax error).
55
+ */
56
+ const loadNeonConfig = async (cwd) => {
57
+ try {
58
+ const { config, resolvedPath } = await loadConfigFromFile({ cwd });
59
+ return {
60
+ config,
61
+ configDir: dirname(resolvedPath),
62
+ configPath: resolvedPath,
63
+ };
64
+ }
65
+ catch (err) {
66
+ const message = err instanceof Error ? err.message : String(err);
67
+ if (/Could not find a Neon config file/i.test(message)) {
68
+ return null;
69
+ }
70
+ throw err;
71
+ }
72
+ };
package/dev/inputs.js ADDED
@@ -0,0 +1,63 @@
1
+ import { resolve } from 'node:path';
2
+ const defaultDeps = {
3
+ isPackaged: () => process.pkg !== undefined,
4
+ loadEsbuild: (name) => import(name),
5
+ };
6
+ /**
7
+ * Resolve the exact set of files esbuild reads to produce the bundle for
8
+ * `source` — the entry plus every local module it imports (npm deps are left
9
+ * external, so they never appear). These are the files the dev watcher should
10
+ * watch, so a single edit triggers exactly one rebuild.
11
+ *
12
+ * Returns absolute paths, or `null` when the precise set cannot be computed —
13
+ * either inside the packaged binary (which cannot import esbuild as a module;
14
+ * it shells out to a binary that has no JSON-metafile equivalent here) or on a
15
+ * platform where the esbuild module won't load. Callers fall back to a coarser
16
+ * watch in that case.
17
+ *
18
+ * This performs a metafile-only pass (`write:false`, `metafile:true`) so it
19
+ * never emits output; the actual bundle bytes still come from `bundleEntry`.
20
+ */
21
+ export const resolveWatchInputs = async (source, deps = defaultDeps) => {
22
+ if (deps.isPackaged())
23
+ return null;
24
+ // esbuild is resolved by a COMPUTED specifier, never the literal string
25
+ // 'esbuild', for the same reason as src/utils/esbuild.ts: rollup and
26
+ // @yao-pkg/pkg statically scan for literal import()/require() and would pull
27
+ // esbuild's native Go binary into the bundle/snapshot. Keep it invisible.
28
+ const name = ['es', 'build'].join('');
29
+ let esbuild;
30
+ try {
31
+ esbuild = await deps.loadEsbuild(name);
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ let metafile;
37
+ try {
38
+ // Mirrors bundleEntry's flags so the resolved input graph matches the real
39
+ // bundle. metafile:true + write:false makes this a pure analysis pass.
40
+ const result = await esbuild.build({
41
+ entryPoints: [source],
42
+ bundle: true,
43
+ write: false,
44
+ metafile: true,
45
+ format: 'esm',
46
+ platform: 'node',
47
+ packages: 'external',
48
+ logLevel: 'silent',
49
+ });
50
+ metafile = result.metafile;
51
+ }
52
+ catch {
53
+ // A bundle error here is non-fatal for watching: bundleEntry surfaces the
54
+ // real diagnostic. Fall back to the coarser watch so edits still rebuild.
55
+ return null;
56
+ }
57
+ const inputs = metafile?.inputs;
58
+ if (!inputs)
59
+ return null;
60
+ // metafile input keys are paths relative to esbuild's cwd; resolve to absolute
61
+ // so they compare cleanly against chokidar's watched paths.
62
+ return Object.keys(inputs).map((p) => resolve(process.cwd(), p));
63
+ };
package/dev/runtime.js ADDED
@@ -0,0 +1,146 @@
1
+ import { createServer } from 'node:http';
2
+ import { pathToFileURL } from 'node:url';
3
+ import { resolve } from 'node:path';
4
+ import { getRequestListener } from '@hono/node-server';
5
+ const isFunction = (value) => typeof value === 'function';
6
+ const hasFetchMethod = (value) => typeof value === 'object' &&
7
+ value !== null &&
8
+ 'fetch' in value &&
9
+ typeof value.fetch === 'function';
10
+ /**
11
+ * Resolve the user's exported handler to a single fetch callback.
12
+ *
13
+ * Resolution order (first match wins):
14
+ * 1. `export default { fetch }` — Workers / Neon Functions style
15
+ * 2. `export default function (req)` — bare (async) default function
16
+ */
17
+ export const resolveFetchHandler = (mod) => {
18
+ const defaultExport = mod.default;
19
+ if (hasFetchMethod(defaultExport)) {
20
+ const target = defaultExport;
21
+ return (req) => target.fetch(req);
22
+ }
23
+ if (isFunction(defaultExport)) {
24
+ return defaultExport;
25
+ }
26
+ throw new Error('No request handler found in the source module. Export one of:\n' +
27
+ ' export default { fetch(req) { /* ... */ } }\n' +
28
+ ' export default function (req) { /* ... */ }');
29
+ };
30
+ /**
31
+ * Wrap a fetch handler so user errors become a 500 response (with the message
32
+ * in the body during dev) instead of crashing the child process.
33
+ */
34
+ export const withErrorBoundary = (handler) => {
35
+ return async (req) => {
36
+ try {
37
+ return await handler(req);
38
+ }
39
+ catch (err) {
40
+ const message = err instanceof Error ? (err.stack ?? err.message) : String(err);
41
+ process.stderr.write(`Request handler threw an error:\n${message}\n`);
42
+ return new Response(`Internal Server Error\n\n${message}`, {
43
+ status: 500,
44
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
45
+ });
46
+ }
47
+ };
48
+ };
49
+ const isAddressInUse = (err) => typeof err === 'object' &&
50
+ err !== null &&
51
+ err.code === 'EADDRINUSE';
52
+ const DEFAULT_SEARCH_BASE = 8787;
53
+ const MAX_SEARCH_STEPS = 100;
54
+ const bindPort = async (server, selection, hostname) => {
55
+ if (selection.mode === 'explicit') {
56
+ return listen(server, selection.port, hostname);
57
+ }
58
+ for (let step = 0; step < MAX_SEARCH_STEPS; step++) {
59
+ try {
60
+ return await listen(server, selection.from + step, hostname);
61
+ }
62
+ catch (err) {
63
+ if (!isAddressInUse(err))
64
+ throw err;
65
+ }
66
+ }
67
+ throw new Error(`Could not find a free port in ${selection.from}-${selection.from + MAX_SEARCH_STEPS - 1}`);
68
+ };
69
+ const listen = (server, port, hostname) => new Promise((resolveListen, rejectListen) => {
70
+ const onError = (err) => {
71
+ server.off('listening', onListening);
72
+ rejectListen(err);
73
+ };
74
+ const onListening = () => {
75
+ server.off('error', onError);
76
+ resolveListen(server.address().port);
77
+ };
78
+ server.once('error', onError);
79
+ server.once('listening', onListening);
80
+ server.listen(port, hostname);
81
+ });
82
+ /**
83
+ * Load the (already-bundled) user module, build the listener, and start an HTTP
84
+ * server. Announces the bound port on stdout as `neon-dev:ready <port>` so the
85
+ * parent can render the URL. Resolves with the bound port.
86
+ */
87
+ export const startRuntime = async ({ source, port, hostname, }) => {
88
+ const absoluteSource = resolve(process.cwd(), source);
89
+ const mod = (await import(pathToFileURL(absoluteSource).href));
90
+ const handler = withErrorBoundary(resolveFetchHandler(mod));
91
+ const listener = getRequestListener(handler, { hostname });
92
+ const server = createServer((incoming, outgoing) => {
93
+ void listener(incoming, outgoing);
94
+ });
95
+ const boundPort = await bindPort(server, port, hostname);
96
+ process.stdout.write(`neon-dev:ready ${boundPort}\n`);
97
+ return boundPort;
98
+ };
99
+ /**
100
+ * Build a {@link PortSelection} from the environment. Precedence:
101
+ * 1. `NEON_DEV_PORT` -> explicit bind (crash if taken). Set by `neon dev` from an
102
+ * explicit `--port` / `dev.port`.
103
+ * 2. `PORT` -> explicit bind. This is what `portless` injects (and what a bare
104
+ * `PORT=3000 neon dev` sets), so the runtime binds the port chosen for it.
105
+ * 3. otherwise -> search upward from `NEON_DEV_PORT_BASE` (or the default base).
106
+ */
107
+ export const portSelectionFromEnv = (env) => {
108
+ const explicit = env.NEON_DEV_PORT;
109
+ if (explicit !== undefined && explicit !== '') {
110
+ return { mode: 'explicit', port: parsePort(explicit, 'NEON_DEV_PORT') };
111
+ }
112
+ const injected = env.PORT;
113
+ if (injected !== undefined && injected !== '') {
114
+ return { mode: 'explicit', port: parsePort(injected, 'PORT') };
115
+ }
116
+ const base = Number(env.NEON_DEV_PORT_BASE ?? DEFAULT_SEARCH_BASE);
117
+ return {
118
+ mode: 'search',
119
+ from: Number.isInteger(base) ? base : DEFAULT_SEARCH_BASE,
120
+ };
121
+ };
122
+ const parsePort = (value, varName) => {
123
+ const port = Number(value);
124
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
125
+ throw new Error(`Invalid ${varName}: "${value}"`);
126
+ }
127
+ return port;
128
+ };
129
+ const isDirectExecution = () => {
130
+ const entry = process.argv[1];
131
+ if (!entry)
132
+ return false;
133
+ return import.meta.url === pathToFileURL(entry).href;
134
+ };
135
+ if (isDirectExecution()) {
136
+ const source = process.env.NEON_DEV_SOURCE ?? process.argv[2];
137
+ if (!source) {
138
+ process.stderr.write('neon-dev runtime: missing source path\n');
139
+ process.exit(1);
140
+ }
141
+ startRuntime({ source, port: portSelectionFromEnv(process.env) }).catch((err) => {
142
+ const msg = err instanceof Error ? err.message : String(err);
143
+ process.stderr.write(`neon-dev runtime failed to start: ${msg}\n`);
144
+ process.exit(1);
145
+ });
146
+ }