@quenty/nevermore-cli 4.42.0 → 4.42.1-canary.783.30683606415.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.
Files changed (172) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/args/global-args.d.ts +13 -0
  3. package/dist/args/global-args.d.ts.map +1 -1
  4. package/dist/commands/batch-command/batch-deploy-command.d.ts +3 -0
  5. package/dist/commands/batch-command/batch-deploy-command.d.ts.map +1 -1
  6. package/dist/commands/batch-command/batch-deploy-command.js +90 -1
  7. package/dist/commands/batch-command/batch-deploy-command.js.map +1 -1
  8. package/dist/commands/batch-command/batch-test-command.d.ts.map +1 -1
  9. package/dist/commands/batch-command/batch-test-command.js +7 -2
  10. package/dist/commands/batch-command/batch-test-command.js.map +1 -1
  11. package/dist/commands/deploy-command/deploy-init.js +1 -1
  12. package/dist/commands/deploy-command/deploy-init.js.map +1 -1
  13. package/dist/commands/deploy-command/index.d.ts +3 -0
  14. package/dist/commands/deploy-command/index.d.ts.map +1 -1
  15. package/dist/commands/deploy-command/index.js +301 -60
  16. package/dist/commands/deploy-command/index.js.map +1 -1
  17. package/dist/commands/deploy-command/select-target.js +1 -1
  18. package/dist/commands/deploy-command/select-target.js.map +1 -1
  19. package/dist/commands/deploy-command/version-command.d.ts.map +1 -1
  20. package/dist/commands/deploy-command/version-command.js +138 -12
  21. package/dist/commands/deploy-command/version-command.js.map +1 -1
  22. package/dist/commands/test-command/test-command.d.ts.map +1 -1
  23. package/dist/commands/test-command/test-command.js +17 -7
  24. package/dist/commands/test-command/test-command.js.map +1 -1
  25. package/dist/nevermore.js +17 -0
  26. package/dist/nevermore.js.map +1 -1
  27. package/dist/utils/batch/changed-packages-utils.d.ts +1 -1
  28. package/dist/utils/batch/changed-packages-utils.d.ts.map +1 -1
  29. package/dist/utils/batch/changed-packages-utils.js +1 -1
  30. package/dist/utils/batch/changed-packages-utils.js.map +1 -1
  31. package/dist/utils/build/base-place-resolver-factory.d.ts +25 -0
  32. package/dist/utils/build/base-place-resolver-factory.d.ts.map +1 -0
  33. package/dist/utils/build/base-place-resolver-factory.js +40 -0
  34. package/dist/utils/build/base-place-resolver-factory.js.map +1 -0
  35. package/dist/utils/build/build.d.ts +13 -2
  36. package/dist/utils/build/build.d.ts.map +1 -1
  37. package/dist/utils/build/build.js.map +1 -1
  38. package/dist/utils/build/upload.d.ts +1 -1
  39. package/dist/utils/build/upload.d.ts.map +1 -1
  40. package/dist/utils/deploy/deploy-metadata.d.ts +1 -1
  41. package/dist/utils/deploy/deploy-metadata.d.ts.map +1 -1
  42. package/dist/utils/job-context/base-job-context.d.ts +3 -1
  43. package/dist/utils/job-context/base-job-context.d.ts.map +1 -1
  44. package/dist/utils/job-context/base-job-context.js +45 -6
  45. package/dist/utils/job-context/base-job-context.js.map +1 -1
  46. package/dist/utils/job-context/batch-script-job-context.d.ts +6 -0
  47. package/dist/utils/job-context/batch-script-job-context.d.ts.map +1 -1
  48. package/dist/utils/job-context/batch-script-job-context.js +13 -0
  49. package/dist/utils/job-context/batch-script-job-context.js.map +1 -1
  50. package/dist/utils/job-context/cloud-job-context.d.ts +2 -1
  51. package/dist/utils/job-context/cloud-job-context.d.ts.map +1 -1
  52. package/dist/utils/job-context/cloud-job-context.js +2 -2
  53. package/dist/utils/job-context/cloud-job-context.js.map +1 -1
  54. package/dist/utils/job-context/local-job-context.d.ts +2 -1
  55. package/dist/utils/job-context/local-job-context.d.ts.map +1 -1
  56. package/dist/utils/job-context/local-job-context.js +2 -2
  57. package/dist/utils/job-context/local-job-context.js.map +1 -1
  58. package/dist/utils/open-cloud/open-cloud-client.d.ts +20 -2
  59. package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
  60. package/dist/utils/open-cloud/open-cloud-client.js +74 -20
  61. package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
  62. package/dist/utils/open-cloud/open-cloud-client.test.js +89 -12
  63. package/dist/utils/open-cloud/open-cloud-client.test.js.map +1 -1
  64. package/dist/utils/testing/runner/combined-project-generator.d.ts +1 -1
  65. package/dist/utils/testing/runner/combined-project-generator.d.ts.map +1 -1
  66. package/dist/utils/testing/runner/test-runner.d.ts +1 -1
  67. package/dist/utils/testing/runner/test-runner.d.ts.map +1 -1
  68. package/dist/utils/watch/github-context.d.ts +50 -0
  69. package/dist/utils/watch/github-context.d.ts.map +1 -0
  70. package/dist/utils/watch/github-context.js +125 -0
  71. package/dist/utils/watch/github-context.js.map +1 -0
  72. package/dist/utils/watch/github-context.test.d.ts +2 -0
  73. package/dist/utils/watch/github-context.test.d.ts.map +1 -0
  74. package/dist/utils/watch/github-context.test.js +112 -0
  75. package/dist/utils/watch/github-context.test.js.map +1 -0
  76. package/dist/utils/watch/http-watch-registry.d.ts +40 -0
  77. package/dist/utils/watch/http-watch-registry.d.ts.map +1 -0
  78. package/dist/utils/watch/http-watch-registry.js +262 -0
  79. package/dist/utils/watch/http-watch-registry.js.map +1 -0
  80. package/dist/utils/watch/http-watch-registry.test.d.ts +2 -0
  81. package/dist/utils/watch/http-watch-registry.test.d.ts.map +1 -0
  82. package/dist/utils/watch/http-watch-registry.test.js +211 -0
  83. package/dist/utils/watch/http-watch-registry.test.js.map +1 -0
  84. package/dist/utils/watch/local-watch-loop.d.ts +50 -0
  85. package/dist/utils/watch/local-watch-loop.d.ts.map +1 -0
  86. package/dist/utils/watch/local-watch-loop.js +103 -0
  87. package/dist/utils/watch/local-watch-loop.js.map +1 -0
  88. package/dist/utils/watch/local-watch-loop.test.d.ts +2 -0
  89. package/dist/utils/watch/local-watch-loop.test.d.ts.map +1 -0
  90. package/dist/utils/watch/local-watch-loop.test.js +170 -0
  91. package/dist/utils/watch/local-watch-loop.test.js.map +1 -0
  92. package/dist/utils/watch/register-notify-watches.d.ts +41 -0
  93. package/dist/utils/watch/register-notify-watches.d.ts.map +1 -0
  94. package/dist/utils/watch/register-notify-watches.js +159 -0
  95. package/dist/utils/watch/register-notify-watches.js.map +1 -0
  96. package/dist/utils/watch/register-notify-watches.test.d.ts +2 -0
  97. package/dist/utils/watch/register-notify-watches.test.d.ts.map +1 -0
  98. package/dist/utils/watch/register-notify-watches.test.js +225 -0
  99. package/dist/utils/watch/register-notify-watches.test.js.map +1 -0
  100. package/dist/utils/watch/register-watches.d.ts +68 -0
  101. package/dist/utils/watch/register-watches.d.ts.map +1 -0
  102. package/dist/utils/watch/register-watches.js +221 -0
  103. package/dist/utils/watch/register-watches.js.map +1 -0
  104. package/dist/utils/watch/register-watches.test.d.ts +2 -0
  105. package/dist/utils/watch/register-watches.test.d.ts.map +1 -0
  106. package/dist/utils/watch/register-watches.test.js +576 -0
  107. package/dist/utils/watch/register-watches.test.js.map +1 -0
  108. package/dist/utils/watch/stream-watch-loop.d.ts +54 -0
  109. package/dist/utils/watch/stream-watch-loop.d.ts.map +1 -0
  110. package/dist/utils/watch/stream-watch-loop.js +282 -0
  111. package/dist/utils/watch/stream-watch-loop.js.map +1 -0
  112. package/dist/utils/watch/stream-watch-loop.test.d.ts +2 -0
  113. package/dist/utils/watch/stream-watch-loop.test.d.ts.map +1 -0
  114. package/dist/utils/watch/stream-watch-loop.test.js +392 -0
  115. package/dist/utils/watch/stream-watch-loop.test.js.map +1 -0
  116. package/dist/utils/watch/websocket-watch-stream.d.ts +25 -0
  117. package/dist/utils/watch/websocket-watch-stream.d.ts.map +1 -0
  118. package/dist/utils/watch/websocket-watch-stream.js +306 -0
  119. package/dist/utils/watch/websocket-watch-stream.js.map +1 -0
  120. package/dist/utils/watch/websocket-watch-stream.test.d.ts +2 -0
  121. package/dist/utils/watch/websocket-watch-stream.test.d.ts.map +1 -0
  122. package/dist/utils/watch/websocket-watch-stream.test.js +475 -0
  123. package/dist/utils/watch/websocket-watch-stream.test.js.map +1 -0
  124. package/package.json +7 -4
  125. package/src/args/global-args.ts +13 -0
  126. package/src/commands/batch-command/batch-deploy-command.ts +105 -1
  127. package/src/commands/batch-command/batch-test-command.ts +8 -2
  128. package/src/commands/deploy-command/deploy-init.ts +1 -1
  129. package/src/commands/deploy-command/index.ts +331 -32
  130. package/src/commands/deploy-command/select-target.ts +1 -1
  131. package/src/commands/deploy-command/version-command.ts +215 -21
  132. package/src/commands/test-command/test-command.ts +24 -18
  133. package/src/nevermore.ts +19 -0
  134. package/src/utils/batch/changed-packages-utils.ts +1 -1
  135. package/src/utils/build/base-place-resolver-factory.ts +49 -0
  136. package/src/utils/build/build.ts +13 -2
  137. package/src/utils/build/upload.ts +1 -1
  138. package/src/utils/deploy/deploy-metadata.ts +1 -1
  139. package/src/utils/job-context/base-job-context.ts +68 -6
  140. package/src/utils/job-context/batch-script-job-context.ts +18 -0
  141. package/src/utils/job-context/cloud-job-context.ts +7 -2
  142. package/src/utils/job-context/local-job-context.ts +7 -2
  143. package/src/utils/open-cloud/open-cloud-client.test.ts +133 -14
  144. package/src/utils/open-cloud/open-cloud-client.ts +127 -34
  145. package/src/utils/testing/runner/combined-project-generator.ts +1 -1
  146. package/src/utils/testing/runner/test-runner.ts +1 -1
  147. package/src/utils/watch/github-context.test.ts +154 -0
  148. package/src/utils/watch/github-context.ts +184 -0
  149. package/src/utils/watch/http-watch-registry.test.ts +330 -0
  150. package/src/utils/watch/http-watch-registry.ts +341 -0
  151. package/src/utils/watch/local-watch-loop.test.ts +197 -0
  152. package/src/utils/watch/local-watch-loop.ts +190 -0
  153. package/src/utils/watch/register-notify-watches.test.ts +282 -0
  154. package/src/utils/watch/register-notify-watches.ts +226 -0
  155. package/src/utils/watch/register-watches.test.ts +696 -0
  156. package/src/utils/watch/register-watches.ts +369 -0
  157. package/src/utils/watch/stream-watch-loop.test.ts +625 -0
  158. package/src/utils/watch/stream-watch-loop.ts +411 -0
  159. package/src/utils/watch/websocket-watch-stream.test.ts +581 -0
  160. package/src/utils/watch/websocket-watch-stream.ts +403 -0
  161. package/tsconfig.json +19 -6
  162. package/tsconfig.tsbuildinfo +1 -1
  163. package/dist/utils/build/deploy-config.d.ts +0 -71
  164. package/dist/utils/build/deploy-config.d.ts.map +0 -1
  165. package/dist/utils/build/deploy-config.js +0 -164
  166. package/dist/utils/build/deploy-config.js.map +0 -1
  167. package/dist/utils/build/deploy-config.test.d.ts +0 -2
  168. package/dist/utils/build/deploy-config.test.d.ts.map +0 -1
  169. package/dist/utils/build/deploy-config.test.js +0 -126
  170. package/dist/utils/build/deploy-config.test.js.map +0 -1
  171. package/src/utils/build/deploy-config.test.ts +0 -149
  172. package/src/utils/build/deploy-config.ts +0 -259
@@ -0,0 +1,403 @@
1
+ import { WebSocket } from 'ws';
2
+ import {
3
+ type WatchStream,
4
+ type WatchStreamEvent,
5
+ type WatchStreamNotify,
6
+ type WatchStreamReady,
7
+ type WatchStreamResult,
8
+ type WatchStreamOptions,
9
+ } from '@quenty/nevermore-deploy';
10
+
11
+ /**
12
+ * The monitor was released, or its lease lapsed. Retrying cannot help, so this
13
+ * ends the stream rather than backing off against something that is gone.
14
+ */
15
+ const CLOSE_MONITOR_GONE = 4004;
16
+
17
+ /** Orderly shutdown on the service's side. Reconnect promptly, not as a fault. */
18
+ const CLOSE_GOING_AWAY = 1001;
19
+
20
+ /**
21
+ * The service retires a connection roughly hourly so that reconnecting
22
+ * re-verifies the token — a socket must not outlive a revoked credential.
23
+ * Routine by design, so it reconnects promptly and is not reported as a drop.
24
+ */
25
+ const CLOSE_REAUTHENTICATE = 4005;
26
+
27
+ /**
28
+ * Handshake refusals worth explaining rather than retrying blindly.
29
+ *
30
+ * A websocket that fails to upgrade reports only "Unexpected server response:
31
+ * <status>", which is the same message for a misconfigured proxy and an expired
32
+ * token — two problems with nothing in common. These are the statuses the API
33
+ * documents, each mapped to the thing that actually has to change.
34
+ */
35
+ const HANDSHAKE_FAILURES: Record<
36
+ number,
37
+ { permanent: boolean; describe: () => string }
38
+ > = {
39
+ 401: {
40
+ permanent: true,
41
+ describe: () =>
42
+ 'The watch service rejected the token on the stream handshake. Set ' +
43
+ 'NEVERMORE_WATCH_TOKEN to a valid, unexpired GitHub token.',
44
+ },
45
+ 404: {
46
+ permanent: true,
47
+ describe: () =>
48
+ 'The watch monitor was not found, or the token cannot write to the ' +
49
+ 'repository that owns it. A stream is readable only by a token with ' +
50
+ 'write access to that repository.',
51
+ },
52
+ 426: {
53
+ permanent: false,
54
+ describe: () =>
55
+ 'The watch service answered the stream handshake with 426 Upgrade ' +
56
+ 'Required, which means it never saw an Upgrade header — this client ' +
57
+ 'sent one, so something in front of the service is stripping it. That ' +
58
+ 'is a proxy configuration problem on the service side (nginx needs ' +
59
+ 'proxy_http_version 1.1 plus the Upgrade and Connection headers ' +
60
+ 'forwarded), not something to fix here.',
61
+ },
62
+ 429: {
63
+ permanent: false,
64
+ describe: () =>
65
+ 'Too many concurrent watch streams are open for this monitor. Close ' +
66
+ 'another `--watch` session, or wait for one to time out.',
67
+ },
68
+ 503: {
69
+ permanent: false,
70
+ describe: () =>
71
+ 'The watch service is not accepting streams right now. Retrying.',
72
+ },
73
+ };
74
+
75
+ const RECONNECT_BASE_MS = 1_000;
76
+ const RECONNECT_MAX_MS = 30_000;
77
+
78
+ /**
79
+ * Floor under a prompt reconnect. A server that accepts, sends `ready` and
80
+ * immediately closes would otherwise be reconnected to as fast as the loop can
81
+ * spin — around a hundred connections a second, each costing the service a full
82
+ * token verification. That is a stampede aimed at something already failing, so
83
+ * even a "come back now" close waits this long.
84
+ */
85
+ const RECONNECT_MIN_MS = 250;
86
+
87
+ /**
88
+ * How long a connection must last to count as healthy enough to reset the
89
+ * backoff. Receiving `ready` is not enough by itself: a crash-looping server
90
+ * sends one every time, which would pin the delay at its minimum forever.
91
+ */
92
+ const HEALTHY_CONNECTION_MS = 10_000;
93
+
94
+ /**
95
+ * Answers the service's heartbeat at the application layer as well as the
96
+ * protocol one. A proxy that terminates and re-originates the socket can eat
97
+ * ping/pong control frames while happily forwarding text, which looks exactly
98
+ * like a healthy connection until nothing arrives for an hour.
99
+ */
100
+ const APP_PING_INTERVAL_MS = 30_000;
101
+
102
+ export interface WebSocketWatchStreamOptions {
103
+ /**
104
+ * The register endpoint this monitor was created against. The stream URL is
105
+ * derived from it so the host is configured in exactly one place, and so no
106
+ * service address is ever written down in this repo.
107
+ */
108
+ registerUrl: string;
109
+ /** Injected in tests. */
110
+ connect?: (url: string, headers: Record<string, string>) => WebSocket;
111
+ /** Injected in tests; a real session cannot wait ten seconds per assertion. */
112
+ healthyConnectionMs?: number;
113
+ }
114
+
115
+ export class WebSocketWatchStream implements WatchStream {
116
+ private readonly _registerUrl: string;
117
+ private readonly _connect: (
118
+ url: string,
119
+ headers: Record<string, string>
120
+ ) => WebSocket;
121
+ private readonly _healthyConnectionMs: number;
122
+
123
+ public constructor(options: WebSocketWatchStreamOptions) {
124
+ this._registerUrl = options.registerUrl;
125
+ this._connect =
126
+ options.connect ?? ((url, headers) => new WebSocket(url, { headers }));
127
+ this._healthyConnectionMs =
128
+ options.healthyConnectionMs ?? HEALTHY_CONNECTION_MS;
129
+ }
130
+
131
+ public async runAsync(
132
+ options: WatchStreamOptions
133
+ ): Promise<WatchStreamResult> {
134
+ const url = this._streamUrl(options.monitorId);
135
+
136
+ let reconnects = 0;
137
+ let notifications = 0;
138
+ let attempt = 0;
139
+
140
+ while (!options.signal.aborted) {
141
+ const outcome = await this._holdAsync(url, options, (n) => {
142
+ notifications += n;
143
+ });
144
+
145
+ if (outcome.ending === 'monitor-gone') {
146
+ return {
147
+ ending: 'monitor-gone',
148
+ reason: outcome.reason,
149
+ reconnects,
150
+ notifications,
151
+ };
152
+ }
153
+ if (outcome.ending === 'rejected') {
154
+ return {
155
+ ending: 'rejected',
156
+ reason: outcome.reason,
157
+ reconnects,
158
+ notifications,
159
+ };
160
+ }
161
+ if (options.signal.aborted) {
162
+ break;
163
+ }
164
+
165
+ // A connection that carried `ready` *and* lasted proves the endpoint is
166
+ // healthy, so the next drop backs off from scratch rather than inheriting
167
+ // a delay from a problem that is over. Both halves matter: a server that
168
+ // sends `ready` and then drops would otherwise never back off at all.
169
+ const healthy =
170
+ outcome.wasReady && outcome.livedMs >= this._healthyConnectionMs;
171
+ attempt = healthy ? 0 : attempt + 1;
172
+ reconnects++;
173
+ // Backoff applies to every close, including the ones that mean "come back
174
+ // now". A healthy connection has already reset `attempt`, so a real
175
+ // shutdown or the hourly retirement reconnects promptly anyway — while a
176
+ // server stuck emitting one of those codes still backs off instead of
177
+ // being hammered at a flat rate forever.
178
+ const delay = Math.max(RECONNECT_MIN_MS, _backoffMs(attempt));
179
+ // A retirement is scheduled maintenance rather than a drop, and saying so
180
+ // hourly would train people to ignore it. But it is only maintenance if
181
+ // the connection actually lasted; arriving on a short-lived one means
182
+ // something is wrong, and silence would hide it completely.
183
+ if (!outcome.routine || !healthy) {
184
+ options.handlers.onReconnect?.(reconnects, outcome.reason);
185
+ }
186
+ await _sleepAsync(delay, options.signal);
187
+ }
188
+
189
+ return { ending: 'aborted', reconnects, notifications };
190
+ }
191
+
192
+ /** `.../v1/register/30m/` and `.../v1/monitors/{id}/stream` share a root. */
193
+ private _streamUrl(monitorId: string): string {
194
+ const url = new URL(this._registerUrl);
195
+ const marker = '/v1/register/';
196
+ const index = url.pathname.indexOf(marker);
197
+ if (index < 0) {
198
+ throw new Error(
199
+ `Cannot derive a watch stream URL from "${this._registerUrl}" — ` +
200
+ `expected its path to contain "${marker}".`
201
+ );
202
+ }
203
+ url.pathname = `${url.pathname.slice(
204
+ 0,
205
+ index
206
+ )}/v1/monitors/${encodeURIComponent(monitorId)}/stream`.replace(
207
+ /\/{2,}/g,
208
+ '/'
209
+ );
210
+ url.protocol = url.protocol === 'http:' ? 'ws:' : 'wss:';
211
+ return url.toString();
212
+ }
213
+
214
+ private _holdAsync(
215
+ url: string,
216
+ options: WatchStreamOptions,
217
+ countNotifications: (n: number) => void
218
+ ): Promise<_HoldOutcome> {
219
+ return new Promise<_HoldOutcome>((resolve) => {
220
+ const socket = this._connect(url, {
221
+ // Header rather than a query parameter, deliberately: a PAT in a URL
222
+ // lands in every access log between here and the service.
223
+ Authorization: `Bearer ${options.credential}`,
224
+ });
225
+
226
+ let ready = false;
227
+ let settled = false;
228
+ const openedAt = Date.now();
229
+ let notifications = 0;
230
+ let pinger: NodeJS.Timeout | undefined;
231
+
232
+ // Handlers are async and messages arrive whenever they arrive, so they
233
+ // are chained onto one tail. Two rebuilds of the same place running
234
+ // concurrently would race each other's uploads.
235
+ //
236
+ // Every link swallows its own rejection. The port does not promise a
237
+ // handler is safe, and a rejected tail would both strand `finish` — the
238
+ // watch hanging forever — and take every later frame with it, since
239
+ // `.then` on a rejected promise never runs.
240
+ let tail: Promise<void> = Promise.resolve();
241
+ const chain = (run: () => Promise<void>): void => {
242
+ tail = tail.then(run).catch((err: unknown) => {
243
+ options.handlers.onHandlerError?.(
244
+ err instanceof Error ? err : new Error(String(err))
245
+ );
246
+ });
247
+ };
248
+
249
+ // Stamped here rather than at each call site, so a new way of ending a
250
+ // connection cannot forget to report how long it lasted and silently
251
+ // count itself healthy.
252
+ const finish = (outcome: Omit<_HoldOutcome, 'livedMs'>) => {
253
+ if (settled) {
254
+ return;
255
+ }
256
+ settled = true;
257
+ if (pinger) {
258
+ clearInterval(pinger);
259
+ }
260
+ options.signal.removeEventListener('abort', onAbort);
261
+ countNotifications(notifications);
262
+ const settledOutcome: _HoldOutcome = {
263
+ ...outcome,
264
+ livedMs: Date.now() - openedAt,
265
+ };
266
+ // Let queued handlers drain before reporting, so a rebuild that was in
267
+ // flight when the socket dropped still finishes and still records. A
268
+ // rejected tail resolves too: a handler that threw has already been
269
+ // reported, and must not strand the watch.
270
+ void tail.then(
271
+ () => resolve(settledOutcome),
272
+ () => resolve(settledOutcome)
273
+ );
274
+ };
275
+
276
+ const onAbort = () => {
277
+ socket.close();
278
+ finish({ ending: 'closed', reason: 'stopped', wasReady: ready });
279
+ };
280
+ options.signal.addEventListener('abort', onAbort, { once: true });
281
+
282
+ socket.on('open', () => {
283
+ // 'open' can still fire after an abort during CONNECTING, and an
284
+ // interval installed then would never be cleared.
285
+ if (settled) {
286
+ return;
287
+ }
288
+ pinger = setInterval(() => {
289
+ if (socket.readyState === WebSocket.OPEN) {
290
+ socket.send(JSON.stringify({ type: 'ping' }));
291
+ }
292
+ }, APP_PING_INTERVAL_MS);
293
+ pinger.unref?.();
294
+ });
295
+
296
+ socket.on('message', (data: { toString(): string }) => {
297
+ let message: { type?: string } | undefined;
298
+ try {
299
+ message = JSON.parse(data.toString()) as { type?: string };
300
+ } catch {
301
+ // A frame we cannot parse is the service being newer than us, not a
302
+ // reason to drop a working connection.
303
+ return;
304
+ }
305
+
306
+ switch (message.type) {
307
+ case 'ready':
308
+ ready = true;
309
+ chain(() =>
310
+ options.handlers.onReadyAsync(message as WatchStreamReady)
311
+ );
312
+ break;
313
+ case 'notify':
314
+ notifications++;
315
+ chain(() =>
316
+ options.handlers.onNotifyAsync(message as WatchStreamNotify)
317
+ );
318
+ break;
319
+ case 'event':
320
+ options.handlers.onEvent?.(message as WatchStreamEvent);
321
+ break;
322
+ default:
323
+ break;
324
+ }
325
+ });
326
+
327
+ // Fires before 'error' when the upgrade itself was refused, and unlike
328
+ // 'error' it carries the status — which is the only thing that tells a
329
+ // stripped Upgrade header apart from a dead token.
330
+ socket.on('unexpected-response', (_request, response) => {
331
+ const status = response.statusCode ?? 0;
332
+ const known = HANDSHAKE_FAILURES[status];
333
+ // Registering this listener makes teardown ours: `ws` stops doing it.
334
+ response.resume();
335
+ response.destroy();
336
+ finish({
337
+ ending: known?.permanent ? 'rejected' : 'closed',
338
+ reason: known
339
+ ? known.describe()
340
+ : `the stream handshake failed with ${status}`,
341
+ wasReady: ready,
342
+ });
343
+ });
344
+
345
+ socket.on('error', (err: Error) => {
346
+ // The socket may still be half-open, and nothing else will close it
347
+ // once we have stopped listening.
348
+ socket.terminate();
349
+ finish({ ending: 'closed', reason: err.message, wasReady: ready });
350
+ });
351
+
352
+ socket.on('close', (code: number, reason: Buffer) => {
353
+ if (code === CLOSE_MONITOR_GONE) {
354
+ finish({
355
+ ending: 'monitor-gone',
356
+ reason: reason.toString() || 'monitor released or lease expired',
357
+ wasReady: ready,
358
+ });
359
+ return;
360
+ }
361
+ finish({
362
+ ending: 'closed',
363
+ reason: reason.toString() || `closed with ${code}`,
364
+ wasReady: ready,
365
+ immediate: code === CLOSE_GOING_AWAY || code === CLOSE_REAUTHENTICATE,
366
+ routine: code === CLOSE_REAUTHENTICATE,
367
+ });
368
+ });
369
+ });
370
+ }
371
+ }
372
+
373
+ interface _HoldOutcome {
374
+ ending: 'closed' | 'monitor-gone' | 'rejected';
375
+ reason: string;
376
+ /** Whether this connection got far enough to deliver its `ready` frame. */
377
+ wasReady: boolean;
378
+ /** How long the connection lasted, used to tell healthy from flapping. */
379
+ livedMs: number;
380
+ /** Set for a shutdown or retirement — "come back now" rather than a failure. */
381
+ immediate?: boolean;
382
+ /** Set when the close was scheduled maintenance and not worth reporting. */
383
+ routine?: boolean;
384
+ }
385
+
386
+ function _backoffMs(attempt: number): number {
387
+ return Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** (attempt - 1));
388
+ }
389
+
390
+ function _sleepAsync(ms: number, signal: AbortSignal): Promise<void> {
391
+ if (ms <= 0 || signal.aborted) {
392
+ return Promise.resolve();
393
+ }
394
+ return new Promise((resolve) => {
395
+ const timer = setTimeout(finish, ms);
396
+ function finish() {
397
+ clearTimeout(timer);
398
+ signal.removeEventListener('abort', finish);
399
+ resolve();
400
+ }
401
+ signal.addEventListener('abort', finish, { once: true });
402
+ });
403
+ }
package/tsconfig.json CHANGED
@@ -8,10 +8,23 @@
8
8
  "declaration": true
9
9
  },
10
10
  "references": [
11
- { "path": "../cli-output-helpers" },
12
- { "path": "../nevermore-template-helpers" },
13
- { "path": "../nevermore-cli-helpers" },
14
- { "path": "../studio-bridge" }
11
+ {
12
+ "path": "../cli-output-helpers"
13
+ },
14
+ {
15
+ "path": "../nevermore-template-helpers"
16
+ },
17
+ {
18
+ "path": "../nevermore-cli-helpers"
19
+ },
20
+ {
21
+ "path": "../studio-bridge"
22
+ },
23
+ {
24
+ "path": "../nevermore-deploy"
25
+ }
15
26
  ],
16
- "include": ["src/**/*"]
17
- }
27
+ "include": [
28
+ "src/**/*"
29
+ ]
30
+ }