@interactive-inc/flume 0.10.1 → 0.11.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.
- package/README.md +90 -30
- package/dist/discord.js +3 -3
- package/dist/flume-source.d.ts +5 -1
- package/dist/flume-source.js +39 -57
- package/dist/github.js +7 -9
- package/dist/index.d.ts +143 -37
- package/dist/index.js +183 -98
- package/dist/safe-json-parse.js +1 -1
- package/dist/safe-read-text.js +17 -3
- package/dist/safe-stringify.js +17 -153
- package/dist/schedule-reconnect.js +153 -0
- package/dist/slack.js +4 -5
- package/dist/time.d.ts +4 -2
- package/dist/time.js +17 -13
- package/package.json +11 -4
- package/dist/connection-error.js +0 -16
- package/dist/http-error.js +0 -16
package/README.md
CHANGED
|
@@ -48,6 +48,7 @@ if (running instanceof Error) throw running
|
|
|
48
48
|
|
|
49
49
|
// later
|
|
50
50
|
await running.close()
|
|
51
|
+
await running.drain() // outside callbacks: finish accepted callback work and diagnostics
|
|
51
52
|
```
|
|
52
53
|
|
|
53
54
|
`new Flume({ sources, ...options })` — a single options object; `sources` is the only required field, everything else is optional. There is one unified firehose: `onEvent` (push) and `FlumeRunning.stream()` (pull) both deliver the same `FlumeStreamItem` — received events **and** every log (status transitions, errors, debug) merged into one stream. The consumer filters by `item.kind` (`"event"` / `"log"`) and `item.log.level`. This is built for piping the whole picture into an agent (Claude / Codex) so it notices disconnects on its own. `onError` is a convenience filter that additionally receives only the `level: "error"` logs (route it straight to Sentry).
|
|
@@ -70,8 +71,10 @@ Flume ──open()──▶ FlumeRunning ──close()──▶ FlumeClosed
|
|
|
70
71
|
```
|
|
71
72
|
|
|
72
73
|
- `Flume.open()` returns `FlumeRunning | FlumeStartError`. Branch with `instanceof Error`. On partial failure (one source fails while another succeeds), the already-opened sources are rolled back and a `FlumeStartError` is returned with per-source detail in `.message`. Calling `open()` a second time on the same `Flume` instance returns `FlumeStartError` at runtime — the type system also rejects calling `open()` on the returned `FlumeRunning`/`FlumeClosed` handles.
|
|
73
|
-
- `FlumeRunning.close()` returns a `FlumeClosed` snapshot.
|
|
74
|
-
- `
|
|
74
|
+
- `FlumeRunning.close()` waits for sources to stop and returns a `FlumeClosed` snapshot. It is idempotent and concurrent-safe, and can be awaited inside `onEvent` or `onError`.
|
|
75
|
+
- `FlumeRunning.drain()` waits for accepted callbacks and their failure diagnostics. Call it **outside callbacks**, normally after `close()`, when shutdown must also finish callback work. Calling it inside a callback would wait for that callback itself.
|
|
76
|
+
- `open()` and `close()` do not wait for observation callbacks. A slow callback cannot hold startup or source shutdown open. Callback delivery remains serial; accepted callbacks may finish after `close()` returns.
|
|
77
|
+
- `FlumeClosed` exposes `statuses()` and `errors()` — frozen snapshots of final source states and disconnect failures. No `open`, no `close`, no leaking source references.
|
|
75
78
|
- An `AbortSignal` on `Flume` drives an automatic transition to `FlumeClosed`.
|
|
76
79
|
- `FlumeRunning.kind === "running"` and `FlumeClosed.kind === "closed"` provide a runtime discriminator when generic code holds the union.
|
|
77
80
|
|
|
@@ -89,8 +92,11 @@ const closed = await running.close()
|
|
|
89
92
|
closed.close() // type error
|
|
90
93
|
closed.open() // type error
|
|
91
94
|
closed.statuses() // [{ source: "discord", status: "disconnected" }, ...]
|
|
95
|
+
await running.drain() // finish callback delivery before exiting the host process
|
|
92
96
|
```
|
|
93
97
|
|
|
98
|
+
Migration from 0.10.1: shutdown code that relied on `close()` waiting for `onEvent` / `onError` should now use `await running.close(); await running.drain()` outside those callbacks. A callback may use `await running.close()` on its own. Pull streams finish after the accepted callbacks and their failure diagnostics have drained. A callback that never settles can therefore keep `drain()` and stream completion pending, while source shutdown still completes.
|
|
99
|
+
|
|
94
100
|
## Dynamic groups
|
|
95
101
|
|
|
96
102
|
A `Flume` is single-use: its source set is fixed at construction and the FSM is terminal once closed. To add or drop sources at runtime, compose at a higher level with `FlumeConfluence` — it holds many `Flume` instances, merges all their firehoses into one `onEvent`, and lets you `add` / `remove` groups while the others keep running.
|
|
@@ -100,7 +106,7 @@ import { FlumeConfluence } from "@interactive-inc/flume"
|
|
|
100
106
|
|
|
101
107
|
const confluence = new FlumeConfluence({
|
|
102
108
|
onEvent: (item) => {
|
|
103
|
-
if (item.kind === "event") feedToAgent(item.event)
|
|
109
|
+
if (item.kind === "event") feedToAgent(item.groupId, item.event)
|
|
104
110
|
if (item.kind === "log" && item.log.action === "status") noticeDisconnect(item.log)
|
|
105
111
|
},
|
|
106
112
|
reconnect: { maxAttempts: 10 },
|
|
@@ -112,7 +118,9 @@ await confluence.remove("team-a") // stops only team-a
|
|
|
112
118
|
await confluence.closeAll()
|
|
113
119
|
```
|
|
114
120
|
|
|
115
|
-
`add(id, sources)` starts a fresh `Flume` for that group and returns `Error | null` (a duplicate id or a failed start is returned, never thrown).
|
|
121
|
+
`add(id, sources)` starts a fresh `Flume` for that group and returns `Error | null` (a duplicate id or a failed start is returned, never thrown). Every merged item is stamped with that id as `groupId`, while `item.event.source` / `item.log.source` identifies the protocol source inside the group. Each group is an independent `Flume`, so a failure in one group never rolls back another. `replace(id, sources)` opens a replacement first and closes the previous group only after the new one is ready; a failed replacement leaves the previous group running.
|
|
122
|
+
|
|
123
|
+
Source instances are single-use. Reusing an existing instance returns a `FlumeStartError` and preserves its current owner's connection. `remove()` / `closeAll()` wait for source shutdown; callbacks already accepted by those groups may finish afterward.
|
|
116
124
|
|
|
117
125
|
## Sub-entries
|
|
118
126
|
|
|
@@ -126,24 +134,32 @@ import { FlumeGitHubSource } from "@interactive-inc/flume/github"
|
|
|
126
134
|
import { FlumeTimeSource } from "@interactive-inc/flume/time"
|
|
127
135
|
```
|
|
128
136
|
|
|
129
|
-
- `@interactive-inc/flume` — `Flume`, `FlumeConfluence`, `FlumeRunning`, `FlumeClosed`, `FlumeSource` (abstract base for third-party sources), `createFlumeDefaultDeps`, errors, types
|
|
137
|
+
- `@interactive-inc/flume` — `Flume`, `FlumeConfluence`, `FlumeRunning`, `FlumeClosed`, `FlumeSource` (abstract base for third-party sources), `createFlumeDefaultDeps`, errors, types, and the common `attempt` / `safe*` utilities used by custom sources
|
|
130
138
|
- `@interactive-inc/flume/discord` — `FlumeDiscordSource`, `FlumeDiscordGatewayIntents`, `flumeExtractDiscordMeta`
|
|
131
139
|
- `@interactive-inc/flume/slack` — `FlumeSlackSource`, `flumeExtractSlackMeta`
|
|
132
140
|
- `@interactive-inc/flume/github` — `FlumeGitHubSource`, `flumeExtractGitHubMeta`
|
|
133
|
-
- `@interactive-inc/flume/time` — `FlumeTimeSource`, `parseCron`, `flumeCronNext`
|
|
141
|
+
- `@interactive-inc/flume/time` — `FlumeTimeSource`, `parseCron`, `flumeCronNext`, `flumeCollectCatchupMatches`
|
|
134
142
|
|
|
135
143
|
## Custom sources
|
|
136
144
|
|
|
137
145
|
Extend `FlumeSource` to plug in any protocol. The base class owns `start`/`stop`/`status`, the per-source event queue, status emission (logged on every transition), and consumed/stopped guards. You implement `connect` (open the protocol, emit events, set status) and `disconnect` (tear it down).
|
|
138
146
|
|
|
139
147
|
```ts
|
|
140
|
-
import {
|
|
141
|
-
|
|
148
|
+
import {
|
|
149
|
+
attempt,
|
|
150
|
+
FlumeSource,
|
|
151
|
+
safeErrorMessage,
|
|
152
|
+
safeInvokeCallback,
|
|
153
|
+
safeJsonParse,
|
|
154
|
+
safeNow,
|
|
155
|
+
safeReadText,
|
|
156
|
+
} from "@interactive-inc/flume"
|
|
157
|
+
import type { FlumeSourceStartContext, FlumeTimerHandle } from "@interactive-inc/flume"
|
|
142
158
|
|
|
143
159
|
class MyWebhookSource extends FlumeSource {
|
|
144
160
|
readonly name = "my-webhook"
|
|
145
161
|
|
|
146
|
-
private timer:
|
|
162
|
+
private timer: FlumeTimerHandle | null = null
|
|
147
163
|
|
|
148
164
|
constructor(private readonly options: { url: string; pollInterval?: number }) {
|
|
149
165
|
super()
|
|
@@ -152,34 +168,73 @@ class MyWebhookSource extends FlumeSource {
|
|
|
152
168
|
protected async connect(ctx: FlumeSourceStartContext): Promise<Error | null> {
|
|
153
169
|
this.setStatus("connecting")
|
|
154
170
|
const interval = (this.options.pollInterval ?? 30) * 1000
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
171
|
+
const timer = attempt(() => ctx.deps.setInterval(() => this.runPoll(ctx), interval))
|
|
172
|
+
if (timer instanceof Error) return timer
|
|
173
|
+
this.timer = timer
|
|
158
174
|
this.setStatus("connected")
|
|
159
175
|
return null
|
|
160
176
|
}
|
|
161
177
|
|
|
162
178
|
protected disconnect(): void {
|
|
163
|
-
|
|
179
|
+
const timer = this.timer
|
|
164
180
|
this.timer = null
|
|
181
|
+
const ctx = this.context
|
|
182
|
+
if (timer === null || ctx === null) return
|
|
183
|
+
|
|
184
|
+
const result = attempt(() => ctx.deps.clearInterval(timer))
|
|
185
|
+
if (result instanceof Error) {
|
|
186
|
+
ctx.log.error({
|
|
187
|
+
action: "timer.clear.error",
|
|
188
|
+
message: safeErrorMessage({ error: result }),
|
|
189
|
+
error: result,
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private runPoll(ctx: FlumeSourceStartContext): void {
|
|
195
|
+
safeInvokeCallback({
|
|
196
|
+
fn: async () => {
|
|
197
|
+
const result = await this.poll(ctx)
|
|
198
|
+
if (result instanceof Error) {
|
|
199
|
+
ctx.log.error({
|
|
200
|
+
action: "poll.error",
|
|
201
|
+
message: safeErrorMessage({ error: result }),
|
|
202
|
+
error: result,
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
onError: (error) => {
|
|
207
|
+
ctx.log.error({
|
|
208
|
+
action: "poll.unhandled",
|
|
209
|
+
message: safeErrorMessage({ error }),
|
|
210
|
+
error,
|
|
211
|
+
})
|
|
212
|
+
},
|
|
213
|
+
})
|
|
165
214
|
}
|
|
166
215
|
|
|
167
|
-
private async poll(ctx: FlumeSourceStartContext): Promise<
|
|
168
|
-
const
|
|
169
|
-
|
|
216
|
+
private async poll(ctx: FlumeSourceStartContext): Promise<Error | null> {
|
|
217
|
+
const response = await attempt(() => ctx.deps.fetch(this.options.url))
|
|
218
|
+
if (response instanceof Error) return response
|
|
219
|
+
const raw = await safeReadText({ response, context: this.name })
|
|
220
|
+
if (raw instanceof Error) return raw
|
|
221
|
+
const payload = safeJsonParse(raw)
|
|
222
|
+
if (payload instanceof Error) return payload
|
|
223
|
+
|
|
170
224
|
this.emit({
|
|
171
225
|
source: "custom",
|
|
172
226
|
sourceName: this.name,
|
|
173
227
|
type: "webhook",
|
|
174
228
|
data: { payload },
|
|
175
229
|
meta: { event_type: "webhook" },
|
|
176
|
-
receivedAt: ctx.deps
|
|
230
|
+
receivedAt: safeNow({ deps: ctx.deps }),
|
|
177
231
|
})
|
|
232
|
+
return null
|
|
178
233
|
}
|
|
179
234
|
}
|
|
180
235
|
```
|
|
181
236
|
|
|
182
|
-
`this.emit({...})` queues events through the base's serial queue and routes them to `ctx.onEvent` with `attempt()` isolation. `this.setStatus(...)` deduplicates idempotent transitions and logs the change (
|
|
237
|
+
`this.emit({...})` queues events through the base's serial queue and routes them to `ctx.onEvent` with `attempt()` isolation. `this.setStatus(...)` deduplicates idempotent transitions and logs the change. IO started after `connect()` returns, such as timer callbacks, must use `attempt()` or `safeInvokeCallback()` as shown above; both are exported from the root entry so custom sources do not need raw try/catch.
|
|
183
238
|
|
|
184
239
|
## Time source
|
|
185
240
|
|
|
@@ -211,6 +266,8 @@ new FlumeTimeSource({
|
|
|
211
266
|
|
|
212
267
|
`tick.firedAt` is the scheduled wall-clock time (epoch ms), not the exact `setTimeout` firing instant. A throwing `message()` is isolated and falls back to the defaults. Parse the cron up front with `parseCron(expr)` (returns `FlumeCron | FlumeParseError`) or compute the next fire with `flumeCronNext(cron, afterMs)`.
|
|
213
268
|
|
|
269
|
+
With `statePersister`, state writes are serialized in tick order and source shutdown waits for queued writes to settle. A failed write is logged and later writes still run. Keep the persister's IO bounded and avoid waiting for Flume lifecycle completion inside it. Stopping during `load()` releases startup immediately and ignores a late result; cancellation of the host's underlying read remains the persister's responsibility. Use `catchupPolicy` (`off`, `lastOnly`, or `missed`) to control replay on the next start. When multiple sources share a persistence key, the host must also serialize or atomically order their writes.
|
|
270
|
+
|
|
214
271
|
## Pull stream
|
|
215
272
|
|
|
216
273
|
`FlumeRunning.stream()` is the pull form of the same firehose as `onEvent` — an async iterator over `FlumeStreamItem`, handy for feeding an agent (Claude / Codex) with `for await`, where backpressure falls out naturally from how fast you pull.
|
|
@@ -225,10 +282,10 @@ for await (const item of running.stream()) {
|
|
|
225
282
|
if (item.kind === "event") await handleWithAgent(item.event)
|
|
226
283
|
if (item.kind === "log" && item.log.action === "status") noticeDisconnect(item.log)
|
|
227
284
|
}
|
|
228
|
-
//
|
|
285
|
+
// after close / signal abort, the loop ends once accepted callback diagnostics are delivered
|
|
229
286
|
```
|
|
230
287
|
|
|
231
|
-
The iterator ends cleanly
|
|
288
|
+
The iterator ends cleanly after source shutdown and accepted callback work; `break`ing out unsubscribes automatically. When a slow consumer lets the buffer overflow, the oldest items are dropped by default:
|
|
232
289
|
|
|
233
290
|
```ts
|
|
234
291
|
running.stream({ buffer: 5000, onOverflow: "drop-newest" })
|
|
@@ -284,7 +341,7 @@ type FlumeEvent = FlumeDiscordEvent | FlumeSlackEvent | FlumeGitHubEvent | Flume
|
|
|
284
341
|
|
|
285
342
|
## Observability
|
|
286
343
|
|
|
287
|
-
Flume never calls a third-party service. Every internal action is reported through the firehose (`onEvent` push / `stream()` pull) as `{ kind: "log", log }` items — there are no silent paths. Each `FlumeLog` is tagged with `source: "flume"`, `source: "discord"`, `source: "slack"`, `source: "github"`, `source: "time"`, or your custom source's `name`. `onError` is a convenience filter that additionally receives only the `level: "error"` subset (route it straight to Sentry).
|
|
344
|
+
Flume never calls a third-party service. Every internal action is reported through the firehose (`onEvent` push / `stream()` pull) as `{ kind: "log", log }` items — there are no silent internal paths. Each `FlumeLog` is tagged with `source: "flume"`, `source: "discord"`, `source: "slack"`, `source: "github"`, `source: "time"`, or your custom source's `name`. `onError` is a convenience filter that additionally receives only the `level: "error"` subset (route it straight to Sentry).
|
|
288
345
|
|
|
289
346
|
```ts
|
|
290
347
|
type FlumeLog = {
|
|
@@ -307,6 +364,9 @@ What gets logged:
|
|
|
307
364
|
- Reconnect — `reconnect.scheduled` (with delay in ms), `reconnect.exhausted` (with attempt count), `reconnect.reset` (on successful connect after retries), `reconnect.cancel` (on stop).
|
|
308
365
|
- Status transitions — `status` action with `previous → next`.
|
|
309
366
|
- Errors — `level: "error"` carries the `error` field; use `onError` for a pre-filtered error sink.
|
|
367
|
+
- Observation sink failures — a failed `onEvent` emits `onEvent.error` to `onError` and the pull stream; a failed `onError` emits `onError.error` to `onEvent` and the pull stream. `detail` identifies the callback plus the failed item's kind, source, and event type or log action. `FlumeConfluence` also stamps `groupId` onto logs sent to its `onError`. The peer notification is attempted once, without recursive reporting.
|
|
368
|
+
|
|
369
|
+
For maximum diagnostics, configure both callbacks or keep a pull stream consumer. If both push callbacks fail, both failure logs remain available to the pull stream; startup logs are replayed to its first subscriber. `FlumeConfluence` has no pull iterator, so configure its `onError` as the independent diagnostic sink.
|
|
310
370
|
|
|
311
371
|
Route it anywhere — Sentry, Datadog, `console`, a file — your choice. Filter the log items out of the firehose:
|
|
312
372
|
|
|
@@ -396,9 +456,9 @@ new Flume({
|
|
|
396
456
|
|
|
397
457
|
## Safety
|
|
398
458
|
|
|
399
|
-
- Ordering — each source has its own `FlumeSerialQueue` and per-source events are
|
|
459
|
+
- Ordering — each source has its own `FlumeSerialQueue` and per-source events are published FIFO. The public `onEvent` and `onError` callbacks share one global queue, so callback invocations never race across sources. `close()` stops the sources; a subsequent `drain()` waits for callbacks and their failure diagnostics. Pull subscribers receive items when they are published and have independent bounded buffers. The push callback queue is unbounded; use pull-only consumption for a bounded slow consumer.
|
|
400
460
|
- Duplicate suppression — Slack envelopes are deduped by `envelope_id` (`FlumeSlackSeenCache`) to absorb ack retries. GitHub notifications are deduped by `id + updated_at` (`FlumeGitHubSeenCache`). Discord uses session resume so the Gateway does not re-emit dispatches.
|
|
401
|
-
- Partial-failure rollback — if any source fails during `Flume.open()`,
|
|
461
|
+
- Partial-failure rollback — if any source fails during `Flume.open()`, sources acquired by that open are stopped, including partially connected failures. A source that refused reuse remains with its existing owner. A `FlumeStartError` is returned with per-source detail.
|
|
402
462
|
- Idempotent close — `FlumeRunning.close()` is safe to call concurrently; the first call wins and subsequent callers receive the same `FlumeClosed` snapshot. The same guard exists at source level: a double-`close()` (e.g. via signal abort racing a manual close) does not re-invoke `disconnect()`.
|
|
403
463
|
|
|
404
464
|
## Errors
|
|
@@ -410,9 +470,9 @@ Flume does not throw on protocol/network failures. Every entry point returns `T
|
|
|
410
470
|
- `FlumeHttpError` — HTTP call returned an error payload (e.g. Slack `ok: false`)
|
|
411
471
|
- `FlumeParseError` — Unparseable WebSocket frame
|
|
412
472
|
|
|
413
|
-
Exceptions thrown from `onEvent` are
|
|
473
|
+
Exceptions thrown or rejected from `onEvent` / `onError` are isolated and emitted as `onEvent.error` / `onError.error` diagnostics; they are never rethrown into the protocol loop.
|
|
414
474
|
|
|
415
|
-
|
|
475
|
+
With type-valid constructor input, the library guarantees that protocol IO and user callback failures do not escape `open()`, `close()`, the `onEvent` invocation path, the `stream()` iterator, the abort-signal path, or the `onError` callback. These boundaries route work through internal `safe*` wrappers and the generic `attempt()` helper.
|
|
416
476
|
|
|
417
477
|
## Supported sources
|
|
418
478
|
|
|
@@ -442,11 +502,11 @@ const github = new FlumeGitHubSource({ token })
|
|
|
442
502
|
|
|
443
503
|
```bash
|
|
444
504
|
bun install
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
505
|
+
vp pack # build dist/
|
|
506
|
+
vp run typecheck # typecheck
|
|
507
|
+
vp test # tests
|
|
508
|
+
vp lint # lint
|
|
509
|
+
vp fmt # format
|
|
450
510
|
```
|
|
451
511
|
|
|
452
512
|
The library itself is runtime-agnostic. The dev toolchain (build / test / lint) uses Bun + `vite-plus` + `vitest`, but the published `dist/` is plain ESM with TypeScript declarations and runs on Node 22+, Bun, Deno, Cloudflare Workers, or modern browsers. Runtimes without a global `WebSocket` (e.g. fetch-only edge functions) can still use `FlumeGitHubSource`; `FlumeDiscordSource` / `FlumeSlackSource` will return `FlumeStartError` at `open()` time if `deps.WebSocket` is `null`.
|
package/dist/discord.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { a as
|
|
2
|
-
import {
|
|
3
|
-
import { i as safeRandom, n as scheduleFlumeReconnect, r as FlumeReconnector, t as safeStringify } from "./safe-stringify.js";
|
|
1
|
+
import { a as safeInvokeCallback, c as FlumeParseError, d as safeErrorMessage, i as safeNow, l as attempt, r as FlumeLogger, s as FlumeStartError, t as FlumeSource, u as safeNormalizeError } from "./flume-source.js";
|
|
2
|
+
import { n as safeRandom, r as FlumeConnectionError, t as safeStringify } from "./safe-stringify.js";
|
|
4
3
|
import { t as isRecord } from "./is-record.js";
|
|
5
4
|
import { t as safeJsonParse } from "./safe-json-parse.js";
|
|
5
|
+
import { n as FlumeReconnector, t as scheduleFlumeReconnect } from "./schedule-reconnect.js";
|
|
6
6
|
import { z } from "zod/v4";
|
|
7
7
|
//#region lib/discord/discord-gateway-session.ts
|
|
8
8
|
var FlumeDiscordGatewaySession = class FlumeDiscordGatewaySession {
|
package/dist/flume-source.d.ts
CHANGED
|
@@ -244,7 +244,9 @@ type FlumeTimeMessage = {
|
|
|
244
244
|
/**
|
|
245
245
|
* 起動 / 終了をまたいだ状態を 1 つ載せる純粋な DI ポート。flume 内部で fs / db / network を
|
|
246
246
|
* 触らないように、I/O の場所と方式は host が決める。load の失敗は null 復帰扱い、save の
|
|
247
|
-
* 失敗は best-effort (source 側で log するが throw しない)
|
|
247
|
+
* 失敗は best-effort (source 側で log するが throw しない)。Time source は save を直列実行し、
|
|
248
|
+
* stop 時に queued save の完了を待つ。persister 内から Flume の終了を await しない。
|
|
249
|
+
* stop は load の待機を解除するが、host 側で開始した read IO 自体は中断しない。
|
|
248
250
|
*/
|
|
249
251
|
type FlumeStatePersister<S> = {
|
|
250
252
|
load(): Promise<S | null>;
|
|
@@ -342,6 +344,8 @@ declare abstract class FlumeSource {
|
|
|
342
344
|
* subclass が protocol 状態遷移をユーザーに通知する。同一 (status, detail) の連続は冪等
|
|
343
345
|
*/
|
|
344
346
|
protected setStatus(status: FlumeStatus, detail?: string): void;
|
|
347
|
+
/** await をまたぐ接続処理が、停止後に新しいリソースを作らないための guard。 */
|
|
348
|
+
protected get isStopped(): boolean;
|
|
345
349
|
/** subclass が現在の status を読みたい場合 */
|
|
346
350
|
protected get currentStatus(): FlumeStatus;
|
|
347
351
|
/** subclass が start ctx を再参照したい場合 (stop 後は null) */
|
package/dist/flume-source.js
CHANGED
|
@@ -82,6 +82,30 @@ var FlumeStartError = class extends Error {
|
|
|
82
82
|
}
|
|
83
83
|
};
|
|
84
84
|
//#endregion
|
|
85
|
+
//#region lib/errors/source-reuse-error.ts
|
|
86
|
+
/** 起動を取得する前の拒否。Flume は他の起動処理が所有する Source を rollback しない。 */
|
|
87
|
+
var FlumeSourceReuseError = class extends FlumeStartError {};
|
|
88
|
+
//#endregion
|
|
89
|
+
//#region lib/utils/safe-invoke-callback.ts
|
|
90
|
+
/**
|
|
91
|
+
* fire-and-forget でユーザーコールバックを呼び出す。sync throw と async reject のどちらも
|
|
92
|
+
* `onError(Error)` に正規化して通知。`onError` 自身が throw しても外に漏らさない。
|
|
93
|
+
* 戻り値を持たない fire-and-forget 専用のため log/出力先には依存しない (caller が onError で決める)
|
|
94
|
+
*/
|
|
95
|
+
function safeInvokeCallback(props) {
|
|
96
|
+
try {
|
|
97
|
+
Promise.resolve(props.fn()).catch((err) => {
|
|
98
|
+
try {
|
|
99
|
+
props.onError(safeNormalizeError({ value: err }));
|
|
100
|
+
} catch {}
|
|
101
|
+
}).catch(() => {});
|
|
102
|
+
} catch (err) {
|
|
103
|
+
try {
|
|
104
|
+
props.onError(safeNormalizeError({ value: err }));
|
|
105
|
+
} catch {}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
//#endregion
|
|
85
109
|
//#region lib/utils/safe-now.ts
|
|
86
110
|
/**
|
|
87
111
|
* `deps.now()` を保護する。throw / 非数値 / 非有限値が返った場合は `Date.now()` へ
|
|
@@ -145,64 +169,26 @@ var FlumeLogger = class FlumeLogger {
|
|
|
145
169
|
error: input.error,
|
|
146
170
|
detail: input.detail
|
|
147
171
|
};
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
172
|
+
safeInvokeCallback({
|
|
173
|
+
fn: () => handler(log),
|
|
174
|
+
onError: () => {}
|
|
175
|
+
});
|
|
151
176
|
}
|
|
152
177
|
};
|
|
153
178
|
//#endregion
|
|
154
|
-
//#region lib/utils/safe-invoke-callback.ts
|
|
155
|
-
/**
|
|
156
|
-
* fire-and-forget でユーザーコールバックを呼び出す。sync throw と async reject のどちらも
|
|
157
|
-
* `onError(Error)` に正規化して通知。`onError` 自身が throw しても外に漏らさない。
|
|
158
|
-
* 戻り値を持たない fire-and-forget 専用のため log/出力先には依存しない (caller が onError で決める)
|
|
159
|
-
*/
|
|
160
|
-
function safeInvokeCallback(props) {
|
|
161
|
-
try {
|
|
162
|
-
Promise.resolve(props.fn()).catch((err) => {
|
|
163
|
-
try {
|
|
164
|
-
props.onError(safeNormalizeError({ value: err }));
|
|
165
|
-
} catch {}
|
|
166
|
-
}).catch(() => {});
|
|
167
|
-
} catch (err) {
|
|
168
|
-
try {
|
|
169
|
-
props.onError(safeNormalizeError({ value: err }));
|
|
170
|
-
} catch {}
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
//#endregion
|
|
174
179
|
//#region lib/utils/serial-queue.ts
|
|
175
180
|
/**
|
|
176
181
|
* 投入順を保ったまま task を直列実行する。各 task は前の完了を待ってから走る。
|
|
177
182
|
* task が throw しても後続には伝播しない (キュー自体は止まらない)。
|
|
178
|
-
* maxDepth を超えた場合は新規 task を drop し onOverflow に通知。
|
|
179
|
-
* cancel() 後は add() が no-op になり、既に積まれた未実行 task も実行せずに流れ落ちる。
|
|
180
183
|
* drain() は待機中に追加された task も含めてキューが空になるまで待つ
|
|
181
184
|
*/
|
|
182
185
|
var FlumeSerialQueue = class {
|
|
183
|
-
props;
|
|
184
186
|
chain = Promise.resolve();
|
|
185
|
-
depth = 0;
|
|
186
|
-
cancelled = false;
|
|
187
|
-
constructor(props = {}) {
|
|
188
|
-
this.props = props;
|
|
189
|
-
}
|
|
190
187
|
add(task) {
|
|
191
|
-
if (this.cancelled) return Promise.resolve();
|
|
192
|
-
if (this.props.maxDepth !== void 0 && this.depth >= this.props.maxDepth) {
|
|
193
|
-
this.props.onOverflow?.({
|
|
194
|
-
dropped: 1,
|
|
195
|
-
depth: this.depth
|
|
196
|
-
});
|
|
197
|
-
return Promise.resolve();
|
|
198
|
-
}
|
|
199
|
-
this.depth++;
|
|
200
188
|
const completion = this.chain.then(async () => {
|
|
201
189
|
try {
|
|
202
|
-
|
|
203
|
-
} catch {}
|
|
204
|
-
this.depth--;
|
|
205
|
-
}
|
|
190
|
+
await task();
|
|
191
|
+
} catch {}
|
|
206
192
|
});
|
|
207
193
|
this.chain = completion;
|
|
208
194
|
return completion;
|
|
@@ -214,15 +200,6 @@ var FlumeSerialQueue = class {
|
|
|
214
200
|
if (this.chain === current) return;
|
|
215
201
|
}
|
|
216
202
|
}
|
|
217
|
-
cancel() {
|
|
218
|
-
this.cancelled = true;
|
|
219
|
-
}
|
|
220
|
-
size() {
|
|
221
|
-
return this.depth;
|
|
222
|
-
}
|
|
223
|
-
isCancelled() {
|
|
224
|
-
return this.cancelled;
|
|
225
|
-
}
|
|
226
203
|
};
|
|
227
204
|
//#endregion
|
|
228
205
|
//#region lib/source-helpers/flume-status-emitter.ts
|
|
@@ -312,8 +289,8 @@ var FlumeSource = class {
|
|
|
312
289
|
abortHandler = null;
|
|
313
290
|
queue = new FlumeSerialQueue();
|
|
314
291
|
async start(ctx) {
|
|
315
|
-
if (this.consumed) return new
|
|
316
|
-
if (this.stopped) return new
|
|
292
|
+
if (this.consumed) return new FlumeSourceReuseError("Source already started");
|
|
293
|
+
if (this.stopped) return new FlumeSourceReuseError("Source already stopped");
|
|
317
294
|
this.consumed = true;
|
|
318
295
|
this.ctx = ctx;
|
|
319
296
|
this.statusEmitter = new FlumeStatusEmitter({
|
|
@@ -355,7 +332,7 @@ var FlumeSource = class {
|
|
|
355
332
|
*/
|
|
356
333
|
emit(event) {
|
|
357
334
|
const ctx = this.ctx;
|
|
358
|
-
if (!ctx) return;
|
|
335
|
+
if (!ctx || this.stopped) return;
|
|
359
336
|
this.queue.add(async () => {
|
|
360
337
|
const result = await attempt(() => Promise.resolve(ctx.onEvent(event)));
|
|
361
338
|
if (result instanceof Error) ctx.log.error({
|
|
@@ -369,8 +346,13 @@ var FlumeSource = class {
|
|
|
369
346
|
* subclass が protocol 状態遷移をユーザーに通知する。同一 (status, detail) の連続は冪等
|
|
370
347
|
*/
|
|
371
348
|
setStatus(status, detail) {
|
|
349
|
+
if (this.stopped) return;
|
|
372
350
|
this.statusEmitter?.set(status, detail);
|
|
373
351
|
}
|
|
352
|
+
/** await をまたぐ接続処理が、停止後に新しいリソースを作らないための guard。 */
|
|
353
|
+
get isStopped() {
|
|
354
|
+
return this.stopped;
|
|
355
|
+
}
|
|
374
356
|
/** subclass が現在の status を読みたい場合 */
|
|
375
357
|
get currentStatus() {
|
|
376
358
|
return this.statusEmitter?.value ?? "disconnected";
|
|
@@ -414,4 +396,4 @@ var FlumeSource = class {
|
|
|
414
396
|
}
|
|
415
397
|
};
|
|
416
398
|
//#endregion
|
|
417
|
-
export {
|
|
399
|
+
export { safeInvokeCallback as a, FlumeParseError as c, safeErrorMessage as d, safeNow as i, attempt as l, FlumeSerialQueue as n, FlumeSourceReuseError as o, FlumeLogger as r, FlumeStartError as s, FlumeSource as t, safeNormalizeError as u };
|
package/dist/github.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { c as FlumeParseError, d as safeErrorMessage, i as safeNow, l as attempt, r as FlumeLogger, t as FlumeSource, u as safeNormalizeError } from "./flume-source.js";
|
|
2
|
+
import { n as FlumeHttpError, t as safeReadText } from "./safe-read-text.js";
|
|
3
3
|
import { t as safeJsonParse } from "./safe-json-parse.js";
|
|
4
|
-
import { t as safeReadText } from "./safe-read-text.js";
|
|
5
4
|
import { z } from "zod/v4";
|
|
6
5
|
//#region lib/github/extract-github-meta.ts
|
|
7
6
|
function flumeExtractGitHubMeta(notification) {
|
|
@@ -161,10 +160,10 @@ var FlumeGitHubPoller = class {
|
|
|
161
160
|
async poll() {
|
|
162
161
|
if (this.inFlight || this.isStoppedFlag) return null;
|
|
163
162
|
this.inFlight = true;
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const cause = safeNormalizeError({ value:
|
|
163
|
+
const attempted = await attempt(async () => ({ result: await this.pollOnce() }));
|
|
164
|
+
this.inFlight = false;
|
|
165
|
+
if (attempted instanceof Error) {
|
|
166
|
+
const cause = safeNormalizeError({ value: attempted });
|
|
168
167
|
const error = new FlumeHttpError({
|
|
169
168
|
message: `poll loop threw: ${safeErrorMessage({ error: cause })}`,
|
|
170
169
|
status: 0,
|
|
@@ -177,9 +176,8 @@ var FlumeGitHubPoller = class {
|
|
|
177
176
|
});
|
|
178
177
|
if (!this.bootstrapped) return error;
|
|
179
178
|
return null;
|
|
180
|
-
} finally {
|
|
181
|
-
this.inFlight = false;
|
|
182
179
|
}
|
|
180
|
+
return attempted.result;
|
|
183
181
|
}
|
|
184
182
|
async pollOnce() {
|
|
185
183
|
const params = new URLSearchParams({
|