@kodycodes/cli 1.23.1 → 1.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.
- package/README.md +34 -1
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +2 -0
- package/dist/cli.js.map +1 -1
- package/dist/dev/dev-bundler.d.ts +25 -0
- package/dist/dev/dev-bundler.js +51 -0
- package/dist/dev/dev-bundler.js.map +1 -0
- package/dist/dev/dev-events.d.ts +50 -0
- package/dist/dev/dev-events.js +81 -0
- package/dist/dev/dev-events.js.map +1 -0
- package/dist/dev/dev-protocol.d.ts +13 -0
- package/dist/dev/dev-protocol.js +14 -0
- package/dist/dev/dev-protocol.js.map +1 -0
- package/dist/dev/dev-watcher.d.ts +20 -0
- package/dist/dev/dev-watcher.js +108 -0
- package/dist/dev/dev-watcher.js.map +1 -0
- package/dist/dev/error-page.d.ts +21 -0
- package/dist/dev/error-page.js +64 -0
- package/dist/dev/error-page.js.map +1 -0
- package/dist/dev/live-reload.d.ts +45 -0
- package/dist/dev/live-reload.js +142 -0
- package/dist/dev/live-reload.js.map +1 -0
- package/dist/dev/process-reaper.d.ts +40 -0
- package/dist/dev/process-reaper.js +144 -0
- package/dist/dev/process-reaper.js.map +1 -0
- package/dist/dev/stack-trace.d.ts +11 -0
- package/dist/dev/stack-trace.js +56 -0
- package/dist/dev/stack-trace.js.map +1 -0
- package/dist/dev/workerd-process.d.ts +28 -0
- package/dist/dev/workerd-process.js +118 -0
- package/dist/dev/workerd-process.js.map +1 -0
- package/dist/dev-command.d.ts +2 -0
- package/dist/dev-command.js +18 -4
- package/dist/dev-command.js.map +1 -1
- package/dist/help.js +8 -1
- package/dist/help.js.map +1 -1
- package/dist/local-bundle.d.ts +43 -3
- package/dist/local-bundle.js +94 -33
- package/dist/local-bundle.js.map +1 -1
- package/dist/package-app-dev.d.ts +21 -3
- package/dist/package-app-dev.js +446 -192
- package/dist/package-app-dev.js.map +1 -1
- package/dist/package-local-runtime-source.d.ts +8 -0
- package/dist/package-local-runtime-source.js +69 -5
- package/dist/package-local-runtime-source.js.map +1 -1
- package/package.json +3 -1
package/dist/package-app-dev.js
CHANGED
|
@@ -1,18 +1,31 @@
|
|
|
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
|
|
4
|
-
*
|
|
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 {
|
|
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 {
|
|
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 { createLiveReloadHub, injectLiveReloadScript, liveReloadClientPath, liveReloadClientSource, liveReloadEventsPath, liveReloadScriptTag, shouldInjectLiveReload, } from './dev/live-reload.js';
|
|
25
|
+
import { devWorkDirPrefix, startProcessReaper, sweepStaleDevDirs, } from './dev/process-reaper.js';
|
|
26
|
+
import { createStackMapper } from './dev/stack-trace.js';
|
|
27
|
+
import { cleanWorkerdStderrLine, startWorkerd, } from './dev/workerd-process.js';
|
|
28
|
+
import { BundleError, formatBundleDiagnostic } from './local-bundle.js';
|
|
16
29
|
import { localExecuteGatewayFetchShimModuleName, resolveLocalPackageGraph, writePackageModuleFiles, } from './local-package-graph.js';
|
|
17
30
|
import { createRuntimeFlatAliasSource, createWorkerdConfig, localWorkerModuleNames, } from './local-runtime-source.js';
|
|
18
31
|
import { buildLocalPackageContext } from './local-package-run.js';
|
|
@@ -22,7 +35,11 @@ import { createPackageAppEntrySource, createPackageLocalRuntimeModuleSource, run
|
|
|
22
35
|
import { resolvePackagePath } from './package-manifest.js';
|
|
23
36
|
import { redact } from './redact.js';
|
|
24
37
|
import { ensureWorkerdBinary } from './workerd-binary.js';
|
|
25
|
-
const
|
|
38
|
+
const defaultDebounceMs = 120;
|
|
39
|
+
/** Longest a request waits for an in-progress rebuild before the old build answers. */
|
|
40
|
+
const rebuildGateMs = 10_000;
|
|
41
|
+
/** Longest a retired generation may keep draining in-flight requests. */
|
|
42
|
+
const drainTimeoutMs = 10_000;
|
|
26
43
|
export async function startPackageAppDev(options) {
|
|
27
44
|
if (!options.manifest.app?.entry) {
|
|
28
45
|
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 +47,16 @@ export async function startPackageAppDev(options) {
|
|
|
30
47
|
assertTokenSafeApiUrl(options.apiUrl);
|
|
31
48
|
const host = options.host ?? '127.0.0.1';
|
|
32
49
|
const appBasePath = normalizeBasePath(options.appBasePath ?? '');
|
|
50
|
+
const root = options.manifest.root;
|
|
33
51
|
const runSecret = randomBytes(32).toString('hex');
|
|
34
|
-
const
|
|
52
|
+
const emit = (event) => options.onEvent?.(event);
|
|
53
|
+
const liveReload = options.liveReload ?? options.watch !== false;
|
|
54
|
+
const errorPageTail = liveReload
|
|
55
|
+
? `<script>globalThis.__kodyDevErrorPage = true</script>${liveReloadScriptTag}`
|
|
56
|
+
: '';
|
|
57
|
+
void sweepStaleDevDirs().catch(() => { });
|
|
58
|
+
const workDir = await mkdtemp(join(tmpdir(), devWorkDirPrefix));
|
|
59
|
+
const reaper = await startProcessReaper({ workDir });
|
|
35
60
|
const workerdPath = options.workerdPath ??
|
|
36
61
|
(await ensureWorkerdBinary({
|
|
37
62
|
onDownload: (url) => options.onStatus?.(`Downloading workerd from ${url}…`),
|
|
@@ -42,15 +67,40 @@ export async function startPackageAppDev(options) {
|
|
|
42
67
|
fetchFn: options.fetchFn,
|
|
43
68
|
...(options.org ? { org: options.org } : {}),
|
|
44
69
|
};
|
|
45
|
-
|
|
46
|
-
let
|
|
47
|
-
let
|
|
48
|
-
let
|
|
70
|
+
const generations = new Set();
|
|
71
|
+
let active;
|
|
72
|
+
let buildFailure = null;
|
|
73
|
+
let generationCounter = 0;
|
|
74
|
+
let requestCounter = 0;
|
|
49
75
|
let closed = false;
|
|
50
|
-
|
|
51
|
-
let
|
|
76
|
+
let buildLoop = null;
|
|
77
|
+
let rebuildAgain = false;
|
|
78
|
+
const changedFiles = new Set();
|
|
79
|
+
const liveReloadHub = createLiveReloadHub({
|
|
80
|
+
state: () => ({
|
|
81
|
+
buildId: active?.id ?? null,
|
|
82
|
+
error: buildFailure ? buildErrorView(buildFailure) : null,
|
|
83
|
+
}),
|
|
84
|
+
});
|
|
85
|
+
let markStarted = () => { };
|
|
86
|
+
const started = new Promise((resolve) => {
|
|
87
|
+
markStarted = resolve;
|
|
88
|
+
});
|
|
89
|
+
const killAllSync = () => {
|
|
90
|
+
for (const generation of generations) {
|
|
91
|
+
try {
|
|
92
|
+
process.kill(generation.workerd.pid, 'SIGKILL');
|
|
93
|
+
}
|
|
94
|
+
catch { }
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
rmSync(workDir, { recursive: true, force: true });
|
|
98
|
+
}
|
|
99
|
+
catch { }
|
|
100
|
+
};
|
|
101
|
+
process.once('exit', killAllSync);
|
|
52
102
|
const proxy = createServer((req, res) => {
|
|
53
|
-
void
|
|
103
|
+
void handleRequest(req, res);
|
|
54
104
|
});
|
|
55
105
|
await new Promise((resolve, reject) => {
|
|
56
106
|
proxy.once('error', reject);
|
|
@@ -66,12 +116,12 @@ export async function startPackageAppDev(options) {
|
|
|
66
116
|
});
|
|
67
117
|
const mockFetchGateway = await resolveMockFetchGateway({
|
|
68
118
|
target: options.target,
|
|
69
|
-
packageRoot:
|
|
119
|
+
packageRoot: root,
|
|
70
120
|
passthroughHosts: options.passthroughHosts,
|
|
71
121
|
allowPrivateNetwork: options.allowPrivateNetwork,
|
|
72
122
|
onStatus: options.onStatus,
|
|
73
123
|
});
|
|
74
|
-
bridge = await startBridge({
|
|
124
|
+
const bridge = await startBridge({
|
|
75
125
|
runSecret,
|
|
76
126
|
forward: async (call, signal) => {
|
|
77
127
|
const mocked = tryHandleMockGatewayFetchCall({
|
|
@@ -90,14 +140,80 @@ export async function startPackageAppDev(options) {
|
|
|
90
140
|
},
|
|
91
141
|
});
|
|
92
142
|
const entryPath = resolvePackagePath(options.manifest, options.manifest.app.entry);
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
143
|
+
const bundler = createDevBundler({ entryPath, root });
|
|
144
|
+
const workerdEnv = { ...process.env, [runSecretEnvVar]: runSecret };
|
|
145
|
+
delete workerdEnv[apiTokenEnvVar];
|
|
146
|
+
function requestBuild(paths) {
|
|
147
|
+
for (const path of paths)
|
|
148
|
+
changedFiles.add(path);
|
|
149
|
+
if (buildLoop) {
|
|
150
|
+
rebuildAgain = true;
|
|
151
|
+
return buildLoop;
|
|
152
|
+
}
|
|
153
|
+
buildLoop = (async () => {
|
|
154
|
+
do {
|
|
155
|
+
rebuildAgain = false;
|
|
156
|
+
const files = [...changedFiles];
|
|
157
|
+
changedFiles.clear();
|
|
158
|
+
await buildOnce(files);
|
|
159
|
+
} while (rebuildAgain && !closed);
|
|
160
|
+
})().finally(() => {
|
|
161
|
+
buildLoop = null;
|
|
162
|
+
});
|
|
163
|
+
return buildLoop;
|
|
164
|
+
}
|
|
165
|
+
async function buildOnce(files) {
|
|
166
|
+
const started = performance.now();
|
|
167
|
+
emit({ type: 'rebuild_start', files: files.map((file) => relative(root, file) || file) });
|
|
168
|
+
let bundle;
|
|
169
|
+
try {
|
|
170
|
+
bundle = await bundler.build();
|
|
171
|
+
watcher?.track([...bundle.inputs, options.manifest.packageJsonPath]);
|
|
172
|
+
if (active && !buildFailure && bundle.code === active.code) {
|
|
173
|
+
emit({
|
|
174
|
+
type: 'rebuild_ok',
|
|
175
|
+
ms: elapsed(started),
|
|
176
|
+
buildId: active.id,
|
|
177
|
+
restarted: false,
|
|
178
|
+
});
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const generation = await startGeneration(bundle);
|
|
182
|
+
if (closed) {
|
|
183
|
+
await stopGeneration(generation);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const previous = active;
|
|
187
|
+
active = generation;
|
|
188
|
+
buildFailure = null;
|
|
189
|
+
if (previous)
|
|
190
|
+
retireGeneration(previous);
|
|
191
|
+
emit({ type: 'rebuild_ok', ms: elapsed(started), buildId: generation.id, restarted: true });
|
|
192
|
+
liveReloadHub.broadcast({ event: 'reload', data: { buildId: generation.id } });
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
buildFailure = toBuildFailure(error);
|
|
196
|
+
const [first] = buildFailure.diagnostics;
|
|
197
|
+
emit({
|
|
198
|
+
type: 'build_error',
|
|
199
|
+
ms: elapsed(started),
|
|
200
|
+
message: buildFailure.message,
|
|
201
|
+
file: first?.file ?? null,
|
|
202
|
+
line: first?.line ?? null,
|
|
203
|
+
col: first?.col ?? null,
|
|
204
|
+
diagnostics: buildFailure.diagnostics,
|
|
205
|
+
});
|
|
206
|
+
liveReloadHub.broadcast({ event: 'build_error', data: buildErrorView(buildFailure) });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
async function startGeneration(bundle) {
|
|
210
|
+
const id = ++generationCounter;
|
|
211
|
+
const dir = join(workDir, `g${id}`);
|
|
96
212
|
await writeWorkerFiles({
|
|
97
|
-
workDir,
|
|
98
|
-
code,
|
|
213
|
+
workDir: dir,
|
|
214
|
+
code: bundle.code,
|
|
99
215
|
packageContext,
|
|
100
|
-
packageRoot:
|
|
216
|
+
packageRoot: root,
|
|
101
217
|
mockStorage: options.target === 'mock',
|
|
102
218
|
bridgePort: bridge.port,
|
|
103
219
|
allowPrivateNetwork: options.allowPrivateNetwork,
|
|
@@ -109,112 +225,299 @@ export async function startPackageAppDev(options) {
|
|
|
109
225
|
mockFetchGateway,
|
|
110
226
|
onStatus: options.onStatus,
|
|
111
227
|
});
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
228
|
+
let trackedPid;
|
|
229
|
+
let workerd;
|
|
230
|
+
try {
|
|
231
|
+
workerd = await startWorkerd({
|
|
232
|
+
workerdPath,
|
|
233
|
+
configPath: join(dir, 'config.capnp'),
|
|
234
|
+
cwd: dir,
|
|
235
|
+
env: workerdEnv,
|
|
236
|
+
onSpawn: (pid) => {
|
|
237
|
+
trackedPid = pid;
|
|
238
|
+
reaper.track(pid);
|
|
239
|
+
},
|
|
240
|
+
onStdoutLine: (line) => emit(parseWorkerdStdoutLine(line)),
|
|
241
|
+
onStderrLine: (line) => {
|
|
242
|
+
const cleaned = cleanWorkerdStderrLine(line);
|
|
243
|
+
if (cleaned === null)
|
|
244
|
+
return;
|
|
245
|
+
emit({
|
|
246
|
+
type: 'log',
|
|
247
|
+
source: 'workerd',
|
|
248
|
+
level: 'error',
|
|
249
|
+
message: redact(cleaned),
|
|
250
|
+
request: null,
|
|
251
|
+
});
|
|
252
|
+
},
|
|
253
|
+
});
|
|
119
254
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
255
|
+
catch (error) {
|
|
256
|
+
// onSpawn tracks before listen; a failed start never reaches the
|
|
257
|
+
// exited/untrack path, so drop the pid here before pid reuse.
|
|
258
|
+
if (trackedPid !== undefined)
|
|
259
|
+
reaper.untrack(trackedPid);
|
|
260
|
+
await rm(dir, { recursive: true, force: true }).catch(() => { });
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
const generation = {
|
|
264
|
+
id,
|
|
265
|
+
dir,
|
|
266
|
+
workerd,
|
|
267
|
+
code: bundle.code,
|
|
268
|
+
mapStack: createStackMapper({
|
|
269
|
+
sourceMap: bundle.sourceMap,
|
|
270
|
+
userModule: localWorkerModuleNames.user,
|
|
271
|
+
}),
|
|
272
|
+
inflight: 0,
|
|
273
|
+
retired: false,
|
|
274
|
+
stopped: false,
|
|
275
|
+
};
|
|
276
|
+
generations.add(generation);
|
|
277
|
+
void workerd.exited.then((detail) => {
|
|
278
|
+
reaper.untrack(workerd.pid);
|
|
279
|
+
if (closed || generation.retired || active !== generation)
|
|
280
|
+
return;
|
|
281
|
+
active = undefined;
|
|
282
|
+
buildFailure = {
|
|
283
|
+
message: `workerd stopped unexpectedly (${detail}). Save a file to restart it.`,
|
|
284
|
+
diagnostics: [],
|
|
285
|
+
};
|
|
286
|
+
options.onError?.(buildFailure.message);
|
|
134
287
|
});
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
288
|
+
return generation;
|
|
289
|
+
}
|
|
290
|
+
function retireGeneration(generation) {
|
|
291
|
+
generation.retired = true;
|
|
292
|
+
if (generation.inflight === 0) {
|
|
293
|
+
void stopGeneration(generation);
|
|
294
|
+
return;
|
|
139
295
|
}
|
|
140
|
-
|
|
141
|
-
|
|
296
|
+
setTimeout(() => void stopGeneration(generation), drainTimeoutMs).unref();
|
|
297
|
+
}
|
|
298
|
+
async function stopGeneration(generation) {
|
|
299
|
+
if (generation.stopped)
|
|
300
|
+
return;
|
|
301
|
+
generation.stopped = true;
|
|
302
|
+
generation.retired = true;
|
|
303
|
+
await generation.workerd.kill();
|
|
304
|
+
reaper.untrack(generation.workerd.pid);
|
|
305
|
+
generations.delete(generation);
|
|
306
|
+
await rm(generation.dir, { recursive: true, force: true });
|
|
142
307
|
}
|
|
143
|
-
async function
|
|
308
|
+
async function waitForBuild() {
|
|
309
|
+
const pending = buildLoop ?? (active || buildFailure ? null : started);
|
|
310
|
+
if (!pending)
|
|
311
|
+
return;
|
|
312
|
+
await Promise.race([
|
|
313
|
+
pending.catch(() => { }),
|
|
314
|
+
new Promise((resolve) => setTimeout(resolve, rebuildGateMs).unref()),
|
|
315
|
+
]);
|
|
316
|
+
}
|
|
317
|
+
function handleDevRoute(req, res) {
|
|
318
|
+
const pathname = stripMount(new URL(req.url ?? '/', hostedUrl).pathname, appBasePath);
|
|
319
|
+
if (!liveReload)
|
|
320
|
+
return false;
|
|
321
|
+
if (pathname === liveReloadEventsPath) {
|
|
322
|
+
liveReloadHub.handleEvents(req, res);
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
if (pathname === liveReloadClientPath) {
|
|
326
|
+
res.writeHead(200, {
|
|
327
|
+
'content-type': 'text/javascript; charset=utf-8',
|
|
328
|
+
'cache-control': 'no-store',
|
|
329
|
+
});
|
|
330
|
+
res.end(liveReloadClientSource);
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
async function handleRequest(req, res) {
|
|
144
336
|
try {
|
|
145
|
-
if (
|
|
146
|
-
|
|
147
|
-
|
|
337
|
+
if (handleDevRoute(req, res))
|
|
338
|
+
return;
|
|
339
|
+
await waitForBuild();
|
|
340
|
+
if (buildFailure) {
|
|
341
|
+
sendDevError(req, res, 500, buildErrorView(buildFailure), {
|
|
342
|
+
htmlTail: errorPageTail,
|
|
343
|
+
});
|
|
148
344
|
return;
|
|
149
345
|
}
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
346
|
+
const generation = active;
|
|
347
|
+
if (!generation) {
|
|
348
|
+
res.writeHead(503, { 'content-type': 'text/plain; charset=utf-8' });
|
|
349
|
+
res.end(closed ? 'kody dev is shutting down.\n' : 'kody dev is still starting.\n');
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
generation.inflight += 1;
|
|
353
|
+
try {
|
|
354
|
+
await forwardRequest(generation, req, res);
|
|
355
|
+
}
|
|
356
|
+
finally {
|
|
357
|
+
generation.inflight -= 1;
|
|
358
|
+
if (generation.retired && generation.inflight === 0) {
|
|
359
|
+
void stopGeneration(generation);
|
|
360
|
+
}
|
|
156
361
|
}
|
|
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
362
|
}
|
|
192
363
|
catch (error) {
|
|
193
364
|
const message = error instanceof Error ? error.message : String(error);
|
|
194
|
-
|
|
195
|
-
options.onError?.(workerdLogs ? `${message}\nworkerd stderr:\n${workerdLogs}` : message);
|
|
365
|
+
options.onError?.(`Dev proxy error: ${message}`);
|
|
196
366
|
if (!res.headersSent) {
|
|
197
|
-
res.writeHead(502, { 'content-type': 'text/plain' });
|
|
198
|
-
res.end(`Dev proxy error: ${message}`);
|
|
367
|
+
res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' });
|
|
368
|
+
res.end(`Dev proxy error: ${message}\n`);
|
|
199
369
|
}
|
|
370
|
+
else if (!res.writableEnded) {
|
|
371
|
+
res.destroy();
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
function forwardRequest(generation, req, res) {
|
|
376
|
+
const incomingUrl = new URL(req.url ?? '/', hostedUrl);
|
|
377
|
+
const restPath = stripMount(incomingUrl.pathname, appBasePath);
|
|
378
|
+
const forwardUrl = new URL(restPath + incomingUrl.search, `http://127.0.0.1:${generation.workerd.port}`);
|
|
379
|
+
const tag = {
|
|
380
|
+
id: String(++requestCounter),
|
|
381
|
+
method: req.method ?? 'GET',
|
|
382
|
+
path: restPath + incomingUrl.search,
|
|
383
|
+
};
|
|
384
|
+
const headers = {
|
|
385
|
+
...req.headers,
|
|
386
|
+
host: `127.0.0.1:${generation.workerd.port}`,
|
|
387
|
+
[runSecretHeader]: runSecret,
|
|
388
|
+
[devRequestIdHeader]: tag.id,
|
|
389
|
+
};
|
|
390
|
+
return new Promise((resolve, reject) => {
|
|
391
|
+
const upstream = httpRequest(forwardUrl, { method: req.method, headers }, (upstreamRes) => {
|
|
392
|
+
if (upstreamRes.headers[devErrorHeader]) {
|
|
393
|
+
void readStream(upstreamRes)
|
|
394
|
+
.then((raw) => {
|
|
395
|
+
respondWithRuntimeError(generation, req, res, tag, raw);
|
|
396
|
+
resolve();
|
|
397
|
+
})
|
|
398
|
+
.catch(reject);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const status = upstreamRes.statusCode ?? 502;
|
|
402
|
+
if (status >= 500) {
|
|
403
|
+
emit({
|
|
404
|
+
type: 'log',
|
|
405
|
+
source: 'app',
|
|
406
|
+
level: 'warn',
|
|
407
|
+
message: `responded HTTP ${status}`,
|
|
408
|
+
request: tag,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
if (liveReload &&
|
|
412
|
+
shouldInjectLiveReload({
|
|
413
|
+
method: req.method,
|
|
414
|
+
status,
|
|
415
|
+
headers: upstreamRes.headers,
|
|
416
|
+
})) {
|
|
417
|
+
void readStream(upstreamRes)
|
|
418
|
+
.then((html) => {
|
|
419
|
+
const body = Buffer.from(injectLiveReloadScript(html), 'utf8');
|
|
420
|
+
const headers = { ...upstreamRes.headers };
|
|
421
|
+
delete headers['transfer-encoding'];
|
|
422
|
+
headers['content-length'] = String(body.length);
|
|
423
|
+
res.writeHead(status, headers);
|
|
424
|
+
res.end(body);
|
|
425
|
+
resolve();
|
|
426
|
+
})
|
|
427
|
+
.catch(reject);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
res.writeHead(status, upstreamRes.headers);
|
|
431
|
+
upstreamRes.pipe(res);
|
|
432
|
+
upstreamRes.on('end', () => resolve());
|
|
433
|
+
upstreamRes.on('error', reject);
|
|
434
|
+
});
|
|
435
|
+
upstream.on('error', reject);
|
|
436
|
+
res.on('close', () => {
|
|
437
|
+
if (!res.writableFinished)
|
|
438
|
+
upstream.destroy();
|
|
439
|
+
resolve();
|
|
440
|
+
});
|
|
441
|
+
req.pipe(upstream);
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
function respondWithRuntimeError(generation, req, res, tag, raw) {
|
|
445
|
+
let message = 'Uncaught error';
|
|
446
|
+
let stack = '';
|
|
447
|
+
try {
|
|
448
|
+
const parsed = JSON.parse(raw);
|
|
449
|
+
message = `${parsed.name ?? 'Error'}: ${parsed.message ?? ''}`;
|
|
450
|
+
stack = parsed.stack ?? message;
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
stack = raw;
|
|
200
454
|
}
|
|
455
|
+
const mapped = redact(generation.mapStack(stack));
|
|
456
|
+
emit({ type: 'runtime_error', message: redact(message), stack: mapped, request: tag });
|
|
457
|
+
const view = {
|
|
458
|
+
kind: 'runtime',
|
|
459
|
+
title: redact(message),
|
|
460
|
+
detail: mapped,
|
|
461
|
+
request: `${tag.method} ${tag.path}`,
|
|
462
|
+
};
|
|
463
|
+
liveReloadHub.broadcast({ event: 'runtime_error', data: view });
|
|
464
|
+
sendDevError(req, res, 500, view, { htmlTail: errorPageTail });
|
|
201
465
|
}
|
|
202
|
-
await rebuildAndServe();
|
|
203
466
|
const watcher = options.watch === false
|
|
204
467
|
? null
|
|
205
|
-
:
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
468
|
+
: (() => {
|
|
469
|
+
let timer;
|
|
470
|
+
const pending = new Set();
|
|
471
|
+
return createDevWatcher({
|
|
472
|
+
root,
|
|
473
|
+
onChange: (path) => {
|
|
474
|
+
if (closed)
|
|
475
|
+
return;
|
|
476
|
+
pending.add(path);
|
|
477
|
+
clearTimeout(timer);
|
|
478
|
+
timer = setTimeout(() => {
|
|
479
|
+
const paths = [...pending];
|
|
480
|
+
pending.clear();
|
|
481
|
+
void requestBuild(paths);
|
|
482
|
+
}, options.debounceMs ?? defaultDebounceMs);
|
|
483
|
+
},
|
|
484
|
+
});
|
|
485
|
+
})();
|
|
486
|
+
const close = async () => {
|
|
487
|
+
if (closed)
|
|
488
|
+
return;
|
|
489
|
+
closed = true;
|
|
490
|
+
process.off('exit', killAllSync);
|
|
491
|
+
await watcher?.close();
|
|
492
|
+
await buildLoop?.catch(() => { });
|
|
493
|
+
await bundler.dispose();
|
|
494
|
+
await Promise.all([...generations].map((generation) => stopGeneration(generation)));
|
|
495
|
+
reaper.close();
|
|
496
|
+
await bridge.close();
|
|
497
|
+
liveReloadHub.close();
|
|
498
|
+
proxy.closeAllConnections();
|
|
499
|
+
await new Promise((resolve) => {
|
|
500
|
+
proxy.close(() => resolve());
|
|
217
501
|
});
|
|
502
|
+
await rm(workDir, { recursive: true, force: true });
|
|
503
|
+
};
|
|
504
|
+
try {
|
|
505
|
+
await Promise.all([requestBuild([]), watcher?.ready]);
|
|
506
|
+
}
|
|
507
|
+
catch (error) {
|
|
508
|
+
await close();
|
|
509
|
+
throw error;
|
|
510
|
+
}
|
|
511
|
+
finally {
|
|
512
|
+
markStarted();
|
|
513
|
+
}
|
|
514
|
+
if (options.watch === false && buildFailure) {
|
|
515
|
+
const message = buildFailure.message;
|
|
516
|
+
await close();
|
|
517
|
+
throw new Error(message);
|
|
518
|
+
}
|
|
519
|
+
if (active)
|
|
520
|
+
options.onStatus?.(`App ready at ${hostedUrl}${appBasePath || '/'}`);
|
|
218
521
|
const fetchPath = async (path, init = {}) => {
|
|
219
522
|
const url = new URL(joinUrlPath(appBasePath, path), hostedUrl);
|
|
220
523
|
const response = await fetch(url, {
|
|
@@ -236,28 +539,40 @@ export async function startPackageAppDev(options) {
|
|
|
236
539
|
appBasePath,
|
|
237
540
|
packageContext,
|
|
238
541
|
fetchPath,
|
|
239
|
-
close
|
|
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
|
-
},
|
|
542
|
+
close,
|
|
259
543
|
};
|
|
260
544
|
}
|
|
545
|
+
function elapsed(started) {
|
|
546
|
+
return Math.round(performance.now() - started);
|
|
547
|
+
}
|
|
548
|
+
function toBuildFailure(error) {
|
|
549
|
+
if (error instanceof BundleError) {
|
|
550
|
+
return { message: error.message, diagnostics: error.diagnostics };
|
|
551
|
+
}
|
|
552
|
+
return {
|
|
553
|
+
message: redact(error instanceof Error ? error.message : String(error)),
|
|
554
|
+
diagnostics: [],
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
function buildErrorView(failure) {
|
|
558
|
+
const [first] = failure.diagnostics;
|
|
559
|
+
return {
|
|
560
|
+
kind: 'build',
|
|
561
|
+
title: first
|
|
562
|
+
? `${first.file ?? 'bundle'}${first.line !== null ? `:${first.line}:${first.col}` : ''} ${first.message}`
|
|
563
|
+
: (failure.message.split('\n')[0] ?? 'Build failed'),
|
|
564
|
+
detail: failure.diagnostics.length > 0
|
|
565
|
+
? failure.diagnostics.map(formatBundleDiagnostic).join('\n\n')
|
|
566
|
+
: failure.message,
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
async function readStream(stream) {
|
|
570
|
+
const chunks = [];
|
|
571
|
+
for await (const chunk of stream) {
|
|
572
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
573
|
+
}
|
|
574
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
575
|
+
}
|
|
261
576
|
async function writeWorkerFiles(input) {
|
|
262
577
|
await mkdir(input.workDir, { recursive: true });
|
|
263
578
|
const packageGraph = await resolveLocalPackageGraph({
|
|
@@ -320,67 +635,6 @@ function joinUrlPath(base, path) {
|
|
|
320
635
|
return rest;
|
|
321
636
|
return `${base}${rest === '/' ? '' : rest}` || base;
|
|
322
637
|
}
|
|
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
638
|
async function startBridge(input) {
|
|
385
639
|
const inFlight = new AbortController();
|
|
386
640
|
const server = createServer(async (request, response) => {
|