@odeva/cli 0.0.11 → 0.0.12
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/dist/commands/app/dev.js +50 -32
- package/dist/lib/dev-runner.js +29 -2
- package/package.json +1 -1
package/dist/commands/app/dev.js
CHANGED
|
@@ -67,25 +67,44 @@ class AppDev extends Command {
|
|
|
67
67
|
PORT: String(port)
|
|
68
68
|
}
|
|
69
69
|
});
|
|
70
|
+
let tunnel;
|
|
71
|
+
let stopWatcher = () => {
|
|
72
|
+
};
|
|
73
|
+
let reloading = Promise.resolve();
|
|
74
|
+
let shuttingDown = false;
|
|
75
|
+
installShutdownHandler(async () => {
|
|
76
|
+
shuttingDown = true;
|
|
77
|
+
this.log("\n" + ui.dim("Cleaning up..."));
|
|
78
|
+
stopWatcher();
|
|
79
|
+
await reloading;
|
|
80
|
+
await Promise.allSettled([
|
|
81
|
+
server.stop(),
|
|
82
|
+
tunnel?.stop(),
|
|
83
|
+
cleanupSubscriptions(api, registered)
|
|
84
|
+
]);
|
|
85
|
+
this.log(ui.ok("Done."));
|
|
86
|
+
});
|
|
70
87
|
await this.verifyLocalDevServer(port, server, api, registered);
|
|
71
|
-
|
|
72
|
-
|
|
88
|
+
tunnel = await this.startReachableTunnel(port, server, (startedTunnel) => {
|
|
89
|
+
tunnel = startedTunnel;
|
|
90
|
+
});
|
|
91
|
+
const activeTunnel = tunnel;
|
|
92
|
+
if (saveDevTunnelUrl(loaded, activeTunnel.url)) {
|
|
73
93
|
this.log(ui.ok(`Wrote tunnel_url to ${ui.code(loaded.path)}`));
|
|
74
94
|
}
|
|
75
|
-
registered = await this.registerDevWebhooks(api, loaded,
|
|
95
|
+
registered = await this.registerDevWebhooks(api, loaded, activeTunnel.url);
|
|
76
96
|
this.log("");
|
|
77
|
-
this.log(`${ui.bold("Tunnel:")} ${
|
|
97
|
+
this.log(`${ui.bold("Tunnel:")} ${activeTunnel.url}`);
|
|
78
98
|
this.log(`${ui.bold("Local:")} http://localhost:${port}`);
|
|
79
99
|
for (const reg of registered) {
|
|
80
100
|
this.log(` ${ui.dim("\u2192")} ${reg.config.topic.padEnd(28)} ${reg.fullUrl}`);
|
|
81
101
|
}
|
|
82
102
|
this.log("");
|
|
83
|
-
const syncedUrls = await this.syncDevAppUrls(api, loaded,
|
|
103
|
+
const syncedUrls = await this.syncDevAppUrls(api, loaded, activeTunnel.url);
|
|
84
104
|
this.printDevAppInfo(syncedUrls);
|
|
85
|
-
await this.ensureDevAppInstalled(api, loaded,
|
|
105
|
+
await this.ensureDevAppInstalled(api, loaded, activeTunnel, port, server, registered);
|
|
86
106
|
this.log(ui.dim("Press Ctrl-C to stop. Subscriptions will be cleaned up on exit."));
|
|
87
107
|
this.log("");
|
|
88
|
-
let shuttingDown = false;
|
|
89
108
|
let restartingServer = false;
|
|
90
109
|
let serverExited = () => {
|
|
91
110
|
};
|
|
@@ -98,8 +117,7 @@ class AppDev extends Command {
|
|
|
98
117
|
});
|
|
99
118
|
};
|
|
100
119
|
watchServerExit(server);
|
|
101
|
-
|
|
102
|
-
const stopWatcher = this.watchDevInputs(loaded.path, async () => {
|
|
120
|
+
stopWatcher = this.watchDevInputs(loaded.path, async () => {
|
|
103
121
|
reloading = reloading.then(async () => {
|
|
104
122
|
this.log("\n" + ui.dim("Reloading app dev config..."));
|
|
105
123
|
const nextLoaded = loadAppConfig(loaded.root);
|
|
@@ -107,10 +125,10 @@ class AppDev extends Command {
|
|
|
107
125
|
if (nextPort !== port) {
|
|
108
126
|
this.log(ui.warn(`Port changed to ${nextPort}; restart \`odeva app dev\` to recreate the tunnel.`));
|
|
109
127
|
}
|
|
110
|
-
if (saveDevTunnelUrl(nextLoaded,
|
|
128
|
+
if (saveDevTunnelUrl(nextLoaded, activeTunnel.url)) {
|
|
111
129
|
this.log(ui.ok(`Wrote tunnel_url to ${ui.code(nextLoaded.path)}`));
|
|
112
130
|
}
|
|
113
|
-
const nextRegistered = await this.registerDevWebhooks(api, nextLoaded,
|
|
131
|
+
const nextRegistered = await this.registerDevWebhooks(api, nextLoaded, activeTunnel.url);
|
|
114
132
|
await cleanupSubscriptions(api, registered);
|
|
115
133
|
registered = nextRegistered;
|
|
116
134
|
const nextAppEnv = loadAppEnv(nextLoaded.path);
|
|
@@ -123,32 +141,20 @@ class AppDev extends Command {
|
|
|
123
141
|
env: {
|
|
124
142
|
...nextAppEnv,
|
|
125
143
|
PORT: String(port),
|
|
126
|
-
ODEVA_TUNNEL_URL:
|
|
144
|
+
ODEVA_TUNNEL_URL: activeTunnel.url,
|
|
127
145
|
...registeredWebhookEnv(registered)
|
|
128
146
|
}
|
|
129
147
|
});
|
|
130
148
|
server = nextServer;
|
|
131
149
|
watchServerExit(server);
|
|
132
|
-
await this.verifyDevServerReachable(
|
|
133
|
-
await this.syncDevAppUrls(api, nextLoaded,
|
|
150
|
+
await this.verifyDevServerReachable(activeTunnel, port, server, api, registered);
|
|
151
|
+
await this.syncDevAppUrls(api, nextLoaded, activeTunnel.url);
|
|
134
152
|
this.log(ui.ok("Reloaded app dev config."));
|
|
135
153
|
}).catch((err) => {
|
|
136
154
|
restartingServer = false;
|
|
137
155
|
this.log(ui.err(`Reload failed: ${err.message}`));
|
|
138
156
|
});
|
|
139
157
|
});
|
|
140
|
-
installShutdownHandler(async () => {
|
|
141
|
-
shuttingDown = true;
|
|
142
|
-
this.log("\n" + ui.dim("Cleaning up..."));
|
|
143
|
-
stopWatcher();
|
|
144
|
-
await reloading;
|
|
145
|
-
await Promise.allSettled([
|
|
146
|
-
server.stop(),
|
|
147
|
-
tunnel.stop(),
|
|
148
|
-
cleanupSubscriptions(api, registered)
|
|
149
|
-
]);
|
|
150
|
-
this.log(ui.ok("Done."));
|
|
151
|
-
});
|
|
152
158
|
await serverExit;
|
|
153
159
|
shuttingDown = true;
|
|
154
160
|
stopWatcher();
|
|
@@ -248,19 +254,19 @@ class AppDev extends Command {
|
|
|
248
254
|
throw err;
|
|
249
255
|
}
|
|
250
256
|
}
|
|
251
|
-
async startReachableTunnel(port, server) {
|
|
257
|
+
async startReachableTunnel(port, server, onTunnelStarted) {
|
|
252
258
|
let lastError;
|
|
253
259
|
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
254
260
|
this.log(ui.dim(`Starting Cloudflare tunnel${attempt > 1 ? ` (attempt ${attempt}/3)` : ""}...`));
|
|
255
261
|
const tunnel = await startQuickTunnel(port);
|
|
262
|
+
onTunnelStarted?.(tunnel);
|
|
256
263
|
this.log(ui.ok(`Tunnel ready at ${ui.code(tunnel.url)}`));
|
|
257
|
-
this.log(ui.dim("
|
|
258
|
-
await new Promise((resolve) => setTimeout(resolve, 2e4));
|
|
259
|
-
this.log(ui.dim("Verifying dev server and tunnel... (trycloudflare can take up to 60s to propagate)"));
|
|
264
|
+
this.log(ui.dim("Verifying dev server and tunnel... (retrying for up to 60s while trycloudflare becomes reachable)"));
|
|
260
265
|
try {
|
|
261
266
|
const result = await waitForDevServerReachable({
|
|
262
267
|
localPort: port,
|
|
263
|
-
tunnelUrl: tunnel.url
|
|
268
|
+
tunnelUrl: tunnel.url,
|
|
269
|
+
onProbeFailure: this.logTunnelProbeProgress()
|
|
264
270
|
});
|
|
265
271
|
this.log(ui.ok(`Tunnel verified via ${ui.code(result.path)} (HTTP ${result.tunnelStatus})`));
|
|
266
272
|
return tunnel;
|
|
@@ -281,7 +287,8 @@ class AppDev extends Command {
|
|
|
281
287
|
try {
|
|
282
288
|
const result = await waitForDevServerReachable({
|
|
283
289
|
localPort: port,
|
|
284
|
-
tunnelUrl: tunnel.url
|
|
290
|
+
tunnelUrl: tunnel.url,
|
|
291
|
+
onProbeFailure: this.logTunnelProbeProgress()
|
|
285
292
|
});
|
|
286
293
|
this.log(ui.ok(`Tunnel verified via ${ui.code(result.path)} (HTTP ${result.tunnelStatus})`));
|
|
287
294
|
} catch (err) {
|
|
@@ -294,6 +301,17 @@ class AppDev extends Command {
|
|
|
294
301
|
throw err;
|
|
295
302
|
}
|
|
296
303
|
}
|
|
304
|
+
logTunnelProbeProgress() {
|
|
305
|
+
let lastLogAt = 0;
|
|
306
|
+
return (probe) => {
|
|
307
|
+
const now = Date.now();
|
|
308
|
+
if (probe.elapsedMs < 5e3 || now - lastLogAt < 5e3) return;
|
|
309
|
+
lastLogAt = now;
|
|
310
|
+
this.log(ui.dim(
|
|
311
|
+
`Still waiting for tunnel ${ui.code(probe.path)} after ${Math.round(probe.elapsedMs / 1e3)}s: ${probe.lastError}`
|
|
312
|
+
));
|
|
313
|
+
};
|
|
314
|
+
}
|
|
297
315
|
watchDevInputs(appConfigPath, onChange) {
|
|
298
316
|
const paths = watchedDevInputPaths(appConfigPath);
|
|
299
317
|
let timer;
|
package/dist/lib/dev-runner.js
CHANGED
|
@@ -99,15 +99,20 @@ function spawnDevServer(opts) {
|
|
|
99
99
|
}
|
|
100
100
|
async function waitForDevServerReachable(opts) {
|
|
101
101
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
102
|
+
const startedAt = Date.now();
|
|
102
103
|
const deadline = Date.now() + (opts.timeoutMs ?? 6e4);
|
|
103
104
|
const requestTimeoutMs = opts.requestTimeoutMs ?? 2500;
|
|
104
105
|
const mismatchGraceMs = opts.mismatchGraceMs ?? 45e3;
|
|
106
|
+
const cloudflareErrorGraceMs = opts.cloudflareErrorGraceMs ?? 1e4;
|
|
107
|
+
const networkErrorGraceMs = opts.networkErrorGraceMs ?? 1e4;
|
|
105
108
|
const probe = await waitForLocalProbe(opts.localPort, deadline, requestTimeoutMs, fetchImpl);
|
|
106
109
|
const tunnelUrl = new URL(opts.tunnelUrl);
|
|
107
110
|
tunnelUrl.pathname = probe.path;
|
|
108
111
|
tunnelUrl.search = "";
|
|
109
112
|
let lastError = "not ready";
|
|
110
113
|
let mismatchSince = null;
|
|
114
|
+
let cloudflareErrorSince = null;
|
|
115
|
+
let networkErrorSince = null;
|
|
111
116
|
while (Date.now() < deadline) {
|
|
112
117
|
try {
|
|
113
118
|
const response = await fetchWithTimeout(fetchImpl, tunnelUrl, requestTimeoutMs);
|
|
@@ -119,18 +124,40 @@ async function waitForDevServerReachable(opts) {
|
|
|
119
124
|
};
|
|
120
125
|
}
|
|
121
126
|
lastError = `HTTP ${response.status}`;
|
|
122
|
-
|
|
123
|
-
|
|
127
|
+
if (isCloudflareTunnelErrorStatus(response.status)) {
|
|
128
|
+
cloudflareErrorSince ??= Date.now();
|
|
129
|
+
mismatchSince = null;
|
|
130
|
+
networkErrorSince = null;
|
|
131
|
+
if (Date.now() - cloudflareErrorSince >= cloudflareErrorGraceMs) break;
|
|
132
|
+
} else {
|
|
133
|
+
mismatchSince ??= Date.now();
|
|
134
|
+
cloudflareErrorSince = null;
|
|
135
|
+
networkErrorSince = null;
|
|
136
|
+
if (Date.now() - mismatchSince >= mismatchGraceMs) break;
|
|
137
|
+
}
|
|
124
138
|
} catch (err) {
|
|
125
139
|
lastError = err instanceof Error ? err.message : String(err);
|
|
126
140
|
mismatchSince = null;
|
|
141
|
+
cloudflareErrorSince = null;
|
|
142
|
+
networkErrorSince ??= Date.now();
|
|
143
|
+
if (Date.now() - networkErrorSince >= networkErrorGraceMs) break;
|
|
127
144
|
}
|
|
145
|
+
opts.onProbeFailure?.({
|
|
146
|
+
elapsedMs: Date.now() - startedAt,
|
|
147
|
+
path: probe.path,
|
|
148
|
+
localStatus: probe.status,
|
|
149
|
+
tunnelUrl: tunnelUrl.toString(),
|
|
150
|
+
lastError
|
|
151
|
+
});
|
|
128
152
|
await delay(500);
|
|
129
153
|
}
|
|
130
154
|
throw new CliError(`Cloudflare tunnel did not reach the local dev server at ${tunnelUrl.toString()}.`, {
|
|
131
155
|
hint: `Local ${probe.path} returned HTTP ${probe.status}, but the tunnel returned ${lastError}. Restart \`odeva app dev\` if the quick tunnel URL expired.`
|
|
132
156
|
});
|
|
133
157
|
}
|
|
158
|
+
function isCloudflareTunnelErrorStatus(status) {
|
|
159
|
+
return status === 530 || status === 502 || status === 503 || status === 504;
|
|
160
|
+
}
|
|
134
161
|
async function waitForLocalDevServer(opts) {
|
|
135
162
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
136
163
|
const deadline = Date.now() + (opts.timeoutMs ?? 3e4);
|