@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.
@@ -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
- const tunnel = await this.startReachableTunnel(port, server);
72
- if (saveDevTunnelUrl(loaded, tunnel.url)) {
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, tunnel.url);
95
+ registered = await this.registerDevWebhooks(api, loaded, activeTunnel.url);
76
96
  this.log("");
77
- this.log(`${ui.bold("Tunnel:")} ${tunnel.url}`);
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, tunnel.url);
103
+ const syncedUrls = await this.syncDevAppUrls(api, loaded, activeTunnel.url);
84
104
  this.printDevAppInfo(syncedUrls);
85
- await this.ensureDevAppInstalled(api, loaded, tunnel, port, server, registered);
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
- let reloading = Promise.resolve();
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, tunnel.url)) {
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, tunnel.url);
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: 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(tunnel, port, server, api, registered);
133
- await this.syncDevAppUrls(api, nextLoaded, tunnel.url);
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("Waiting 20s for trycloudflare DNS to propagate before first probe..."));
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;
@@ -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
- mismatchSince ??= Date.now();
123
- if (Date.now() - mismatchSince >= mismatchGraceMs) break;
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@odeva/cli",
3
- "version": "0.0.11",
3
+ "version": "0.0.12",
4
4
  "description": "Build apps on the Odeva booking platform — scaffold, develop, deploy.",
5
5
  "license": "MIT",
6
6
  "type": "module",