@ccmsg/cli 0.3.3 → 0.3.5
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/package.json +1 -1
- package/src/cli.ts +23 -0
- package/src/daemon/registry.ts +11 -1
- package/src/instance/config.ts +59 -4
- package/src/instance/instance.ts +2 -6
- package/src/messaging/delivery.ts +10 -4
- package/src/messaging/notify.ts +12 -3
- package/src/topics/egress.ts +162 -0
- package/src/topics/index.ts +1 -0
- package/src/topics/topics.ts +0 -0
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -34,11 +34,22 @@ const SESSION_ENV = HARNESSES.flatMap((harness) => [...HARNESS[harness].sessionE
|
|
|
34
34
|
import { hookEvent, type StatedMeta, statedMeta } from "./greeting/index.ts";
|
|
35
35
|
import {
|
|
36
36
|
isRunning,
|
|
37
|
+
MERGE_RULES,
|
|
37
38
|
resolveConfigHome,
|
|
38
39
|
resolvePaths,
|
|
39
40
|
resolvePathsFor,
|
|
40
41
|
start,
|
|
41
42
|
} from "./instance/index.ts";
|
|
43
|
+
|
|
44
|
+
/** The merge rules as the help prints them: one line per field path, in the
|
|
45
|
+
* order the schema declares them, so the table a person reads is the table the
|
|
46
|
+
* merge runs on. */
|
|
47
|
+
const MERGE_DOCS: readonly Doc[] = Object.entries(MERGE_RULES).map(([path, rule]) => [
|
|
48
|
+
path,
|
|
49
|
+
rule === "merge"
|
|
50
|
+
? "instances[] 側にある field だけを defaults に重ねる"
|
|
51
|
+
: "instances[] 側にあれば丸ごと置換する (追加・和にはならない)",
|
|
52
|
+
]);
|
|
42
53
|
import {
|
|
43
54
|
type Agent,
|
|
44
55
|
AGENTS,
|
|
@@ -98,6 +109,10 @@ interface Command {
|
|
|
98
109
|
readonly usage?: string;
|
|
99
110
|
readonly options?: readonly Doc[];
|
|
100
111
|
readonly env?: readonly Doc[];
|
|
112
|
+
/** Anything else this level has to state as a list of names, under a title of
|
|
113
|
+
* its own. Options and environment are the two every level shares; this is
|
|
114
|
+
* for what only one of them has. */
|
|
115
|
+
readonly notes?: readonly { readonly title: string; readonly docs: readonly Doc[] }[];
|
|
101
116
|
readonly children?: readonly Command[];
|
|
102
117
|
readonly run?: (args: readonly string[]) => Promise<unknown>;
|
|
103
118
|
/** Whether running it with nothing after it is a command rather than a
|
|
@@ -148,6 +163,13 @@ const ROOT: Command = {
|
|
|
148
163
|
`config home が動かすもの: ${HARNESSES.join(" | ")} (既定 ${DEFAULT_HARNESS})`,
|
|
149
164
|
],
|
|
150
165
|
],
|
|
166
|
+
notes: [
|
|
167
|
+
{
|
|
168
|
+
title:
|
|
169
|
+
"共通 config で instances[] の値が defaults に重なる規則 (掲載の無いパスは丸ごと置換):",
|
|
170
|
+
docs: MERGE_DOCS,
|
|
171
|
+
},
|
|
172
|
+
],
|
|
151
173
|
run: (args) => added(args),
|
|
152
174
|
},
|
|
153
175
|
{
|
|
@@ -488,6 +510,7 @@ function help(path: readonly Command[]): string {
|
|
|
488
510
|
}
|
|
489
511
|
lines.push("");
|
|
490
512
|
}
|
|
513
|
+
for (const note of at.notes ?? []) section(lines, note.title, note.docs);
|
|
491
514
|
section(lines, "このレベルのオプション:", at.options);
|
|
492
515
|
section(lines, "グローバルオプション:", GLOBAL_OPTIONS);
|
|
493
516
|
section(lines, "環境変数:", [...(at.env ?? []), ...GLOBAL_ENV]);
|
package/src/daemon/registry.ts
CHANGED
|
@@ -3,7 +3,9 @@ import { basename, isAbsolute, join, resolve } from "node:path";
|
|
|
3
3
|
import type { Endpoint, InstanceId, InstancePingResult } from "@ccmsg/protocol";
|
|
4
4
|
import { DEFAULT_HARNESS, type Harness, HARNESS, isHarness } from "../harness/index.ts";
|
|
5
5
|
import {
|
|
6
|
+
type InstanceConfig,
|
|
6
7
|
type InstanceEntry,
|
|
8
|
+
loadConfig,
|
|
7
9
|
loadShared,
|
|
8
10
|
saveShared,
|
|
9
11
|
settingsFor,
|
|
@@ -61,6 +63,14 @@ export interface InstanceRow {
|
|
|
61
63
|
/** One row of `daemon status`: the list's row, plus what the instance itself
|
|
62
64
|
* says when there is one to ask. */
|
|
63
65
|
export interface StatusRow extends InstanceRow {
|
|
66
|
+
/** What this config home's instance is configured with, after the shared
|
|
67
|
+
* file's defaults and its own entry are merged (§8.2).
|
|
68
|
+
*
|
|
69
|
+
* Answered whether or not anything is running, and read from the file rather
|
|
70
|
+
* than asked of the instance: this is what a restart would apply, which is
|
|
71
|
+
* the question an operator who just edited the file has. It carries no
|
|
72
|
+
* secret — the gateway's token is named by the path it is kept at. */
|
|
73
|
+
readonly config: InstanceConfig;
|
|
64
74
|
readonly version?: string;
|
|
65
75
|
readonly network?: InstancePingResult["network"];
|
|
66
76
|
/** The other instances this one names, each with where it is dialled: the id
|
|
@@ -155,7 +165,7 @@ export function list(env: Env): InstanceRow[] {
|
|
|
155
165
|
/** Ask one instance how it is. A config home with nothing behind it answers the
|
|
156
166
|
* list's row and nothing more: not running is a state, not a failure. */
|
|
157
167
|
export async function status(target: Target): Promise<StatusRow> {
|
|
158
|
-
const row = rowFor(target);
|
|
168
|
+
const row = { ...rowFor(target), config: loadConfig(target.paths.configFile, target.dir) };
|
|
159
169
|
const conn = await connect(target.paths.socket);
|
|
160
170
|
if (conn === undefined) return row;
|
|
161
171
|
try {
|
package/src/instance/config.ts
CHANGED
|
@@ -230,13 +230,68 @@ export function saveShared(file: string, shared: SharedConfig): void {
|
|
|
230
230
|
writeFileSync(file, `${JSON.stringify({ defaults: shared.defaults, instances }, null, 2)}\n`);
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
+
/** How one field of the shared file combines an instance's entry with the
|
|
234
|
+
* defaults.
|
|
235
|
+
*
|
|
236
|
+
* `merge` takes the two field by field, so an instance states only what it
|
|
237
|
+
* differs in; `replace` takes the instance's value whole. */
|
|
238
|
+
export type MergeRule = "merge" | "replace";
|
|
239
|
+
|
|
240
|
+
/** The rule for every field path that holds an object or an array, which are
|
|
241
|
+
* the only ones where "combine" could mean more than one thing.
|
|
242
|
+
*
|
|
243
|
+
* Declared beside the parsers rather than derived from the values, because
|
|
244
|
+
* whether a list is a sequence or a set is a fact about what the field means
|
|
245
|
+
* and every list looks the same without it. A path not named here replaces:
|
|
246
|
+
* that is what a scalar can do, and it is what an array does until some field
|
|
247
|
+
* is a set and says so. */
|
|
248
|
+
export const MERGE_RULES: Readonly<Record<string, MergeRule>> = {
|
|
249
|
+
// The same finished list goes to every instance (§7.1), so an instance that
|
|
250
|
+
// writes its own means to run with that one and no other.
|
|
251
|
+
peers: "replace",
|
|
252
|
+
entry: "merge",
|
|
253
|
+
"entry.source_ips": "replace",
|
|
254
|
+
"entry.trusted_proxies": "replace",
|
|
255
|
+
upstream: "merge",
|
|
256
|
+
"upstream.launcher": "merge",
|
|
257
|
+
"upstream.launcher.root_dirs": "replace",
|
|
258
|
+
"upstream.launcher.templates": "replace",
|
|
259
|
+
"upstream.launcher.clean_env": "replace",
|
|
260
|
+
"upstream.launcher.keep_env": "replace",
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
function ruleFor(path: string): MergeRule {
|
|
264
|
+
return MERGE_RULES[path] ?? "replace";
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function plainObject(raw: unknown): raw is Record<string, unknown> {
|
|
268
|
+
return typeof raw === "object" && raw !== null && !Array.isArray(raw);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function merged(
|
|
272
|
+
base: Record<string, unknown>,
|
|
273
|
+
over: Record<string, unknown>,
|
|
274
|
+
at: string,
|
|
275
|
+
): Record<string, unknown> {
|
|
276
|
+
const out: Record<string, unknown> = { ...base };
|
|
277
|
+
for (const [name, value] of Object.entries(over)) {
|
|
278
|
+
const path = at === "" ? name : `${at}.${name}`;
|
|
279
|
+
const under = out[name];
|
|
280
|
+
out[name] =
|
|
281
|
+
ruleFor(path) === "merge" && plainObject(under) && plainObject(value)
|
|
282
|
+
? merged(under, value, path)
|
|
283
|
+
: value;
|
|
284
|
+
}
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
|
|
233
288
|
/** What one config home's instance is configured with: its own entry over the
|
|
234
|
-
* shared defaults,
|
|
235
|
-
* resolves — `daemon run` on an unregistered directory is
|
|
236
|
-
* the built-ins. */
|
|
289
|
+
* shared defaults, by the rule each field path declares. A config home the file
|
|
290
|
+
* does not list still resolves — `daemon run` on an unregistered directory is
|
|
291
|
+
* the defaults plus the built-ins. */
|
|
237
292
|
export function settingsFor(shared: SharedConfig, dir: string): Record<string, unknown> {
|
|
238
293
|
const entry = shared.instances.find((one) => one.dir === dir);
|
|
239
|
-
return
|
|
294
|
+
return merged(shared.defaults, entry?.settings ?? {}, "");
|
|
240
295
|
}
|
|
241
296
|
|
|
242
297
|
/** One instance's settings, read at the shape the instance uses them. */
|
package/src/instance/instance.ts
CHANGED
|
@@ -522,18 +522,14 @@ export class Instance {
|
|
|
522
522
|
...(this.#mesh === undefined ? {} : { cluster: this.#mesh }),
|
|
523
523
|
inbox,
|
|
524
524
|
direct: this.#direct,
|
|
525
|
-
publish: (topic, data, instance, to) =>
|
|
526
|
-
this.#topics.publish(topic, data, instance, to);
|
|
527
|
-
},
|
|
525
|
+
publish: (topic, data, instance, to) => this.#topics.publish(topic, data, instance, to),
|
|
528
526
|
listeners: (topic, to) => this.#topics.subscriberCount(topic, to),
|
|
529
527
|
});
|
|
530
528
|
|
|
531
529
|
this.#notify = new Notify({
|
|
532
530
|
self: this.self,
|
|
533
531
|
label: (sid) => sessionLabel(this.#sessions, sid),
|
|
534
|
-
publish: (topic, data, instance) =>
|
|
535
|
-
this.#topics.publish(topic, data, instance);
|
|
536
|
-
},
|
|
532
|
+
publish: (topic, data, instance) => this.#topics.publish(topic, data, instance),
|
|
537
533
|
});
|
|
538
534
|
|
|
539
535
|
this.#topics.attach("peers", this.#sessions);
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
OpError,
|
|
22
22
|
type Requester,
|
|
23
23
|
} from "../dispatch/index.ts";
|
|
24
|
-
import type { TopicValue, UpstreamResource } from "../topics/index.ts";
|
|
24
|
+
import type { PublishOutcome, TopicValue, UpstreamResource } from "../topics/index.ts";
|
|
25
25
|
import type { DirectRoute } from "./direct.ts";
|
|
26
26
|
import type { Inbox } from "./inbox.ts";
|
|
27
27
|
|
|
@@ -71,7 +71,7 @@ export interface DeliveryDeps {
|
|
|
71
71
|
readonly direct: DirectRoute;
|
|
72
72
|
/** The one way a value reaches subscribers (§6.1), narrowed to the session a
|
|
73
73
|
* message is for. */
|
|
74
|
-
readonly publish: (topic: string, data: unknown, instance: InstanceId, to: Sid) =>
|
|
74
|
+
readonly publish: (topic: string, data: unknown, instance: InstanceId, to: Sid) => PublishOutcome;
|
|
75
75
|
/** How many of that session's connections are listening on `inbox`. */
|
|
76
76
|
readonly listeners: (topic: string, to: Sid) => number;
|
|
77
77
|
}
|
|
@@ -128,8 +128,14 @@ export class Delivery implements UpstreamResource {
|
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
if (this.deps.listeners(INBOX, to) > 0) {
|
|
131
|
-
this.deps.publish(INBOX, [message], this.deps.self, to)
|
|
132
|
-
|
|
131
|
+
if (this.deps.publish(INBOX, [message], this.deps.self, to) === "ok") {
|
|
132
|
+
return { delivered: true };
|
|
133
|
+
}
|
|
134
|
+
// The session is listening but is behind on what it has already been
|
|
135
|
+
// offered, which is the same standing as route (a) turning the message
|
|
136
|
+
// away: it waits in the inbox and is offered again (§4.4).
|
|
137
|
+
this.deps.inbox.hold(to, message);
|
|
138
|
+
return { delivered: false, reason: "throttled" };
|
|
133
139
|
}
|
|
134
140
|
|
|
135
141
|
const { evicted } = this.deps.inbox.hold(to, message);
|
package/src/messaging/notify.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type {
|
|
|
11
11
|
Timestamp,
|
|
12
12
|
} from "@ccmsg/protocol";
|
|
13
13
|
import { type HandlerInput, OpError } from "../dispatch/index.ts";
|
|
14
|
-
import type { TopicValue, UpstreamResource } from "../topics/index.ts";
|
|
14
|
+
import type { PublishOutcome, TopicValue, UpstreamResource } from "../topics/index.ts";
|
|
15
15
|
|
|
16
16
|
/** The one topic a notification reaches a watcher on. */
|
|
17
17
|
const NOTIFY = "notify";
|
|
@@ -23,7 +23,7 @@ export interface NotifyDeps {
|
|
|
23
23
|
readonly label: (sid: Sid) => string;
|
|
24
24
|
/** The one way a value reaches subscribers (§6.1). No `to`: a notification is
|
|
25
25
|
* for whoever is watching, not for one session. */
|
|
26
|
-
readonly publish: (topic: string, data: unknown, instance: InstanceId) =>
|
|
26
|
+
readonly publish: (topic: string, data: unknown, instance: InstanceId) => PublishOutcome;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
/** The `notify` topic and the three ops that speak on it.
|
|
@@ -99,7 +99,16 @@ export class Notify implements UpstreamResource {
|
|
|
99
99
|
text,
|
|
100
100
|
sent_at: now,
|
|
101
101
|
};
|
|
102
|
-
|
|
102
|
+
// A notification is an occurrence, so nothing folds it away and a watcher
|
|
103
|
+
// that cannot keep up is what stops it. The caller hears that rather than
|
|
104
|
+
// the notification going nowhere: it is the one that decides whether to
|
|
105
|
+
// raise another (§6.4).
|
|
106
|
+
if (this.deps.publish(NOTIFY, notification, this.deps.self) === "rate_limited") {
|
|
107
|
+
throw new OpError(
|
|
108
|
+
"internal_error",
|
|
109
|
+
"a watcher is behind on this topic; the notification was not taken",
|
|
110
|
+
);
|
|
111
|
+
}
|
|
103
112
|
return now;
|
|
104
113
|
}
|
|
105
114
|
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type { Requester } from "../dispatch/index.ts";
|
|
2
|
+
|
|
3
|
+
/** How long one terminal's frames are gathered before they go out.
|
|
4
|
+
*
|
|
5
|
+
* The value bounds two things at once. Towards the reader: a person watching a
|
|
6
|
+
* list cannot see a change arrive sooner than the display draws it, so frames
|
|
7
|
+
* closer together than a few display frames are spent on nothing, while a wait
|
|
8
|
+
* long enough to be read as lag starts around a quarter of a second. Towards
|
|
9
|
+
* the cluster: a relayed frame waits once per hop, so the delay a subscriber
|
|
10
|
+
* sees is this value times the hops between it and the instance that produced
|
|
11
|
+
* the value — at 100 ms a two-hop cluster still answers inside the window a
|
|
12
|
+
* person reads as immediate, which a longer period would leave.
|
|
13
|
+
*
|
|
14
|
+
* It is not a poll. Nothing is looked at when the period elapses: the timer is
|
|
15
|
+
* armed only by a frame that has to wait, and an idle terminal has none. */
|
|
16
|
+
export const FLUSH_PERIOD_MS = 100;
|
|
17
|
+
|
|
18
|
+
/** How many frames that cannot be folded one terminal may hold at once.
|
|
19
|
+
*
|
|
20
|
+
* Folded frames need no bound — a topic that replaces its value keeps one entry
|
|
21
|
+
* however often it is stated — so this bounds the occurrences and the deltas,
|
|
22
|
+
* the frames that mean something twice if they arrive twice. At one flush every
|
|
23
|
+
* `FLUSH_PERIOD_MS` a terminal that keeps up drains this many every period, so
|
|
24
|
+
* reaching the limit means the producer has been outrunning the reader by more
|
|
25
|
+
* than 2500 frames a second for as long as the queue has stood: past anything a
|
|
26
|
+
* person, a session or a peer produces, and into the storm this layer exists
|
|
27
|
+
* for. What is over the limit is refused rather than dropped quietly, so the op
|
|
28
|
+
* that raised it is the one that hears about it. */
|
|
29
|
+
export const QUEUE_LIMIT = 256;
|
|
30
|
+
|
|
31
|
+
/** The two things the queue asks of time, so a test can hold both still.
|
|
32
|
+
*
|
|
33
|
+
* `schedule` answers with the way to cancel what it armed, because a terminal
|
|
34
|
+
* that goes away while a flush is pending has to leave nothing behind. */
|
|
35
|
+
export interface EgressClock {
|
|
36
|
+
now(): number;
|
|
37
|
+
schedule(afterMs: number, run: () => void): () => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const REAL_CLOCK: EgressClock = {
|
|
41
|
+
now: () => Date.now(),
|
|
42
|
+
schedule: (afterMs, run) => {
|
|
43
|
+
const timer = setTimeout(run, afterMs);
|
|
44
|
+
return () => {
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** What a caller may move: the period, the limit, and the clock both are read
|
|
51
|
+
* against. */
|
|
52
|
+
export interface EgressOptions {
|
|
53
|
+
readonly periodMs?: number;
|
|
54
|
+
readonly limit?: number;
|
|
55
|
+
readonly clock?: EgressClock;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface Pending {
|
|
59
|
+
/** The key this frame folds on, absent for one that does not fold. */
|
|
60
|
+
readonly fold?: string;
|
|
61
|
+
frame: object;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One terminal's outgoing frames, gathered and let go on a period.
|
|
65
|
+
*
|
|
66
|
+
* A terminal is a person's connection, a mesh peer or a CLI subscriber, and
|
|
67
|
+
* this is the same layer for all three: what differs between them is the socket
|
|
68
|
+
* underneath, not how fast a subscriber can be written to.
|
|
69
|
+
*
|
|
70
|
+
* Three things happen here, and the topic's own granularity decides which. A
|
|
71
|
+
* frame that replaces the value it carries folds onto the one already waiting
|
|
72
|
+
* under the same key, so a value stated a thousand times between two flushes
|
|
73
|
+
* leaves one frame and it is the latest — the reader is never handed a value
|
|
74
|
+
* that has already been superseded, and never misses the last one. A frame that
|
|
75
|
+
* is an occurrence or a delta cannot fold, so it queues in the order it was
|
|
76
|
+
* raised and the queue is bounded: past the bound the frame is refused, which is
|
|
77
|
+
* how the pressure reaches whoever is producing it instead of accumulating
|
|
78
|
+
* here. Both leave together on the flush, in the order they were queued.
|
|
79
|
+
*
|
|
80
|
+
* The first frame after a quiet spell goes out at once: the period bounds how
|
|
81
|
+
* often a flush happens, not how long a lone change waits. */
|
|
82
|
+
export class Egress {
|
|
83
|
+
#queue: Pending[] = [];
|
|
84
|
+
/** The waiting frame per fold key, so a restatement finds its own entry
|
|
85
|
+
* rather than being appended behind it. */
|
|
86
|
+
readonly #folded = new Map<string, Pending>();
|
|
87
|
+
/** How many waiting frames do not fold, which is what the limit counts. */
|
|
88
|
+
#kept = 0;
|
|
89
|
+
#lastFlush = Number.NEGATIVE_INFINITY;
|
|
90
|
+
#cancel: (() => void) | undefined;
|
|
91
|
+
|
|
92
|
+
readonly #periodMs: number;
|
|
93
|
+
readonly #limit: number;
|
|
94
|
+
readonly #clock: EgressClock;
|
|
95
|
+
|
|
96
|
+
constructor(
|
|
97
|
+
private readonly conn: Requester,
|
|
98
|
+
options: EgressOptions = {},
|
|
99
|
+
) {
|
|
100
|
+
this.#periodMs = options.periodMs ?? FLUSH_PERIOD_MS;
|
|
101
|
+
this.#limit = options.limit ?? QUEUE_LIMIT;
|
|
102
|
+
this.#clock = options.clock ?? REAL_CLOCK;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Take one frame for this terminal. `fold` is the key it replaces itself
|
|
106
|
+
* under, absent for a frame that has to be sent as often as it is raised.
|
|
107
|
+
*
|
|
108
|
+
* `false` is the queue refusing the frame: it is full of frames that cannot
|
|
109
|
+
* be folded, and the caller is the one that can answer for it. */
|
|
110
|
+
push(frame: object, fold?: string): boolean {
|
|
111
|
+
if (fold === undefined) {
|
|
112
|
+
if (this.#kept >= this.#limit) return false;
|
|
113
|
+
this.#kept += 1;
|
|
114
|
+
this.#queue.push({ frame });
|
|
115
|
+
} else {
|
|
116
|
+
const held = this.#folded.get(fold);
|
|
117
|
+
if (held !== undefined) {
|
|
118
|
+
// In place: the value moves, its position among the occurrences around
|
|
119
|
+
// it does not.
|
|
120
|
+
held.frame = frame;
|
|
121
|
+
this.#arm();
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
const entry: Pending = { fold, frame };
|
|
125
|
+
this.#queue.push(entry);
|
|
126
|
+
this.#folded.set(fold, entry);
|
|
127
|
+
}
|
|
128
|
+
this.#arm();
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Send everything waiting, in the order it was queued. */
|
|
133
|
+
flush(): void {
|
|
134
|
+
this.#cancel?.();
|
|
135
|
+
this.#cancel = undefined;
|
|
136
|
+
this.#lastFlush = this.#clock.now();
|
|
137
|
+
const queue = this.#queue;
|
|
138
|
+
this.#queue = [];
|
|
139
|
+
this.#folded.clear();
|
|
140
|
+
this.#kept = 0;
|
|
141
|
+
for (const entry of queue) this.conn.send(entry.frame);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The terminal is done with: what was queued goes out, and nothing armed
|
|
145
|
+
* outlives it. */
|
|
146
|
+
release(): void {
|
|
147
|
+
this.flush();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
#arm(): void {
|
|
151
|
+
if (this.#cancel !== undefined) return;
|
|
152
|
+
const wait = this.#periodMs - (this.#clock.now() - this.#lastFlush);
|
|
153
|
+
if (wait <= 0) {
|
|
154
|
+
this.flush();
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
this.#cancel = this.#clock.schedule(wait, () => {
|
|
158
|
+
this.#cancel = undefined;
|
|
159
|
+
this.flush();
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
package/src/topics/index.ts
CHANGED
package/src/topics/topics.ts
CHANGED
|
Binary file
|