neonctl 2.23.1 → 2.24.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/README.md +109 -18
- package/commands/auth.js +12 -0
- package/commands/branches.js +14 -6
- package/commands/bucket.js +368 -0
- package/commands/checkout.js +49 -61
- package/commands/config.js +249 -0
- package/commands/deploy.js +25 -0
- package/commands/dev.js +743 -0
- package/commands/env.js +121 -0
- package/commands/index.js +10 -0
- package/commands/link.js +76 -12
- package/config_format.js +66 -0
- package/dev/env.js +199 -0
- package/dev/functions.js +72 -0
- package/dev/inputs.js +63 -0
- package/dev/runtime.js +146 -0
- package/env_file.js +146 -0
- package/index.js +2 -0
- package/package.json +7 -2
- package/pkg.js +23 -1
- package/psql/command/cmd_format.js +12 -12
- package/psql/command/cmd_io.js +2 -2
- package/psql/command/cmd_restrict.js +8 -8
- package/psql/describe/formatters.js +1 -1
- package/psql/print/aligned.js +30 -30
- package/psql/print/json.js +5 -5
- package/psql/scanner/sql.js +4 -4
- package/storage_api.js +114 -0
- package/utils/branch_picker.js +88 -0
- package/utils/esbuild.js +8 -5
- package/utils/zip.js +1 -1
package/commands/dev.js
ADDED
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { once } from 'node:events';
|
|
3
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
import { log } from '../log.js';
|
|
8
|
+
import { bundleEntry } from '../utils/esbuild.js';
|
|
9
|
+
import { resolveDevEnv } from '../dev/env.js';
|
|
10
|
+
import { resolveFunctionsFromConfig, } from '../dev/functions.js';
|
|
11
|
+
import { resolveWatchInputs } from '../dev/inputs.js';
|
|
12
|
+
import { branchIdResolve } from '../utils/enrichers.js';
|
|
13
|
+
export const command = 'dev';
|
|
14
|
+
export const describe = 'Run Neon Functions locally with a dev server';
|
|
15
|
+
export const builder = (argv) => argv
|
|
16
|
+
.usage('$0 dev [--source <path>] [options]')
|
|
17
|
+
.example('$0 dev --source ./functions/hello.ts', 'Serve one function on a free port with hot reload')
|
|
18
|
+
.example('$0 dev', 'Serve every function declared in neon.ts (one dev server each)')
|
|
19
|
+
.example('$0 dev --source ./functions/hello.ts --port 3000', 'Serve one function on an explicit port (fails if the port is taken)')
|
|
20
|
+
.options({
|
|
21
|
+
source: {
|
|
22
|
+
describe: 'Path to a single function entry module. Omit to serve every ' +
|
|
23
|
+
'function declared in neon.ts.',
|
|
24
|
+
type: 'string',
|
|
25
|
+
},
|
|
26
|
+
port: {
|
|
27
|
+
describe: 'Port to listen on (single-function mode only, with --source). ' +
|
|
28
|
+
'Fails if taken. Without it (and without a PORT env var) a free ' +
|
|
29
|
+
'port is chosen automatically.',
|
|
30
|
+
type: 'number',
|
|
31
|
+
},
|
|
32
|
+
})
|
|
33
|
+
.strict();
|
|
34
|
+
export const handler = async (props) => {
|
|
35
|
+
if (props.source !== undefined) {
|
|
36
|
+
await runSingleSource(props);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
// No --source: --port has no single target to bind, so reject it explicitly
|
|
40
|
+
// rather than silently ignoring it.
|
|
41
|
+
if (props.port !== undefined) {
|
|
42
|
+
throw new Error('--port can only be used with --source. To set ports for the functions ' +
|
|
43
|
+
'in neon.ts, give each one a `dev.port` in its config.');
|
|
44
|
+
}
|
|
45
|
+
await runFromConfig(props);
|
|
46
|
+
};
|
|
47
|
+
/** Single-function mode: serve exactly the `--source` path (legacy behavior). */
|
|
48
|
+
const runSingleSource = async (props) => {
|
|
49
|
+
const source = resolve(process.cwd(), props.source);
|
|
50
|
+
if (!existsSync(source)) {
|
|
51
|
+
throw new Error(`Source file not found: ${source}`);
|
|
52
|
+
}
|
|
53
|
+
const branchId = await resolveBranchId(props);
|
|
54
|
+
const { vars: neonEnv, skipped } = await resolveDevEnv({
|
|
55
|
+
cwd: process.cwd(),
|
|
56
|
+
...(props.projectId ? { projectId: props.projectId } : {}),
|
|
57
|
+
...(branchId ? { branchId } : {}),
|
|
58
|
+
...(props.apiKey ? { apiKey: props.apiKey } : {}),
|
|
59
|
+
});
|
|
60
|
+
const unit = {
|
|
61
|
+
slug: null,
|
|
62
|
+
source,
|
|
63
|
+
bundleDir: join(process.cwd(), 'node_modules', '.neon-dev'),
|
|
64
|
+
childEnv: buildChildEnv(neonEnv, portFromProps(props.port)),
|
|
65
|
+
label: null,
|
|
66
|
+
envSummary: { neon: Object.keys(neonEnv), fn: [] },
|
|
67
|
+
};
|
|
68
|
+
// No config reload in single-source mode: there's exactly one file to serve, and
|
|
69
|
+
// nothing to add or remove. neon.ts hot-reload is config-mode only.
|
|
70
|
+
await runSupervisor([unit], {
|
|
71
|
+
...(skipped ? { envNote: skipped.reason } : {}),
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Multi-function mode: serve every function declared in neon.ts. Requires a neon.ts
|
|
76
|
+
* (there is no single source to fall back on), one dev server per function.
|
|
77
|
+
*/
|
|
78
|
+
const runFromConfig = async (props) => {
|
|
79
|
+
const branchId = await resolveBranchId(props);
|
|
80
|
+
const resolved = await resolveFunctionsFromConfig(process.cwd());
|
|
81
|
+
if (resolved === null) {
|
|
82
|
+
throw new Error('No --source given and no neon.ts found. Pass --source <path> to run a ' +
|
|
83
|
+
'single function, or add a neon.ts that declares functions under ' +
|
|
84
|
+
'`preview.functions`.');
|
|
85
|
+
}
|
|
86
|
+
const { configPath, functions } = resolved;
|
|
87
|
+
if (functions.length === 0) {
|
|
88
|
+
throw new Error('neon.ts has no functions to serve. Add at least one under ' +
|
|
89
|
+
'`preview.functions`, or pass --source <path>.');
|
|
90
|
+
}
|
|
91
|
+
const { vars: neonEnv, skipped } = await resolveDevEnv({
|
|
92
|
+
cwd: process.cwd(),
|
|
93
|
+
...(props.projectId ? { projectId: props.projectId } : {}),
|
|
94
|
+
...(branchId ? { branchId } : {}),
|
|
95
|
+
...(props.apiKey ? { apiKey: props.apiKey } : {}),
|
|
96
|
+
});
|
|
97
|
+
const units = planFunctionsToUnits(functions, neonEnv, DEFAULT_PORT_BASE);
|
|
98
|
+
// Re-derive the units from neon.ts on demand so the config watcher can hot-add/remove
|
|
99
|
+
// functions without restarting the dev server. `searchBase` lets a freshly-added unit
|
|
100
|
+
// start probing above the ports already taken by live units (the runtime still walks
|
|
101
|
+
// upward from there, so this never fails — it just keeps startup deterministic).
|
|
102
|
+
const replan = async (searchBase) => {
|
|
103
|
+
const re = await resolveFunctionsFromConfig(process.cwd());
|
|
104
|
+
if (re === null)
|
|
105
|
+
return null;
|
|
106
|
+
return planFunctionsToUnits(re.functions, neonEnv, searchBase);
|
|
107
|
+
};
|
|
108
|
+
await runSupervisor(units, {
|
|
109
|
+
reload: { configPath, replan },
|
|
110
|
+
...(skipped ? { envNote: skipped.reason } : {}),
|
|
111
|
+
});
|
|
112
|
+
};
|
|
113
|
+
/**
|
|
114
|
+
* Map a list of {@link PlannedFunction}s to {@link ServedUnit}s, coordinating the search
|
|
115
|
+
* base across them so search-mode functions don't all probe the same starting port.
|
|
116
|
+
*
|
|
117
|
+
* Each search-mode (no `dev.port`, non-portless) function gets a distinct base starting at
|
|
118
|
+
* `searchBase`; the runtime still walks upward from its base, so an occupied base
|
|
119
|
+
* self-resolves and this never fails — the offset just makes startup deterministic.
|
|
120
|
+
*/
|
|
121
|
+
const planFunctionsToUnits = (functions, neonEnv, searchBase) => {
|
|
122
|
+
let searchOffset = 0;
|
|
123
|
+
return functions.map((fn) => {
|
|
124
|
+
const base = searchBase + searchOffset;
|
|
125
|
+
if (!fn.portless && fn.port === undefined)
|
|
126
|
+
searchOffset += 1;
|
|
127
|
+
return plannedToUnit(fn, neonEnv, base);
|
|
128
|
+
});
|
|
129
|
+
};
|
|
130
|
+
/**
|
|
131
|
+
* Resolve the selected branch id from props, if any. Best-effort: a failure here only
|
|
132
|
+
* means env injection is skipped, so it never throws.
|
|
133
|
+
*/
|
|
134
|
+
const resolveBranchId = async (props) => {
|
|
135
|
+
if (!props.apiClient || !props.projectId)
|
|
136
|
+
return undefined;
|
|
137
|
+
const branch = props.branch ?? props.id;
|
|
138
|
+
if (!branch)
|
|
139
|
+
return undefined;
|
|
140
|
+
try {
|
|
141
|
+
return await branchIdResolve({
|
|
142
|
+
branch,
|
|
143
|
+
apiClient: props.apiClient,
|
|
144
|
+
projectId: props.projectId,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
log.debug('dev: could not resolve branch id: %s', err instanceof Error ? err.message : String(err));
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
const DEFAULT_PORT_BASE = 8787;
|
|
153
|
+
const portFromProps = (port) => {
|
|
154
|
+
if (port !== undefined)
|
|
155
|
+
return { mode: 'explicit', port };
|
|
156
|
+
if (process.env.PORT !== undefined && process.env.PORT !== '') {
|
|
157
|
+
return { mode: 'explicit', port: Number(process.env.PORT) };
|
|
158
|
+
}
|
|
159
|
+
return { mode: 'search', from: DEFAULT_PORT_BASE };
|
|
160
|
+
};
|
|
161
|
+
/**
|
|
162
|
+
* Translate a {@link PlannedFunction} into a {@link ServedUnit}. Port rules:
|
|
163
|
+
* - portless: portless assigns the port and injects PORT, which the runtime honors — so
|
|
164
|
+
* we set no port env (`inherit`) and `dev.port` is ignored. Wrapped with
|
|
165
|
+
* `portless <slug>` for a stable `slug.localhost` URL.
|
|
166
|
+
* - explicit `dev.port`: bind exactly, fail if taken.
|
|
167
|
+
* - no `dev.port`: search for a free port (base coordinated by the caller).
|
|
168
|
+
* Per-function neon.ts env layers over the shared branch env.
|
|
169
|
+
*/
|
|
170
|
+
const plannedToUnit = (fn, branchEnv, searchBase) => {
|
|
171
|
+
const port = fn.portless
|
|
172
|
+
? { mode: 'inherit' }
|
|
173
|
+
: fn.port !== undefined
|
|
174
|
+
? { mode: 'explicit', port: fn.port }
|
|
175
|
+
: { mode: 'search', from: searchBase };
|
|
176
|
+
const childEnv = buildChildEnv({ ...branchEnv, ...fn.env }, port);
|
|
177
|
+
return {
|
|
178
|
+
slug: fn.slug,
|
|
179
|
+
source: fn.source,
|
|
180
|
+
bundleDir: join(process.cwd(), 'node_modules', '.neon-dev', fn.slug),
|
|
181
|
+
childEnv,
|
|
182
|
+
label: fn.slug,
|
|
183
|
+
envSummary: { neon: Object.keys(branchEnv), fn: Object.keys(fn.env) },
|
|
184
|
+
// Signature of the function's *own* neon.ts config (NOT the dynamically-chosen search
|
|
185
|
+
// base) so reconcile can tell a real change from a no-op save. A search-mode function
|
|
186
|
+
// re-planned with a different base must hash identically, or it would be needlessly
|
|
187
|
+
// restarted — see reconcile().
|
|
188
|
+
configKey: JSON.stringify({
|
|
189
|
+
source: fn.source,
|
|
190
|
+
port: fn.port ?? null,
|
|
191
|
+
portless: fn.portless,
|
|
192
|
+
env: fn.env,
|
|
193
|
+
}),
|
|
194
|
+
...(fn.portless ? { portless: { slug: fn.slug } } : {}),
|
|
195
|
+
};
|
|
196
|
+
};
|
|
197
|
+
/**
|
|
198
|
+
* Build a child's env. Neon vars layer over the inherited environment (so the branch's
|
|
199
|
+
* DATABASE_URL wins over a stale inherited value); a function that loads its own `.env`
|
|
200
|
+
* at runtime still overrides them. The port spec is encoded for the runtime via
|
|
201
|
+
* NEON_DEV_PORT (explicit) or NEON_DEV_PORT_BASE (search).
|
|
202
|
+
*/
|
|
203
|
+
const buildChildEnv = (neonEnv, port) => {
|
|
204
|
+
const env = { ...process.env, ...neonEnv };
|
|
205
|
+
delete env.NEON_DEV_PORT;
|
|
206
|
+
delete env.NEON_DEV_PORT_BASE;
|
|
207
|
+
if (port.mode === 'explicit') {
|
|
208
|
+
env.NEON_DEV_PORT = String(port.port);
|
|
209
|
+
}
|
|
210
|
+
else if (port.mode === 'search') {
|
|
211
|
+
env.NEON_DEV_PORT_BASE = String(port.from);
|
|
212
|
+
}
|
|
213
|
+
// 'inherit': set neither, so an injected PORT (portless) drives the runtime.
|
|
214
|
+
return env;
|
|
215
|
+
};
|
|
216
|
+
const READY_PATTERN = /neon-dev:ready (\d+)/;
|
|
217
|
+
/**
|
|
218
|
+
* Supervise one or more {@link ServedUnit}s: bundle + start each in its own child, watch
|
|
219
|
+
* its inputs for hot reload, and tear everything down cleanly on shutdown. Units are
|
|
220
|
+
* independent — one crashing or failing to start does not stop the others (it is shown
|
|
221
|
+
* as errored and recovered on the next edit). A single SIGINT/SIGTERM shuts all of them
|
|
222
|
+
* down, tree-killing each child so no descendant (e.g. a portless-wrapped runtime) is
|
|
223
|
+
* orphaned.
|
|
224
|
+
*
|
|
225
|
+
* In config mode, `reload` lets the supervisor watch `neon.ts` and reconcile the live set
|
|
226
|
+
* of units when it changes: a newly-declared function is hot-added (its own child, watcher,
|
|
227
|
+
* and port) and a removed one is torn down — all without disturbing the functions that
|
|
228
|
+
* stayed the same. A function whose config (env/port/portless/source) changed is restarted
|
|
229
|
+
* in place; siblings are untouched.
|
|
230
|
+
*/
|
|
231
|
+
const runSupervisor = async (units, options = {}) => {
|
|
232
|
+
const { reload, envNote } = options;
|
|
233
|
+
if (hasPortlessUnit(units)) {
|
|
234
|
+
assertPortlessAvailable();
|
|
235
|
+
}
|
|
236
|
+
const runtimePath = resolveRuntimePath();
|
|
237
|
+
let shuttingDown = false;
|
|
238
|
+
const running = units.map(makeRunningUnit);
|
|
239
|
+
const bundleAndStart = async (r) => {
|
|
240
|
+
let bundlePath;
|
|
241
|
+
try {
|
|
242
|
+
bundlePath = await writeBundle(r.unit.source, r.unit.bundleDir);
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
r.status = 'error';
|
|
246
|
+
logUnit(r.unit, chalk.red('bundle failed: ') +
|
|
247
|
+
(err instanceof Error ? err.message : String(err)));
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (r.watcher)
|
|
251
|
+
await r.watcher.sync();
|
|
252
|
+
const next = spawnChild(r.unit, runtimePath, bundlePath);
|
|
253
|
+
r.child = next;
|
|
254
|
+
const ready = waitForReady(next);
|
|
255
|
+
pipeChildOutput(next, r.unit.label);
|
|
256
|
+
next.on('exit', (code, signal) => {
|
|
257
|
+
if (shuttingDown || r.child !== next)
|
|
258
|
+
return;
|
|
259
|
+
if (signal) {
|
|
260
|
+
log.debug('runtime for %s exited via %s', r.unit.slug ?? '(source)', signal);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (code && code !== 0 && r.everReady) {
|
|
264
|
+
r.status = 'error';
|
|
265
|
+
logUnit(r.unit, chalk.red(`exited with code ${code} (waiting for a change)`));
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
const port = await ready;
|
|
269
|
+
if (port !== null) {
|
|
270
|
+
r.boundPort = port;
|
|
271
|
+
r.everReady = true;
|
|
272
|
+
r.status = 'ready';
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
r.status = 'error';
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
const restart = (r) => {
|
|
279
|
+
if (shuttingDown)
|
|
280
|
+
return;
|
|
281
|
+
if (r.restartTimer)
|
|
282
|
+
clearTimeout(r.restartTimer);
|
|
283
|
+
r.restartTimer = setTimeout(() => {
|
|
284
|
+
void (async () => {
|
|
285
|
+
logUnit(r.unit, chalk.dim('change detected, restarting…'));
|
|
286
|
+
if (r.child)
|
|
287
|
+
await killTree(r.child);
|
|
288
|
+
if (shuttingDown)
|
|
289
|
+
return;
|
|
290
|
+
await bundleAndStart(r);
|
|
291
|
+
if (r.status === 'ready') {
|
|
292
|
+
logUnit(r.unit, chalk.green('ready') + ` ${urlFor(r.boundPort)}`);
|
|
293
|
+
}
|
|
294
|
+
})();
|
|
295
|
+
}, 150);
|
|
296
|
+
};
|
|
297
|
+
// Bring a unit fully online: create its source watcher (before the first bundle so
|
|
298
|
+
// bundleAndStart can sync the watch set on every run) then bundle + spawn it.
|
|
299
|
+
const startUnit = async (r) => {
|
|
300
|
+
r.watcher = await startWatcher(r.unit.source, () => {
|
|
301
|
+
restart(r);
|
|
302
|
+
});
|
|
303
|
+
await bundleAndStart(r);
|
|
304
|
+
};
|
|
305
|
+
// Tear a unit down completely: stop its restart timer + watcher, reap its child tree, and
|
|
306
|
+
// remove its bundle dir. Used both on shutdown and when neon.ts drops a function.
|
|
307
|
+
const stopUnit = async (r) => {
|
|
308
|
+
if (r.restartTimer)
|
|
309
|
+
clearTimeout(r.restartTimer);
|
|
310
|
+
await r.watcher?.close();
|
|
311
|
+
if (r.child)
|
|
312
|
+
await killTree(r.child);
|
|
313
|
+
rmSync(r.unit.bundleDir, { recursive: true, force: true });
|
|
314
|
+
};
|
|
315
|
+
// Start every unit. They are independent: keep going if one fails.
|
|
316
|
+
await Promise.all(running.map((r) => startUnit(r)));
|
|
317
|
+
if (running.every((r) => r.status === 'error')) {
|
|
318
|
+
await Promise.all(running.map((r) => stopUnit(r)));
|
|
319
|
+
throw new Error('No function started. See the output above for details.');
|
|
320
|
+
}
|
|
321
|
+
printBanner(running, envNote);
|
|
322
|
+
// Config mode only: watch neon.ts and reconcile the live unit set when it changes.
|
|
323
|
+
// Reconciles are serialized: a burst of saves (editor write-then-format) must not run
|
|
324
|
+
// overlapping diffs against the mutating `running` array. A trailing run coalesces the
|
|
325
|
+
// burst and picks up the latest config.
|
|
326
|
+
let configWatcher = null;
|
|
327
|
+
if (reload) {
|
|
328
|
+
const ops = {
|
|
329
|
+
isShuttingDown: () => shuttingDown,
|
|
330
|
+
startUnit,
|
|
331
|
+
stopUnit,
|
|
332
|
+
restartUnit: restart,
|
|
333
|
+
};
|
|
334
|
+
let inFlight = null;
|
|
335
|
+
let pending = false;
|
|
336
|
+
const drive = () => {
|
|
337
|
+
if (inFlight) {
|
|
338
|
+
pending = true;
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
inFlight = (async () => {
|
|
342
|
+
do {
|
|
343
|
+
pending = false;
|
|
344
|
+
await reconcileOnce(running, reload.replan, ops);
|
|
345
|
+
} while (pending && !shuttingDown);
|
|
346
|
+
})().finally(() => {
|
|
347
|
+
inFlight = null;
|
|
348
|
+
});
|
|
349
|
+
};
|
|
350
|
+
configWatcher = await startConfigWatcher(reload.configPath, drive);
|
|
351
|
+
}
|
|
352
|
+
await new Promise((resolveRun) => {
|
|
353
|
+
const shutdown = () => {
|
|
354
|
+
if (shuttingDown)
|
|
355
|
+
return;
|
|
356
|
+
shuttingDown = true;
|
|
357
|
+
void (async () => {
|
|
358
|
+
await configWatcher?.close();
|
|
359
|
+
await Promise.all(running.map((r) => stopUnit(r)));
|
|
360
|
+
log.info(chalk.dim('Stopped the dev server.'));
|
|
361
|
+
resolveRun();
|
|
362
|
+
})();
|
|
363
|
+
};
|
|
364
|
+
process.on('SIGINT', shutdown);
|
|
365
|
+
process.on('SIGTERM', shutdown);
|
|
366
|
+
});
|
|
367
|
+
};
|
|
368
|
+
const makeRunningUnit = (unit) => ({
|
|
369
|
+
unit,
|
|
370
|
+
child: null,
|
|
371
|
+
boundPort: null,
|
|
372
|
+
everReady: false,
|
|
373
|
+
restartTimer: null,
|
|
374
|
+
watcher: null,
|
|
375
|
+
status: 'starting',
|
|
376
|
+
});
|
|
377
|
+
/**
|
|
378
|
+
* Pure slug-keyed diff of the live units against the freshly-resolved desired set:
|
|
379
|
+
* - a slug present now but not before → **add** (new child + watcher + port),
|
|
380
|
+
* - a slug gone from neon.ts → **remove** (torn down),
|
|
381
|
+
* - a slug whose config (source/port/portless/env) changed → **restart** in place,
|
|
382
|
+
* - an unchanged slug → left out of the plan entirely (never touched).
|
|
383
|
+
* Functions that stayed the same never die, so an edit that only adds a function is
|
|
384
|
+
* non-disruptive. `desired === null` (neon.ts deleted) is treated as "no functions".
|
|
385
|
+
*/
|
|
386
|
+
export const diffUnits = (running, desired) => {
|
|
387
|
+
const desiredBySlug = new Map();
|
|
388
|
+
for (const u of desired ?? []) {
|
|
389
|
+
if (u.slug !== null)
|
|
390
|
+
desiredBySlug.set(u.slug, u);
|
|
391
|
+
}
|
|
392
|
+
const runningBySlug = new Map();
|
|
393
|
+
for (const r of running) {
|
|
394
|
+
if (r.unit.slug !== null)
|
|
395
|
+
runningBySlug.set(r.unit.slug, r);
|
|
396
|
+
}
|
|
397
|
+
const plan = { remove: [], restart: [], add: [] };
|
|
398
|
+
for (const [slug, r] of runningBySlug) {
|
|
399
|
+
if (!desiredBySlug.has(slug))
|
|
400
|
+
plan.remove.push(r);
|
|
401
|
+
}
|
|
402
|
+
for (const [slug, want] of desiredBySlug) {
|
|
403
|
+
const r = runningBySlug.get(slug);
|
|
404
|
+
if (r) {
|
|
405
|
+
if (r.unit.configKey !== want.configKey) {
|
|
406
|
+
r.unit = want;
|
|
407
|
+
plan.restart.push(r);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
plan.add.push(want);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return plan;
|
|
415
|
+
};
|
|
416
|
+
/**
|
|
417
|
+
* Run one reconcile: re-resolve neon.ts (ignoring the change with a clear message if it no
|
|
418
|
+
* longer loads), {@link diffUnits} against the live set, then apply the plan — tearing down
|
|
419
|
+
* removed functions, restarting changed ones in place, and hot-adding new ones. Mutates
|
|
420
|
+
* `running` in place so the surrounding supervisor sees the converged set.
|
|
421
|
+
*/
|
|
422
|
+
const reconcileOnce = async (running, replan, ops) => {
|
|
423
|
+
if (ops.isShuttingDown())
|
|
424
|
+
return;
|
|
425
|
+
let desired;
|
|
426
|
+
try {
|
|
427
|
+
desired = await replan(nextSearchBase(running));
|
|
428
|
+
}
|
|
429
|
+
catch (err) {
|
|
430
|
+
log.info(chalk.red('neon.ts change ignored: ') +
|
|
431
|
+
(err instanceof Error ? err.message : String(err)) +
|
|
432
|
+
chalk.dim(' (fix it and save again)'));
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (ops.isShuttingDown())
|
|
436
|
+
return;
|
|
437
|
+
if (hasPortlessUnit(desired ?? []))
|
|
438
|
+
assertPortlessAvailable();
|
|
439
|
+
const plan = diffUnits(running, desired);
|
|
440
|
+
for (const r of plan.remove) {
|
|
441
|
+
logUnit(r.unit, chalk.dim('removed from neon.ts, stopping…'));
|
|
442
|
+
await ops.stopUnit(r);
|
|
443
|
+
const idx = running.indexOf(r);
|
|
444
|
+
if (idx !== -1)
|
|
445
|
+
running.splice(idx, 1);
|
|
446
|
+
}
|
|
447
|
+
for (const r of plan.restart)
|
|
448
|
+
ops.restartUnit(r);
|
|
449
|
+
if (plan.add.length > 0) {
|
|
450
|
+
const added = plan.add.map((unit) => {
|
|
451
|
+
const r = makeRunningUnit(unit);
|
|
452
|
+
running.push(r);
|
|
453
|
+
logUnit(unit, chalk.dim('added in neon.ts, starting…'));
|
|
454
|
+
return r;
|
|
455
|
+
});
|
|
456
|
+
await Promise.all(added.map((r) => ops.startUnit(r)));
|
|
457
|
+
for (const r of added) {
|
|
458
|
+
if (r.status === 'ready') {
|
|
459
|
+
const env = formatEnvSummary(r.unit.envSummary);
|
|
460
|
+
logUnit(r.unit, chalk.green('ready') +
|
|
461
|
+
` ${urlFor(r.boundPort)}` +
|
|
462
|
+
(env ? chalk.dim(` ${env}`) : ''));
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
/**
|
|
468
|
+
* Choose a port search base above every port the live units already bound, so a hot-added
|
|
469
|
+
* search-mode function starts probing where there's room. The runtime still walks upward
|
|
470
|
+
* from here, so it never fails even if this guess is taken — it just keeps things tidy.
|
|
471
|
+
*/
|
|
472
|
+
const nextSearchBase = (running) => {
|
|
473
|
+
let max = DEFAULT_PORT_BASE - 1;
|
|
474
|
+
for (const r of running) {
|
|
475
|
+
if (r.boundPort !== null && r.boundPort > max)
|
|
476
|
+
max = r.boundPort;
|
|
477
|
+
}
|
|
478
|
+
return max + 1;
|
|
479
|
+
};
|
|
480
|
+
const hasPortlessUnit = (units) => units.some((u) => u.portless !== undefined);
|
|
481
|
+
/**
|
|
482
|
+
* Spawn the child for a unit. A portless unit is wrapped as `portless <slug> node
|
|
483
|
+
* <runtime> <bundle>`: portless assigns a port, injects it as PORT (which the runtime
|
|
484
|
+
* honors), and exposes the server at `slug.localhost`. A plain unit runs the bundled
|
|
485
|
+
* output directly under `node`.
|
|
486
|
+
*
|
|
487
|
+
* Spawned detached (own process group) so killTree can reap the whole group — important
|
|
488
|
+
* for the portless case, where the tree is portless -> node runtime.
|
|
489
|
+
*/
|
|
490
|
+
const spawnChild = (unit, runtimePath, bundlePath) => {
|
|
491
|
+
if (unit.portless) {
|
|
492
|
+
return spawn('portless', [unit.portless.slug, process.execPath, runtimePath, bundlePath], {
|
|
493
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
494
|
+
env: unit.childEnv,
|
|
495
|
+
detached: true,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
return spawn(process.execPath, [runtimePath, bundlePath], {
|
|
499
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
500
|
+
env: unit.childEnv,
|
|
501
|
+
detached: true,
|
|
502
|
+
});
|
|
503
|
+
};
|
|
504
|
+
/** Fail early with an actionable message if a portless unit is requested but the binary is missing. */
|
|
505
|
+
const assertPortlessAvailable = () => {
|
|
506
|
+
const result = spawnSyncCheck('portless');
|
|
507
|
+
if (!result) {
|
|
508
|
+
throw new Error('A function sets `dev.portless: true`, but the `portless` command was not ' +
|
|
509
|
+
'found on your PATH. Install it globally (e.g. `npm i -g portless`) or ' +
|
|
510
|
+
'remove `dev.portless` from the function in neon.ts.');
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
const spawnSyncCheck = (bin) => {
|
|
514
|
+
try {
|
|
515
|
+
// Synchronous, no-side-effect probe: `which`/`where` resolves the binary.
|
|
516
|
+
const probe = process.platform === 'win32' ? 'where' : 'which';
|
|
517
|
+
const { status } = spawnSync(probe, [bin]);
|
|
518
|
+
return status === 0;
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
return false;
|
|
522
|
+
}
|
|
523
|
+
};
|
|
524
|
+
const writeBundle = async (source, bundleDir) => {
|
|
525
|
+
const files = await bundleEntry(source);
|
|
526
|
+
mkdirSync(bundleDir, { recursive: true });
|
|
527
|
+
// bundleEntry emits `index.mjs` (+ `index.mjs.map`). The `.mjs` extension makes Node load
|
|
528
|
+
// it as ESM directly, so no `package.json` `"type": "module"` marker is needed, and esbuild
|
|
529
|
+
// points the sourcemap link at `index.mjs.map` for us.
|
|
530
|
+
for (const [name, contents] of Object.entries(files)) {
|
|
531
|
+
writeFileSync(join(bundleDir, name), contents);
|
|
532
|
+
}
|
|
533
|
+
return join(bundleDir, 'index.mjs');
|
|
534
|
+
};
|
|
535
|
+
const urlFor = (port) => port === null ? chalk.red('not running') : `http://localhost:${port}`;
|
|
536
|
+
const waitForReady = (child) => new Promise((resolveReady) => {
|
|
537
|
+
let settled = false;
|
|
538
|
+
let buffer = '';
|
|
539
|
+
const onData = (chunk) => {
|
|
540
|
+
buffer += chunk.toString();
|
|
541
|
+
const match = READY_PATTERN.exec(buffer);
|
|
542
|
+
if (match && !settled) {
|
|
543
|
+
settled = true;
|
|
544
|
+
child.stdout?.off('data', onData);
|
|
545
|
+
resolveReady(Number(match[1]));
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
child.stdout?.on('data', onData);
|
|
549
|
+
child.once('exit', () => {
|
|
550
|
+
if (!settled) {
|
|
551
|
+
settled = true;
|
|
552
|
+
resolveReady(null);
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
/**
|
|
557
|
+
* Forward the child's stdout/stderr to the parent, swallowing the machine-readable
|
|
558
|
+
* `neon-dev:ready` line. When `label` is set (multi-function mode), every line is
|
|
559
|
+
* prefixed with `[slug]` so concurrent servers' output stays readable.
|
|
560
|
+
*/
|
|
561
|
+
const pipeChildOutput = (child, label) => {
|
|
562
|
+
const prefix = label ? chalk.dim(`[${label}] `) : '';
|
|
563
|
+
const forward = (stream) => {
|
|
564
|
+
let buffer = '';
|
|
565
|
+
child[stream]?.on('data', (chunk) => {
|
|
566
|
+
buffer += chunk.toString();
|
|
567
|
+
const lines = buffer.split('\n');
|
|
568
|
+
buffer = lines.pop() ?? '';
|
|
569
|
+
for (const line of lines) {
|
|
570
|
+
if (READY_PATTERN.test(line))
|
|
571
|
+
continue;
|
|
572
|
+
process[stream].write(`${prefix}${line}\n`);
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
};
|
|
576
|
+
forward('stdout');
|
|
577
|
+
forward('stderr');
|
|
578
|
+
};
|
|
579
|
+
const printBanner = (running, envNote) => {
|
|
580
|
+
log.info('');
|
|
581
|
+
log.info(chalk.green.bold(' Neon Functions dev server'));
|
|
582
|
+
log.info('');
|
|
583
|
+
for (const r of running) {
|
|
584
|
+
const name = r.unit.label ?? 'function';
|
|
585
|
+
const url = urlFor(r.boundPort);
|
|
586
|
+
log.info(` ${chalk.dim(name.padEnd(20))} ${url}`);
|
|
587
|
+
const env = formatEnvSummary(r.unit.envSummary);
|
|
588
|
+
if (env)
|
|
589
|
+
log.info(` ${' '.repeat(20)} ${chalk.dim(env)}`);
|
|
590
|
+
}
|
|
591
|
+
if (envNote) {
|
|
592
|
+
log.info('');
|
|
593
|
+
log.info(` ${chalk.yellow('!')} ${chalk.dim(`Neon env: ${envNote}`)}`);
|
|
594
|
+
}
|
|
595
|
+
log.info('');
|
|
596
|
+
};
|
|
597
|
+
/**
|
|
598
|
+
* Render a unit's injected env into one transparent line for the banner, e.g.
|
|
599
|
+
* `env: DATABASE_URL, DATABASE_URL_UNPOOLED · neon.ts: RESEND_API_KEY`. Var **names** only
|
|
600
|
+
* (never values — they're secrets). Returns `''` when nothing is injected, so the caller can
|
|
601
|
+
* skip the line. Exported for unit testing.
|
|
602
|
+
*/
|
|
603
|
+
export const formatEnvSummary = (summary) => {
|
|
604
|
+
if (!summary)
|
|
605
|
+
return '';
|
|
606
|
+
const parts = [];
|
|
607
|
+
if (summary.neon.length > 0) {
|
|
608
|
+
parts.push(`env: ${[...summary.neon].sort().join(', ')}`);
|
|
609
|
+
}
|
|
610
|
+
if (summary.fn.length > 0) {
|
|
611
|
+
parts.push(`neon.ts: ${[...summary.fn].sort().join(', ')}`);
|
|
612
|
+
}
|
|
613
|
+
return parts.join(' · ');
|
|
614
|
+
};
|
|
615
|
+
const logUnit = (unit, message) => {
|
|
616
|
+
const prefix = unit.label ? chalk.dim(`[${unit.label}] `) : '';
|
|
617
|
+
log.info(`${prefix}${message}`);
|
|
618
|
+
};
|
|
619
|
+
const startWatcher = async (source, restart) => {
|
|
620
|
+
const { default: chokidar } = await import('chokidar');
|
|
621
|
+
const initialInputs = await resolveWatchInputs(source);
|
|
622
|
+
if (initialInputs === null) {
|
|
623
|
+
return startDirectoryWatcher(chokidar, source, restart);
|
|
624
|
+
}
|
|
625
|
+
return startInputWatcher(chokidar, source, initialInputs, restart);
|
|
626
|
+
};
|
|
627
|
+
/**
|
|
628
|
+
* Watch the neon.ts file itself for changes, firing `onChange` on every save. Used by the
|
|
629
|
+
* supervisor (config mode only) to hot-add/remove/restart functions when the declared set
|
|
630
|
+
* changes. We watch the single config file rather than its import graph: editing neon.ts to
|
|
631
|
+
* add or remove a function is the case that matters, and a plain file watch is robust where
|
|
632
|
+
* the esbuild-based input resolution (built for function bundles) is not a fit for a
|
|
633
|
+
* jiti-loaded config.
|
|
634
|
+
*/
|
|
635
|
+
const startConfigWatcher = async (configPath, onChange) => {
|
|
636
|
+
const { default: chokidar } = await import('chokidar');
|
|
637
|
+
const watcher = chokidar.watch(configPath, { ignoreInitial: true });
|
|
638
|
+
await once(watcher, 'ready');
|
|
639
|
+
watcher.on('all', () => {
|
|
640
|
+
onChange();
|
|
641
|
+
});
|
|
642
|
+
return { sync: () => Promise.resolve(), close: () => watcher.close() };
|
|
643
|
+
};
|
|
644
|
+
const startInputWatcher = async (chokidar, source, initialInputs, restart) => {
|
|
645
|
+
const watched = new Set([source, ...initialInputs]);
|
|
646
|
+
const watcher = chokidar.watch([...watched], { ignoreInitial: true });
|
|
647
|
+
await once(watcher, 'ready');
|
|
648
|
+
watcher.on('all', () => {
|
|
649
|
+
restart();
|
|
650
|
+
});
|
|
651
|
+
const sync = async () => {
|
|
652
|
+
const next = await resolveWatchInputs(source);
|
|
653
|
+
if (next === null)
|
|
654
|
+
return;
|
|
655
|
+
const desired = new Set([source, ...next]);
|
|
656
|
+
for (const file of desired) {
|
|
657
|
+
if (!watched.has(file)) {
|
|
658
|
+
watcher.add(file);
|
|
659
|
+
watched.add(file);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
for (const file of watched) {
|
|
663
|
+
if (!desired.has(file)) {
|
|
664
|
+
watcher.unwatch(file);
|
|
665
|
+
watched.delete(file);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
};
|
|
669
|
+
return { sync, close: () => watcher.close() };
|
|
670
|
+
};
|
|
671
|
+
const startDirectoryWatcher = async (chokidar, source, restart) => {
|
|
672
|
+
const watchedDir = dirname(source);
|
|
673
|
+
const isIgnored = (p) => {
|
|
674
|
+
const segments = p.split(/[/\\]/);
|
|
675
|
+
return (segments.includes('node_modules') ||
|
|
676
|
+
segments.includes('.git') ||
|
|
677
|
+
segments.includes('dist'));
|
|
678
|
+
};
|
|
679
|
+
const watcher = chokidar.watch(watchedDir, {
|
|
680
|
+
ignoreInitial: true,
|
|
681
|
+
ignored: (path) => isIgnored(path),
|
|
682
|
+
});
|
|
683
|
+
await once(watcher, 'ready');
|
|
684
|
+
watcher.on('all', () => {
|
|
685
|
+
restart();
|
|
686
|
+
});
|
|
687
|
+
return { sync: () => Promise.resolve(), close: () => watcher.close() };
|
|
688
|
+
};
|
|
689
|
+
/**
|
|
690
|
+
* Terminate a child and every descendant it spawned. The child is started `detached`, so
|
|
691
|
+
* on POSIX it leads its own process group and a negative-PID signal reaps the group
|
|
692
|
+
* (covering portless -> neonctl -> node). On Windows there are no POSIX groups, so we
|
|
693
|
+
* shell out to `taskkill /T` to kill the tree. Escalates SIGTERM -> SIGKILL after 2s.
|
|
694
|
+
*/
|
|
695
|
+
const killTree = (child) => {
|
|
696
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
697
|
+
return Promise.resolve();
|
|
698
|
+
}
|
|
699
|
+
const pid = child.pid;
|
|
700
|
+
return new Promise((resolveKill) => {
|
|
701
|
+
const timeout = setTimeout(() => {
|
|
702
|
+
forceKill(child, pid);
|
|
703
|
+
}, 2000);
|
|
704
|
+
child.once('exit', () => {
|
|
705
|
+
clearTimeout(timeout);
|
|
706
|
+
resolveKill();
|
|
707
|
+
});
|
|
708
|
+
if (pid !== undefined && process.platform !== 'win32') {
|
|
709
|
+
try {
|
|
710
|
+
process.kill(-pid, 'SIGTERM');
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
catch {
|
|
714
|
+
// Fall through to a direct kill if the group is already gone.
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
child.kill('SIGTERM');
|
|
718
|
+
});
|
|
719
|
+
};
|
|
720
|
+
const forceKill = (child, pid) => {
|
|
721
|
+
if (pid === undefined) {
|
|
722
|
+
child.kill('SIGKILL');
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
if (process.platform === 'win32') {
|
|
726
|
+
spawnSync('taskkill', ['/pid', String(pid), '/T', '/F']);
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
try {
|
|
730
|
+
process.kill(-pid, 'SIGKILL');
|
|
731
|
+
}
|
|
732
|
+
catch {
|
|
733
|
+
child.kill('SIGKILL');
|
|
734
|
+
}
|
|
735
|
+
};
|
|
736
|
+
const resolveRuntimePath = () => {
|
|
737
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
738
|
+
const candidate = join(here, '..', 'dev', 'runtime.js');
|
|
739
|
+
if (!existsSync(candidate)) {
|
|
740
|
+
throw new Error(`Could not locate the dev runtime at ${candidate}`);
|
|
741
|
+
}
|
|
742
|
+
return candidate;
|
|
743
|
+
};
|