@arizeai/phoenix-client 5.2.1 → 5.4.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 +3 -3
- package/dist/esm/__generated__/api/v1.d.ts +321 -9
- package/dist/esm/__generated__/api/v1.d.ts.map +1 -1
- package/dist/esm/experiments/createExperiment.d.ts +39 -0
- package/dist/esm/experiments/createExperiment.d.ts.map +1 -0
- package/dist/esm/experiments/createExperiment.js +43 -0
- package/dist/esm/experiments/createExperiment.js.map +1 -0
- package/dist/esm/experiments/deleteExperiment.d.ts +36 -0
- package/dist/esm/experiments/deleteExperiment.d.ts.map +1 -0
- package/dist/esm/experiments/deleteExperiment.js +49 -0
- package/dist/esm/experiments/deleteExperiment.js.map +1 -0
- package/dist/esm/experiments/getExperimentInfo.d.ts.map +1 -1
- package/dist/esm/experiments/getExperimentInfo.js +9 -2
- package/dist/esm/experiments/getExperimentInfo.js.map +1 -1
- package/dist/esm/experiments/helpers/asExperimentEvaluator.d.ts +19 -0
- package/dist/esm/experiments/helpers/asExperimentEvaluator.d.ts.map +1 -0
- package/dist/esm/experiments/helpers/asExperimentEvaluator.js +19 -0
- package/dist/esm/experiments/helpers/asExperimentEvaluator.js.map +1 -0
- package/dist/esm/experiments/helpers/fromPhoenixLLMEvaluator.d.ts +9 -0
- package/dist/esm/experiments/helpers/fromPhoenixLLMEvaluator.d.ts.map +1 -0
- package/dist/esm/experiments/helpers/fromPhoenixLLMEvaluator.js +18 -0
- package/dist/esm/experiments/helpers/fromPhoenixLLMEvaluator.js.map +1 -0
- package/dist/esm/experiments/helpers/getExperimentEvaluators.d.ts +6 -0
- package/dist/esm/experiments/helpers/getExperimentEvaluators.d.ts.map +1 -0
- package/dist/esm/experiments/helpers/getExperimentEvaluators.js +58 -0
- package/dist/esm/experiments/helpers/getExperimentEvaluators.js.map +1 -0
- package/dist/esm/experiments/helpers/index.d.ts +4 -0
- package/dist/esm/experiments/helpers/index.d.ts.map +1 -0
- package/dist/esm/experiments/helpers/index.js +4 -0
- package/dist/esm/experiments/helpers/index.js.map +1 -0
- package/dist/esm/experiments/index.d.ts +6 -0
- package/dist/esm/experiments/index.d.ts.map +1 -1
- package/dist/esm/experiments/index.js +6 -0
- package/dist/esm/experiments/index.js.map +1 -1
- package/dist/esm/experiments/listExperiments.d.ts +29 -0
- package/dist/esm/experiments/listExperiments.d.ts.map +1 -0
- package/dist/esm/experiments/listExperiments.js +59 -0
- package/dist/esm/experiments/listExperiments.js.map +1 -0
- package/dist/esm/experiments/resumeEvaluation.d.ts +105 -0
- package/dist/esm/experiments/resumeEvaluation.d.ts.map +1 -0
- package/dist/esm/experiments/resumeEvaluation.js +559 -0
- package/dist/esm/experiments/resumeEvaluation.js.map +1 -0
- package/dist/esm/experiments/resumeExperiment.d.ts +102 -0
- package/dist/esm/experiments/resumeExperiment.d.ts.map +1 -0
- package/dist/esm/experiments/resumeExperiment.js +517 -0
- package/dist/esm/experiments/resumeExperiment.js.map +1 -0
- package/dist/esm/experiments/runExperiment.d.ts +4 -3
- package/dist/esm/experiments/runExperiment.d.ts.map +1 -1
- package/dist/esm/experiments/runExperiment.js +32 -3
- package/dist/esm/experiments/runExperiment.js.map +1 -1
- package/dist/esm/prompts/createPrompt.d.ts +19 -1
- package/dist/esm/prompts/createPrompt.d.ts.map +1 -1
- package/dist/esm/prompts/createPrompt.js +14 -1
- package/dist/esm/prompts/createPrompt.js.map +1 -1
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/esm/types/experiments.d.ts +66 -3
- package/dist/esm/types/experiments.d.ts.map +1 -1
- package/dist/esm/utils/channel.d.ts +229 -0
- package/dist/esm/utils/channel.d.ts.map +1 -0
- package/dist/esm/utils/channel.js +352 -0
- package/dist/esm/utils/channel.js.map +1 -0
- package/dist/esm/utils/formatPromptMessages.d.ts.map +1 -1
- package/dist/esm/utils/getPromptBySelector.d.ts.map +1 -1
- package/dist/esm/utils/isHttpError.d.ts +21 -0
- package/dist/esm/utils/isHttpError.d.ts.map +1 -0
- package/dist/esm/utils/isHttpError.js +33 -0
- package/dist/esm/utils/isHttpError.js.map +1 -0
- package/dist/src/__generated__/api/v1.d.ts +321 -9
- package/dist/src/__generated__/api/v1.d.ts.map +1 -1
- package/dist/src/experiments/createExperiment.d.ts +39 -0
- package/dist/src/experiments/createExperiment.d.ts.map +1 -0
- package/dist/src/experiments/createExperiment.js +43 -0
- package/dist/src/experiments/createExperiment.js.map +1 -0
- package/dist/src/experiments/deleteExperiment.d.ts +36 -0
- package/dist/src/experiments/deleteExperiment.d.ts.map +1 -0
- package/dist/src/experiments/deleteExperiment.js +52 -0
- package/dist/src/experiments/deleteExperiment.js.map +1 -0
- package/dist/src/experiments/getExperimentInfo.d.ts.map +1 -1
- package/dist/src/experiments/getExperimentInfo.js +9 -2
- package/dist/src/experiments/getExperimentInfo.js.map +1 -1
- package/dist/src/experiments/helpers/asExperimentEvaluator.d.ts +19 -0
- package/dist/src/experiments/helpers/asExperimentEvaluator.d.ts.map +1 -0
- package/dist/src/experiments/helpers/asExperimentEvaluator.js +22 -0
- package/dist/src/experiments/helpers/asExperimentEvaluator.js.map +1 -0
- package/dist/src/experiments/helpers/fromPhoenixLLMEvaluator.d.ts +9 -0
- package/dist/src/experiments/helpers/fromPhoenixLLMEvaluator.d.ts.map +1 -0
- package/dist/src/experiments/helpers/fromPhoenixLLMEvaluator.js +21 -0
- package/dist/src/experiments/helpers/fromPhoenixLLMEvaluator.js.map +1 -0
- package/dist/src/experiments/helpers/getExperimentEvaluators.d.ts +6 -0
- package/dist/src/experiments/helpers/getExperimentEvaluators.d.ts.map +1 -0
- package/dist/src/experiments/helpers/getExperimentEvaluators.js +61 -0
- package/dist/src/experiments/helpers/getExperimentEvaluators.js.map +1 -0
- package/dist/src/experiments/helpers/index.d.ts +4 -0
- package/dist/src/experiments/helpers/index.d.ts.map +1 -0
- package/dist/src/experiments/helpers/index.js +20 -0
- package/dist/src/experiments/helpers/index.js.map +1 -0
- package/dist/src/experiments/index.d.ts +6 -0
- package/dist/src/experiments/index.d.ts.map +1 -1
- package/dist/src/experiments/index.js +6 -0
- package/dist/src/experiments/index.js.map +1 -1
- package/dist/src/experiments/listExperiments.d.ts +29 -0
- package/dist/src/experiments/listExperiments.d.ts.map +1 -0
- package/dist/src/experiments/listExperiments.js +66 -0
- package/dist/src/experiments/listExperiments.js.map +1 -0
- package/dist/src/experiments/resumeEvaluation.d.ts +105 -0
- package/dist/src/experiments/resumeEvaluation.d.ts.map +1 -0
- package/dist/src/experiments/resumeEvaluation.js +585 -0
- package/dist/src/experiments/resumeEvaluation.js.map +1 -0
- package/dist/src/experiments/resumeExperiment.d.ts +102 -0
- package/dist/src/experiments/resumeExperiment.d.ts.map +1 -0
- package/dist/src/experiments/resumeExperiment.js +540 -0
- package/dist/src/experiments/resumeExperiment.js.map +1 -0
- package/dist/src/experiments/runExperiment.d.ts +4 -3
- package/dist/src/experiments/runExperiment.d.ts.map +1 -1
- package/dist/src/experiments/runExperiment.js +32 -3
- package/dist/src/experiments/runExperiment.js.map +1 -1
- package/dist/src/prompts/createPrompt.d.ts +19 -1
- package/dist/src/prompts/createPrompt.d.ts.map +1 -1
- package/dist/src/prompts/createPrompt.js +14 -1
- package/dist/src/prompts/createPrompt.js.map +1 -1
- package/dist/src/types/experiments.d.ts +66 -3
- package/dist/src/types/experiments.d.ts.map +1 -1
- package/dist/src/utils/channel.d.ts +229 -0
- package/dist/src/utils/channel.d.ts.map +1 -0
- package/dist/src/utils/channel.js +385 -0
- package/dist/src/utils/channel.js.map +1 -0
- package/dist/src/utils/formatPromptMessages.d.ts.map +1 -1
- package/dist/src/utils/getPromptBySelector.d.ts.map +1 -1
- package/dist/src/utils/isHttpError.d.ts +21 -0
- package/dist/src/utils/isHttpError.d.ts.map +1 -0
- package/dist/src/utils/isHttpError.js +37 -0
- package/dist/src/utils/isHttpError.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -5
- package/src/__generated__/api/v1.ts +321 -9
- package/src/experiments/createExperiment.ts +90 -0
- package/src/experiments/deleteExperiment.ts +67 -0
- package/src/experiments/getExperimentInfo.ts +9 -2
- package/src/experiments/helpers/asExperimentEvaluator.ts +29 -0
- package/src/experiments/helpers/fromPhoenixLLMEvaluator.ts +24 -0
- package/src/experiments/helpers/getExperimentEvaluators.ts +74 -0
- package/src/experiments/helpers/index.ts +3 -0
- package/src/experiments/index.ts +6 -0
- package/src/experiments/listExperiments.ts +83 -0
- package/src/experiments/resumeEvaluation.ts +804 -0
- package/src/experiments/resumeExperiment.ts +745 -0
- package/src/experiments/runExperiment.ts +37 -5
- package/src/prompts/createPrompt.ts +19 -1
- package/src/types/experiments.ts +72 -3
- package/src/utils/channel.ts +397 -0
- package/src/utils/isHttpError.ts +45 -0
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
import type {
|
|
28
28
|
Evaluator,
|
|
29
29
|
ExperimentEvaluationRun,
|
|
30
|
+
ExperimentEvaluatorLike,
|
|
30
31
|
ExperimentInfo,
|
|
31
32
|
ExperimentRun,
|
|
32
33
|
ExperimentRunID,
|
|
@@ -44,6 +45,9 @@ import {
|
|
|
44
45
|
getExperimentUrl,
|
|
45
46
|
} from "../utils/urlUtils";
|
|
46
47
|
|
|
48
|
+
import { getExperimentInfo } from "./getExperimentInfo";
|
|
49
|
+
import { getExperimentEvaluators } from "./helpers";
|
|
50
|
+
|
|
47
51
|
import assert from "assert";
|
|
48
52
|
import { queue } from "async";
|
|
49
53
|
import invariant from "tiny-invariant";
|
|
@@ -85,7 +89,7 @@ export type RunExperimentParams = ClientFn & {
|
|
|
85
89
|
/**
|
|
86
90
|
* The evaluators to use
|
|
87
91
|
*/
|
|
88
|
-
evaluators?:
|
|
92
|
+
evaluators?: ExperimentEvaluatorLike[];
|
|
89
93
|
/**
|
|
90
94
|
* The logger to use
|
|
91
95
|
*/
|
|
@@ -200,6 +204,8 @@ export async function runExperiment({
|
|
|
200
204
|
let taskTracer: Tracer;
|
|
201
205
|
let experiment: ExperimentInfo;
|
|
202
206
|
if (isDryRun) {
|
|
207
|
+
const now = new Date().toISOString();
|
|
208
|
+
const totalExamples = nExamples;
|
|
203
209
|
experiment = {
|
|
204
210
|
id: localId(),
|
|
205
211
|
datasetId: dataset.id,
|
|
@@ -208,6 +214,13 @@ export async function runExperiment({
|
|
|
208
214
|
datasetSplits: datasetSelector?.splits ?? [],
|
|
209
215
|
projectName,
|
|
210
216
|
metadata: experimentMetadata,
|
|
217
|
+
repetitions,
|
|
218
|
+
createdAt: now,
|
|
219
|
+
updatedAt: now,
|
|
220
|
+
exampleCount: totalExamples,
|
|
221
|
+
successfulRunCount: 0,
|
|
222
|
+
failedRunCount: 0,
|
|
223
|
+
missingRunCount: totalExamples * repetitions,
|
|
211
224
|
};
|
|
212
225
|
taskTracer = createNoOpProvider().getTracer("no-op");
|
|
213
226
|
} else {
|
|
@@ -241,7 +254,14 @@ export async function runExperiment({
|
|
|
241
254
|
// @todo: the dataset should return splits in response body
|
|
242
255
|
datasetSplits: datasetSelector?.splits ?? [],
|
|
243
256
|
projectName,
|
|
244
|
-
|
|
257
|
+
repetitions: experimentResponse.repetitions,
|
|
258
|
+
metadata: experimentResponse.metadata || {},
|
|
259
|
+
createdAt: experimentResponse.created_at,
|
|
260
|
+
updatedAt: experimentResponse.updated_at,
|
|
261
|
+
exampleCount: experimentResponse.example_count,
|
|
262
|
+
successfulRunCount: experimentResponse.successful_run_count,
|
|
263
|
+
failedRunCount: experimentResponse.failed_run_count,
|
|
264
|
+
missingRunCount: experimentResponse.missing_run_count,
|
|
245
265
|
};
|
|
246
266
|
// Initialize the tracer, now that we have a project name
|
|
247
267
|
const baseUrl = client.config.baseUrl;
|
|
@@ -334,6 +354,16 @@ export async function runExperiment({
|
|
|
334
354
|
|
|
335
355
|
logger.info(`✅ Experiment ${experiment.id} completed`);
|
|
336
356
|
|
|
357
|
+
// Refresh experiment info from server to get updated counts (non-dry-run only)
|
|
358
|
+
if (!isDryRun) {
|
|
359
|
+
const updatedExperiment = await getExperimentInfo({
|
|
360
|
+
client,
|
|
361
|
+
experimentId: experiment.id,
|
|
362
|
+
});
|
|
363
|
+
// Update the experiment info with the latest from the server
|
|
364
|
+
Object.assign(ranExperiment, updatedExperiment);
|
|
365
|
+
}
|
|
366
|
+
|
|
337
367
|
if (!isDryRun && client.config.baseUrl) {
|
|
338
368
|
const experimentUrl = getExperimentUrl({
|
|
339
369
|
baseUrl: client.config.baseUrl,
|
|
@@ -506,7 +536,7 @@ export async function evaluateExperiment({
|
|
|
506
536
|
**/
|
|
507
537
|
experiment: RanExperiment;
|
|
508
538
|
/** The evaluators to use */
|
|
509
|
-
evaluators:
|
|
539
|
+
evaluators: ExperimentEvaluatorLike[];
|
|
510
540
|
/** The client to use */
|
|
511
541
|
client?: PhoenixClient;
|
|
512
542
|
/** The logger to use */
|
|
@@ -624,7 +654,8 @@ export async function evaluateExperiment({
|
|
|
624
654
|
|
|
625
655
|
// Run evaluators against all runs
|
|
626
656
|
// Flat list of evaluator + run tuples
|
|
627
|
-
const
|
|
657
|
+
const normalizedEvaluators = getExperimentEvaluators(evaluators);
|
|
658
|
+
const evaluatorsAndRuns = normalizedEvaluators.flatMap((evaluator) =>
|
|
628
659
|
runsToEvaluate.map((run) => ({
|
|
629
660
|
evaluator,
|
|
630
661
|
run,
|
|
@@ -646,7 +677,7 @@ export async function evaluateExperiment({
|
|
|
646
677
|
[SemanticConventions.OPENINFERENCE_SPAN_KIND]:
|
|
647
678
|
OpenInferenceSpanKind.EVALUATOR,
|
|
648
679
|
[SemanticConventions.INPUT_MIME_TYPE]: MimeType.JSON,
|
|
649
|
-
[SemanticConventions.INPUT_VALUE]:
|
|
680
|
+
[SemanticConventions.INPUT_VALUE]: ensureString({
|
|
650
681
|
input: examplesById[evaluatorAndRun.run.datasetExampleId]?.input,
|
|
651
682
|
output: evaluatorAndRun.run.output,
|
|
652
683
|
expected:
|
|
@@ -797,6 +828,7 @@ async function runEvaluator({
|
|
|
797
828
|
* @param params.kind - The kind of evaluator (e.g., "CODE", "LLM")
|
|
798
829
|
* @param params.evaluate - The evaluator function.
|
|
799
830
|
* @returns The evaluator object.
|
|
831
|
+
* @deprecated use asExperimentEvaluator instead
|
|
800
832
|
*/
|
|
801
833
|
export function asEvaluator({
|
|
802
834
|
name,
|
|
@@ -28,6 +28,11 @@ export interface CreatePromptParams extends ClientFn, PromptData {
|
|
|
28
28
|
* The description of the prompt
|
|
29
29
|
*/
|
|
30
30
|
description?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Optional metadata for the prompt as a JSON object
|
|
33
|
+
* @example { "environment": "production", "version": "1.0" }
|
|
34
|
+
*/
|
|
35
|
+
metadata?: Record<string, unknown>;
|
|
31
36
|
/**
|
|
32
37
|
* The prompt version to push onto the history of the prompt
|
|
33
38
|
*/
|
|
@@ -39,8 +44,21 @@ export interface CreatePromptParams extends ClientFn, PromptData {
|
|
|
39
44
|
*
|
|
40
45
|
* If a prompt with the same name exists, a new version of the prompt will be appended to the history.
|
|
41
46
|
*
|
|
42
|
-
* @param params - The parameters to create a prompt.
|
|
47
|
+
* @param params - The parameters to create a prompt, including optional metadata.
|
|
43
48
|
* @returns The created prompt version.
|
|
49
|
+
* @example
|
|
50
|
+
* ```typescript
|
|
51
|
+
* await createPrompt({
|
|
52
|
+
* name: "my-prompt",
|
|
53
|
+
* description: "A helpful prompt",
|
|
54
|
+
* metadata: { environment: "production", team: "ai" },
|
|
55
|
+
* version: promptVersion({
|
|
56
|
+
* modelProvider: "OPENAI",
|
|
57
|
+
* modelName: "gpt-4",
|
|
58
|
+
* template: [{ role: "user", content: "Hello {{name}}" }]
|
|
59
|
+
* })
|
|
60
|
+
* });
|
|
61
|
+
* ```
|
|
44
62
|
*/
|
|
45
63
|
export async function createPrompt({
|
|
46
64
|
client: _client,
|
package/src/types/experiments.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import type { LLMEvaluator } from "@arizeai/phoenix-evals";
|
|
2
|
+
|
|
1
3
|
import { AnnotatorKind } from "./annotations";
|
|
2
4
|
import { Node } from "./core";
|
|
3
|
-
import { Example } from "./datasets";
|
|
5
|
+
import { Example, ExampleWithId } from "./datasets";
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* An experiment is a set of task runs on a dataset version
|
|
@@ -11,18 +13,77 @@ export interface ExperimentInfo extends Node {
|
|
|
11
13
|
// @todo: mark this as required when experiment API returns it
|
|
12
14
|
datasetSplits?: string[];
|
|
13
15
|
/**
|
|
14
|
-
*
|
|
16
|
+
* Number of times the experiment is repeated
|
|
15
17
|
*/
|
|
16
|
-
|
|
18
|
+
repetitions: number;
|
|
17
19
|
/**
|
|
18
20
|
* Metadata about the experiment as an object of key values
|
|
19
21
|
* e.x. model name
|
|
20
22
|
*/
|
|
21
23
|
metadata: Record<string, unknown>;
|
|
24
|
+
/**
|
|
25
|
+
* The project under which the experiment task traces are recorded
|
|
26
|
+
* Note: This can be null when no project is associated with the experiment
|
|
27
|
+
*/
|
|
28
|
+
projectName: string | null;
|
|
29
|
+
/**
|
|
30
|
+
* The creation timestamp of the experiment
|
|
31
|
+
*/
|
|
32
|
+
createdAt: string;
|
|
33
|
+
/**
|
|
34
|
+
* The last update timestamp of the experiment
|
|
35
|
+
*/
|
|
36
|
+
updatedAt: string;
|
|
37
|
+
/**
|
|
38
|
+
* Number of examples in the experiment
|
|
39
|
+
*/
|
|
40
|
+
exampleCount: number;
|
|
41
|
+
/**
|
|
42
|
+
* Number of successful runs in the experiment
|
|
43
|
+
*/
|
|
44
|
+
successfulRunCount: number;
|
|
45
|
+
/**
|
|
46
|
+
* Number of failed runs in the experiment
|
|
47
|
+
*/
|
|
48
|
+
failedRunCount: number;
|
|
49
|
+
/**
|
|
50
|
+
* Number of missing (not yet executed) runs in the experiment
|
|
51
|
+
*/
|
|
52
|
+
missingRunCount: number;
|
|
22
53
|
}
|
|
23
54
|
|
|
24
55
|
export type ExperimentRunID = string;
|
|
25
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Represents incomplete experiment runs for a dataset example
|
|
59
|
+
* Groups all incomplete repetitions for a single example
|
|
60
|
+
*/
|
|
61
|
+
export interface IncompleteRun {
|
|
62
|
+
/**
|
|
63
|
+
* The dataset example that has incomplete runs
|
|
64
|
+
*/
|
|
65
|
+
datasetExample: Example;
|
|
66
|
+
/**
|
|
67
|
+
* List of repetition numbers that need to be run for this example
|
|
68
|
+
*/
|
|
69
|
+
repetitionNumbers: number[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface IncompleteEvaluation {
|
|
73
|
+
/**
|
|
74
|
+
* The experiment run with incomplete evaluations
|
|
75
|
+
*/
|
|
76
|
+
experimentRun: ExperimentRun;
|
|
77
|
+
/**
|
|
78
|
+
* The dataset example for this run
|
|
79
|
+
*/
|
|
80
|
+
datasetExample: ExampleWithId;
|
|
81
|
+
/**
|
|
82
|
+
* List of evaluation names that are incomplete (either missing or failed)
|
|
83
|
+
*/
|
|
84
|
+
evaluationNames: string[];
|
|
85
|
+
}
|
|
86
|
+
|
|
26
87
|
/**
|
|
27
88
|
* A map of an experiment runId to the run
|
|
28
89
|
*/
|
|
@@ -117,3 +178,11 @@ export interface ExperimentParameters {
|
|
|
117
178
|
*/
|
|
118
179
|
nExamples: number;
|
|
119
180
|
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* A type that represents any type of evaluator that can be used in an experiment.
|
|
184
|
+
* Unknown is used to capture evaluators from an external library such as phoenix-evals.
|
|
185
|
+
*/
|
|
186
|
+
export type ExperimentEvaluatorLike =
|
|
187
|
+
| Evaluator
|
|
188
|
+
| LLMEvaluator<Record<string, unknown>>;
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A bounded, buffered CSP channel implementation for TypeScript.
|
|
3
|
+
*
|
|
4
|
+
* Implements the Producer-Consumer pattern with automatic backpressure via
|
|
5
|
+
* blocking send/receive semantics. Based on Communicating Sequential Processes (Hoare, 1978).
|
|
6
|
+
*
|
|
7
|
+
* Properties:
|
|
8
|
+
* - Bounded buffer: O(capacity) memory usage
|
|
9
|
+
* - Blocking send: Blocks when buffer is full
|
|
10
|
+
* - Blocking receive: Blocks when buffer is empty
|
|
11
|
+
* - Graceful shutdown: Close drains buffer before terminating
|
|
12
|
+
*
|
|
13
|
+
* Performance Characteristics:
|
|
14
|
+
* - send(): O(R) where R = pending receivers (typically 0-10)
|
|
15
|
+
* - receive(): O(B + S) where B = buffer size, S = pending senders
|
|
16
|
+
* - Uses Array.shift() which is O(n) but acceptable for small queues
|
|
17
|
+
* - Same complexity trade-off as async.queue, p-limit, and similar libraries
|
|
18
|
+
* - For typical usage (buffer < 100, queues < 10), overhead is negligible (<10ms per 5000 operations)
|
|
19
|
+
*
|
|
20
|
+
* Note: Could be optimized to O(1) with linked list or circular buffer, but current
|
|
21
|
+
* implementation prioritizes simplicity and is comparable to standard JS libraries.
|
|
22
|
+
*
|
|
23
|
+
* Deadlock Prevention:
|
|
24
|
+
* JavaScript channels use cooperative blocking via Promises, not true thread blocking.
|
|
25
|
+
* Deadlocks are rare but possible in certain patterns:
|
|
26
|
+
*
|
|
27
|
+
* ❌ AVOID: Sequential operations on unbuffered channels
|
|
28
|
+
* ```typescript
|
|
29
|
+
* const ch = new Channel<number>(0);
|
|
30
|
+
* await ch.send(1); // Blocks forever - no receiver started
|
|
31
|
+
* await ch.receive(); // Never reached
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
* ❌ AVOID: Circular dependencies between channels
|
|
35
|
+
* ```typescript
|
|
36
|
+
* const ch1 = new Channel(0);
|
|
37
|
+
* const ch2 = new Channel(0);
|
|
38
|
+
* // Task 1: await ch1.send() → await ch2.receive()
|
|
39
|
+
* // Task 2: await ch2.send() → await ch1.receive()
|
|
40
|
+
* // Both block on send, never reach receive
|
|
41
|
+
* ```
|
|
42
|
+
*
|
|
43
|
+
* ✅ SAFE: Concurrent start with buffered channels (recommended pattern)
|
|
44
|
+
* ```typescript
|
|
45
|
+
* const ch = new Channel<number>(); // Default (10) is safe
|
|
46
|
+
*
|
|
47
|
+
* // Start producer immediately
|
|
48
|
+
* const producer = (async () => {
|
|
49
|
+
* for (let i = 0; i < 100; i++) {
|
|
50
|
+
* await ch.send(i);
|
|
51
|
+
* }
|
|
52
|
+
* ch.close(); // Always close in finally block
|
|
53
|
+
* })();
|
|
54
|
+
*
|
|
55
|
+
* // Start consumers immediately
|
|
56
|
+
* const consumers = Array.from({ length: 5 }, async () => {
|
|
57
|
+
* for await (const value of ch) {
|
|
58
|
+
* await processValue(value);
|
|
59
|
+
* }
|
|
60
|
+
* });
|
|
61
|
+
*
|
|
62
|
+
* // Wait for all to complete
|
|
63
|
+
* await Promise.all([producer, ...consumers]);
|
|
64
|
+
* ```
|
|
65
|
+
*
|
|
66
|
+
* Best Practices:
|
|
67
|
+
* 1. Use default capacity or higher (10+) for production - provides safety and throughput
|
|
68
|
+
* 2. Always close() channels in a finally block to prevent hanging operations
|
|
69
|
+
* 3. Start producers and consumers concurrently, not sequentially
|
|
70
|
+
* 4. Use for-await loops for automatic cleanup on close
|
|
71
|
+
* 5. Avoid circular dependencies between channels
|
|
72
|
+
* 6. Handle errors in workers so they don't crash and leave channel blocked
|
|
73
|
+
* 7. Only use unbuffered (capacity=0) when you need strict happens-before guarantees
|
|
74
|
+
*
|
|
75
|
+
* @see https://en.wikipedia.org/wiki/Communicating_sequential_processes
|
|
76
|
+
*
|
|
77
|
+
* @template T The type of values sent through the channel
|
|
78
|
+
*
|
|
79
|
+
* @example Safe Producer-Consumer Pattern
|
|
80
|
+
* ```typescript
|
|
81
|
+
* // Default capacity (10) is safe for most cases
|
|
82
|
+
* const ch = new Channel<number>(); // or explicit: new Channel<number>(50)
|
|
83
|
+
*
|
|
84
|
+
* // Producer with proper cleanup
|
|
85
|
+
* const producer = (async () => {
|
|
86
|
+
* try {
|
|
87
|
+
* for (let i = 0; i < 100; i++) {
|
|
88
|
+
* await ch.send(i); // Blocks if buffer full (backpressure)
|
|
89
|
+
* }
|
|
90
|
+
* } finally {
|
|
91
|
+
* ch.close(); // Guaranteed cleanup
|
|
92
|
+
* }
|
|
93
|
+
* })();
|
|
94
|
+
*
|
|
95
|
+
* // Multiple consumers
|
|
96
|
+
* const consumers = Array.from({ length: 3 }, async () => {
|
|
97
|
+
* for await (const value of ch) {
|
|
98
|
+
* console.log(value);
|
|
99
|
+
* }
|
|
100
|
+
* });
|
|
101
|
+
*
|
|
102
|
+
* await Promise.all([producer, ...consumers]);
|
|
103
|
+
* ```
|
|
104
|
+
*
|
|
105
|
+
* @example Unbuffered Channel (Rendezvous)
|
|
106
|
+
* ```typescript
|
|
107
|
+
* const ch = new Channel<number>(0); // Unbuffered - use with care!
|
|
108
|
+
*
|
|
109
|
+
* // Must start both operations before awaiting
|
|
110
|
+
* const sendPromise = ch.send(42); // Starts but doesn't block caller yet
|
|
111
|
+
* const value = await ch.receive(); // Unblocks the sender
|
|
112
|
+
* await sendPromise; // Now safe to await
|
|
113
|
+
* ```
|
|
114
|
+
*/
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Internal type for blocked senders waiting to deliver values
|
|
118
|
+
*/
|
|
119
|
+
interface Sender<T> {
|
|
120
|
+
readonly value: T;
|
|
121
|
+
readonly resolve: () => void;
|
|
122
|
+
readonly reject: (error: Error) => void;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Internal type for blocked receivers waiting for values
|
|
127
|
+
*/
|
|
128
|
+
interface Receiver<T> {
|
|
129
|
+
readonly resolve: (value: T | typeof CLOSED) => void;
|
|
130
|
+
readonly reject: (error: Error) => void;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Custom error class for channel operations
|
|
135
|
+
*/
|
|
136
|
+
export class ChannelError extends Error {
|
|
137
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
138
|
+
super(message, options);
|
|
139
|
+
this.name = "ChannelError";
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Error messages for channel operations
|
|
145
|
+
*/
|
|
146
|
+
const ERRORS = {
|
|
147
|
+
SEND_TO_CLOSED: "Cannot send to closed channel",
|
|
148
|
+
CLOSED_WHILE_BLOCKED: "Channel closed while send was blocked",
|
|
149
|
+
NEGATIVE_CAPACITY: "Channel capacity must be non-negative",
|
|
150
|
+
} as const satisfies Record<string, string>;
|
|
151
|
+
|
|
152
|
+
export class Channel<T> {
|
|
153
|
+
#buffer: T[] = [];
|
|
154
|
+
#sendQueue: Sender<T>[] = [];
|
|
155
|
+
#receiveQueue: Receiver<T>[] = [];
|
|
156
|
+
#closed = false;
|
|
157
|
+
readonly #capacity: number;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Create a new channel with the specified buffer capacity.
|
|
161
|
+
*
|
|
162
|
+
* @param capacity - Buffer size (default: 10)
|
|
163
|
+
* - 0: Unbuffered/rendezvous channel - strict synchronization, higher deadlock risk.
|
|
164
|
+
* Use only when you need guaranteed happens-before ordering.
|
|
165
|
+
* - 1-100: Buffered channel - recommended for production use.
|
|
166
|
+
* - Higher values: Better throughput but more memory usage.
|
|
167
|
+
*
|
|
168
|
+
* @example
|
|
169
|
+
* ```typescript
|
|
170
|
+
* // Default buffered (safe for most cases)
|
|
171
|
+
* const ch1 = new Channel<number>();
|
|
172
|
+
*
|
|
173
|
+
* // Explicit buffer size (production pattern)
|
|
174
|
+
* const ch2 = new Channel<number>(50);
|
|
175
|
+
*
|
|
176
|
+
* // Unbuffered (advanced - strict synchronization)
|
|
177
|
+
* const ch3 = new Channel<number>(0);
|
|
178
|
+
* ```
|
|
179
|
+
*/
|
|
180
|
+
constructor(capacity: number = 10) {
|
|
181
|
+
if (capacity < 0) {
|
|
182
|
+
throw new ChannelError(ERRORS.NEGATIVE_CAPACITY);
|
|
183
|
+
}
|
|
184
|
+
this.#capacity = capacity;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Send a value to the channel
|
|
189
|
+
* Blocks if the buffer is full until space is available
|
|
190
|
+
*
|
|
191
|
+
* @param value - The value to send
|
|
192
|
+
* @throws {ChannelError} If channel is closed
|
|
193
|
+
*/
|
|
194
|
+
async send(value: T): Promise<void> {
|
|
195
|
+
if (this.#closed) {
|
|
196
|
+
throw new ChannelError(ERRORS.SEND_TO_CLOSED);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Direct delivery to waiting receiver
|
|
200
|
+
const receiver = this.#receiveQueue.shift();
|
|
201
|
+
if (receiver) {
|
|
202
|
+
receiver.resolve(value);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Add to buffer if space available
|
|
207
|
+
if (this.#buffer.length < this.#capacity) {
|
|
208
|
+
this.#buffer.push(value);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Block until space available
|
|
213
|
+
return new Promise<void>((resolve, reject) => {
|
|
214
|
+
this.#sendQueue.push({ value, resolve, reject });
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Receive a value from the channel
|
|
220
|
+
* Blocks if no value is available until one arrives
|
|
221
|
+
*
|
|
222
|
+
* @returns The received value, or CLOSED symbol if channel is closed and empty
|
|
223
|
+
*/
|
|
224
|
+
async receive(): Promise<T | typeof CLOSED> {
|
|
225
|
+
// Drain buffer first
|
|
226
|
+
if (this.#buffer.length > 0) {
|
|
227
|
+
const value = this.#buffer.shift()!;
|
|
228
|
+
|
|
229
|
+
// Unblock a waiting sender
|
|
230
|
+
const sender = this.#sendQueue.shift();
|
|
231
|
+
if (sender) {
|
|
232
|
+
this.#buffer.push(sender.value);
|
|
233
|
+
sender.resolve();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return value;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Direct handoff from waiting sender (critical for unbuffered channels)
|
|
240
|
+
const sender = this.#sendQueue.shift();
|
|
241
|
+
if (sender) {
|
|
242
|
+
sender.resolve();
|
|
243
|
+
return sender.value;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Channel closed and empty
|
|
247
|
+
if (this.#closed) {
|
|
248
|
+
return CLOSED;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Block until value available
|
|
252
|
+
return new Promise<T | typeof CLOSED>((resolve, reject) => {
|
|
253
|
+
this.#receiveQueue.push({ resolve, reject });
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Try to receive a value without blocking
|
|
259
|
+
* Returns immediately with value or undefined if channel is empty
|
|
260
|
+
*
|
|
261
|
+
* @returns The received value, CLOSED if channel is closed, or undefined if empty
|
|
262
|
+
*
|
|
263
|
+
* @example
|
|
264
|
+
* ```typescript
|
|
265
|
+
* const ch = new Channel<number>(10);
|
|
266
|
+
* await ch.send(42);
|
|
267
|
+
*
|
|
268
|
+
* const value = ch.tryReceive();
|
|
269
|
+
* if (value !== undefined && value !== CLOSED) {
|
|
270
|
+
* console.log("Got:", value);
|
|
271
|
+
* }
|
|
272
|
+
* ```
|
|
273
|
+
*/
|
|
274
|
+
tryReceive(): T | typeof CLOSED | undefined {
|
|
275
|
+
// Drain buffer first
|
|
276
|
+
if (this.#buffer.length > 0) {
|
|
277
|
+
const value = this.#buffer.shift()!;
|
|
278
|
+
|
|
279
|
+
// Unblock a waiting sender
|
|
280
|
+
const sender = this.#sendQueue.shift();
|
|
281
|
+
if (sender) {
|
|
282
|
+
this.#buffer.push(sender.value);
|
|
283
|
+
sender.resolve();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return value;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Direct handoff from waiting sender
|
|
290
|
+
const sender = this.#sendQueue.shift();
|
|
291
|
+
if (sender) {
|
|
292
|
+
sender.resolve();
|
|
293
|
+
return sender.value;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Channel closed and empty
|
|
297
|
+
if (this.#closed) {
|
|
298
|
+
return CLOSED;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Channel empty but not closed
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Close the channel
|
|
307
|
+
* No more sends allowed, but remaining values can be received
|
|
308
|
+
*/
|
|
309
|
+
close(): void {
|
|
310
|
+
if (this.#closed) return;
|
|
311
|
+
this.#closed = true;
|
|
312
|
+
|
|
313
|
+
// Resolve all blocked receivers
|
|
314
|
+
for (const receiver of this.#receiveQueue) {
|
|
315
|
+
receiver.resolve(CLOSED);
|
|
316
|
+
}
|
|
317
|
+
this.#receiveQueue = [];
|
|
318
|
+
|
|
319
|
+
// Reject all blocked senders
|
|
320
|
+
const error = new ChannelError(ERRORS.CLOSED_WHILE_BLOCKED);
|
|
321
|
+
for (const sender of this.#sendQueue) {
|
|
322
|
+
sender.reject(error);
|
|
323
|
+
}
|
|
324
|
+
this.#sendQueue = [];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Check if channel is closed
|
|
329
|
+
*/
|
|
330
|
+
get isClosed(): boolean {
|
|
331
|
+
return this.#closed;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Get current buffer length
|
|
336
|
+
*/
|
|
337
|
+
get length(): number {
|
|
338
|
+
return this.#buffer.length;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Get the channel's capacity
|
|
343
|
+
*/
|
|
344
|
+
get capacity(): number {
|
|
345
|
+
return this.#capacity;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Get the number of blocked senders waiting
|
|
350
|
+
*/
|
|
351
|
+
get pendingSends(): number {
|
|
352
|
+
return this.#sendQueue.length;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Get the number of blocked receivers waiting
|
|
357
|
+
*/
|
|
358
|
+
get pendingReceives(): number {
|
|
359
|
+
return this.#receiveQueue.length;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Async iterator support for for-await-of loops
|
|
364
|
+
*/
|
|
365
|
+
async *[Symbol.asyncIterator](): AsyncIterableIterator<T> {
|
|
366
|
+
while (true) {
|
|
367
|
+
const value = await this.receive();
|
|
368
|
+
if (value === CLOSED) return;
|
|
369
|
+
yield value;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Special symbol to indicate channel is closed
|
|
376
|
+
*/
|
|
377
|
+
export const CLOSED = Symbol("CLOSED");
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Type guard to check if a value is the CLOSED symbol
|
|
381
|
+
*
|
|
382
|
+
* @param value - Value to check
|
|
383
|
+
* @returns true if value is CLOSED symbol
|
|
384
|
+
*
|
|
385
|
+
* @example
|
|
386
|
+
* ```typescript
|
|
387
|
+
* const value = await ch.receive();
|
|
388
|
+
* if (isClosed(value)) {
|
|
389
|
+
* console.log("Channel is closed");
|
|
390
|
+
* } else {
|
|
391
|
+
* console.log("Got value:", value);
|
|
392
|
+
* }
|
|
393
|
+
* ```
|
|
394
|
+
*/
|
|
395
|
+
export function isClosed<T>(value: T | typeof CLOSED): value is typeof CLOSED {
|
|
396
|
+
return value === CLOSED;
|
|
397
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type guard to check if an error is an HTTP error with a response status code.
|
|
3
|
+
* This safely narrows the type without unsafe type assertions.
|
|
4
|
+
*
|
|
5
|
+
* @param error - The error to check
|
|
6
|
+
* @returns True if the error has a response.status property
|
|
7
|
+
*/
|
|
8
|
+
export function isHttpError(
|
|
9
|
+
error: unknown
|
|
10
|
+
): error is { response: { status: number } } {
|
|
11
|
+
if (typeof error !== "object" || error === null) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (!("response" in error)) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const errorWithResponse = error as { response: unknown };
|
|
20
|
+
|
|
21
|
+
if (
|
|
22
|
+
typeof errorWithResponse.response !== "object" ||
|
|
23
|
+
errorWithResponse.response === null
|
|
24
|
+
) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const response = errorWithResponse.response as Record<string, unknown>;
|
|
29
|
+
|
|
30
|
+
return "status" in response && typeof response.status === "number";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Safely checks if an error is an HTTP error with a specific status code.
|
|
35
|
+
*
|
|
36
|
+
* @param error - The error to check
|
|
37
|
+
* @param statusCode - The status code to check for
|
|
38
|
+
* @returns True if the error has the specified status code
|
|
39
|
+
*/
|
|
40
|
+
export function isHttpErrorWithStatus(
|
|
41
|
+
error: unknown,
|
|
42
|
+
statusCode: number
|
|
43
|
+
): boolean {
|
|
44
|
+
return isHttpError(error) && error.response.status === statusCode;
|
|
45
|
+
}
|