@wringer/stream 0.0.0-stage → 0.0.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/LICENSE +21 -0
- package/README.md +75 -2
- package/dist/append-whole-text.d.ts +1 -0
- package/dist/build-file-step.d.ts +16 -0
- package/dist/build-stream-slice.d.ts +12 -0
- package/dist/collect-lines.d.ts +5 -0
- package/dist/index.d.ts +5 -0
- package/dist/pick-command-stop-delay.d.ts +6 -0
- package/dist/pick-file-read.d.ts +10 -0
- package/dist/read-file-from.d.ts +11 -0
- package/dist/read-stream-slice.d.ts +8 -0
- package/dist/record-writer-machine.d.ts +65 -0
- package/dist/source-phase-machine.d.ts +21 -0
- package/dist/start-stream-recorder.d.ts +39 -0
- package/dist/stream.d.ts +29 -0
- package/dist/types.d.ts +2 -0
- package/dist/wait-or-abort.d.ts +1 -0
- package/package.json +47 -4
- package/src/append-whole-text.ts +30 -0
- package/src/build-file-step.ts +39 -0
- package/src/build-stream-slice.ts +34 -0
- package/src/collect-lines.ts +13 -0
- package/src/index.ts +5 -0
- package/src/pick-command-stop-delay.ts +23 -0
- package/src/pick-file-read.ts +22 -0
- package/src/read-file-from.ts +54 -0
- package/src/read-stream-slice.ts +84 -0
- package/src/record-writer-machine.ts +185 -0
- package/src/source-phase-machine.ts +132 -0
- package/src/start-stream-recorder.ts +354 -0
- package/src/stream.ts +175 -0
- package/src/types.ts +4 -0
- package/src/wait-or-abort.ts +7 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Geoff Whatley
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,76 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
|
+
<h1>@wringer/stream</h1>
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
<p>
|
|
5
|
+
<a href="https://www.npmjs.com/package/@wringer/stream"><img src="https://img.shields.io/npm/v/@wringer/stream" alt="npm version"></a>
|
|
6
|
+
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
|
|
7
|
+
</p>
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
The stream driver for wringer: it records a target's logs for the whole run, and reads them back
|
|
11
|
+
from a cursor.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bun add @wringer/core @wringer/stream
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The package runs on [Bun](https://bun.sh) 1.3 or newer.
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { createWringer, ssh } from '@wringer/core';
|
|
25
|
+
import { stream } from '@wringer/stream';
|
|
26
|
+
|
|
27
|
+
export const registry = createWringer({ name: 'shop' })
|
|
28
|
+
.use(stream({}))
|
|
29
|
+
.target('staging', {
|
|
30
|
+
mode: 'attach',
|
|
31
|
+
access: 'read-only',
|
|
32
|
+
host: ssh({ destination: 'deploy@staging.shop.example' }),
|
|
33
|
+
drivers: {
|
|
34
|
+
stream: () => ({
|
|
35
|
+
sources: { jobs: { command: ['journalctl', '-f', '-u', 'shop-jobs', '-o', 'cat'] } },
|
|
36
|
+
}),
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
.build();
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
An action calls `ctx.drivers.stream.read({ name, from })`. The CLI adds `stream read <name>`, and
|
|
43
|
+
the doctor check `stream.recording`, which fails when a source has stopped or its records fail to
|
|
44
|
+
write.
|
|
45
|
+
|
|
46
|
+
## Sources
|
|
47
|
+
|
|
48
|
+
- `{ command: [...] }` runs the command on the target's host and records each line it prints. At the
|
|
49
|
+
stop, the recorder reads on until the command prints nothing for 200 ms, for at most 2 s, then
|
|
50
|
+
kills it.
|
|
51
|
+
- `{ file: '<path>' }` follows a file on the machine where the run lives. The supervisor stops the
|
|
52
|
+
app first, then reads the file to its end, so the lines the app logs while it shuts down are
|
|
53
|
+
recorded.
|
|
54
|
+
- On an `attach` target, the run owns no app, so the recorder stops without waiting for one. A line
|
|
55
|
+
the app writes after the recorder's final read stays out of the stream.
|
|
56
|
+
- A target with no `sources` records `app`: the log of the app that the run launched.
|
|
57
|
+
|
|
58
|
+
The run's supervisor writes each source to `streams/<name>.ndjson` in the run folder, one
|
|
59
|
+
`{ at, line }` record per line. A record keeps at most 4096 characters of its line. When a source
|
|
60
|
+
logs faster than the disk takes it, the supervisor holds at most 10,000 pending records per source,
|
|
61
|
+
plus the batch it is writing. It drops the rest, and then writes a record that counts the dropped
|
|
62
|
+
lines. A batch that fails to write stays queued for the next write, and the source reads as failed
|
|
63
|
+
until a write succeeds. When the run stops with records still unwritten, or with a source it could
|
|
64
|
+
not read, the run ends as crashed, and its crash reason names the stream and the error. The `down`
|
|
65
|
+
or `detach` that stopped the run then fails with an `environment` error that names them too.
|
|
66
|
+
|
|
67
|
+
## Cursors
|
|
68
|
+
|
|
69
|
+
A cursor is a byte offset into a stream's file. `use` marks the end of each stream, and every
|
|
70
|
+
envelope holds the current ends in `cursors`.
|
|
71
|
+
|
|
72
|
+
- A read with no `from` starts at the cursor that `use` last marked, so it returns only what was
|
|
73
|
+
logged since.
|
|
74
|
+
- A read returns the records up to the `maxBytes` option, 64 KiB by default, with the next `cursor`
|
|
75
|
+
and `more` when the stream holds more. A read may pass a smaller `maxBytes`, down to 32 KiB.
|
|
76
|
+
- A cursor past the end of the file belongs to an older run, so the read starts over at 0.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function appendWholeText(path: string, text: string): Promise<void>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { FilePosition } from './read-file-from.ts';
|
|
2
|
+
export interface FileFollowState {
|
|
3
|
+
readonly position: FilePosition;
|
|
4
|
+
readonly carry: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ChunkInfo {
|
|
7
|
+
readonly start: number;
|
|
8
|
+
readonly identity: string | null;
|
|
9
|
+
readonly byteCount: number;
|
|
10
|
+
}
|
|
11
|
+
export interface FileStep {
|
|
12
|
+
readonly restarted: boolean;
|
|
13
|
+
readonly state: FileFollowState;
|
|
14
|
+
readonly more: boolean;
|
|
15
|
+
}
|
|
16
|
+
export declare function buildFileStep(state: FileFollowState, chunk: ChunkInfo): FileStep;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const streamRecordSchema: z.ZodObject<{
|
|
3
|
+
at: z.ZodString;
|
|
4
|
+
line: z.ZodString;
|
|
5
|
+
}, z.core.$strict>;
|
|
6
|
+
export type StreamRecord = z.infer<typeof streamRecordSchema>;
|
|
7
|
+
export interface StreamSlice {
|
|
8
|
+
readonly records: readonly StreamRecord[];
|
|
9
|
+
readonly cursor: number;
|
|
10
|
+
readonly more: boolean;
|
|
11
|
+
}
|
|
12
|
+
export declare function buildStreamSlice(bytes: Buffer, from: number, size: number): StreamSlice;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { stream, streamReadSchema, streamSliceSchema } from './stream.ts';
|
|
2
|
+
export type { StreamClient, StreamRead, StreamSliceData } from './stream.ts';
|
|
3
|
+
export { streamRecordSchema } from './build-stream-slice.ts';
|
|
4
|
+
export type { StreamRecord } from './build-stream-slice.ts';
|
|
5
|
+
export type { StreamSource } from './start-stream-recorder.ts';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { FilePosition } from './read-file-from.ts';
|
|
2
|
+
export interface FileFacts {
|
|
3
|
+
readonly size: number;
|
|
4
|
+
readonly identity: string;
|
|
5
|
+
}
|
|
6
|
+
export interface FileRead {
|
|
7
|
+
readonly start: number;
|
|
8
|
+
readonly length: number;
|
|
9
|
+
}
|
|
10
|
+
export declare function pickFileRead(file: FileFacts, from: FilePosition, limitBytes: number): FileRead;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { OpenFile } from './types.ts';
|
|
2
|
+
export interface FilePosition {
|
|
3
|
+
readonly offset: number;
|
|
4
|
+
readonly identity: string | null;
|
|
5
|
+
}
|
|
6
|
+
export interface FileChunk {
|
|
7
|
+
readonly start: number;
|
|
8
|
+
readonly identity: string | null;
|
|
9
|
+
readonly bytes: Uint8Array;
|
|
10
|
+
}
|
|
11
|
+
export declare function readFileFrom(path: string, from: FilePosition, limitBytes: number, openFile: OpenFile): Promise<FileChunk>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { StreamSlice } from './build-stream-slice.ts';
|
|
2
|
+
import type { OpenFile } from './types.ts';
|
|
3
|
+
export interface StreamSliceRequest {
|
|
4
|
+
readonly path: string;
|
|
5
|
+
readonly from: number;
|
|
6
|
+
readonly maxBytes: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function readStreamSlice(request: StreamSliceRequest, openFile: OpenFile): Promise<StreamSlice>;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
interface WriterInput {
|
|
2
|
+
readonly path: string;
|
|
3
|
+
readonly pendingLimit: number;
|
|
4
|
+
readonly now: () => Date;
|
|
5
|
+
readonly write: (text: string) => Promise<void>;
|
|
6
|
+
readonly onError: (error: unknown) => void;
|
|
7
|
+
readonly onRecovered: () => void;
|
|
8
|
+
}
|
|
9
|
+
interface WriterContext extends WriterInput {
|
|
10
|
+
readonly pending: readonly string[];
|
|
11
|
+
readonly batch: readonly string[];
|
|
12
|
+
readonly batchDropped: number;
|
|
13
|
+
readonly dropped: number;
|
|
14
|
+
readonly failure: string | null;
|
|
15
|
+
readonly drains: readonly PromiseWithResolvers<null>[];
|
|
16
|
+
}
|
|
17
|
+
type WriterEvent = {
|
|
18
|
+
readonly type: 'add';
|
|
19
|
+
readonly record: string;
|
|
20
|
+
} | {
|
|
21
|
+
readonly type: 'drain';
|
|
22
|
+
readonly settle: PromiseWithResolvers<null>;
|
|
23
|
+
};
|
|
24
|
+
interface BatchInput {
|
|
25
|
+
readonly write: WriterInput['write'];
|
|
26
|
+
readonly lines: readonly string[];
|
|
27
|
+
}
|
|
28
|
+
export declare const recordWriterMachine: import("xstate").StateMachine<WriterContext, WriterEvent, {
|
|
29
|
+
[x: string]: import("xstate").ActorRefFromLogic<import("xstate").PromiseActorLogic<null, BatchInput, import("xstate").EventObject>> | undefined;
|
|
30
|
+
}, {
|
|
31
|
+
src: "writeBatch";
|
|
32
|
+
logic: import("xstate").PromiseActorLogic<null, BatchInput, import("xstate").EventObject>;
|
|
33
|
+
id: string | undefined;
|
|
34
|
+
}, {
|
|
35
|
+
type: "enqueue";
|
|
36
|
+
params: import("xstate").NonReducibleUnknown;
|
|
37
|
+
} | {
|
|
38
|
+
type: "keepBatch";
|
|
39
|
+
params: import("xstate").NonReducibleUnknown;
|
|
40
|
+
} | {
|
|
41
|
+
type: "keepDrain";
|
|
42
|
+
params: import("xstate").NonReducibleUnknown;
|
|
43
|
+
} | {
|
|
44
|
+
type: "rejectDrains";
|
|
45
|
+
params: import("xstate").NonReducibleUnknown;
|
|
46
|
+
} | {
|
|
47
|
+
type: "reportRecovery";
|
|
48
|
+
params: unknown;
|
|
49
|
+
} | {
|
|
50
|
+
type: "resolveDrains";
|
|
51
|
+
params: import("xstate").NonReducibleUnknown;
|
|
52
|
+
} | {
|
|
53
|
+
type: "takeBatch";
|
|
54
|
+
params: import("xstate").NonReducibleUnknown;
|
|
55
|
+
}, {
|
|
56
|
+
type: "hasPending";
|
|
57
|
+
params: unknown;
|
|
58
|
+
}, never, "idle" | "stalled" | "writing", string, WriterInput, import("xstate").NonReducibleUnknown, import("xstate").EventObject, import("xstate").MetaObject, {
|
|
59
|
+
states: {
|
|
60
|
+
readonly idle: {};
|
|
61
|
+
readonly writing: {};
|
|
62
|
+
readonly stalled: {};
|
|
63
|
+
};
|
|
64
|
+
}, import("xstate").MetaObject>;
|
|
65
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type SourcePhase = 'running' | 'running-unwritten' | 'exited' | 'exited-unwritten' | 'failed' | 'failed-unwritten';
|
|
2
|
+
export type SourceEventType = 'exit' | 'fail' | 'write-failed' | 'write-recovered';
|
|
3
|
+
export interface SourceEvent {
|
|
4
|
+
readonly type: SourceEventType;
|
|
5
|
+
readonly detail: string | null;
|
|
6
|
+
}
|
|
7
|
+
export interface SourceDetails {
|
|
8
|
+
readonly reading: string | null;
|
|
9
|
+
readonly write: string | null;
|
|
10
|
+
}
|
|
11
|
+
interface SourceContext {
|
|
12
|
+
readonly phase: SourcePhase;
|
|
13
|
+
readonly details: SourceDetails;
|
|
14
|
+
}
|
|
15
|
+
export declare const sourcePhaseMachine: import("xstate").StateMachine<SourceContext, SourceEvent, {}, never, {
|
|
16
|
+
type: "move";
|
|
17
|
+
params: import("xstate").NonReducibleUnknown;
|
|
18
|
+
}, never, never, {}, string, import("xstate").NonReducibleUnknown, import("xstate").NonReducibleUnknown, import("xstate").EventObject, import("xstate").MetaObject, {}, import("xstate").MetaObject>;
|
|
19
|
+
export declare function canMoveSource(from: SourcePhase, to: SourcePhase): boolean;
|
|
20
|
+
export declare function pickSourcePhase(phase: SourcePhase, event: SourceEventType): SourcePhase;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Access, Host } from '@wringer/core';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import type { FileChunk, FilePosition } from './read-file-from.ts';
|
|
4
|
+
import type { SourceDetails, SourcePhase } from './source-phase-machine.ts';
|
|
5
|
+
export declare const streamSourceSchema: z.ZodUnion<readonly [z.ZodObject<{
|
|
6
|
+
command: z.ZodArray<z.ZodString>;
|
|
7
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
8
|
+
file: z.ZodString;
|
|
9
|
+
}, z.core.$strict>]>;
|
|
10
|
+
export type StreamSource = z.infer<typeof streamSourceSchema>;
|
|
11
|
+
export interface RecorderRequest {
|
|
12
|
+
readonly sources: Readonly<Record<string, StreamSource>>;
|
|
13
|
+
readonly directory: string;
|
|
14
|
+
readonly host: Host;
|
|
15
|
+
readonly access: Access;
|
|
16
|
+
readonly now: () => Date;
|
|
17
|
+
readonly createDirectory: (path: string) => Promise<void>;
|
|
18
|
+
readonly appendText: (path: string, text: string) => Promise<void>;
|
|
19
|
+
readonly readFileFrom: (path: string, from: FilePosition, limitBytes: number) => Promise<FileChunk>;
|
|
20
|
+
readonly wait: (delayMs: number, signal: AbortSignal) => Promise<void>;
|
|
21
|
+
readonly pendingLimit: number;
|
|
22
|
+
}
|
|
23
|
+
export declare const sourceStatusSchema: z.ZodObject<{
|
|
24
|
+
name: z.ZodString;
|
|
25
|
+
state: z.ZodEnum<{
|
|
26
|
+
exited: "exited";
|
|
27
|
+
failed: "failed";
|
|
28
|
+
running: "running";
|
|
29
|
+
}>;
|
|
30
|
+
detail: z.ZodNullable<z.ZodString>;
|
|
31
|
+
}, z.core.$strict>;
|
|
32
|
+
type SourceStatus = z.infer<typeof sourceStatusSchema>;
|
|
33
|
+
export interface StreamRecorder {
|
|
34
|
+
readonly status: () => readonly SourceStatus[];
|
|
35
|
+
readonly stop: () => Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
export declare function startStreamRecorder(request: RecorderRequest): Promise<StreamRecorder>;
|
|
38
|
+
export declare function buildSourceStatus(name: string, phase: SourcePhase, details: SourceDetails): SourceStatus;
|
|
39
|
+
export {};
|
package/dist/stream.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const streamReadSchema: z.ZodObject<{
|
|
3
|
+
name: z.ZodString;
|
|
4
|
+
from: z.ZodOptional<z.ZodNumber>;
|
|
5
|
+
maxBytes: z.ZodOptional<z.ZodNumber>;
|
|
6
|
+
}, z.core.$strict>;
|
|
7
|
+
export declare const streamSliceSchema: z.ZodObject<{
|
|
8
|
+
name: z.ZodString;
|
|
9
|
+
records: z.ZodArray<z.ZodObject<{
|
|
10
|
+
at: z.ZodString;
|
|
11
|
+
line: z.ZodString;
|
|
12
|
+
}, z.core.$strict>>;
|
|
13
|
+
cursor: z.ZodNumber;
|
|
14
|
+
more: z.ZodBoolean;
|
|
15
|
+
}, z.core.$strict>;
|
|
16
|
+
export declare const stream: import("@wringer/core").DriverFactory<"stream", {
|
|
17
|
+
maxBytes?: number | undefined;
|
|
18
|
+
}, {
|
|
19
|
+
sources?: Record<string, {
|
|
20
|
+
command: string[];
|
|
21
|
+
} | {
|
|
22
|
+
file: string;
|
|
23
|
+
}> | undefined;
|
|
24
|
+
}, StreamClient>;
|
|
25
|
+
export type StreamRead = z.input<typeof streamReadSchema>;
|
|
26
|
+
export type StreamSliceData = z.infer<typeof streamSliceSchema>;
|
|
27
|
+
export interface StreamClient {
|
|
28
|
+
readonly read: (request: StreamRead) => Promise<StreamSliceData>;
|
|
29
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function waitOrAbort(delayMs: number, signal: AbortSignal): Promise<void>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wringer/stream",
|
|
3
|
-
"version": "0.0.0
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "The stream driver for wringer: records a target's logs during a run and reads them from a cursor",
|
|
5
|
+
"homepage": "https://github.com/zgeoff/wringer/tree/main/packages/stream#readme",
|
|
6
|
+
"bugs": "https://github.com/zgeoff/wringer/issues",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/zgeoff/wringer.git",
|
|
11
|
+
"directory": "packages/stream"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src",
|
|
15
|
+
"!src/**/*.test.ts",
|
|
16
|
+
"dist",
|
|
17
|
+
"!src/test-utils"
|
|
18
|
+
],
|
|
19
|
+
"type": "module",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"default": "./src/index.ts"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "rm -rf dist && tsrs -p tsconfig.build.json",
|
|
31
|
+
"typecheck": "tsrs --noEmit"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"xstate": "5.33.3",
|
|
35
|
+
"zod": "4.6.7"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@wringer/core": "0.1.0",
|
|
39
|
+
"@zgeoff/std": "0.4.2",
|
|
40
|
+
"@zgeoff/testing": "0.2.1",
|
|
41
|
+
"tiny-invariant": "1.3.3"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@wringer/core": "0.1.0"
|
|
45
|
+
},
|
|
46
|
+
"engines": {
|
|
47
|
+
"bun": ">=1.3"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { appendFile, stat, truncate } from 'node:fs/promises';
|
|
2
|
+
import { isErrorCode } from '@wringer/core';
|
|
3
|
+
|
|
4
|
+
// An append that fails part-way, such as on a full disk, leaves the bytes it wrote; they are cut,
|
|
5
|
+
// so a retry of the same text never doubles a record or splices a torn one into the file.
|
|
6
|
+
export async function appendWholeText(path: string, text: string): Promise<void> {
|
|
7
|
+
const size = await readFileSize(path);
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
await appendFile(path, text);
|
|
11
|
+
} catch (error) {
|
|
12
|
+
await truncate(path, size);
|
|
13
|
+
|
|
14
|
+
throw error;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function readFileSize(path: string): Promise<number> {
|
|
19
|
+
try {
|
|
20
|
+
const info = await stat(path);
|
|
21
|
+
|
|
22
|
+
return info.size;
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if (isErrorCode(error, 'ENOENT')) {
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { FilePosition } from './read-file-from.ts';
|
|
2
|
+
|
|
3
|
+
export interface FileFollowState {
|
|
4
|
+
readonly position: FilePosition;
|
|
5
|
+
|
|
6
|
+
// The text after the last newline, which waits for the rest of its line.
|
|
7
|
+
readonly carry: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ChunkInfo {
|
|
11
|
+
readonly start: number;
|
|
12
|
+
readonly identity: string | null;
|
|
13
|
+
readonly byteCount: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface FileStep {
|
|
17
|
+
// The file shrank or was replaced, so its reader starts over with a fresh decoder.
|
|
18
|
+
readonly restarted: boolean;
|
|
19
|
+
readonly state: FileFollowState;
|
|
20
|
+
|
|
21
|
+
// A read stops at its limit, and a read of a file that changed under it can stop short of that,
|
|
22
|
+
// so only a read that returns no bytes reached the end.
|
|
23
|
+
readonly more: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// A file that starts over drops the old file's unfinished line, so it never joins the new file's
|
|
27
|
+
// text.
|
|
28
|
+
export function buildFileStep(state: FileFollowState, chunk: ChunkInfo): FileStep {
|
|
29
|
+
const restarted = chunk.start < state.position.offset;
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
restarted,
|
|
33
|
+
state: {
|
|
34
|
+
position: { offset: chunk.start + chunk.byteCount, identity: chunk.identity },
|
|
35
|
+
carry: restarted ? '' : state.carry,
|
|
36
|
+
},
|
|
37
|
+
more: chunk.byteCount > 0,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const streamRecordSchema = z.strictObject({ at: z.string(), line: z.string() });
|
|
4
|
+
|
|
5
|
+
export type StreamRecord = z.infer<typeof streamRecordSchema>;
|
|
6
|
+
|
|
7
|
+
export interface StreamSlice {
|
|
8
|
+
readonly records: readonly StreamRecord[];
|
|
9
|
+
readonly cursor: number;
|
|
10
|
+
readonly more: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const newline = 0x0a;
|
|
14
|
+
|
|
15
|
+
// The bytes read from `from` end in a part record when the read stopped mid-line; the slice keeps
|
|
16
|
+
// the whole records, and its cursor points at the first byte it left.
|
|
17
|
+
export function buildStreamSlice(bytes: Buffer, from: number, size: number): StreamSlice {
|
|
18
|
+
const end = bytes.lastIndexOf(newline) + 1;
|
|
19
|
+
|
|
20
|
+
const records = bytes
|
|
21
|
+
.subarray(0, end)
|
|
22
|
+
.toString('utf8')
|
|
23
|
+
.split('\n')
|
|
24
|
+
.filter((line) => line !== '')
|
|
25
|
+
.map((line) => parseRecord(line));
|
|
26
|
+
|
|
27
|
+
return { records, cursor: from + end, more: from + end < size };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseRecord(line: string): StreamRecord {
|
|
31
|
+
const parsed: unknown = JSON.parse(line);
|
|
32
|
+
|
|
33
|
+
return streamRecordSchema.parse(parsed);
|
|
34
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface LineSplit {
|
|
2
|
+
readonly lines: readonly string[];
|
|
3
|
+
readonly carry: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
// The text after the last newline waits for the rest of its line. It keeps one character past
|
|
7
|
+
// `capLength`, enough for the record to show the cut, so a file that never ends its line stays
|
|
8
|
+
// small.
|
|
9
|
+
export function collectLines(carry: string, text: string, capLength: number): LineSplit {
|
|
10
|
+
const parts = `${carry}${text}`.split('\n');
|
|
11
|
+
|
|
12
|
+
return { lines: parts.slice(0, -1), carry: (parts.at(-1) ?? '').slice(0, capLength + 1) };
|
|
13
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { stream, streamReadSchema, streamSliceSchema } from './stream.ts';
|
|
2
|
+
export type { StreamClient, StreamRead, StreamSliceData } from './stream.ts';
|
|
3
|
+
export { streamRecordSchema } from './build-stream-slice.ts';
|
|
4
|
+
export type { StreamRecord } from './build-stream-slice.ts';
|
|
5
|
+
export type { StreamSource } from './start-stream-recorder.ts';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface CommandStopFacts {
|
|
2
|
+
readonly nowMs: number;
|
|
3
|
+
|
|
4
|
+
// When the recorder began to stop.
|
|
5
|
+
readonly stopMs: number;
|
|
6
|
+
|
|
7
|
+
// When the command last printed a line, or null before its first.
|
|
8
|
+
readonly lastLineMs: number | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// The command's output has to go this long without a line before the recorder kills it.
|
|
12
|
+
const commandQuietMs = 200;
|
|
13
|
+
|
|
14
|
+
// The longest a stop waits for a command that never goes quiet.
|
|
15
|
+
const commandStopLimitMs = 2000;
|
|
16
|
+
|
|
17
|
+
// How long the recorder still reads a command source once it began to stop, or 0 to kill it now.
|
|
18
|
+
export function pickCommandStopDelay(facts: CommandStopFacts): number {
|
|
19
|
+
const quietAtMs = Math.max(facts.stopMs, facts.lastLineMs ?? facts.stopMs) + commandQuietMs;
|
|
20
|
+
const killAtMs = Math.min(quietAtMs, facts.stopMs + commandStopLimitMs);
|
|
21
|
+
|
|
22
|
+
return Math.max(0, killAtMs - facts.nowMs);
|
|
23
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { FilePosition } from './read-file-from.ts';
|
|
2
|
+
|
|
3
|
+
export interface FileFacts {
|
|
4
|
+
readonly size: number;
|
|
5
|
+
|
|
6
|
+
// The device and inode of the file at the path now.
|
|
7
|
+
readonly identity: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface FileRead {
|
|
11
|
+
readonly start: number;
|
|
12
|
+
readonly length: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// A file that shrank, such as a log rotated in place, or a new file at the path, such as a log
|
|
16
|
+
// rotated by rename, reads from 0.
|
|
17
|
+
export function pickFileRead(file: FileFacts, from: FilePosition, limitBytes: number): FileRead {
|
|
18
|
+
const replaced = from.identity !== null && from.identity !== file.identity;
|
|
19
|
+
const start = replaced || file.size < from.offset ? 0 : from.offset;
|
|
20
|
+
|
|
21
|
+
return { start, length: Math.min(file.size - start, limitBytes) };
|
|
22
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { isErrorCode } from '@wringer/core';
|
|
2
|
+
import { pickFileRead } from './pick-file-read.ts';
|
|
3
|
+
import type { OpenFile } from './types.ts';
|
|
4
|
+
|
|
5
|
+
// Where a reader left a file: the byte offset, and the device and inode of the file it read, or
|
|
6
|
+
// null before it read one.
|
|
7
|
+
export interface FilePosition {
|
|
8
|
+
readonly offset: number;
|
|
9
|
+
readonly identity: string | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface FileChunk {
|
|
13
|
+
// Where `bytes` starts: the offset, or 0 once the file shrank below it or was replaced.
|
|
14
|
+
readonly start: number;
|
|
15
|
+
readonly identity: string | null;
|
|
16
|
+
readonly bytes: Uint8Array;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Up to `limitBytes` of a file from `from`. A file that does not exist yet reads as empty, as
|
|
20
|
+
// `tail -F` treats it.
|
|
21
|
+
export async function readFileFrom(
|
|
22
|
+
path: string,
|
|
23
|
+
from: FilePosition,
|
|
24
|
+
limitBytes: number,
|
|
25
|
+
openFile: OpenFile,
|
|
26
|
+
): Promise<FileChunk> {
|
|
27
|
+
const handle = await openFile(path).catch((error: unknown) => {
|
|
28
|
+
if (isErrorCode(error, 'ENOENT')) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
throw error;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (handle === null) {
|
|
36
|
+
return { start: from.offset, identity: from.identity, bytes: new Uint8Array() };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const stats = await handle.stat();
|
|
41
|
+
|
|
42
|
+
const identity = `${stats.dev}:${stats.ino}`;
|
|
43
|
+
const plan = pickFileRead({ size: stats.size, identity }, from, limitBytes);
|
|
44
|
+
|
|
45
|
+
const bytes = new Uint8Array(plan.length);
|
|
46
|
+
|
|
47
|
+
// A file truncated after the stat yields fewer bytes than the buffer holds.
|
|
48
|
+
const read = await handle.read(bytes, 0, bytes.length, plan.start);
|
|
49
|
+
|
|
50
|
+
return { start: plan.start, identity, bytes: bytes.subarray(0, read.bytesRead) };
|
|
51
|
+
} finally {
|
|
52
|
+
await handle.close();
|
|
53
|
+
}
|
|
54
|
+
}
|