@nilejs/client 0.0.2 → 1.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/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # @nilejs/client
2
2
 
3
- A standalone, type-safe client for the [Nile](https://github.com/nile-js/nile) backend framework.
3
+ A type-safe frontend client for [Nile Js](https://nile-js.github.io/nile/) backends.
4
4
 
5
5
  ## Features
6
6
 
7
7
  - **Type-safe:** Generates and consumes types from your Nile backend for full compile-time payload validation.
8
- - **Graceful Error Handling:** Uses the result pattern `{ error, data }` instead of throwing exceptions.
9
- - **Zero Runtime Dependencies:** Built on raw `fetch` for maximum compatibility (uses `slang-ts` for safety).
8
+ - **Graceful Error Handling:** Returns the same Result pattern as the Nile Js backend (`isOk`, `isErr`, `value`, `error`) instead of throwing exceptions.
9
+ - **Small:** HTTP calls use the platform `fetch`. The only runtime dependency is `socket.io-client`, for the socket client.
10
10
  - **Flexible:** Supports custom headers, credentials, and timeouts per request.
11
11
 
12
12
  ## Installation
@@ -38,16 +38,16 @@ const nile = createNileClient({
38
38
  ### 2. Invoke Actions
39
39
 
40
40
  ```typescript
41
- const { error, data } = await nile.invoke({
41
+ const result = await nile.invoke({
42
42
  service: "tasks",
43
43
  action: "create",
44
44
  payload: { title: "Buy milk" },
45
45
  });
46
46
 
47
- if (error) {
48
- console.error("Action failed:", error);
47
+ if (result.isErr) {
48
+ console.error("Action failed:", result.error);
49
49
  } else {
50
- console.log("Success:", data);
50
+ console.log("Success:", result.value);
51
51
  }
52
52
  ```
53
53
 
@@ -78,10 +78,18 @@ await nile.invoke({
78
78
 
79
79
  ```typescript
80
80
  // Explore services and actions
81
- const { data: services } = await nile.explore({ service: "*", action: "*" });
81
+ const services = await nile.explore({ service: "*", action: "*" });
82
+
83
+ if (services.isOk) {
84
+ console.log(services.value);
85
+ }
82
86
 
83
87
  // Fetch action schemas
84
- const { data: schemas } = await nile.schema({ service: "tasks", action: "*" });
88
+ const schemas = await nile.schema({ service: "tasks", action: "*" });
89
+
90
+ if (schemas.isOk) {
91
+ console.log(schemas.value);
92
+ }
85
93
  ```
86
94
 
87
95
  ## API
@@ -97,7 +105,12 @@ Returns a Nile client instance.
97
105
 
98
106
  ### Methods
99
107
 
100
- All methods return a `Promise<ClientResult<T>>` where `ClientResult<T>` is `{ error: string | null, data: T | null }`.
108
+ All methods return a `Promise<ClientResult<T>>`. Calls never throw:
109
+
110
+ - Success: `{ isOk: true, isErr: false, value: T, error: null, message? }`, where `message` is the server's success message for HTTP calls
111
+ - Failure: `{ isOk: false, isErr: true, value: null, error: string, details }`, where `details` holds any data the server attached, such as validation issues. Network failures and timeouts arrive as failures too.
112
+
113
+ Check `isErr` first, then read `error` or `value`. The older `data` field is still present as a deprecated alias (the value on success, `null` on failure) so existing code keeps working.
101
114
 
102
115
  - `invoke(params)`: Execute a specific service action.
103
116
  - `explore(params)`: Discover services/actions using wildcards.
package/dist/index.cjs CHANGED
@@ -1,144 +1,386 @@
1
- "use strict";
2
- var __defProp = Object.defineProperty;
3
- var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
- var __getOwnPropNames = Object.getOwnPropertyNames;
5
- var __hasOwnProp = Object.prototype.hasOwnProperty;
6
- var __export = (target, all) => {
7
- for (var name in all)
8
- __defProp(target, name, { get: all[name], enumerable: true });
9
- };
10
- var __copyProps = (to, from, except, desc) => {
11
- if (from && typeof from === "object" || typeof from === "function") {
12
- for (let key of __getOwnPropNames(from))
13
- if (!__hasOwnProp.call(to, key) && key !== except)
14
- __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
- }
16
- return to;
17
- };
18
- var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
-
20
- // src/index.ts
21
- var index_exports = {};
22
- __export(index_exports, {
23
- createNileClient: () => createNileClient
24
- });
25
- module.exports = __toCommonJS(index_exports);
26
-
27
- // src/safe-try.ts
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region src/result.ts
3
+ /**
4
+ * A successful client result. `data` mirrors `value` so code written for
5
+ * earlier clients keeps working. `message` is present only when the server sent one.
6
+ */
7
+ function clientOk(value, message) {
8
+ return {
9
+ isOk: true,
10
+ isErr: false,
11
+ value,
12
+ error: null,
13
+ ...message === void 0 ? {} : { message },
14
+ data: value
15
+ };
16
+ }
17
+ /**
18
+ * A failed client result with a readable message and any data the server
19
+ * attached to the failure. `data` stays null, as it was for earlier clients.
20
+ */
21
+ function clientErr(error, details = null) {
22
+ return {
23
+ isOk: false,
24
+ isErr: true,
25
+ value: null,
26
+ error,
27
+ details,
28
+ data: null
29
+ };
30
+ }
31
+ //#endregion
32
+ //#region src/safe-try.ts
33
+ /**
34
+ * Gracefully handles both synchronous and asynchronous operations by returning a result object.
35
+ * This replaces the need for external slang-ts dependency and avoids try/catch boilerplate.
36
+ *
37
+ * @param fn - The function to execute (sync or async)
38
+ * @returns A result object containing either the value or the error
39
+ *
40
+ * @example
41
+ * const { isOk, value, error } = await safeTry(() => fetch(url));
42
+ */
28
43
  function safeTry(fn) {
29
- try {
30
- const result = fn();
31
- if (result instanceof Promise) {
32
- return result.then(
33
- (value) => ({
34
- isOk: true,
35
- value,
36
- isErr: false,
37
- error: null
38
- })
39
- ).catch(
40
- (error) => ({
41
- isOk: false,
42
- value: null,
43
- isErr: true,
44
- error
45
- })
46
- );
47
- }
48
- return { isOk: true, value: result, isErr: false, error: null };
49
- } catch (error) {
50
- return { isOk: false, value: null, isErr: true, error };
51
- }
44
+ try {
45
+ const result = fn();
46
+ if (result instanceof Promise) return result.then((value) => ({
47
+ isOk: true,
48
+ value,
49
+ isErr: false,
50
+ error: null
51
+ })).catch((error) => ({
52
+ isOk: false,
53
+ value: null,
54
+ isErr: true,
55
+ error
56
+ }));
57
+ return {
58
+ isOk: true,
59
+ value: result,
60
+ isErr: false,
61
+ error: null
62
+ };
63
+ } catch (error) {
64
+ return {
65
+ isOk: false,
66
+ value: null,
67
+ isErr: true,
68
+ error
69
+ };
70
+ }
52
71
  }
53
-
54
- // src/request.ts
72
+ //#endregion
73
+ //#region src/request.ts
74
+ /**
75
+ * Handles JSON communication with the Nile /services endpoint.
76
+ * Uses fetch + safeTry for crash safety and returns a Result that never throws.
77
+ */
55
78
  async function sendNileRequest(config, intent, service, action, payload, options = {}) {
56
- const { timeout = config.timeout ?? 3e4, ...fetchOptions } = options;
57
- const controller = new AbortController();
58
- const timeoutId = setTimeout(() => controller.abort(), timeout);
59
- const requestResult = await safeTry(async () => {
60
- const response = await fetch(`${config.baseUrl}/services`, {
61
- method: "POST",
62
- headers: {
63
- "Content-Type": "application/json",
64
- ...config.headers,
65
- ...fetchOptions.headers
66
- },
67
- credentials: config.credentials,
68
- body: JSON.stringify({
69
- intent,
70
- service,
71
- action,
72
- payload: payload ?? {}
73
- }),
74
- signal: controller.signal,
75
- ...fetchOptions
76
- });
77
- clearTimeout(timeoutId);
78
- const json = await response.json();
79
- return json;
80
- });
81
- if (requestResult.isErr) {
82
- const error = requestResult.error;
83
- let errorMsg = "An unknown error occurred";
84
- if (error instanceof Error) {
85
- errorMsg = error.name === "AbortError" ? "Request timed out" : error.message;
86
- } else {
87
- errorMsg = String(error);
88
- }
89
- return { error: errorMsg, data: null };
90
- }
91
- const result = requestResult.value;
92
- if (!result.status) {
93
- return { error: result.message, data: result.data };
94
- }
95
- return { error: null, data: result.data };
79
+ const { timeout = config.timeout ?? 3e4, ...fetchOptions } = options;
80
+ const controller = new AbortController();
81
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
82
+ const requestResult = await safeTry(async () => {
83
+ const response = await fetch(`${config.baseUrl}/services`, {
84
+ method: "POST",
85
+ headers: {
86
+ "Content-Type": "application/json",
87
+ ...config.headers,
88
+ ...fetchOptions.headers
89
+ },
90
+ credentials: config.credentials,
91
+ body: JSON.stringify({
92
+ intent,
93
+ service,
94
+ action,
95
+ payload: payload ?? {}
96
+ }),
97
+ signal: controller.signal,
98
+ ...fetchOptions
99
+ });
100
+ clearTimeout(timeoutId);
101
+ return await response.json();
102
+ });
103
+ if (requestResult.isErr) return resolveError(requestResult.error);
104
+ const result = requestResult.value;
105
+ if (!result.status) return clientErr(result.message, result.data ?? null);
106
+ return clientOk(result.data, result.message);
96
107
  }
97
-
98
- // src/create-client.ts
108
+ /**
109
+ * Handles multipart form-data uploads to the Nile /services endpoint.
110
+ * Builds a FormData body with RPC routing fields (intent, service, action),
111
+ * user-provided fields, and file attachments.
112
+ *
113
+ * Content-Type header is intentionally omitted so the runtime sets the
114
+ * correct multipart boundary automatically.
115
+ */
116
+ async function sendUploadRequest(config, service, action, files, fields, options = {}) {
117
+ const { timeout = config.timeout ?? 3e4, ...fetchOptions } = options;
118
+ const controller = new AbortController();
119
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
120
+ const formData = buildFormData(service, action, files, fields);
121
+ const requestResult = await safeTry(async () => {
122
+ const { "Content-Type": _ct, ...safeHeaders } = config.headers ?? {};
123
+ const { "Content-Type": _ct2, ...safeFetchHeaders } = fetchOptions.headers ?? {};
124
+ const response = await fetch(`${config.baseUrl}/services`, {
125
+ method: "POST",
126
+ headers: {
127
+ ...safeHeaders,
128
+ ...safeFetchHeaders
129
+ },
130
+ credentials: config.credentials,
131
+ body: formData,
132
+ signal: controller.signal,
133
+ ...fetchOptions
134
+ });
135
+ clearTimeout(timeoutId);
136
+ return await response.json();
137
+ });
138
+ if (requestResult.isErr) return resolveError(requestResult.error);
139
+ const result = requestResult.value;
140
+ if (!result.status) return clientErr(result.message, result.data ?? null);
141
+ return clientOk(result.data, result.message);
142
+ }
143
+ /**
144
+ * Builds a FormData instance with RPC routing fields and file/field entries.
145
+ * The server expects 'intent', 'service', 'action' as string fields.
146
+ */
147
+ function buildFormData(service, action, files, fields) {
148
+ const formData = new FormData();
149
+ formData.append("intent", "execute");
150
+ formData.append("service", service);
151
+ formData.append("action", action);
152
+ if (fields) for (const [key, value] of Object.entries(fields)) formData.append(key, value);
153
+ for (const [key, value] of Object.entries(files)) if (Array.isArray(value)) for (const file of value) formData.append(key, file);
154
+ else formData.append(key, value);
155
+ return formData;
156
+ }
157
+ /** Resolve error from safeTry into a ClientResult */
158
+ function resolveError(error) {
159
+ let errorMsg = "An unknown error occurred";
160
+ if (error instanceof Error) errorMsg = error.name === "AbortError" ? "Request timed out" : error.message;
161
+ else errorMsg = String(error);
162
+ return clientErr(errorMsg);
163
+ }
164
+ //#endregion
165
+ //#region src/create-client.ts
166
+ /**
167
+ * Creates a type-safe Nile client for interacting with backend services.
168
+ *
169
+ * @param config - The client configuration including baseUrl
170
+ * @returns A factory object with invoke, explore, schema, and upload methods
171
+ *
172
+ * @example
173
+ * const nile = createNileClient<ServicePayloads>({ baseUrl: "/api" });
174
+ * const result = await nile.invoke({
175
+ * service: "tasks",
176
+ * action: "create",
177
+ * payload: { title: "Buy milk" }
178
+ * });
179
+ * if (result.isErr) {
180
+ * console.error(result.error);
181
+ * } else {
182
+ * console.log(result.value);
183
+ * }
184
+ */
99
185
  function createNileClient(config) {
100
- return {
101
- /** Invoke a service action through the backend engine pipeline */
102
- invoke: async (params) => {
103
- const { service, action, payload, ...options } = params;
104
- const result = await sendNileRequest(
105
- config,
106
- "execute",
107
- service,
108
- action,
109
- payload,
110
- options
111
- );
112
- return result;
113
- },
114
- /** Discover available services and actions (supports "*" wildcard) */
115
- explore: async (params) => {
116
- const { service, action, ...options } = params;
117
- return await sendNileRequest(
118
- config,
119
- "explore",
120
- service,
121
- action,
122
- {},
123
- options
124
- );
125
- },
126
- /** Get action schemas as JSON Schema (supports "*" wildcard) */
127
- schema: async (params) => {
128
- const { service, action, ...options } = params;
129
- return await sendNileRequest(
130
- config,
131
- "schema",
132
- service,
133
- action,
134
- {},
135
- options
136
- );
137
- }
138
- };
186
+ return {
187
+ /** Invoke a service action through the backend engine pipeline */
188
+ invoke: async (params) => {
189
+ const { service, action, payload, fetchOptions } = params;
190
+ return await sendNileRequest(config, "execute", service, action, payload, fetchOptions);
191
+ },
192
+ /** Discover available services and actions (supports "*" wildcard) */
193
+ explore: async (params) => {
194
+ const { service, action, fetchOptions } = params;
195
+ return await sendNileRequest(config, "explore", service, action, {}, fetchOptions);
196
+ },
197
+ /** Get action schemas as JSON Schema (supports "*" wildcard) */
198
+ schema: async (params) => {
199
+ const { service, action, fetchOptions } = params;
200
+ return await sendNileRequest(config, "schema", service, action, {}, fetchOptions);
201
+ },
202
+ /** Upload files to an action via multipart form-data */
203
+ upload: async (params) => {
204
+ const { service, action, files, fields, fetchOptions } = params;
205
+ return await sendUploadRequest(config, service, action, files, fields, fetchOptions);
206
+ }
207
+ };
208
+ }
209
+ //#endregion
210
+ //#region src/socket-client.ts
211
+ /** Own a lazy Socket.IO connection with no offline write buffering or automatic invocation retries. */
212
+ function createNileSocketClient(config) {
213
+ const timeout = config.timeout ?? 3e4;
214
+ if (!Number.isSafeInteger(timeout) || timeout < 1) throw new Error("Socket timeout must be positive");
215
+ let socket;
216
+ let connecting;
217
+ let closed = false;
218
+ const listeners = /* @__PURE__ */ new Set();
219
+ const pending = /* @__PURE__ */ new Set();
220
+ const fail = (error) => clientErr(error);
221
+ const settleCallback = (fn) => {
222
+ Promise.resolve().then(fn).catch(() => void 0);
223
+ };
224
+ const request = async (event, input) => {
225
+ const connected = await connect();
226
+ if (connected.isErr) return fail(connected.error);
227
+ const current = connected.value;
228
+ if (!current.connected || closed) return fail("Socket disconnected");
229
+ return new Promise((resolve) => {
230
+ let settled = false;
231
+ const finish = (result) => {
232
+ if (settled) return;
233
+ settled = true;
234
+ pending.delete(cancel);
235
+ clearTimeout(timer);
236
+ resolve(result);
237
+ };
238
+ const cancel = () => finish(fail("Socket disconnected"));
239
+ const timer = setTimeout(() => finish(fail("Socket acknowledgement timed out")), timeout);
240
+ pending.add(cancel);
241
+ current.emit(event, input, (result) => {
242
+ if (!result || typeof result !== "object" || !("error" in result) || !("data" in result)) {
243
+ finish(fail("Invalid socket response"));
244
+ return;
245
+ }
246
+ if (typeof result.error === "string") {
247
+ finish(clientErr(result.error, result.data));
248
+ return;
249
+ }
250
+ finish(clientOk(result.data));
251
+ });
252
+ });
253
+ };
254
+ const rejoin = async () => {
255
+ for (const listener of listeners) {
256
+ if (!listener.active) continue;
257
+ const result = await request("nile:receive", {
258
+ topic: listener.topic,
259
+ scope: listener.scope
260
+ });
261
+ if (result.isOk) {
262
+ listener.normalizedScope = result.value?.scope;
263
+ continue;
264
+ }
265
+ const message = result.error;
266
+ settleCallback(() => config.onSubscriptionError?.({
267
+ topic: listener.topic,
268
+ error: message
269
+ }));
270
+ }
271
+ };
272
+ const connect = () => {
273
+ if (closed) return Promise.resolve(fail("Socket client is closed"));
274
+ if (socket?.connected) return Promise.resolve(clientOk(socket));
275
+ if (connecting) return connecting;
276
+ connecting = import("socket.io-client").then(({ io }) => new Promise((resolve) => {
277
+ if (closed) {
278
+ resolve(fail("Socket client is closed"));
279
+ return;
280
+ }
281
+ const current = socket ?? io(config.baseUrl, {
282
+ path: config.path ?? "/socket.io",
283
+ autoConnect: false,
284
+ withCredentials: true,
285
+ timeout,
286
+ auth: (done) => {
287
+ Promise.resolve().then(() => typeof config.auth === "function" ? config.auth() : config.auth ?? {}).then(done).catch(() => done({}));
288
+ }
289
+ });
290
+ if (!socket) {
291
+ socket = current;
292
+ current.on("disconnect", () => {
293
+ for (const cancel of [...pending]) cancel();
294
+ });
295
+ current.on("connect", () => {
296
+ rejoin();
297
+ });
298
+ current.on("nile:message", (message) => dispatchMessage(message, listeners));
299
+ }
300
+ let settled = false;
301
+ const finish = (result) => {
302
+ if (settled) return;
303
+ settled = true;
304
+ clearTimeout(timer);
305
+ current.off("connect", ready);
306
+ current.off("connect_error", failed);
307
+ pending.delete(cancel);
308
+ resolve(result);
309
+ };
310
+ const ready = () => finish(clientOk(current));
311
+ const failed = () => finish(fail("Socket connection failed"));
312
+ const cancel = () => finish(fail("Socket client closed"));
313
+ const timer = setTimeout(() => finish(fail("Socket connection timed out")), timeout);
314
+ pending.add(cancel);
315
+ current.once("connect", ready);
316
+ current.once("connect_error", failed);
317
+ current.connect();
318
+ })).catch(() => fail("Could not initialize socket client")).finally(() => {
319
+ connecting = void 0;
320
+ });
321
+ return connecting;
322
+ };
323
+ return {
324
+ invoke: (input) => request("nile:invoke", input),
325
+ send: (input) => request("nile:send", input),
326
+ async receive(input) {
327
+ const listener = {
328
+ ...input,
329
+ scope: input.scope,
330
+ onMessage: input.onMessage,
331
+ active: true
332
+ };
333
+ listeners.add(listener);
334
+ const result = await request("nile:receive", {
335
+ topic: input.topic,
336
+ scope: input.scope
337
+ });
338
+ if (result.isErr) {
339
+ listeners.delete(listener);
340
+ return fail(result.error);
341
+ }
342
+ listener.normalizedScope = result.value?.scope;
343
+ return clientOk({ unsubscribe: () => {
344
+ listener.active = false;
345
+ listeners.delete(listener);
346
+ const shared = [...listeners].some((item) => item.topic === listener.topic && stableScope(item.scope) === stableScope(listener.scope));
347
+ if (shared || closed) return Promise.resolve(clientOk({ subscribed: shared }));
348
+ return request("nile:unsubscribe", {
349
+ topic: listener.topic,
350
+ scope: listener.scope
351
+ });
352
+ } });
353
+ },
354
+ close() {
355
+ closed = true;
356
+ listeners.clear();
357
+ for (const cancel of [...pending]) cancel();
358
+ socket?.disconnect();
359
+ socket?.removeAllListeners();
360
+ }
361
+ };
139
362
  }
140
- // Annotate the CommonJS export names for ESM import in node:
141
- 0 && (module.exports = {
142
- createNileClient
143
- });
363
+ /** Match scoped messages independently of object property insertion order. */
364
+ function stableScope(scope) {
365
+ if (scope === void 0 || scope === null) return "null";
366
+ if (Array.isArray(scope)) return `[${scope.map(stableScope).join(",")}]`;
367
+ if (typeof scope === "object") return `{${Object.keys(scope).sort().map((key) => `${JSON.stringify(key)}:${stableScope(scope[key])}`).join(",")}}`;
368
+ return JSON.stringify(scope);
369
+ }
370
+ /** Deliver messages only to listeners for the same validated topic and scope. */
371
+ function dispatchMessage(message, listeners) {
372
+ if (!message || typeof message !== "object" || !("topic" in message)) return;
373
+ const scope = stableScope("scope" in message ? message.scope : void 0);
374
+ for (const listener of listeners) {
375
+ if (!listener.active || listener.topic !== message.topic) continue;
376
+ if (stableScope(listener.normalizedScope ?? listener.scope) !== scope) continue;
377
+ Promise.resolve().then(() => listener.onMessage(message)).catch(() => void 0);
378
+ }
379
+ }
380
+ //#endregion
381
+ exports.buildFormData = buildFormData;
382
+ exports.createNileClient = createNileClient;
383
+ exports.createNileSocketClient = createNileSocketClient;
384
+ exports.sendUploadRequest = sendUploadRequest;
385
+
144
386
  //# sourceMappingURL=index.cjs.map