@kodycodes/cli 1.23.1 → 1.23.2

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.
Files changed (39) hide show
  1. package/README.md +26 -0
  2. package/dist/dev/dev-bundler.d.ts +25 -0
  3. package/dist/dev/dev-bundler.js +51 -0
  4. package/dist/dev/dev-bundler.js.map +1 -0
  5. package/dist/dev/dev-events.d.ts +50 -0
  6. package/dist/dev/dev-events.js +81 -0
  7. package/dist/dev/dev-events.js.map +1 -0
  8. package/dist/dev/dev-protocol.d.ts +13 -0
  9. package/dist/dev/dev-protocol.js +14 -0
  10. package/dist/dev/dev-protocol.js.map +1 -0
  11. package/dist/dev/dev-watcher.d.ts +20 -0
  12. package/dist/dev/dev-watcher.js +108 -0
  13. package/dist/dev/dev-watcher.js.map +1 -0
  14. package/dist/dev/error-page.d.ts +21 -0
  15. package/dist/dev/error-page.js +64 -0
  16. package/dist/dev/error-page.js.map +1 -0
  17. package/dist/dev/process-reaper.d.ts +40 -0
  18. package/dist/dev/process-reaper.js +144 -0
  19. package/dist/dev/process-reaper.js.map +1 -0
  20. package/dist/dev/stack-trace.d.ts +11 -0
  21. package/dist/dev/stack-trace.js +56 -0
  22. package/dist/dev/stack-trace.js.map +1 -0
  23. package/dist/dev/workerd-process.d.ts +28 -0
  24. package/dist/dev/workerd-process.js +118 -0
  25. package/dist/dev/workerd-process.js.map +1 -0
  26. package/dist/dev-command.js +17 -4
  27. package/dist/dev-command.js.map +1 -1
  28. package/dist/help.js +5 -0
  29. package/dist/help.js.map +1 -1
  30. package/dist/local-bundle.d.ts +43 -3
  31. package/dist/local-bundle.js +94 -33
  32. package/dist/local-bundle.js.map +1 -1
  33. package/dist/package-app-dev.d.ts +16 -3
  34. package/dist/package-app-dev.js +389 -192
  35. package/dist/package-app-dev.js.map +1 -1
  36. package/dist/package-local-runtime-source.d.ts +8 -0
  37. package/dist/package-local-runtime-source.js +69 -5
  38. package/dist/package-local-runtime-source.js.map +1 -1
  39. package/package.json +3 -1
@@ -1,18 +1,30 @@
1
1
  /**
2
2
  * Long-running `kody dev` app server: bundles `kody.app.entry` the same way
3
- * local-bundle/publish does for esbuild, serves via workerd, strips the mount
4
- * at the Node proxy, and rebuilds on file changes.
3
+ * local-bundle/publish does for esbuild, serves via workerd behind a Node
4
+ * proxy that strips the mount, and rebuilds on file changes.
5
+ *
6
+ * Each successful rebuild starts a new workerd "generation" and only swaps the
7
+ * proxy over once it is listening; the previous generation drains in-flight
8
+ * requests and is then stopped. Requests that arrive mid-rebuild wait for it,
9
+ * so a save is never answered by a 503 or a stale bundle.
5
10
  */
6
- import { spawn } from 'node:child_process';
7
11
  import { randomBytes } from 'node:crypto';
8
- import { watch } from 'node:fs';
12
+ import { rmSync } from 'node:fs';
9
13
  import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
10
14
  import { createServer, request as httpRequest, } from 'node:http';
11
15
  import { tmpdir } from 'node:os';
12
- import { join } from 'node:path';
16
+ import { join, relative } from 'node:path';
13
17
  import { assertTokenSafeApiUrl, callCapabilityProxy, } from './capability-proxy.js';
14
18
  import { apiTokenEnvVar } from './defaults.js';
15
- import { bundleLocalUserModule } from './local-bundle.js';
19
+ import { createDevBundler } from './dev/dev-bundler.js';
20
+ import { parseWorkerdStdoutLine } from './dev/dev-events.js';
21
+ import { devErrorHeader, devRequestIdHeader } from './dev/dev-protocol.js';
22
+ import { createDevWatcher } from './dev/dev-watcher.js';
23
+ import { sendDevError } from './dev/error-page.js';
24
+ import { devWorkDirPrefix, startProcessReaper, sweepStaleDevDirs, } from './dev/process-reaper.js';
25
+ import { createStackMapper } from './dev/stack-trace.js';
26
+ import { cleanWorkerdStderrLine, startWorkerd, } from './dev/workerd-process.js';
27
+ import { BundleError, formatBundleDiagnostic } from './local-bundle.js';
16
28
  import { localExecuteGatewayFetchShimModuleName, resolveLocalPackageGraph, writePackageModuleFiles, } from './local-package-graph.js';
17
29
  import { createRuntimeFlatAliasSource, createWorkerdConfig, localWorkerModuleNames, } from './local-runtime-source.js';
18
30
  import { buildLocalPackageContext } from './local-package-run.js';
@@ -22,7 +34,11 @@ import { createPackageAppEntrySource, createPackageLocalRuntimeModuleSource, run
22
34
  import { resolvePackagePath } from './package-manifest.js';
23
35
  import { redact } from './redact.js';
24
36
  import { ensureWorkerdBinary } from './workerd-binary.js';
25
- const workerdStartTimeoutMs = 30_000;
37
+ const defaultDebounceMs = 120;
38
+ /** Longest a request waits for an in-progress rebuild before the old build answers. */
39
+ const rebuildGateMs = 10_000;
40
+ /** Longest a retired generation may keep draining in-flight requests. */
41
+ const drainTimeoutMs = 10_000;
26
42
  export async function startPackageAppDev(options) {
27
43
  if (!options.manifest.app?.entry) {
28
44
  throw new Error('This package has no kody.app.entry. Add `"kody": { "app": { "entry": "./app/router.ts" } }` or use `kody dev job|webhook|emit|check`.');
@@ -30,8 +46,12 @@ export async function startPackageAppDev(options) {
30
46
  assertTokenSafeApiUrl(options.apiUrl);
31
47
  const host = options.host ?? '127.0.0.1';
32
48
  const appBasePath = normalizeBasePath(options.appBasePath ?? '');
49
+ const root = options.manifest.root;
33
50
  const runSecret = randomBytes(32).toString('hex');
34
- const workDir = await mkdtemp(join(tmpdir(), 'kody-dev-'));
51
+ const emit = (event) => options.onEvent?.(event);
52
+ void sweepStaleDevDirs().catch(() => { });
53
+ const workDir = await mkdtemp(join(tmpdir(), devWorkDirPrefix));
54
+ const reaper = await startProcessReaper({ workDir });
35
55
  const workerdPath = options.workerdPath ??
36
56
  (await ensureWorkerdBinary({
37
57
  onDownload: (url) => options.onStatus?.(`Downloading workerd from ${url}…`),
@@ -42,15 +62,34 @@ export async function startPackageAppDev(options) {
42
62
  fetchFn: options.fetchFn,
43
63
  ...(options.org ? { org: options.org } : {}),
44
64
  };
45
- let bridge;
46
- let child;
47
- let workerdPort;
48
- let reloadTimer;
65
+ const generations = new Set();
66
+ let active;
67
+ let buildFailure = null;
68
+ let generationCounter = 0;
69
+ let requestCounter = 0;
49
70
  let closed = false;
50
- /** Last workerd stderr chunk(s) — surfaced when the app returns HTTP 5xx. */
51
- let lastWorkerdStderr = '';
71
+ let buildLoop = null;
72
+ let rebuildAgain = false;
73
+ const changedFiles = new Set();
74
+ let markStarted = () => { };
75
+ const started = new Promise((resolve) => {
76
+ markStarted = resolve;
77
+ });
78
+ const killAllSync = () => {
79
+ for (const generation of generations) {
80
+ try {
81
+ process.kill(generation.workerd.pid, 'SIGKILL');
82
+ }
83
+ catch { }
84
+ }
85
+ try {
86
+ rmSync(workDir, { recursive: true, force: true });
87
+ }
88
+ catch { }
89
+ };
90
+ process.once('exit', killAllSync);
52
91
  const proxy = createServer((req, res) => {
53
- void proxyRequest(req, res);
92
+ void handleRequest(req, res);
54
93
  });
55
94
  await new Promise((resolve, reject) => {
56
95
  proxy.once('error', reject);
@@ -66,12 +105,12 @@ export async function startPackageAppDev(options) {
66
105
  });
67
106
  const mockFetchGateway = await resolveMockFetchGateway({
68
107
  target: options.target,
69
- packageRoot: options.manifest.root,
108
+ packageRoot: root,
70
109
  passthroughHosts: options.passthroughHosts,
71
110
  allowPrivateNetwork: options.allowPrivateNetwork,
72
111
  onStatus: options.onStatus,
73
112
  });
74
- bridge = await startBridge({
113
+ const bridge = await startBridge({
75
114
  runSecret,
76
115
  forward: async (call, signal) => {
77
116
  const mocked = tryHandleMockGatewayFetchCall({
@@ -90,14 +129,78 @@ export async function startPackageAppDev(options) {
90
129
  },
91
130
  });
92
131
  const entryPath = resolvePackagePath(options.manifest, options.manifest.app.entry);
93
- async function rebuildAndServe() {
94
- options.onStatus?.(`Bundling ${entryPath}…`);
95
- const code = await bundleLocalUserModule(entryPath);
132
+ const bundler = createDevBundler({ entryPath, root });
133
+ const workerdEnv = { ...process.env, [runSecretEnvVar]: runSecret };
134
+ delete workerdEnv[apiTokenEnvVar];
135
+ function requestBuild(paths) {
136
+ for (const path of paths)
137
+ changedFiles.add(path);
138
+ if (buildLoop) {
139
+ rebuildAgain = true;
140
+ return buildLoop;
141
+ }
142
+ buildLoop = (async () => {
143
+ do {
144
+ rebuildAgain = false;
145
+ const files = [...changedFiles];
146
+ changedFiles.clear();
147
+ await buildOnce(files);
148
+ } while (rebuildAgain && !closed);
149
+ })().finally(() => {
150
+ buildLoop = null;
151
+ });
152
+ return buildLoop;
153
+ }
154
+ async function buildOnce(files) {
155
+ const started = performance.now();
156
+ emit({ type: 'rebuild_start', files: files.map((file) => relative(root, file) || file) });
157
+ let bundle;
158
+ try {
159
+ bundle = await bundler.build();
160
+ watcher?.track([...bundle.inputs, options.manifest.packageJsonPath]);
161
+ if (active && !buildFailure && bundle.code === active.code) {
162
+ emit({
163
+ type: 'rebuild_ok',
164
+ ms: elapsed(started),
165
+ buildId: active.id,
166
+ restarted: false,
167
+ });
168
+ return;
169
+ }
170
+ const generation = await startGeneration(bundle);
171
+ if (closed) {
172
+ await stopGeneration(generation);
173
+ return;
174
+ }
175
+ const previous = active;
176
+ active = generation;
177
+ buildFailure = null;
178
+ if (previous)
179
+ retireGeneration(previous);
180
+ emit({ type: 'rebuild_ok', ms: elapsed(started), buildId: generation.id, restarted: true });
181
+ }
182
+ catch (error) {
183
+ buildFailure = toBuildFailure(error);
184
+ const [first] = buildFailure.diagnostics;
185
+ emit({
186
+ type: 'build_error',
187
+ ms: elapsed(started),
188
+ message: buildFailure.message,
189
+ file: first?.file ?? null,
190
+ line: first?.line ?? null,
191
+ col: first?.col ?? null,
192
+ diagnostics: buildFailure.diagnostics,
193
+ });
194
+ }
195
+ }
196
+ async function startGeneration(bundle) {
197
+ const id = ++generationCounter;
198
+ const dir = join(workDir, `g${id}`);
96
199
  await writeWorkerFiles({
97
- workDir,
98
- code,
200
+ workDir: dir,
201
+ code: bundle.code,
99
202
  packageContext,
100
- packageRoot: options.manifest.root,
203
+ packageRoot: root,
101
204
  mockStorage: options.target === 'mock',
102
205
  bridgePort: bridge.port,
103
206
  allowPrivateNetwork: options.allowPrivateNetwork,
@@ -109,112 +212,255 @@ export async function startPackageAppDev(options) {
109
212
  mockFetchGateway,
110
213
  onStatus: options.onStatus,
111
214
  });
112
- await restartWorkerd();
113
- }
114
- async function restartWorkerd() {
115
- if (child) {
116
- child.kill('SIGKILL');
117
- child = undefined;
118
- workerdPort = undefined;
215
+ let trackedPid;
216
+ let workerd;
217
+ try {
218
+ workerd = await startWorkerd({
219
+ workerdPath,
220
+ configPath: join(dir, 'config.capnp'),
221
+ cwd: dir,
222
+ env: workerdEnv,
223
+ onSpawn: (pid) => {
224
+ trackedPid = pid;
225
+ reaper.track(pid);
226
+ },
227
+ onStdoutLine: (line) => emit(parseWorkerdStdoutLine(line)),
228
+ onStderrLine: (line) => {
229
+ const cleaned = cleanWorkerdStderrLine(line);
230
+ if (cleaned === null)
231
+ return;
232
+ emit({
233
+ type: 'log',
234
+ source: 'workerd',
235
+ level: 'error',
236
+ message: redact(cleaned),
237
+ request: null,
238
+ });
239
+ },
240
+ });
119
241
  }
120
- const env = { ...process.env, [runSecretEnvVar]: runSecret };
121
- delete env[apiTokenEnvVar];
122
- const configPath = join(workDir, 'config.capnp');
123
- lastWorkerdStderr = '';
124
- child = spawn(workerdPath, ['serve', configPath, '--control-fd=3'], {
125
- cwd: workDir,
126
- env,
127
- stdio: ['ignore', 'pipe', 'pipe', 'pipe'],
128
- });
129
- child.stderr?.on('data', (chunk) => {
130
- lastWorkerdStderr += chunk.toString('utf8');
131
- if (lastWorkerdStderr.length > 32_000) {
132
- lastWorkerdStderr = lastWorkerdStderr.slice(-24_000);
133
- }
242
+ catch (error) {
243
+ // onSpawn tracks before listen; a failed start never reaches the
244
+ // exited/untrack path, so drop the pid here before pid reuse.
245
+ if (trackedPid !== undefined)
246
+ reaper.untrack(trackedPid);
247
+ await rm(dir, { recursive: true, force: true }).catch(() => { });
248
+ throw error;
249
+ }
250
+ const generation = {
251
+ id,
252
+ dir,
253
+ workerd,
254
+ code: bundle.code,
255
+ mapStack: createStackMapper({
256
+ sourceMap: bundle.sourceMap,
257
+ userModule: localWorkerModuleNames.user,
258
+ }),
259
+ inflight: 0,
260
+ retired: false,
261
+ stopped: false,
262
+ };
263
+ generations.add(generation);
264
+ void workerd.exited.then((detail) => {
265
+ reaper.untrack(workerd.pid);
266
+ if (closed || generation.retired || active !== generation)
267
+ return;
268
+ active = undefined;
269
+ buildFailure = {
270
+ message: `workerd stopped unexpectedly (${detail}). Save a file to restart it.`,
271
+ diagnostics: [],
272
+ };
273
+ options.onError?.(buildFailure.message);
134
274
  });
135
- const workerd = watchWorkerd(child);
136
- const started = await workerd.listening;
137
- if ('exitDetail' in started) {
138
- throw new Error(`workerd failed to start${started.exitDetail}`);
275
+ return generation;
276
+ }
277
+ function retireGeneration(generation) {
278
+ generation.retired = true;
279
+ if (generation.inflight === 0) {
280
+ void stopGeneration(generation);
281
+ return;
139
282
  }
140
- workerdPort = started.port;
141
- options.onStatus?.(`App ready at ${hostedUrl}${appBasePath || '/'}`);
283
+ setTimeout(() => void stopGeneration(generation), drainTimeoutMs).unref();
284
+ }
285
+ async function stopGeneration(generation) {
286
+ if (generation.stopped)
287
+ return;
288
+ generation.stopped = true;
289
+ generation.retired = true;
290
+ await generation.workerd.kill();
291
+ reaper.untrack(generation.workerd.pid);
292
+ generations.delete(generation);
293
+ await rm(generation.dir, { recursive: true, force: true });
294
+ }
295
+ async function waitForBuild() {
296
+ const pending = buildLoop ?? (active || buildFailure ? null : started);
297
+ if (!pending)
298
+ return;
299
+ await Promise.race([
300
+ pending.catch(() => { }),
301
+ new Promise((resolve) => setTimeout(resolve, rebuildGateMs).unref()),
302
+ ]);
142
303
  }
143
- async function proxyRequest(req, res) {
304
+ async function handleRequest(req, res) {
144
305
  try {
145
- if (!workerdPort) {
146
- res.writeHead(503, { 'content-type': 'text/plain' });
147
- res.end('Dev server is still starting.');
306
+ await waitForBuild();
307
+ if (buildFailure) {
308
+ sendDevError(req, res, 500, buildErrorView(buildFailure));
148
309
  return;
149
310
  }
150
- const incomingUrl = new URL(req.url ?? '/', hostedUrl);
151
- const restPath = stripMount(incomingUrl.pathname, appBasePath);
152
- const forwardUrl = new URL(restPath + incomingUrl.search, `http://127.0.0.1:${workerdPort}`);
153
- const chunks = [];
154
- for await (const chunk of req) {
155
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
311
+ const generation = active;
312
+ if (!generation) {
313
+ res.writeHead(503, { 'content-type': 'text/plain; charset=utf-8' });
314
+ res.end(closed ? 'kody dev is shutting down.\n' : 'kody dev is still starting.\n');
315
+ return;
316
+ }
317
+ generation.inflight += 1;
318
+ try {
319
+ await forwardRequest(generation, req, res);
320
+ }
321
+ finally {
322
+ generation.inflight -= 1;
323
+ if (generation.retired && generation.inflight === 0) {
324
+ void stopGeneration(generation);
325
+ }
156
326
  }
157
- const body = Buffer.concat(chunks);
158
- const headers = {
159
- ...req.headers,
160
- host: `127.0.0.1:${workerdPort}`,
161
- [runSecretHeader]: runSecret,
162
- };
163
- delete headers['content-length'];
164
- await new Promise((resolve, reject) => {
165
- const upstream = httpRequest(forwardUrl, { method: req.method, headers }, (upstreamRes) => {
166
- const status = upstreamRes.statusCode ?? 502;
167
- const chunks = [];
168
- upstreamRes.on('data', (chunk) => {
169
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
170
- });
171
- upstreamRes.on('end', () => {
172
- const responseBody = Buffer.concat(chunks);
173
- if (status >= 500) {
174
- const preview = redact(responseBody.toString('utf8').slice(0, 2000));
175
- const workerdLogs = redact(lastWorkerdStderr.trim());
176
- options.onError?.(`App handler HTTP ${status}${workerdLogs ? `\nworkerd stderr:\n${workerdLogs}` : ''}${preview ? `\nbody:\n${preview}` : ''}`);
177
- }
178
- if (!res.headersSent) {
179
- res.writeHead(status, upstreamRes.headers);
180
- }
181
- res.end(responseBody);
182
- resolve();
183
- });
184
- upstreamRes.on('error', reject);
185
- });
186
- upstream.on('error', reject);
187
- if (body.length > 0)
188
- upstream.write(body);
189
- upstream.end();
190
- });
191
327
  }
192
328
  catch (error) {
193
329
  const message = error instanceof Error ? error.message : String(error);
194
- const workerdLogs = redact(lastWorkerdStderr.trim());
195
- options.onError?.(workerdLogs ? `${message}\nworkerd stderr:\n${workerdLogs}` : message);
330
+ options.onError?.(`Dev proxy error: ${message}`);
196
331
  if (!res.headersSent) {
197
- res.writeHead(502, { 'content-type': 'text/plain' });
198
- res.end(`Dev proxy error: ${message}`);
332
+ res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' });
333
+ res.end(`Dev proxy error: ${message}\n`);
334
+ }
335
+ else if (!res.writableEnded) {
336
+ res.destroy();
199
337
  }
200
338
  }
201
339
  }
202
- await rebuildAndServe();
340
+ function forwardRequest(generation, req, res) {
341
+ const incomingUrl = new URL(req.url ?? '/', hostedUrl);
342
+ const restPath = stripMount(incomingUrl.pathname, appBasePath);
343
+ const forwardUrl = new URL(restPath + incomingUrl.search, `http://127.0.0.1:${generation.workerd.port}`);
344
+ const tag = {
345
+ id: String(++requestCounter),
346
+ method: req.method ?? 'GET',
347
+ path: restPath + incomingUrl.search,
348
+ };
349
+ const headers = {
350
+ ...req.headers,
351
+ host: `127.0.0.1:${generation.workerd.port}`,
352
+ [runSecretHeader]: runSecret,
353
+ [devRequestIdHeader]: tag.id,
354
+ };
355
+ return new Promise((resolve, reject) => {
356
+ const upstream = httpRequest(forwardUrl, { method: req.method, headers }, (upstreamRes) => {
357
+ if (upstreamRes.headers[devErrorHeader]) {
358
+ void readStream(upstreamRes)
359
+ .then((raw) => {
360
+ respondWithRuntimeError(generation, req, res, tag, raw);
361
+ resolve();
362
+ })
363
+ .catch(reject);
364
+ return;
365
+ }
366
+ const status = upstreamRes.statusCode ?? 502;
367
+ if (status >= 500) {
368
+ emit({
369
+ type: 'log',
370
+ source: 'app',
371
+ level: 'warn',
372
+ message: `responded HTTP ${status}`,
373
+ request: tag,
374
+ });
375
+ }
376
+ res.writeHead(status, upstreamRes.headers);
377
+ upstreamRes.pipe(res);
378
+ upstreamRes.on('end', () => resolve());
379
+ upstreamRes.on('error', reject);
380
+ });
381
+ upstream.on('error', reject);
382
+ res.on('close', () => {
383
+ if (!res.writableFinished)
384
+ upstream.destroy();
385
+ resolve();
386
+ });
387
+ req.pipe(upstream);
388
+ });
389
+ }
390
+ function respondWithRuntimeError(generation, req, res, tag, raw) {
391
+ let message = 'Uncaught error';
392
+ let stack = '';
393
+ try {
394
+ const parsed = JSON.parse(raw);
395
+ message = `${parsed.name ?? 'Error'}: ${parsed.message ?? ''}`;
396
+ stack = parsed.stack ?? message;
397
+ }
398
+ catch {
399
+ stack = raw;
400
+ }
401
+ const mapped = redact(generation.mapStack(stack));
402
+ emit({ type: 'runtime_error', message: redact(message), stack: mapped, request: tag });
403
+ sendDevError(req, res, 500, {
404
+ kind: 'runtime',
405
+ title: redact(message),
406
+ detail: mapped,
407
+ request: `${tag.method} ${tag.path}`,
408
+ });
409
+ }
203
410
  const watcher = options.watch === false
204
411
  ? null
205
- : watch(options.manifest.root, { recursive: true }, (_event, filename) => {
206
- if (closed)
207
- return;
208
- if (filename && shouldIgnoreWatchPath(filename))
209
- return;
210
- clearTimeout(reloadTimer);
211
- reloadTimer = setTimeout(() => {
212
- void rebuildAndServe().catch((error) => {
213
- const message = error instanceof Error ? error.message : String(error);
214
- options.onError?.(message);
215
- });
216
- }, 120);
412
+ : (() => {
413
+ let timer;
414
+ const pending = new Set();
415
+ return createDevWatcher({
416
+ root,
417
+ onChange: (path) => {
418
+ if (closed)
419
+ return;
420
+ pending.add(path);
421
+ clearTimeout(timer);
422
+ timer = setTimeout(() => {
423
+ const paths = [...pending];
424
+ pending.clear();
425
+ void requestBuild(paths);
426
+ }, options.debounceMs ?? defaultDebounceMs);
427
+ },
428
+ });
429
+ })();
430
+ const close = async () => {
431
+ if (closed)
432
+ return;
433
+ closed = true;
434
+ process.off('exit', killAllSync);
435
+ await watcher?.close();
436
+ await buildLoop?.catch(() => { });
437
+ await bundler.dispose();
438
+ await Promise.all([...generations].map((generation) => stopGeneration(generation)));
439
+ reaper.close();
440
+ await bridge.close();
441
+ proxy.closeAllConnections();
442
+ await new Promise((resolve) => {
443
+ proxy.close(() => resolve());
217
444
  });
445
+ await rm(workDir, { recursive: true, force: true });
446
+ };
447
+ try {
448
+ await Promise.all([requestBuild([]), watcher?.ready]);
449
+ }
450
+ catch (error) {
451
+ await close();
452
+ throw error;
453
+ }
454
+ finally {
455
+ markStarted();
456
+ }
457
+ if (options.watch === false && buildFailure) {
458
+ const message = buildFailure.message;
459
+ await close();
460
+ throw new Error(message);
461
+ }
462
+ if (active)
463
+ options.onStatus?.(`App ready at ${hostedUrl}${appBasePath || '/'}`);
218
464
  const fetchPath = async (path, init = {}) => {
219
465
  const url = new URL(joinUrlPath(appBasePath, path), hostedUrl);
220
466
  const response = await fetch(url, {
@@ -236,28 +482,40 @@ export async function startPackageAppDev(options) {
236
482
  appBasePath,
237
483
  packageContext,
238
484
  fetchPath,
239
- close: async () => {
240
- closed = true;
241
- clearTimeout(reloadTimer);
242
- watcher?.close();
243
- const stopping = child;
244
- if (stopping) {
245
- const exited = new Promise((resolve) => {
246
- stopping.once('exit', () => resolve());
247
- stopping.once('error', () => resolve());
248
- });
249
- stopping.kill('SIGKILL');
250
- await Promise.race([exited, new Promise((r) => setTimeout(r, 1000))]);
251
- child = undefined;
252
- }
253
- await bridge?.close();
254
- await new Promise((resolve) => {
255
- proxy.close(() => resolve());
256
- });
257
- await rm(workDir, { recursive: true, force: true });
258
- },
485
+ close,
486
+ };
487
+ }
488
+ function elapsed(started) {
489
+ return Math.round(performance.now() - started);
490
+ }
491
+ function toBuildFailure(error) {
492
+ if (error instanceof BundleError) {
493
+ return { message: error.message, diagnostics: error.diagnostics };
494
+ }
495
+ return {
496
+ message: redact(error instanceof Error ? error.message : String(error)),
497
+ diagnostics: [],
259
498
  };
260
499
  }
500
+ function buildErrorView(failure) {
501
+ const [first] = failure.diagnostics;
502
+ return {
503
+ kind: 'build',
504
+ title: first
505
+ ? `${first.file ?? 'bundle'}${first.line !== null ? `:${first.line}:${first.col}` : ''} ${first.message}`
506
+ : (failure.message.split('\n')[0] ?? 'Build failed'),
507
+ detail: failure.diagnostics.length > 0
508
+ ? failure.diagnostics.map(formatBundleDiagnostic).join('\n\n')
509
+ : failure.message,
510
+ };
511
+ }
512
+ async function readStream(stream) {
513
+ const chunks = [];
514
+ for await (const chunk of stream) {
515
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
516
+ }
517
+ return Buffer.concat(chunks).toString('utf8');
518
+ }
261
519
  async function writeWorkerFiles(input) {
262
520
  await mkdir(input.workDir, { recursive: true });
263
521
  const packageGraph = await resolveLocalPackageGraph({
@@ -320,67 +578,6 @@ function joinUrlPath(base, path) {
320
578
  return rest;
321
579
  return `${base}${rest === '/' ? '' : rest}` || base;
322
580
  }
323
- function shouldIgnoreWatchPath(filename) {
324
- return (filename.includes('node_modules') ||
325
- filename.includes('.git') ||
326
- filename.endsWith('.map') ||
327
- filename.startsWith('.'));
328
- }
329
- function watchWorkerd(child) {
330
- let stderrText = '';
331
- child.stderr?.on('data', (chunk) => {
332
- stderrText += chunk.toString('utf8');
333
- });
334
- child.stdout?.resume();
335
- const stderr = () => {
336
- const trimmed = redact(stderrText.trim());
337
- return trimmed ? `\n${trimmed}` : '';
338
- };
339
- const exited = new Promise((resolve) => {
340
- child.once('exit', (code, signal) => {
341
- resolve(` (workerd exit ${signal ?? code})${stderr()}`);
342
- });
343
- });
344
- const listening = new Promise((resolve, reject) => {
345
- const control = child.stdio[3];
346
- let buffered = '';
347
- const timer = setTimeout(() => {
348
- reject(new Error(`workerd did not start within ${workerdStartTimeoutMs / 1000}s.${stderr()}`));
349
- }, workerdStartTimeoutMs);
350
- control?.on('data', (chunk) => {
351
- buffered += chunk.toString('utf8');
352
- for (const line of buffered.split('\n')) {
353
- const port = parseListenPort(line);
354
- if (port !== null) {
355
- clearTimeout(timer);
356
- resolve({ port });
357
- }
358
- }
359
- });
360
- child.once('error', (error) => {
361
- clearTimeout(timer);
362
- reject(new Error(`Could not start workerd: ${error.message}`));
363
- });
364
- void exited.then((exitDetail) => {
365
- clearTimeout(timer);
366
- resolve({ exitDetail });
367
- });
368
- });
369
- return { listening, exited, stderr };
370
- }
371
- function parseListenPort(line) {
372
- if (!line.trim())
373
- return null;
374
- try {
375
- const event = JSON.parse(line);
376
- return event.event === 'listen' && event.socket === 'http' && typeof event.port === 'number'
377
- ? event.port
378
- : null;
379
- }
380
- catch {
381
- return null;
382
- }
383
- }
384
581
  async function startBridge(input) {
385
582
  const inFlight = new AbortController();
386
583
  const server = createServer(async (request, response) => {