@ironflow/browser 0.1.0-test.2 → 0.2.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/dist/client.js CHANGED
@@ -3,11 +3,13 @@
3
3
  *
4
4
  * Singleton client for browser-based real-time interactions with Ironflow.
5
5
  */
6
- import { NotConfiguredError, IronflowError, ValidationError, createLogger, createNoopLogger, DEFAULT_TIMEOUTS, TriggerResponseSchema, RunResponseSchema, ListRunsResponseSchema, RunStatusSchema, ErrorResponseSchema, safeJsonParse, patterns, } from "@ironflow/core";
6
+ import { NotConfiguredError, IronflowError, ValidationError, createLogger, createNoopLogger, DEFAULT_TIMEOUTS, HEADERS, TriggerResponseSchema, RunResponseSchema, ListRunsResponseSchema, RunStatusSchema, ErrorResponseSchema, safeJsonParse, patterns, } from "@ironflow/core";
7
7
  import { mergeConfig } from "./config.js";
8
8
  import { SubscriptionManager, } from "./subscription.js";
9
9
  import { createWebSocketTransport } from "./transport/websocket.js";
10
10
  import { createConnectRPCTransport } from "./transport/connectrpc.js";
11
+ import { BrowserKVClient } from "./kv.js";
12
+ import { BrowserConfigClient } from "./config-client.js";
11
13
  /**
12
14
  * Ironflow browser client singleton
13
15
  */
@@ -43,6 +45,7 @@ class IronflowClient {
43
45
  reconnectDelay: this.config.reconnect.backoff.initial,
44
46
  maxReconnectDelay: this.config.reconnect.backoff.max,
45
47
  reconnectBackoff: this.config.reconnect.backoff.multiplier,
48
+ environment: this.config.environment,
46
49
  };
47
50
  // Create transport based on config (ConnectRPC by default)
48
51
  if (this.config.transport === "websocket") {
@@ -85,11 +88,15 @@ class IronflowClient {
85
88
  const serverUrl = this.config?.serverUrl ?? "http://localhost:9123";
86
89
  try {
87
90
  // Try to get server capabilities via ConnectRPC endpoint
91
+ const detectHeaders = {
92
+ "Content-Type": "application/json",
93
+ };
94
+ if (this.config?.environment) {
95
+ detectHeaders[HEADERS.ENVIRONMENT] = this.config.environment;
96
+ }
88
97
  const response = await fetch(`${serverUrl}/ironflow.v1.IronflowService/GetCapabilities`, {
89
98
  method: "POST",
90
- headers: {
91
- "Content-Type": "application/json",
92
- },
99
+ headers: detectHeaders,
93
100
  body: "{}",
94
101
  });
95
102
  if (response.ok) {
@@ -263,6 +270,187 @@ class IronflowClient {
263
270
  const response = await this.request(RunResponseSchema, "POST", "/ironflow.v1.IronflowService/CancelRun", { id: runId, reason });
264
271
  return this.mapRunResponse(response);
265
272
  }
273
+ /**
274
+ * Retry a failed run
275
+ */
276
+ async retryRun(runId, fromStep) {
277
+ this.ensureConfigured();
278
+ const response = await this.request(RunResponseSchema, "POST", "/ironflow.v1.IronflowService/RetryRun", { id: runId, fromStep });
279
+ return this.mapRunResponse(response);
280
+ }
281
+ /**
282
+ * Patch a step's output (hot patching)
283
+ */
284
+ async patchStep(stepId, output, reason) {
285
+ this.ensureConfigured();
286
+ const url = `${this.config.serverUrl}/api/v1/steps/patch`;
287
+ const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
288
+ const controller = new AbortController();
289
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
290
+ try {
291
+ const headers = {
292
+ "Content-Type": "application/json",
293
+ [HEADERS.ENVIRONMENT]: this.config.environment,
294
+ };
295
+ if (this.config.auth?.apiKey) {
296
+ headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
297
+ }
298
+ const response = await fetch(url, {
299
+ method: "POST",
300
+ headers,
301
+ body: JSON.stringify({ step_id: stepId, output, reason: reason || "" }),
302
+ signal: controller.signal,
303
+ });
304
+ if (!response.ok) {
305
+ const error = safeJsonParse(await response.text());
306
+ throw new IronflowError(error?.message || `Patch step failed: ${response.status}`, { code: error?.code || "PATCH_FAILED" });
307
+ }
308
+ }
309
+ finally {
310
+ clearTimeout(timeoutId);
311
+ }
312
+ }
313
+ /**
314
+ * Resume a paused or failed run
315
+ */
316
+ async resumeRun(runId, fromStep) {
317
+ this.ensureConfigured();
318
+ const url = `${this.config.serverUrl}/api/v1/runs/resume`;
319
+ const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
320
+ const controller = new AbortController();
321
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
322
+ try {
323
+ const headers = {
324
+ "Content-Type": "application/json",
325
+ [HEADERS.ENVIRONMENT]: this.config.environment,
326
+ };
327
+ if (this.config.auth?.apiKey) {
328
+ headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
329
+ }
330
+ const response = await fetch(url, {
331
+ method: "POST",
332
+ headers,
333
+ body: JSON.stringify({ run_id: runId, from_step: fromStep || "" }),
334
+ signal: controller.signal,
335
+ });
336
+ if (!response.ok) {
337
+ const error = safeJsonParse(await response.text());
338
+ throw new IronflowError(error?.message || `Resume run failed: ${response.status}`, { code: error?.code || "RESUME_FAILED" });
339
+ }
340
+ return response.json();
341
+ }
342
+ finally {
343
+ clearTimeout(timeoutId);
344
+ }
345
+ }
346
+ /**
347
+ * List registered functions
348
+ */
349
+ async listFunctions() {
350
+ this.ensureConfigured();
351
+ const url = `${this.config.serverUrl}/api/v1/functions`;
352
+ const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
353
+ const controller = new AbortController();
354
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
355
+ try {
356
+ const headers = {
357
+ [HEADERS.ENVIRONMENT]: this.config.environment,
358
+ };
359
+ if (this.config.auth?.apiKey) {
360
+ headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
361
+ }
362
+ const response = await fetch(url, {
363
+ method: "GET",
364
+ headers,
365
+ signal: controller.signal,
366
+ });
367
+ if (!response.ok) {
368
+ throw new IronflowError(`List functions failed: ${response.status}`, { code: "LIST_FUNCTIONS_FAILED" });
369
+ }
370
+ const data = await response.json();
371
+ return data.functions || [];
372
+ }
373
+ finally {
374
+ clearTimeout(timeoutId);
375
+ }
376
+ }
377
+ /**
378
+ * List connected workers
379
+ */
380
+ async listWorkers() {
381
+ this.ensureConfigured();
382
+ const url = `${this.config.serverUrl}/api/v1/workers`;
383
+ const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
384
+ const controller = new AbortController();
385
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
386
+ try {
387
+ const headers = {
388
+ [HEADERS.ENVIRONMENT]: this.config.environment,
389
+ };
390
+ if (this.config.auth?.apiKey) {
391
+ headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
392
+ }
393
+ const response = await fetch(url, {
394
+ method: "GET",
395
+ headers,
396
+ signal: controller.signal,
397
+ });
398
+ if (!response.ok) {
399
+ throw new IronflowError(`List workers failed: ${response.status}`, { code: "LIST_WORKERS_FAILED" });
400
+ }
401
+ const data = await response.json();
402
+ return data.workers || [];
403
+ }
404
+ finally {
405
+ clearTimeout(timeoutId);
406
+ }
407
+ }
408
+ /**
409
+ * Health check
410
+ */
411
+ async health() {
412
+ this.ensureConfigured();
413
+ const url = `${this.config.serverUrl}/health`;
414
+ const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
415
+ const controller = new AbortController();
416
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
417
+ try {
418
+ const response = await fetch(url, {
419
+ method: "GET",
420
+ signal: controller.signal,
421
+ });
422
+ if (!response.ok) {
423
+ throw new IronflowError(`Health check failed: ${response.status}`, { code: "HEALTH_FAILED" });
424
+ }
425
+ return response.json();
426
+ }
427
+ finally {
428
+ clearTimeout(timeoutId);
429
+ }
430
+ }
431
+ /**
432
+ * Get server capabilities
433
+ */
434
+ async getCapabilities() {
435
+ this.ensureConfigured();
436
+ const url = `${this.config.serverUrl}/api/v1/capabilities`;
437
+ const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
438
+ const controller = new AbortController();
439
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
440
+ try {
441
+ const response = await fetch(url, {
442
+ method: "GET",
443
+ signal: controller.signal,
444
+ });
445
+ if (!response.ok) {
446
+ throw new IronflowError(`Get capabilities failed: ${response.status}`, { code: "CAPABILITIES_FAILED" });
447
+ }
448
+ return response.json();
449
+ }
450
+ finally {
451
+ clearTimeout(timeoutId);
452
+ }
453
+ }
266
454
  // ============================================================================
267
455
  // Event Emission
268
456
  // ============================================================================
@@ -282,6 +470,7 @@ class IronflowClient {
282
470
  const response = await this.request(TriggerResponseSchema, "POST", "/ironflow.v1.PubSubService/Emit", {
283
471
  event: eventName,
284
472
  data,
473
+ ...(options?.version ? { version: options.version } : {}),
285
474
  idempotency_key: options?.idempotencyKey,
286
475
  metadata: options?.metadata,
287
476
  namespace: options?.namespace,
@@ -307,6 +496,417 @@ class IronflowClient {
307
496
  return sub;
308
497
  }
309
498
  // ============================================================================
499
+ // Entity Streams
500
+ // ============================================================================
501
+ /**
502
+ * Entity stream operations
503
+ *
504
+ * @example
505
+ * ```typescript
506
+ * // Append an event to a stream
507
+ * const result = await ironflow.streams.append("order-123", {
508
+ * name: "order.created",
509
+ * data: { total: 100 },
510
+ * entityType: "order",
511
+ * });
512
+ *
513
+ * // Read events from a stream
514
+ * const { events } = await ironflow.streams.read("order-123", { limit: 10 });
515
+ *
516
+ * // Get stream info
517
+ * const info = await ironflow.streams.getInfo("order-123");
518
+ * ```
519
+ */
520
+ streams = {
521
+ /**
522
+ * Append an event to an entity stream
523
+ */
524
+ append: async (entityId, input, options) => {
525
+ this.ensureConfigured();
526
+ const response = await this.streamRequest("/ironflow.v1.EntityStreamService/AppendEvent", {
527
+ entity_id: entityId,
528
+ entity_type: input.entityType,
529
+ event_name: input.name,
530
+ data: input.data,
531
+ expected_version: options?.expectedVersion ?? -1,
532
+ idempotency_key: options?.idempotencyKey ?? "",
533
+ version: options?.version ?? 1,
534
+ });
535
+ return {
536
+ entityVersion: Number(response.entityVersion ?? 0),
537
+ eventId: response.eventId ?? "",
538
+ };
539
+ },
540
+ /**
541
+ * Read events from an entity stream
542
+ */
543
+ read: async (entityId, options) => {
544
+ this.ensureConfigured();
545
+ const response = await this.streamRequest("/ironflow.v1.EntityStreamService/ReadStream", {
546
+ entity_id: entityId,
547
+ from_version: options?.fromVersion ?? 0,
548
+ limit: options?.limit ?? 0,
549
+ direction: options?.direction ?? "forward",
550
+ });
551
+ return {
552
+ events: (response.events ?? []).map((e) => ({
553
+ id: e.id,
554
+ name: e.name,
555
+ data: e.data ?? {},
556
+ entityVersion: Number(e.entityVersion ?? 0),
557
+ version: e.version,
558
+ timestamp: e.timestamp,
559
+ source: e.source,
560
+ metadata: e.metadata,
561
+ })),
562
+ totalCount: Number(response.totalCount ?? 0),
563
+ };
564
+ },
565
+ /**
566
+ * Get information about an entity stream
567
+ */
568
+ getInfo: async (entityId) => {
569
+ this.ensureConfigured();
570
+ const response = await this.streamRequest("/ironflow.v1.EntityStreamService/GetStreamInfo", {
571
+ entity_id: entityId,
572
+ });
573
+ return {
574
+ entityId: response.entityId ?? "",
575
+ entityType: response.entityType ?? "",
576
+ version: Number(response.version ?? 0),
577
+ eventCount: Number(response.eventCount ?? 0),
578
+ createdAt: response.createdAt ?? "",
579
+ updatedAt: response.updatedAt ?? "",
580
+ };
581
+ },
582
+ /**
583
+ * Subscribe to real-time events for an entity stream
584
+ *
585
+ * @example
586
+ * ```typescript
587
+ * const sub = await ironflow.streams.subscribe("order-123", {
588
+ * entityType: "order",
589
+ * onEvent: (event) => console.log(event),
590
+ * replay: 100,
591
+ * });
592
+ *
593
+ * // Cleanup
594
+ * sub.unsubscribe();
595
+ * ```
596
+ */
597
+ subscribe: async (entityId, options) => {
598
+ this.ensureConfigured();
599
+ const pattern = `entity:${options.entityType}.${entityId}.>`;
600
+ const sub = await this.subscribe(pattern, {
601
+ onEvent: (event) => {
602
+ const data = event.data;
603
+ const streamEvent = {
604
+ id: data.id ?? "",
605
+ name: data.name ?? "",
606
+ data: data.data ?? {},
607
+ entityVersion: data.entityVersion ?? 0,
608
+ version: data.version ?? 0,
609
+ timestamp: data.timestamp ?? "",
610
+ source: data.source,
611
+ metadata: data.metadata,
612
+ };
613
+ options.onEvent(streamEvent);
614
+ },
615
+ onError: options.onError
616
+ ? (info) => options.onError(new Error(info.message))
617
+ : undefined,
618
+ replay: options.replay,
619
+ });
620
+ return sub;
621
+ },
622
+ };
623
+ // ============================================================================
624
+ // Projections
625
+ // ============================================================================
626
+ /**
627
+ * Get the current state of a projection
628
+ *
629
+ * @example
630
+ * ```typescript
631
+ * const result = await ironflow.getProjection<OrderStats>('order-stats');
632
+ * console.log(result.state); // { totalOrders: 42, ... }
633
+ *
634
+ * // With partition
635
+ * const result = await ironflow.getProjection('order-stats', { partition: 'customer-123' });
636
+ * ```
637
+ */
638
+ async getProjection(name, options) {
639
+ this.ensureConfigured();
640
+ let url = `${this.config.serverUrl}/api/v1/projections/${encodeURIComponent(name)}`;
641
+ if (options?.partition) {
642
+ url += `?partition=${encodeURIComponent(options.partition)}`;
643
+ }
644
+ const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
645
+ const controller = new AbortController();
646
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
647
+ try {
648
+ const headers = {
649
+ [HEADERS.ENVIRONMENT]: this.config.environment,
650
+ };
651
+ if (this.config.auth?.apiKey) {
652
+ headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
653
+ }
654
+ const response = await fetch(url, {
655
+ method: "GET",
656
+ headers,
657
+ signal: controller.signal,
658
+ });
659
+ if (!response.ok) {
660
+ const error = safeJsonParse(await response.text());
661
+ throw new IronflowError(error?.message || `Get projection failed: ${response.status}`, { code: error?.code || "GET_PROJECTION_FAILED" });
662
+ }
663
+ const data = await response.json();
664
+ return {
665
+ name: data.name,
666
+ partition: data.state?.partition_key ?? "__global__",
667
+ state: data.state?.state ?? {},
668
+ lastEventId: data.state?.last_event_id ?? "",
669
+ lastEventTime: data.state?.last_event_time
670
+ ? new Date(data.state.last_event_time)
671
+ : new Date(0),
672
+ version: data.state?.version ?? 0,
673
+ mode: data.mode,
674
+ };
675
+ }
676
+ finally {
677
+ clearTimeout(timeoutId);
678
+ }
679
+ }
680
+ /**
681
+ * Get the status of a projection
682
+ *
683
+ * @example
684
+ * ```typescript
685
+ * const status = await ironflow.getProjectionStatus('order-stats');
686
+ * console.log(status.status); // 'active' | 'rebuilding' | 'paused' | 'error'
687
+ * ```
688
+ */
689
+ async getProjectionStatus(name) {
690
+ this.ensureConfigured();
691
+ const url = `${this.config.serverUrl}/api/v1/projections/${encodeURIComponent(name)}/status`;
692
+ const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
693
+ const controller = new AbortController();
694
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
695
+ try {
696
+ const headers = {
697
+ [HEADERS.ENVIRONMENT]: this.config.environment,
698
+ };
699
+ if (this.config.auth?.apiKey) {
700
+ headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
701
+ }
702
+ const response = await fetch(url, {
703
+ method: "GET",
704
+ headers,
705
+ signal: controller.signal,
706
+ });
707
+ if (!response.ok) {
708
+ const error = safeJsonParse(await response.text());
709
+ throw new IronflowError(error?.message || `Get projection status failed: ${response.status}`, { code: error?.code || "GET_PROJECTION_STATUS_FAILED" });
710
+ }
711
+ const data = await response.json();
712
+ return {
713
+ name: data.name,
714
+ status: data.status,
715
+ mode: data.mode,
716
+ lastEventSeq: data.last_event_seq ?? 0,
717
+ lag: data.lag ?? 0,
718
+ errorMessage: data.error_message || undefined,
719
+ updatedAt: data.updated_at ? new Date(data.updated_at) : new Date(),
720
+ };
721
+ }
722
+ finally {
723
+ clearTimeout(timeoutId);
724
+ }
725
+ }
726
+ /**
727
+ * Trigger a rebuild of a projection
728
+ *
729
+ * @example
730
+ * ```typescript
731
+ * const result = await ironflow.rebuildProjection('order-stats');
732
+ * ```
733
+ */
734
+ async rebuildProjection(name, options) {
735
+ this.ensureConfigured();
736
+ const url = `${this.config.serverUrl}/api/v1/projections/${encodeURIComponent(name)}/rebuild`;
737
+ const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
738
+ const controller = new AbortController();
739
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
740
+ try {
741
+ const headers = {
742
+ "Content-Type": "application/json",
743
+ [HEADERS.ENVIRONMENT]: this.config.environment,
744
+ };
745
+ if (this.config.auth?.apiKey) {
746
+ headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
747
+ }
748
+ const response = await fetch(url, {
749
+ method: "POST",
750
+ headers,
751
+ body: JSON.stringify({
752
+ partition: options?.partition,
753
+ from_event_id: options?.fromEventId,
754
+ dry_run: options?.dryRun,
755
+ }),
756
+ signal: controller.signal,
757
+ });
758
+ if (!response.ok) {
759
+ const error = safeJsonParse(await response.text());
760
+ throw new IronflowError(error?.message || `Rebuild projection failed: ${response.status}`, { code: error?.code || "REBUILD_PROJECTION_FAILED" });
761
+ }
762
+ return response.json();
763
+ }
764
+ finally {
765
+ clearTimeout(timeoutId);
766
+ }
767
+ }
768
+ /**
769
+ * List all registered projections
770
+ *
771
+ * @example
772
+ * ```typescript
773
+ * const projections = await ironflow.listProjections();
774
+ * projections.forEach(p => console.log(p.name, p.status));
775
+ * ```
776
+ */
777
+ async listProjections() {
778
+ this.ensureConfigured();
779
+ const url = `${this.config.serverUrl}/api/v1/projections`;
780
+ const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
781
+ const controller = new AbortController();
782
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
783
+ try {
784
+ const headers = {
785
+ [HEADERS.ENVIRONMENT]: this.config.environment,
786
+ };
787
+ if (this.config.auth?.apiKey) {
788
+ headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
789
+ }
790
+ const response = await fetch(url, {
791
+ method: "GET",
792
+ headers,
793
+ signal: controller.signal,
794
+ });
795
+ if (!response.ok) {
796
+ throw new IronflowError(`List projections failed: ${response.status}`, { code: "LIST_PROJECTIONS_FAILED" });
797
+ }
798
+ const data = await response.json();
799
+ const projections = data.projections || [];
800
+ return projections.map((p) => ({
801
+ name: p.name,
802
+ status: p.status,
803
+ mode: p.mode,
804
+ lastEventSeq: p.last_event_seq ?? 0,
805
+ lag: 0,
806
+ errorMessage: p.error_message || undefined,
807
+ updatedAt: p.updated_at ? new Date(p.updated_at) : new Date(),
808
+ }));
809
+ }
810
+ finally {
811
+ clearTimeout(timeoutId);
812
+ }
813
+ }
814
+ /**
815
+ * Subscribe to real-time updates for a projection
816
+ *
817
+ * @example
818
+ * ```typescript
819
+ * const sub = await ironflow.subscribeToProjection<OrderStats>('order-stats', {
820
+ * onUpdate: (state, event) => console.log('Updated:', state),
821
+ * onError: (error) => console.error(error),
822
+ * });
823
+ *
824
+ * // With partition
825
+ * const sub = await ironflow.subscribeToProjection('order-stats', {
826
+ * onUpdate: (state, event) => console.log('Updated:', state),
827
+ * }, { partition: 'customer-123' });
828
+ *
829
+ * // Cleanup
830
+ * sub.unsubscribe();
831
+ * ```
832
+ */
833
+ async subscribeToProjection(name, callbacks, options) {
834
+ this.ensureConfigured();
835
+ // Build the subscription pattern
836
+ let pattern;
837
+ if (options?.partition) {
838
+ pattern = `system.projection.${name}.${options.partition}.updated`;
839
+ }
840
+ else {
841
+ pattern = `system.projection.${name}.>`;
842
+ }
843
+ const sub = await this.subscribe(pattern, {
844
+ onEvent: (event) => {
845
+ const payload = event.data;
846
+ const state = payload.state ?? {};
847
+ callbacks.onUpdate(state, {
848
+ id: payload.last_event_id ?? "",
849
+ name: payload.last_event_name ?? "",
850
+ });
851
+ },
852
+ onError: callbacks.onError
853
+ ? (info) => callbacks.onError(new Error(info.message))
854
+ : undefined,
855
+ replay: options?.replay,
856
+ });
857
+ return sub;
858
+ }
859
+ // ============================================================================
860
+ // KV Store
861
+ // ============================================================================
862
+ /**
863
+ * KV store operations
864
+ *
865
+ * @example
866
+ * ```typescript
867
+ * const kv = ironflow.kv();
868
+ * const bucket = await kv.createBucket({ name: "sessions", ttlSeconds: 3600 });
869
+ * const handle = kv.bucket("sessions");
870
+ * const { revision } = await handle.put("user.123", { token: "abc" });
871
+ * const entry = await handle.get("user.123");
872
+ *
873
+ * // Watch for changes
874
+ * const watcher = handle.watch({
875
+ * onUpdate: (event) => console.log(event),
876
+ * }, { key: "user.*" });
877
+ * ```
878
+ */
879
+ kv() {
880
+ this.ensureConfigured();
881
+ return new BrowserKVClient(this.config);
882
+ }
883
+ // ============================================================================
884
+ // Config Management
885
+ // ============================================================================
886
+ /**
887
+ * Config management operations
888
+ *
889
+ * @example
890
+ * ```typescript
891
+ * const cfg = ironflow.configManager();
892
+ * await cfg.set("app", { featureX: true, maxRetries: 3 });
893
+ * const { data, revision } = await cfg.get("app");
894
+ * await cfg.patch("app", { maxRetries: 5 });
895
+ * const configs = await cfg.list();
896
+ * await cfg.delete("app");
897
+ *
898
+ * // Watch for changes
899
+ * const sub = await cfg.watch("app", {
900
+ * onEvent: (config) => console.log(config),
901
+ * });
902
+ * sub.unsubscribe();
903
+ * ```
904
+ */
905
+ configManager() {
906
+ this.ensureConfigured();
907
+ return new BrowserConfigClient(this.config, (pattern, callbacks) => this.subscribe(pattern, callbacks));
908
+ }
909
+ // ============================================================================
310
910
  // Pattern Helpers (static)
311
911
  // ============================================================================
312
912
  /**
@@ -332,6 +932,15 @@ class IronflowClient {
332
932
  }
333
933
  this.transport = null;
334
934
  }
935
+ /**
936
+ * Reset client state for testing. Not intended for production use.
937
+ * @internal
938
+ */
939
+ _resetForTesting() {
940
+ this.cleanup();
941
+ this.config = null;
942
+ this.logger = createNoopLogger();
943
+ }
335
944
  setupVisibilityHandling() {
336
945
  this.visibilityHandler = () => {
337
946
  if (document.hidden) {
@@ -355,6 +964,7 @@ class IronflowClient {
355
964
  try {
356
965
  const headers = {
357
966
  "Content-Type": "application/json",
967
+ [HEADERS.ENVIRONMENT]: this.config.environment,
358
968
  };
359
969
  if (this.config.auth?.apiKey) {
360
970
  headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
@@ -397,12 +1007,64 @@ class IronflowClient {
397
1007
  throw error;
398
1008
  }
399
1009
  if (error instanceof Error && error.name === "AbortError") {
400
- throw new IronflowError(`Request timeout after ${timeout}ms`, {
1010
+ throw new IronflowError(`Request timeout after ${timeout}ms for ${method} ${path}`, {
401
1011
  code: "TIMEOUT",
402
1012
  retryable: true,
403
1013
  });
404
1014
  }
405
- throw new IronflowError(error instanceof Error ? error.message : "Request failed", {
1015
+ throw new IronflowError(error instanceof Error
1016
+ ? `${method} ${path} failed: ${error.message}`
1017
+ : `${method} ${path} failed`, {
1018
+ code: "REQUEST_FAILED",
1019
+ retryable: true,
1020
+ cause: error instanceof Error ? error : undefined,
1021
+ });
1022
+ }
1023
+ finally {
1024
+ clearTimeout(timeoutId);
1025
+ }
1026
+ }
1027
+ async streamRequest(path, body) {
1028
+ const url = `${this.config.serverUrl}${path}`;
1029
+ const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
1030
+ const controller = new AbortController();
1031
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
1032
+ try {
1033
+ const headers = {
1034
+ "Content-Type": "application/json",
1035
+ [HEADERS.ENVIRONMENT]: this.config.environment,
1036
+ };
1037
+ if (this.config.auth?.apiKey) {
1038
+ headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
1039
+ }
1040
+ else if (this.config.auth?.token) {
1041
+ headers["Authorization"] = `Bearer ${this.config.auth.token}`;
1042
+ }
1043
+ const response = await fetch(url, {
1044
+ method: "POST",
1045
+ headers,
1046
+ body: JSON.stringify(body),
1047
+ signal: controller.signal,
1048
+ });
1049
+ if (!response.ok) {
1050
+ const errorBody = safeJsonParse(await response.text());
1051
+ throw new IronflowError(errorBody?.message ?? `Request failed: ${response.status}`, {
1052
+ code: errorBody?.code ?? `HTTP_${response.status}`,
1053
+ retryable: response.status >= 500,
1054
+ });
1055
+ }
1056
+ return response.json();
1057
+ }
1058
+ catch (error) {
1059
+ if (error instanceof IronflowError) {
1060
+ throw error;
1061
+ }
1062
+ if (error instanceof Error && error.name === "AbortError") {
1063
+ throw new IronflowError(`Request timeout after ${timeout}ms for POST ${path}`, { code: "TIMEOUT", retryable: true });
1064
+ }
1065
+ throw new IronflowError(error instanceof Error
1066
+ ? `POST ${path} failed: ${error.message}`
1067
+ : `POST ${path} failed`, {
406
1068
  code: "REQUEST_FAILED",
407
1069
  retryable: true,
408
1070
  cause: error instanceof Error ? error : undefined,
@@ -413,7 +1075,9 @@ class IronflowClient {
413
1075
  }
414
1076
  }
415
1077
  mapRunResponse(response) {
416
- const statusResult = RunStatusSchema.safeParse(response.status.toLowerCase());
1078
+ // ConnectRPC returns proto enum strings like "RUN_STATUS_COMPLETED" — normalize to "completed"
1079
+ const rawStatus = response.status.toLowerCase().replace(/^run_status_/, "");
1080
+ const statusResult = RunStatusSchema.safeParse(rawStatus);
417
1081
  const status = statusResult.success ? statusResult.data : "failed";
418
1082
  return {
419
1083
  id: response.id,