@asaidimu/hestia 1.0.0 → 1.0.1

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/auth/store.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { HestiaNetworkClient, IdentityProvider } from "../core/client";
1
+ import { HestiaNetworkClient, type IdentityProvider } from "../core/client";
2
2
  import type { LoginResult, ServerHealth, TokenPair } from "./types";
3
3
 
4
4
  export class HestiaAuth {
@@ -29,7 +29,7 @@ export class HestiaAuth {
29
29
  name: string,
30
30
  ): Promise<{ _id_: string; email: string; name: string }> {
31
31
  const res = await this.client.post<{
32
- data: { _id_: string; email: string; name: string, scopes:string };
32
+ data: { _id_: string; email: string; name: string, permissions: string[] };
33
33
  }>("/system/auth/user", { email, password, name });
34
34
  return res.data!.data;
35
35
  }
package/auth/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { UserIdentity } from "../system/identity/types"
1
+ import type { UserIdentity } from "../system/identity/types"
2
2
 
3
3
  export interface TokenPair {
4
4
  access: string
package/blobs/store.ts CHANGED
@@ -2,7 +2,8 @@ import type { QueryDSL } from "@asaidimu/query";
2
2
  import { ReactiveDataStore } from "@asaidimu/utils-store";
3
3
  import { HestiaNetworkClient } from "../core/client";
4
4
  import { createPagedController } from "../core/pager";
5
- import type { Page, PagedData } from "../core/types";
5
+ import type { Document, Page, PagedData, StoreEvent } from "../core/types";
6
+ import type { DocumentStore } from "../core/types";
6
7
  import type {
7
8
  BlobDocument,
8
9
  BlobMeta,
@@ -34,12 +35,7 @@ function pageMeta<T extends Record<string,any>>(items: T[]): Page<T>["page"] {
34
35
  };
35
36
  }
36
37
 
37
- /**
38
- * Namespace-scoped blob facade shaped like HestiaCollection.
39
- * Wraps blob CRUD behind a DocumentStore-compatible interface
40
- * so you can use DataTable + PagedData.
41
- */
42
- export class BlobNamespace {
38
+ export class BlobNamespace implements DocumentStore<BlobMeta, QueryDSL<BlobMeta>, string, QueryDSL<BlobMeta>, Record<string, unknown>, string, Record<string, any>, Record<string, unknown>, { key: string; contentType?: string }, Record<string, unknown>> {
43
39
  private pagerOptions = {};
44
40
  private pager: PagedData<BlobMeta>;
45
41
  private prefixFilter = "";
@@ -85,7 +81,7 @@ export class BlobNamespace {
85
81
  return { data: items.map(asDoc), loading: false, page: pageMeta(items), error: undefined };
86
82
  }
87
83
 
88
- async head(key: string): Promise<BlobDocument | undefined> {
84
+ async read(key: string): Promise<Document<BlobMeta> | undefined> {
89
85
  try {
90
86
  const res = await this.client.get<{ data: BlobMeta }>(
91
87
  `${this.basePath()}/${encodeURIComponent(key)}`,
@@ -104,76 +100,83 @@ export class BlobNamespace {
104
100
  }
105
101
  }
106
102
 
107
- async upload(
108
- key: string,
109
- data: Blob,
110
- contentType?: string,
111
- ): Promise<BlobDocument> {
112
- const headers: Record<string, string> = {};
113
- const ct = contentType || data.type;
114
- if (ct) headers["Content-Type"] = ct;
103
+ async create(_props: { data: Partial<BlobMeta> }): Promise<Document<BlobMeta> | undefined> {
104
+ throw new Error("Use upload() to create blobs");
105
+ }
115
106
 
116
- const res = await this.client.post<{ data: BlobMeta }>(
107
+ async update(props: { data: Partial<BlobMeta>; options?: Record<string, any> }): Promise<Document<BlobMeta> | undefined> {
108
+ const key = props.options?.key as string;
109
+ if (!key) throw new Error("options.key is required for blob update");
110
+ const res = await this.client.patch<{ data: BlobMeta }>(
117
111
  `${this.basePath()}/${encodeURIComponent(key)}`,
118
- data,
119
- { headers, bodyType: "blob" },
112
+ { custom: props.data },
120
113
  );
121
114
  return asDoc(res.data!.data);
122
115
  }
123
116
 
124
- async download(
125
- key: string,
126
- ): Promise<{ data: Blob; contentType: string }> {
127
- const res = await this.client.get<Blob>(
117
+ async delete(key: string): Promise<void> {
118
+ await this.client.delete(
128
119
  `${this.basePath()}/${encodeURIComponent(key)}`,
129
- { responseType: "blob" },
130
120
  );
131
- const blob = res.data!;
132
- return { data: blob, contentType: blob.type };
133
121
  }
134
122
 
135
- async updateMetadata(key: string, custom: Record<string, any>): Promise<BlobMeta> {
136
- const res = await this.client.patch<{ data: BlobMeta }>(
137
- `${this.basePath()}/${encodeURIComponent(key)}`,
138
- { custom },
139
- );
140
- return res.data!.data;
123
+ async list(options?: QueryDSL<BlobMeta>): Promise<Page<BlobMeta>> {
124
+ return this.find(options ?? {});
141
125
  }
142
126
 
143
- async delete(key: string): Promise<void> {
144
- await this.client.delete(
127
+ async upload(props: { file: File; options?: { key?: string; contentType?: string } }): Promise<Document<BlobMeta> | undefined> {
128
+ const key = (props.options as any)?.key as string;
129
+ if (!key) throw new Error("options.key is required for blob upload");
130
+ const headers: Record<string, string> = {};
131
+ const ct = props.options?.contentType || props.file.type;
132
+ if (ct) headers["Content-Type"] = ct;
133
+
134
+ const res = await this.client.post<{ data: BlobMeta }>(
145
135
  `${this.basePath()}/${encodeURIComponent(key)}`,
136
+ props.file,
137
+ { headers, bodyType: "blob" },
146
138
  );
139
+ return asDoc(res.data!.data);
147
140
  }
148
141
 
149
- async list(): Promise<BlobMeta[]> {
150
- const res = await this.client.post<{
151
- data: { blobs: BlobMeta[] };
152
- }>(`${this.basePath()}/query`, {});
153
- return res.data?.data?.blobs ?? [];
142
+ async subscribe(_scope: string, _callback: (event: StoreEvent) => void): Promise<() => void> {
143
+ throw new Error("Subscription not supported for blobs");
154
144
  }
155
145
 
156
- page(): PagedData<BlobMeta> {
146
+ async notify(_event: StoreEvent): Promise<void> {
147
+ throw new Error("Notify not supported for blobs");
148
+ }
149
+
150
+ stream(_options: Record<string, unknown>, _onStreamChange: () => void): {
151
+ stream: () => AsyncIterable<Document<BlobMeta>>;
152
+ cancel: () => void;
153
+ status: () => "active" | "cancelled" | "completed";
154
+ } {
155
+ throw new Error("Stream not supported for blobs");
156
+ }
157
+
158
+ page(_options?: Record<string, unknown>): PagedData<BlobMeta> {
157
159
  return this.pager;
158
160
  }
161
+
162
+ async download(key: string): Promise<{ data: Blob; contentType: string }> {
163
+ const res = await this.client.get<Blob>(
164
+ `${this.basePath()}/${encodeURIComponent(key)}`,
165
+ { responseType: "blob" },
166
+ );
167
+ const blob = res.data!;
168
+ return { data: blob, contentType: blob.type };
169
+ }
159
170
  }
160
171
 
161
- /**
162
- * Top-level blob client. Entry point for all blob operations.
163
- *
164
- * Usage:
165
- * client.blobs.listNamespaces()
166
- * const ns = client.blobs.namespace("my-bucket")
167
- * ns.find({ prefix: "images/" })
168
- * ns.upload("logo.png", file)
169
- * const { data } = await ns.download("logo.png")
170
- */
171
172
  export class HestiaBlobClient {
172
- constructor(private client: HestiaNetworkClient) {}
173
+ private apiPrefix: string;
173
174
 
174
- private nsBase = "/system/blobs";
175
+ constructor(private client: HestiaNetworkClient, apiPrefix: string = "/api") {
176
+ this.apiPrefix = apiPrefix;
177
+ }
175
178
 
176
- // ── Namespace operations ──────────────────────────────────────────────
179
+ private nsBase = "/system/blobs";
177
180
 
178
181
  async namespaces(): Promise<NamespaceInfo[]> {
179
182
  const res = await this.client.post<{
@@ -196,13 +199,11 @@ export class HestiaBlobClient {
196
199
  );
197
200
  }
198
201
 
199
-
200
202
  blob(namespace: string, key:string) {
201
- return `${this.client.base()}${this.nsBase}/blob/${encodeURIComponent(namespace)}/${encodeURIComponent(key)}`
203
+ return `${this.client.base()}${this.apiPrefix}${this.nsBase}/blob/${encodeURIComponent(namespace)}/${encodeURIComponent(key)}`
202
204
  }
203
- // ── Namespace-scoped facade ───────────────────────────────────────────
204
205
 
205
206
  namespace(ns: string): BlobNamespace {
206
207
  return new BlobNamespace(this.client, ns);
207
208
  }
208
- }
209
+ }
@@ -1,59 +1,88 @@
1
1
  import { HestiaNetworkClient } from "../core/client"
2
2
  import { HestiaCollection } from "../core/collection"
3
- import type { Document } from "../core/types"
3
+ import type { Document, Page, PagedData, StoreEvent } from "../core/types"
4
+ import type { DocumentStore } from "../core/types"
4
5
  import type { CollectionMeta } from "./types"
5
6
 
6
- export class HestiaCollections {
7
+ export class HestiaCollections implements DocumentStore<CollectionMeta, Record<string, unknown>, string, Record<string, unknown>, Record<string, unknown>, string, string, Record<string, unknown>> {
7
8
  constructor(private client: HestiaNetworkClient) {}
8
9
 
9
- async list(): Promise<{ collections: CollectionMeta[]; total: number }> {
10
+ async find(_query?: Record<string, unknown>): Promise<Page<CollectionMeta>> {
10
11
  const res = await this.client.get<{
11
12
  data: { name: string; schema: any; created: string; updated: string }[]
12
13
  }>("/system/collections/collection")
13
14
  const items = res.data?.data ?? []
15
+ const docs: Document<CollectionMeta>[] = items.map((i) => ({
16
+ _id_: i.name,
17
+ _metadata_: { checksum: "", created: i.created, updated: i.updated, version: 1 },
18
+ name: i.name,
19
+ schema: i.schema,
20
+ created: i.created,
21
+ updated: i.updated,
22
+ }))
14
23
  return {
15
- collections: items.map((i) => ({
16
- name: i.name,
17
- schema: i.schema,
18
- created: i.created,
19
- updated: i.updated,
20
- })),
21
- total: items.length,
24
+ data: docs,
25
+ loading: false,
26
+ page: { number: 1, size: docs.length, count: docs.length, total: docs.length, pages: 1 },
22
27
  }
23
28
  }
24
29
 
25
- async read(name: string): Promise<CollectionMeta | undefined> {
30
+ async read(name: string): Promise<Document<CollectionMeta> | undefined> {
26
31
  try {
27
32
  const res = await this.client.get<{ data: { name: string; schema: any; created: string; updated: string } }>(
28
33
  `/system/collections/collection/${encodeURIComponent(name)}`,
29
34
  )
30
35
  if (!res.data) return undefined
31
- return res.data.data
36
+ const d = res.data.data
37
+ return { _id_: d.name, _metadata_: { checksum: "", created: d.created, updated: d.updated, version: 1 }, name: d.name, schema: d.schema, created: d.created, updated: d.updated }
32
38
  } catch (err: any) {
33
39
  if (err?.code === "SYNC-001-NF") return undefined
34
40
  throw err
35
41
  }
36
42
  }
37
43
 
38
- async create(schema: any): Promise<Document<{ schema: any }>> {
39
- const res = await this.client.post<{ data: Document<{ schema: any }> }>("/system/collections/collection", schema)
40
- return res.data!.data
44
+ async create(props: { data: Partial<CollectionMeta> }): Promise<Document<CollectionMeta> | undefined> {
45
+ const res = await this.client.post<{ data: Document<{ schema: any }> }>("/system/collections/collection", props.data)
46
+ return res.data!.data as any as Document<CollectionMeta>
41
47
  }
42
48
 
43
- async update(name: string, schema: any): Promise<Document<{ schema: any }>> {
44
- throw new Error("Method not implemented")
45
- // const res = await this.client.patch<{ data: Document<{ schema: any }> }>(
46
- // `/api/admin/collections/${encodeURIComponent(name)}`,
47
- // schema,
48
- // )
49
- // return res.data!.data
49
+ async update(_props: { data: Partial<CollectionMeta>; options?: string }): Promise<Document<CollectionMeta> | undefined> {
50
+ throw new Error("Collection update not implemented")
50
51
  }
51
52
 
52
53
  async delete(name: string): Promise<void> {
53
54
  await this.client.delete(`/system/collections/collection/${encodeURIComponent(name)}`)
54
55
  }
55
56
 
57
+ async list(_options?: Record<string, unknown>): Promise<Page<CollectionMeta>> {
58
+ return this.find()
59
+ }
60
+
61
+ async upload(_props: { file: File }): Promise<Document<CollectionMeta> | undefined> {
62
+ throw new Error("Upload not supported for collections")
63
+ }
64
+
65
+ async subscribe(_scope: string, _callback: (event: StoreEvent) => void): Promise<() => void> {
66
+ throw new Error("Subscription not supported for collections")
67
+ }
68
+
69
+ async notify(_event: StoreEvent): Promise<void> {
70
+ throw new Error("Notify not supported for collections")
71
+ }
72
+
73
+ stream(_options: Record<string, unknown>, _onStreamChange: () => void): {
74
+ stream: () => AsyncIterable<Document<CollectionMeta>>;
75
+ cancel: () => void;
76
+ status: () => "active" | "cancelled" | "completed";
77
+ } {
78
+ throw new Error("Stream not supported for collections")
79
+ }
80
+
81
+ page(_options?: Record<string, unknown>): PagedData<CollectionMeta> {
82
+ throw new Error("Pagination not supported for collection metadata; use documents(name)")
83
+ }
84
+
56
85
  documents<T extends Record<string, any>>(collectionName: string): HestiaCollection<T> {
57
- return new HestiaCollection<T>(this.client,collectionName)
86
+ return new HestiaCollection<T>(this.client, collectionName)
58
87
  }
59
- }
88
+ }
@@ -1,4 +1,4 @@
1
- import { SchemaDefinition } from "@asaidimu/utils-schema"
1
+ import type { SchemaDefinition } from "@asaidimu/utils-schema"
2
2
  import type { Document } from "../core/types"
3
3
 
4
4
  export interface CollectionMeta {
package/container.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { ArtifactContainer } from "@asaidimu/utils-artifacts";
2
1
  import type { SimplePersistence } from "@asaidimu/utils-persistence";
3
2
  import { ReactiveDataStore } from "@asaidimu/utils-store";
4
3
  import { HestiaAuth } from "./auth/store";
@@ -9,7 +8,7 @@ import {
9
8
  } from "./core/client";
10
9
  import { HestiaKeyStore } from "./system/api-keys/store";
11
10
  import { HestiaUsers } from "./system/identity/store";
12
- import { UserIdentity } from "./system/identity/types";
11
+ import type { UserIdentity } from "./system/identity/types";
13
12
  import { HestiaLogs } from "./system/logs/store";
14
13
  import { HestiaPolicies } from "./system/policies/store";
15
14
  import { HestiaBlobClient } from "./blobs/store";
@@ -17,6 +16,7 @@ import { HestiaCapabilities } from "./system/capabilities/store";
17
16
 
18
17
  export interface HestiaConfig {
19
18
  baseUrl: string;
19
+ apiPrefix?: string;
20
20
  persistence?: SimplePersistence<AuthState>;
21
21
  }
22
22
 
@@ -26,11 +26,8 @@ interface AuthState {
26
26
  identity: UserIdentity | null;
27
27
  }
28
28
 
29
- type Registry = Record<string, any>;
30
-
31
29
  export class HestiaClient {
32
30
  readonly store: ReactiveDataStore<AuthState>;
33
- readonly container: ArtifactContainer<Registry, any>;
34
31
  readonly client: HestiaNetworkClient;
35
32
  readonly auth: HestiaAuth;
36
33
  readonly users: HestiaUsers;
@@ -50,8 +47,6 @@ export class HestiaClient {
50
47
  config.persistence,
51
48
  );
52
49
 
53
- this.container = new ArtifactContainer<Registry, any>(this.store);
54
-
55
50
  const tokenProvider: IdentityProvider = {
56
51
  identity: () => this.store.get().identity,
57
52
  token: (key: "access" | "refresh") => this.store.get()[key],
@@ -63,8 +58,10 @@ export class HestiaClient {
63
58
  void (await this.store.set({ access: null, refresh: null })),
64
59
  };
65
60
 
61
+ const apiPrefix = config.apiPrefix ?? "/api";
62
+
66
63
  this.tokenProvider = tokenProvider;
67
- this.client = new HestiaNetworkClient(config.baseUrl, tokenProvider, () =>
64
+ this.client = new HestiaNetworkClient(config.baseUrl, apiPrefix, tokenProvider, () =>
68
65
  this.onAuthStateChanged?.(),
69
66
  );
70
67
 
@@ -75,9 +72,10 @@ export class HestiaClient {
75
72
  this.logs = new HestiaLogs(
76
73
  this.client,
77
74
  config.baseUrl,
75
+ apiPrefix,
78
76
  );
79
77
  this.collections = new HestiaCollections(this.client);
80
- this.blobs = new HestiaBlobClient(this.client);
78
+ this.blobs = new HestiaBlobClient(this.client, apiPrefix);
81
79
  this.capabilities = new HestiaCapabilities(this.client)
82
80
  }
83
81
 
package/core/client.ts CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  } from "@asaidimu/network-client";
6
6
  import { SystemError } from "@asaidimu/utils-error";
7
7
  import { parseErrorBody, toSystemError } from "./errors";
8
- import { UserIdentity } from "../system/identity/types";
8
+ import type { UserIdentity } from "../system/identity/types";
9
9
 
10
10
  export interface IdentityProvider {
11
11
  identity(): UserIdentity | null;
@@ -39,6 +39,22 @@ interface RequestOptions {
39
39
  signal?: AbortSignal;
40
40
  }
41
41
 
42
+ // Handlers driving a live server-sent-events stream opened via
43
+ // HestiaNetworkClient.openStream(). onMessage fires once per parsed SSE
44
+ // "data:" payload (still a raw string — callers decide whether/how to
45
+ // JSON.parse it); onOpen/onClose/onError report connection lifecycle.
46
+ export interface StreamHandlers {
47
+ onMessage: (data: string) => void;
48
+ onError?: (err: Error) => void;
49
+ onOpen?: () => void;
50
+ onClose?: () => void;
51
+ }
52
+
53
+ export interface StreamOptions {
54
+ headers?: Record<string, string>;
55
+ signal?: AbortSignal;
56
+ }
57
+
42
58
  export class HestiaNetworkClient {
43
59
  private raw: NetworkClient;
44
60
  private refreshing: Promise<void> | null = null;
@@ -46,6 +62,7 @@ export class HestiaNetworkClient {
46
62
 
47
63
  constructor(
48
64
  private baseUrl: string,
65
+ private apiPrefix: string,
49
66
  private tokens: IdentityProvider,
50
67
  private onAuthStateChanged?: () => void,
51
68
  ) {
@@ -70,12 +87,39 @@ export class HestiaNetworkClient {
70
87
  base() {
71
88
  return this.baseUrl;
72
89
  }
90
+
91
+ prefix(): string {
92
+ return this.apiPrefix;
93
+ }
94
+
95
+ // Single entry-point for all path manipulation (prefix handling).
96
+ // Returns the relative path that should be appended to the base URL.
97
+ private canonicalPath(path: string): string {
98
+ // 1. Strip any leading slashes so we work with a clean base (e.g., "api/api/system" or "/api/system")
99
+ let cleanPath = path.replace(/^\/+/, "");
100
+
101
+ if (this.apiPrefix) {
102
+ // 2. Strip leading slashes from the prefix too, just to be safe and consistent
103
+ const cleanPrefix = this.apiPrefix.replace(/^\/+/, "");
104
+
105
+ // 3. If the path already starts with the prefix followed by a slash (or is exactly the prefix),
106
+ // remove it so we don't double up.
107
+ const prefixRegex = new RegExp(`^${cleanPrefix}/?`);
108
+ cleanPath = cleanPath.replace(prefixRegex, "");
109
+
110
+ // 4. Combine them cleanly
111
+ return `${cleanPrefix}/${cleanPath}`;
112
+ }
113
+
114
+ return cleanPath;
115
+ }
116
+
73
117
  async storeTokens(access: string, refresh?: string): Promise<void> {
74
118
  this.refreshFailed = false;
75
119
  await this.tokens.setTokens(access, refresh ?? "");
76
120
  }
77
121
 
78
- private async refreshToken(): Promise<void> {
122
+ private async refreshToken(): Promise<void> {
79
123
  if (this.refreshing) return this.refreshing;
80
124
 
81
125
  this.refreshing = this.doRefresh();
@@ -92,7 +136,7 @@ export class HestiaNetworkClient {
92
136
  const body = refresh ? { refresh_token: refresh } : {};
93
137
  const res = await this.raw.patch<{
94
138
  data: { token: { access: string; refresh: string } };
95
- }>("/system/auth/session", body);
139
+ }>(this.canonicalPath("/system/auth/session"), body);
96
140
 
97
141
  if (!res.success || !res.data) {
98
142
  this.tokens.clear();
@@ -114,6 +158,8 @@ export class HestiaNetworkClient {
114
158
  body?: unknown,
115
159
  options?: RequestOptions,
116
160
  ): Promise<HestiaResponse<T>> {
161
+ const fullPath = this.canonicalPath(path);
162
+
117
163
  const opts: any = {};
118
164
  if (options?.headers) opts.headers = options.headers;
119
165
  if (options?.responseType) opts.responseType = options.responseType;
@@ -123,13 +169,13 @@ export class HestiaNetworkClient {
123
169
  let res: ApiResponse<T>;
124
170
 
125
171
  if (method === "GET") {
126
- res = await this.raw.get<T>(path, opts);
172
+ res = await this.raw.get<T>(fullPath, opts);
127
173
  } else {
128
174
  const bodyOpts = options?.bodyType
129
175
  ? { type: options.bodyType as BodyType }
130
176
  : undefined;
131
177
  res = (await (this.raw as any)[method.toLowerCase()](
132
- path,
178
+ fullPath,
133
179
  body,
134
180
  opts,
135
181
  bodyOpts,
@@ -153,7 +199,7 @@ export class HestiaNetworkClient {
153
199
  });
154
200
  }
155
201
 
156
- // If a previous refresh already failed, don't loop — the tokens
202
+ // If a previous refresh already failed, dont loop — the tokens
157
203
  // (including any cookie fallback) are known to be dead.
158
204
  if (this.refreshFailed) {
159
205
  this.tokens.clear();
@@ -180,17 +226,20 @@ export class HestiaNetworkClient {
180
226
  const newAccess = this.tokens.token("access");
181
227
  const retryOpts: any = { ...opts };
182
228
  if (newAccess) {
183
- retryOpts.headers = { ...(opts.headers ?? {}), Authorization: `Bearer ${newAccess}` };
229
+ retryOpts.headers = {
230
+ ...(opts.headers ?? {}),
231
+ Authorization: `Bearer ${newAccess}`,
232
+ };
184
233
  }
185
234
 
186
235
  if (method === "GET") {
187
- res = await this.raw.get<T>(path, retryOpts);
236
+ res = await this.raw.get<T>(fullPath, retryOpts);
188
237
  } else {
189
238
  const bodyOpts = options?.bodyType
190
239
  ? { type: options.bodyType as BodyType }
191
240
  : undefined;
192
241
  res = (await (this.raw as any)[method.toLowerCase()](
193
- path,
242
+ fullPath,
194
243
  body,
195
244
  retryOpts,
196
245
  bodyOpts,
@@ -244,4 +293,116 @@ export class HestiaNetworkClient {
244
293
  ): Promise<HestiaResponse<T>> {
245
294
  return this.request<T>("DELETE", path, body, options);
246
295
  }
296
+
297
+ // Opens an authenticated, long-lived GET request against a
298
+ // "text/event-stream" endpoint (e.g. system:audit:log:stream) and parses
299
+ // standard SSE framing ("data: ...\n\n") off the raw response body.
300
+ //
301
+ // Bypasses `raw` deliberately: the wrapped network-client's request/
302
+ // response cycle is built around single JSON responses, not an
303
+ // indefinitely-open ReadableStream, so this talks to `fetch` directly.
304
+ // On a 401 it will attempt exactly one token refresh and reconnect once
305
+ // before giving up — mirroring `request()`'s retry behavior, but capped
306
+ // at a single attempt since a stream that keeps failing auth shouldn’t
307
+ // loop indefinitely in the background.
308
+ async openStream(
309
+ path: string,
310
+ handlers: StreamHandlers,
311
+ options?: StreamOptions,
312
+ ): Promise<void> {
313
+ const attempt = async (isRetry: boolean): Promise<void> => {
314
+ const access = this.tokens.token("access");
315
+ const isApiKeyAuth = !!options?.headers?.["X-API-Key"];
316
+ const headers: Record<string, string> = {
317
+ Accept: "text/event-stream",
318
+ ...(access && !isApiKeyAuth
319
+ ? { Authorization: `Bearer ${access}` }
320
+ : {}),
321
+ ...(options?.headers ?? {}),
322
+ };
323
+
324
+ const url = `${this.baseUrl.replace(/\/+$/, "")}/${this.canonicalPath(path)}`;
325
+
326
+ let response: Response;
327
+ try {
328
+ response = await fetch(url, {
329
+ method: "GET",
330
+ headers,
331
+ signal: options?.signal,
332
+ });
333
+ } catch (err) {
334
+ if (err instanceof Error && err.name === "AbortError") {
335
+ handlers.onClose?.();
336
+ return;
337
+ }
338
+ handlers.onError?.(err instanceof Error ? err : new Error(String(err)));
339
+ return;
340
+ }
341
+
342
+ if (response.status === 401 && !isRetry && !isApiKeyAuth) {
343
+ try {
344
+ await this.refreshToken();
345
+ } catch {
346
+ this.tokens.clear();
347
+ this.onAuthStateChanged?.();
348
+ handlers.onError?.(
349
+ new SystemError({
350
+ code: "AUTH-002-UNAUTH",
351
+ message: "Session expired",
352
+ }),
353
+ );
354
+ return;
355
+ }
356
+ return attempt(true);
357
+ }
358
+
359
+ if (!response.ok || !response.body) {
360
+ handlers.onError?.(
361
+ new Error(`Stream request failed with status ${response.status}`),
362
+ );
363
+ return;
364
+ }
365
+
366
+ handlers.onOpen?.();
367
+
368
+ const reader = response.body.getReader();
369
+ const decoder = new TextDecoder();
370
+ let buffer = "";
371
+
372
+ try {
373
+ while (true) {
374
+ const { done, value } = await reader.read();
375
+ if (done) break;
376
+ buffer += decoder.decode(value, { stream: true });
377
+
378
+ let separatorIndex = buffer.indexOf("\n\n");
379
+ while (separatorIndex !== -1) {
380
+ const rawEvent = buffer.slice(0, separatorIndex);
381
+ buffer = buffer.slice(separatorIndex + 2);
382
+
383
+ const dataLines = rawEvent
384
+ .split("\n")
385
+ .filter((line) => line.startsWith("data:"))
386
+ .map((line) => line.slice(5).trim());
387
+
388
+ if (dataLines.length > 0) {
389
+ handlers.onMessage(dataLines.join("\n"));
390
+ }
391
+
392
+ separatorIndex = buffer.indexOf("\n\n");
393
+ }
394
+ }
395
+ } catch (err) {
396
+ if (!(err instanceof Error && err.name === "AbortError")) {
397
+ handlers.onError?.(
398
+ err instanceof Error ? err : new Error(String(err)),
399
+ );
400
+ }
401
+ } finally {
402
+ handlers.onClose?.();
403
+ }
404
+ };
405
+
406
+ return attempt(false);
407
+ }
247
408
  }