@khalido/home 0.1.1 → 0.1.3

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 (88) hide show
  1. package/CHANGELOG.md +60 -2
  2. package/README.md +12 -12
  3. package/dist/clock.js +2 -0
  4. package/dist/config.js +3 -1
  5. package/dist/connector.d.ts +9 -2
  6. package/dist/connectors/amber/index.js +1 -0
  7. package/dist/connectors/cast/index.d.ts +10 -5
  8. package/dist/connectors/cast/index.js +36 -4
  9. package/dist/connectors/cast/schemas.d.ts +2 -0
  10. package/dist/connectors/cast/schemas.js +1 -0
  11. package/dist/connectors/cast/state.d.ts +56 -0
  12. package/dist/connectors/cast/state.js +134 -0
  13. package/dist/connectors/cast/writes.d.ts +9 -2
  14. package/dist/connectors/cast/writes.js +67 -13
  15. package/dist/connectors/daikin/index.d.ts +6 -2
  16. package/dist/connectors/daikin/index.js +21 -1
  17. package/dist/connectors/enphase/index.d.ts +5 -1
  18. package/dist/connectors/enphase/index.js +34 -2
  19. package/dist/connectors/hue/index.d.ts +6 -2
  20. package/dist/connectors/hue/index.js +39 -6
  21. package/dist/connectors/printers/index.d.ts +6 -2
  22. package/dist/connectors/printers/index.js +23 -0
  23. package/dist/connectors/spotify/changes.d.ts +1 -1
  24. package/dist/connectors/spotify/changes.js +4 -2
  25. package/dist/connectors/spotify/client.d.ts +1 -1
  26. package/dist/connectors/spotify/index.d.ts +9 -2
  27. package/dist/connectors/spotify/index.js +130 -45
  28. package/dist/connectors/up/index.d.ts +4 -2
  29. package/dist/connectors/up/index.js +3 -1
  30. package/dist/connectors/weather/bom.js +1 -0
  31. package/dist/connectors/weather/google.js +52 -9
  32. package/dist/connectors/weather/index.d.ts +2 -1
  33. package/dist/connectors/weather/index.js +7 -0
  34. package/dist/connectors/weather/source.d.ts +2 -0
  35. package/dist/devices.d.ts +27 -0
  36. package/dist/devices.js +61 -0
  37. package/dist/discover.d.ts +2 -0
  38. package/dist/discover.js +4 -1
  39. package/dist/health.d.ts +2 -1
  40. package/dist/health.js +2 -1
  41. package/dist/home.d.ts +41 -3
  42. package/dist/home.js +24 -7
  43. package/dist/index.d.ts +3 -2
  44. package/dist/index.js +2 -1
  45. package/dist/lane.js +24 -20
  46. package/docs/cast.md +1 -1
  47. package/docs/cli.md +3 -3
  48. package/docs/config.md +8 -3
  49. package/docs/decisions.md +5 -0
  50. package/docs/using.md +17 -0
  51. package/package.json +1 -1
  52. package/skills/use-home/SKILL.md +7 -7
  53. package/src/cli/cast.ts +19 -11
  54. package/src/cli/enphase.ts +5 -1
  55. package/src/cli/spotify.ts +2 -1
  56. package/src/cli/status.ts +26 -3
  57. package/src/clock.ts +1 -0
  58. package/src/config.ts +3 -1
  59. package/src/connector.ts +18 -2
  60. package/src/connectors/amber/index.ts +1 -0
  61. package/src/connectors/cast/README.md +10 -5
  62. package/src/connectors/cast/index.ts +74 -9
  63. package/src/connectors/cast/schemas.ts +1 -0
  64. package/src/connectors/cast/state.ts +189 -0
  65. package/src/connectors/cast/writes.ts +87 -17
  66. package/src/connectors/daikin/README.md +4 -0
  67. package/src/connectors/daikin/index.ts +41 -2
  68. package/src/connectors/enphase/index.ts +49 -2
  69. package/src/connectors/hue/index.ts +57 -8
  70. package/src/connectors/printers/README.md +4 -2
  71. package/src/connectors/printers/index.ts +43 -1
  72. package/src/connectors/spotify/README.md +33 -9
  73. package/src/connectors/spotify/changes.ts +4 -2
  74. package/src/connectors/spotify/client.ts +1 -1
  75. package/src/connectors/spotify/index.ts +157 -49
  76. package/src/connectors/up/README.md +1 -1
  77. package/src/connectors/up/index.ts +6 -2
  78. package/src/connectors/weather/README.md +1 -1
  79. package/src/connectors/weather/bom.ts +1 -0
  80. package/src/connectors/weather/google.ts +66 -10
  81. package/src/connectors/weather/index.ts +16 -2
  82. package/src/connectors/weather/source.ts +2 -0
  83. package/src/devices.ts +102 -0
  84. package/src/discover.ts +6 -2
  85. package/src/health.ts +4 -2
  86. package/src/home.ts +75 -8
  87. package/src/index.ts +5 -1
  88. package/src/lane.ts +23 -20
@@ -1,5 +1,5 @@
1
1
  import { type Clock, localTime, realClock } from "../../clock.ts";
2
- import type { Integration, Status } from "../../home.ts";
2
+ import type { Integration, Reason, Status } from "../../home.ts";
3
3
  import { field, type Manifest } from "../../manifest.ts";
4
4
  import { poller } from "../../poll.ts";
5
5
  import {
@@ -88,7 +88,13 @@ export type WeatherState = Slices & {
88
88
  timeZone?: string; // the config's, which the blocks and the text use
89
89
  sources: Record<
90
90
  string,
91
- { read: boolean; problem?: string; stopped?: string; anomaly?: string }
91
+ {
92
+ read: boolean;
93
+ problem?: string;
94
+ stopped?: string; // it will not be read again, and why
95
+ reason?: Exclude<Reason, "crashed">; // with `stopped`, for code: a dead key shows here even while another source keeps the part connected
96
+ anomaly?: string;
97
+ }
92
98
  >;
93
99
  };
94
100
 
@@ -149,6 +155,7 @@ export function weather(
149
155
  read: r !== undefined,
150
156
  problem: r?.problem,
151
157
  stopped: r?.stop,
158
+ reason: r?.stop ? (r.stopReason ?? "config") : undefined,
152
159
  anomaly: r?.anomaly,
153
160
  },
154
161
  ];
@@ -174,6 +181,13 @@ export function weather(
174
181
  : undefined;
175
182
  update({
176
183
  status,
184
+ // Every source has stopped: a rejected key outranks a wrong setting, since it needs a person.
185
+ reason:
186
+ status !== "unavailable"
187
+ ? undefined
188
+ : Object.values(report).some((r) => r.reason === "auth")
189
+ ? "auth"
190
+ : "config",
177
191
  detail,
178
192
  anomaly: notes("anomaly"),
179
193
  lastGoodAt,
@@ -1,4 +1,5 @@
1
1
  import { ConnectorError, fetchTimeout } from "../../connector.ts";
2
+ import type { Reason } from "../../home.ts";
2
3
 
3
4
  // The one place a weather provider plugs in: a `WeatherSource` returns slices, and `mergeWeather` picks one per slice.
4
5
 
@@ -142,6 +143,7 @@ export type SourceReading = Slices & {
142
143
  problem?: string; // a failed product, a missing option; the source keeps going
143
144
  anomaly?: string; // a value the source sent that could not be read as given
144
145
  stop?: string; // a terminal problem (a rejected key): the integration stops calling this source
146
+ stopReason?: Exclude<Reason, "crashed">; // with `stop`: a person must re-key, or fix a setting (the default)
145
147
  };
146
148
 
147
149
  /**
package/src/devices.ts ADDED
@@ -0,0 +1,102 @@
1
+ import {
2
+ type CastHomeDevice,
3
+ castHomeDevices,
4
+ } from "./connectors/cast/index.ts";
5
+ import {
6
+ type DaikinHomeDevice,
7
+ daikinHomeDevices,
8
+ } from "./connectors/daikin/index.ts";
9
+ import {
10
+ type EnphaseHomeDevice,
11
+ enphaseHomeDevices,
12
+ } from "./connectors/enphase/index.ts";
13
+ import { type HueHomeDevice, hueHomeDevices } from "./connectors/hue/index.ts";
14
+ import {
15
+ type PrinterHomeDevice,
16
+ printersHomeDevices,
17
+ } from "./connectors/printers/index.ts";
18
+ import {
19
+ type SpotifyHomeDevice,
20
+ spotifyHomeDevices,
21
+ } from "./connectors/spotify/index.ts";
22
+ import type { HomeConfig } from "./config.ts";
23
+ import { IDENTITY, sameId } from "./discover.ts";
24
+ import type { DeviceRecord, Part } from "./home.ts";
25
+ import { manifestOf } from "./manifests.ts";
26
+
27
+ // Every device in the home in one list, each answering the same few questions (`DeviceRecord`), with its connector's own
28
+ // typed row in `detail`. A connector with devices exports `<name>HomeDevices(part)` and is listed here, once in the union
29
+ // and once in DEVICES; test/devices.test.ts fails for a manifest that is in neither DEVICES nor SERVICES.
30
+
31
+ /** Any device this library reads. `connector`, then `kind` where a connector has more than one, narrows `detail` and `state`. */
32
+ export type HomeDevice =
33
+ | HueHomeDevice
34
+ | DaikinHomeDevice
35
+ | EnphaseHomeDevice
36
+ | CastHomeDevice
37
+ | PrinterHomeDevice
38
+ | SpotifyHomeDevice;
39
+
40
+ /** Each connector's devices from its part, in the order `home status` lists the connectors. */
41
+ export const DEVICES = {
42
+ hue: hueHomeDevices,
43
+ daikin: daikinHomeDevices,
44
+ enphase: enphaseHomeDevices,
45
+ cast: castHomeDevices,
46
+ printers: printersHomeDevices,
47
+ spotify: spotifyHomeDevices,
48
+ } satisfies Record<string, (part: Part<any> | undefined) => DeviceRecord[]>;
49
+
50
+ /** Connectors that read a service, not a device: nothing to list. */
51
+ export const SERVICES: readonly string[] = ["amber", "up", "weather"];
52
+
53
+ /**
54
+ * Every device in the snapshot. With the home `config`, a device takes the `room` its entry gives it, a Hue room or zone
55
+ * (and its lights) the room whose `hueRoom` names it (a light in a room and a zone, the room's), and a device that reports
56
+ * no name of its own (the Daikin, the Enphase gateway) the entry's `name`. A device's own name is never replaced.
57
+ */
58
+ export function homeDevices(
59
+ snapshot: Record<string, Part | undefined>,
60
+ config?: Pick<HomeConfig, "devices" | "rooms">,
61
+ ): HomeDevice[] {
62
+ const all = Object.entries(DEVICES).flatMap(([name, devices]): HomeDevice[] =>
63
+ devices(snapshot[name] as never),
64
+ );
65
+ if (!config) return all;
66
+ const entries = config.devices ?? [];
67
+ const hueRooms = new Map<string, string>(); // a Hue group's or light's id → the config room showing it
68
+ for (const d of all)
69
+ if (d.connector === "hue" && d.kind === "group") {
70
+ const room = config.rooms?.find(
71
+ (r) =>
72
+ r.hueRoom !== undefined &&
73
+ (r.hueRoom === d.id || r.hueRoom === d.detail.groupedLightId),
74
+ )?.id;
75
+ // A light in a room and a zone takes the room's: rooms come first, and the first one stays.
76
+ if (room)
77
+ for (const id of [d.id, ...d.detail.lightIds])
78
+ if (!hueRooms.has(id)) hueRooms.set(id, room);
79
+ }
80
+ return all.map((d) => {
81
+ const mine = entries.filter((e) => e.connector === d.connector);
82
+ const field = (IDENTITY as Record<string, string>)[d.connector];
83
+ // One device for the connector: its entry is this device. Many: the entry whose identity is this device's id.
84
+ const entry =
85
+ d.id === d.connector
86
+ ? mine[0]
87
+ : mine.find((e) => {
88
+ const id = field && (e as Record<string, unknown>)[field];
89
+ return typeof id === "string" && sameId(id, d.id);
90
+ });
91
+ const room = d.room ?? entry?.room ?? hueRooms.get(d.id);
92
+ // A device with no name of its own carries its connector's title: only then does the entry's name replace it.
93
+ const unnamed = d.name === manifestOf(d.connector)?.title;
94
+ const name = unnamed ? (entry?.name ?? d.name) : d.name;
95
+ // Its line opens with its name, so a new name opens the line too.
96
+ const text =
97
+ name !== d.name && d.text.startsWith(`${d.name}: `)
98
+ ? name + d.text.slice(d.name.length)
99
+ : d.text;
100
+ return { ...d, name, text, ...(room ? { room } : {}) };
101
+ });
102
+ }
package/src/discover.ts CHANGED
@@ -307,6 +307,7 @@ export type CachedAddress = {
307
307
  host: string;
308
308
  port?: number;
309
309
  name?: string;
310
+ model?: string; // Cast: what kind of device, without a discovery
310
311
  seenAt: string;
311
312
  };
312
313
 
@@ -345,7 +346,7 @@ export function addressBook(
345
346
  seen(
346
347
  connector: Connector,
347
348
  id: string | undefined,
348
- address: { host: string; port?: number; name?: string },
349
+ address: { host: string; port?: number; name?: string; model?: string },
349
350
  ): void {
350
351
  if (!cache || id === undefined) return;
351
352
  const key = addressKey(connector, id);
@@ -355,14 +356,17 @@ export function addressBook(
355
356
  c.host === address.host &&
356
357
  c.port === address.port &&
357
358
  isFresh(c) &&
358
- (!address.name || address.name === c.name)
359
+ (!address.name || address.name === c.name) &&
360
+ (!address.model || address.model === c.model)
359
361
  )
360
362
  return;
361
363
  const name = address.name || c?.name;
364
+ const model = address.model || c?.model;
362
365
  entries[key] = {
363
366
  host: address.host,
364
367
  ...(address.port === undefined ? {} : { port: address.port }),
365
368
  ...(name ? { name } : {}),
369
+ ...(model ? { model } : {}),
366
370
  seenAt: new Date(now()).toISOString(),
367
371
  };
368
372
  cache.write({ ...entries });
package/src/health.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Part, Status } from "./home.ts";
1
+ import type { Part, Reason, Status } from "./home.ts";
2
2
  import { MANIFESTS } from "./manifests.ts";
3
3
 
4
4
  /**
@@ -14,6 +14,7 @@ export type Health = {
14
14
  staleAfterMs?: number;
15
15
  downAfterMs: number;
16
16
  detail?: string;
17
+ reason?: Reason;
17
18
  };
18
19
 
19
20
  /** A polling connector is stale after this many polls with no good read: two missed, plus half of one so a slow read that ends in a timeout does not flap it, */
@@ -61,7 +62,7 @@ function healthOf(
61
62
  (part.status !== "connected" && (age(part.downSince) ?? 0) >= downAfterMs);
62
63
  const goodAge = age(part.lastGoodAt) ?? age(part.downSince); // never good: as old as the wait for it
63
64
  const stale = staleAfterMs !== undefined && (goodAge ?? 0) >= staleAfterMs;
64
- const { status, lastGoodAt, downSince, detail } = part;
65
+ const { status, lastGoodAt, downSince, detail, reason } = part;
65
66
  return {
66
67
  level: down ? "down" : stale ? "stale" : "ok",
67
68
  status,
@@ -70,5 +71,6 @@ function healthOf(
70
71
  staleAfterMs,
71
72
  downAfterMs,
72
73
  detail,
74
+ reason,
73
75
  };
74
76
  }
package/src/home.ts CHANGED
@@ -9,15 +9,18 @@ export const envName = z
9
9
  /^[A-Z_][A-Z0-9_]*$/,
10
10
  "an environment variable name (e.g. AMBER_KEY), not the secret itself",
11
11
  );
12
- /** A file a connector needs (a CA, a token), relative to the config file. The caller reads it; the library reads no files. */
12
+ /** A file a connector needs (a CA, a token), relative to the config file or from `~/`. The caller reads it, and expands `~/`; the library reads no files. */
13
13
  export const filePath = z.string().min(1);
14
14
 
15
15
  /** How an integration is doing. "unavailable" means it stopped and will not retry by itself. */
16
16
  export type Status =
17
17
  "connecting" | "connected" | "disconnected" | "unavailable";
18
18
 
19
+ /** Why a part is `unavailable`, for code to switch on; `detail` is the text for people. `auth`: a key or token was rejected or is unusable, so a person must re-key. `config`: a setting or a file is wrong. `crashed`: its `run` threw. */
20
+ export type Reason = "auth" | "config" | "crashed";
21
+
19
22
  /**
20
- * One integration's slice of the home: its status, a human detail, anything odd the device reported, and its state.
23
+ * One integration's slice of the home: its status, a human detail, `reason` (why it is `unavailable`, for code to switch on), anything odd the device reported, and its state.
21
24
  * `createHome` stamps the times (UTC ISO). `lastGoodAt`: its last report while connected, or the read time a connected report
22
25
  * gives when it repeats an older read (weather's 5-minute sun tick). `downSince`: when it stopped being connected (or started,
23
26
  * if it never has); none while connected. Any other time an integration sets is ignored.
@@ -25,12 +28,68 @@ export type Status =
25
28
  export type Part<S = unknown> = {
26
29
  status: Status;
27
30
  detail?: string;
31
+ reason?: Reason;
28
32
  anomaly?: string;
29
33
  state?: S;
30
34
  lastGoodAt?: string;
31
35
  downSince?: string;
32
36
  };
33
37
 
38
+ /**
39
+ * The few things every device answers, whatever it is, in the same words; a connector's `<name>HomeDevices(part)` returns
40
+ * these and `homeDevices(snapshot)` collects them. Everything particular to its kind is in `detail`, the connector's own
41
+ * typed row, and `connector` (with `kind`) says which type that is. Unknown stays absent, never guessed.
42
+ * - `id`: the device's own stable id (a Hue light id, a castId, a printer's UUID), or the connector's name when it has one device.
43
+ * - `kind`: light, group, speaker, display, tv, ac, printer, solar (and `cast` for a Cast model this library doesn't know).
44
+ * - `available`: it answered and can be used now. When it is false, `reason` says why in one word: `offline`, or the
45
+ * connector's own reason for being `unavailable` (`auth`, `config`, `crashed`).
46
+ * - `asOf`: when the device last reported (a push connector) or was last read (a polled one). A quiet light can be hours
47
+ * old and current: `available` says whether it is.
48
+ * - `state`: one short word in that kind's vocabulary: on, off, idle, busy, cool, printing.
49
+ * - `text`: one line for people, "Kitchen: on · 60%".
50
+ * - `room`: a room id from the home config, when one places it.
51
+ * - `members`: a group's device ids.
52
+ */
53
+ export type DeviceRecord<
54
+ C extends string = string,
55
+ K extends string = string,
56
+ S extends string = string,
57
+ D = unknown,
58
+ > = {
59
+ connector: C;
60
+ id: string;
61
+ name: string;
62
+ kind: K;
63
+ available?: boolean;
64
+ reason?: Reason | "offline";
65
+ asOf?: string;
66
+ state?: S;
67
+ text: string;
68
+ room?: string;
69
+ members?: string[];
70
+ detail: D;
71
+ };
72
+
73
+ /**
74
+ * A connector's device records from its part. While the connector isn't connected none is `available`, and each keeps its
75
+ * last values, as the part's state does. A device that isn't available gets its `reason`: the part's when it stopped for
76
+ * good, else `offline`.
77
+ */
78
+ export const deviceRecords = <R extends DeviceRecord>(
79
+ part: Part | undefined,
80
+ records: R[],
81
+ ): R[] => {
82
+ const up = part?.status === "connected";
83
+ // A part that stopped for good without saying why is a custom integration's: `config` is the nearest true word, since it won't come back by itself.
84
+ const why =
85
+ part?.status === "unavailable" ? (part.reason ?? "config") : "offline";
86
+ return records.map((r) =>
87
+ up && r.available !== false
88
+ ? r
89
+ : { ...r, available: false, reason: up ? "offline" : why },
90
+ );
91
+ };
92
+
34
93
  /**
35
94
  * An integration is a folder in `src/` that exports a factory returning this.
36
95
  * `run` keeps its state current until `signal` aborts, reporting each change through `update`.
@@ -94,12 +153,16 @@ export function createHome<I extends Integration<any, string>>(
94
153
  integrations.map((i) => [i.name, { status: "connecting" }]),
95
154
  ) as HomeSnapshot<I>;
96
155
  const waiting = new Map<string, (() => void)[]>(); // refresh() callers, resolved by the integration's next report
97
- const finished = new Set<string>(); // integrations whose run has ended: a refresh of one resolves at once, nothing will report
156
+ const finished = new Set<string>(integrations.map((i) => i.name)); // not running, before start or after its run ended: a refresh of one resolves at once, nothing will report
98
157
  const set = (name: Name, reported: Part) => {
99
158
  const before = (snapshot as Record<string, Part>)[name]!;
100
159
  const part = stamped(reported, before, asOf(clock));
101
160
  snapshot = { ...snapshot, [name]: part };
102
- onChange?.(snapshot, { name, before, after: part } as Change<I>);
161
+ Promise.try(() =>
162
+ onChange?.(snapshot, { name, before, after: part } as Change<I>),
163
+ ).catch((error) =>
164
+ console.error(`onChange threw on ${name}: ${errorText(error)}`),
165
+ ); // the caller's bug, thrown or async, not the device's: the integration keeps running
103
166
  const w = waiting.get(name);
104
167
  if (w) {
105
168
  waiting.delete(name);
@@ -119,7 +182,7 @@ export function createHome<I extends Integration<any, string>>(
119
182
  Extract<I, { name: N }> | undefined,
120
183
  /**
121
184
  * Read now: one integration by name, or every one. Results arrive through the snapshot and `onChange` as usual; the
122
- * promise resolves once each target has reported (its next update, good or bad), or at once for one whose run has ended.
185
+ * promise resolves once each target has reported (its next update, good or bad), or at once for one that isn't running.
123
186
  */
124
187
  refresh(name?: Name): Promise<void> {
125
188
  const targets =
@@ -154,10 +217,14 @@ export function createHome<I extends Integration<any, string>>(
154
217
  ) as HomeSnapshot<I>;
155
218
  await Promise.all(
156
219
  integrations.map((i) =>
157
- i
158
- .run(signal, (part) => set(i.name, part))
220
+ Promise.try(() => i.run(signal, (part) => set(i.name, part)))
159
221
  .catch((error) =>
160
- set(i.name, { status: "unavailable", detail: errorText(error) }),
222
+ set(i.name, {
223
+ status: "unavailable",
224
+ reason: "crashed",
225
+ detail: errorText(error),
226
+ state: (snapshot as Record<string, Part>)[i.name]!.state, // the last good state stays, as it does through a failed read
227
+ }),
161
228
  )
162
229
  .finally(() => {
163
230
  finished.add(i.name);
package/src/index.ts CHANGED
@@ -3,12 +3,15 @@
3
3
  export {
4
4
  type Change,
5
5
  createHome,
6
+ type DeviceRecord,
7
+ deviceRecords,
6
8
  envName,
7
9
  filePath,
8
10
  settle,
9
11
  type HomeSnapshot,
10
12
  type Integration,
11
13
  type Part,
14
+ type Reason,
12
15
  type StateOf,
13
16
  type Status,
14
17
  } from "./home.ts";
@@ -19,8 +22,9 @@ export {
19
22
  PUSH_DOWN_MS,
20
23
  STALE_POLLS,
21
24
  } from "./health.ts";
25
+ export { type HomeDevice, homeDevices } from "./devices.ts";
22
26
  export { asOf, type Clock, realClock } from "./clock.ts";
23
- export { ConnectorError } from "./connector.ts";
27
+ export { type ConnectorCode, ConnectorError } from "./connector.ts";
24
28
  export {
25
29
  bestWindow,
26
30
  type EnergyNow,
package/src/lane.ts CHANGED
@@ -54,28 +54,31 @@ export function writeLane(options: LaneOptions) {
54
54
 
55
55
  async function run() {
56
56
  running = true;
57
- while (queue.length) {
58
- const now = clock.now();
59
- const next = queue.find((p) => readyAt(p.slot) <= now);
60
- if (!next) {
61
- wake = new AbortController();
62
- await clock.sleep(
63
- Math.min(...queue.map((p) => readyAt(p.slot))) - now,
64
- wake.signal,
65
- );
66
- continue;
67
- }
68
- queue.splice(queue.indexOf(next), 1);
69
- lastAt = now;
70
- sent.set(next.slot, [...recent(next.slot), now]);
71
- try {
72
- const result = await next.write(next.body);
73
- for (const w of next.waiters) w.resolve(result);
74
- } catch (error) {
75
- for (const w of next.waiters) w.reject(error);
57
+ try {
58
+ while (queue.length) {
59
+ const now = clock.now();
60
+ const next = queue.find((p) => readyAt(p.slot) <= now);
61
+ if (!next) {
62
+ wake = new AbortController();
63
+ await clock.sleep(
64
+ Math.min(...queue.map((p) => readyAt(p.slot))) - now,
65
+ wake.signal,
66
+ );
67
+ continue;
68
+ }
69
+ queue.splice(queue.indexOf(next), 1);
70
+ lastAt = now;
71
+ sent.set(next.slot, [...recent(next.slot), now]);
72
+ try {
73
+ const result = await next.write(next.body);
74
+ for (const w of next.waiters) w.resolve(result);
75
+ } catch (error) {
76
+ for (const w of next.waiters) w.reject(error);
77
+ }
76
78
  }
79
+ } finally {
80
+ running = false; // a throw here must not leave the lane stuck
77
81
  }
78
- running = false;
79
82
  }
80
83
 
81
84
  return {