@assebc/ng-signal-http 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 ADDED
@@ -0,0 +1,467 @@
1
+ # ng-signal-http
2
+
3
+ [![npm version](https://img.shields.io/npm/v/ng-signal-http)](https://www.npmjs.com/package/ng-signal-http)
4
+ [![bundle size](https://img.shields.io/bundlephobia/minzip/ng-signal-http)](https://bundlephobia.com/package/ng-signal-http)
5
+ [![license](https://img.shields.io/npm/l/ng-signal-http)](https://github.com/assebc/ng-signal-http/blob/master/LICENSE)
6
+ [![Angular](https://img.shields.io/badge/Angular-17%2B-red)](https://angular.dev)
7
+
8
+ Signal-native HTTP client for Angular. Wraps the native Fetch API and returns Angular signals directly — no `toSignal()`, no RxJS required.
9
+
10
+ Built for the post-zoneless Angular era using only `@angular/core` primitives.
11
+
12
+ ---
13
+
14
+ ## Why ng-signal-http?
15
+
16
+ | Feature | `@angular/common/http` | `ng-signal-http` |
17
+ |---|---|---|
18
+ | Returns | `Observable` | `Signal` |
19
+ | RxJS required | Yes | No |
20
+ | Loading state | Manual | Built-in |
21
+ | Error state | Manual | Built-in |
22
+ | Reactive refetch | Manual (`switchMap`) | Automatic |
23
+ | Request cancellation | Manual (`takeUntil`) | Automatic |
24
+ | Retry | Manual (`retryWhen`) | Built-in |
25
+ | Bundle size | ~25 KB | < 15 KB |
26
+
27
+ **Before**
28
+
29
+ ```typescript
30
+ export class UsersComponent {
31
+ private http = inject(HttpClient);
32
+ users = toSignal(this.http.get<User[]>('/api/users'), { initialValue: [] });
33
+ // loading? error? refetch? — manual work.
34
+ }
35
+ ```
36
+
37
+ **After**
38
+
39
+ ```typescript
40
+ export class UsersComponent {
41
+ users = querySignal<User[]>('/api/users');
42
+ // users.data(), users.loading(), users.error(), users.refetch() — done.
43
+ }
44
+ ```
45
+
46
+ ---
47
+
48
+ ## Install
49
+
50
+ ```bash
51
+ npm install ng-signal-http
52
+ ```
53
+
54
+ Peer dependencies: `@angular/core` and `@angular/common` ≥ 17.
55
+
56
+ ---
57
+
58
+ ## Setup
59
+
60
+ Call `provideSignalHttp()` once in `app.config.ts`:
61
+
62
+ ```typescript
63
+ import { ApplicationConfig } from '@angular/core';
64
+ import { provideSignalHttp } from 'ng-signal-http';
65
+
66
+ export const appConfig: ApplicationConfig = {
67
+ providers: [
68
+ provideSignalHttp({
69
+ baseUrl: 'https://api.example.com',
70
+ timeout: 10_000,
71
+ }),
72
+ ],
73
+ };
74
+ ```
75
+
76
+ ---
77
+
78
+ ## Basic usage
79
+
80
+ ### GET — `querySignal`
81
+
82
+ ```typescript
83
+ import { Component } from '@angular/core';
84
+ import { querySignal } from 'ng-signal-http';
85
+
86
+ interface User { id: number; name: string; }
87
+
88
+ @Component({
89
+ template: `
90
+ @if (user.loading()) { <p>Loading…</p> }
91
+ @if (user.error()) { <p>Error: {{ user.error()?.message }}</p> }
92
+ @if (user.data()) { <p>{{ user.data()?.name }}</p> }
93
+ `,
94
+ })
95
+ export class UserComponent {
96
+ user = querySignal<User>('/users/1');
97
+ }
98
+ ```
99
+
100
+ ### POST / PUT / PATCH / DELETE — `mutationSignal`
101
+
102
+ ```typescript
103
+ import { Component } from '@angular/core';
104
+ import { mutationSignal } from 'ng-signal-http';
105
+
106
+ interface CreateUser { name: string; email: string; }
107
+ interface User { id: number; name: string; email: string; }
108
+
109
+ @Component({
110
+ template: `
111
+ <button (click)="submit()" [disabled]="newUser.isPending()">Create</button>
112
+ @if (newUser.data()) { <p>Created id: {{ newUser.data()?.id }}</p> }
113
+ @if (newUser.error()) { <p>{{ newUser.error()?.message }}</p> }
114
+ `,
115
+ })
116
+ export class CreateUserComponent {
117
+ newUser = mutationSignal<CreateUser, User>(
118
+ (input) => ({ url: '/users', method: 'POST', body: input }),
119
+ );
120
+
121
+ submit() {
122
+ this.newUser.mutate({ name: 'Alice', email: 'alice@example.com' });
123
+ }
124
+ }
125
+ ```
126
+
127
+ ---
128
+
129
+ ## Reactive queries
130
+
131
+ `querySignal` tracks every signal read inside the URL factory and automatically refetches when any of them change:
132
+
133
+ ```typescript
134
+ import { Component, signal } from '@angular/core';
135
+ import { querySignal } from 'ng-signal-http';
136
+
137
+ @Component({
138
+ template: `
139
+ <input type="number" [value]="userId()" (input)="userId.set(+$event.target.value)" />
140
+ @if (user.loading()) { <span>Loading…</span> }
141
+ <p>{{ user.data()?.name }}</p>
142
+ `,
143
+ })
144
+ export class UserComponent {
145
+ userId = signal(1);
146
+
147
+ // Automatically refetches whenever userId() changes.
148
+ user = querySignal<User>(() => `/users/${this.userId()}`);
149
+ }
150
+ ```
151
+
152
+ ### Lazy queries
153
+
154
+ ```typescript
155
+ search = querySignal<Result[]>(() => `/search?q=${this.query()}`, { lazy: true });
156
+
157
+ onSearch() {
158
+ this.search.refetch();
159
+ }
160
+ ```
161
+
162
+ ### Polling
163
+
164
+ ```typescript
165
+ stats = querySignal('/dashboard/stats', { refetchInterval: 30_000 });
166
+ ```
167
+
168
+ ### Refetch on focus / reconnect
169
+
170
+ ```typescript
171
+ feed = querySignal('/feed', {
172
+ staleTime: 60_000, // only refetch if data is older than 60 s
173
+ refetchOnFocus: true, // refetch when window regains focus (if stale)
174
+ refetchOnReconnect: true, // refetch when network comes back online
175
+ });
176
+ ```
177
+
178
+ ---
179
+
180
+ ## Mutations
181
+
182
+ ```typescript
183
+ import { mutationSignal } from 'ng-signal-http';
184
+
185
+ updatePost = mutationSignal<{ id: number; title: string }, Post>(
186
+ ({ id, ...body }) => ({ url: `/posts/${id}`, method: 'PUT', body }),
187
+ {
188
+ onSuccess: (post) => console.log('Updated:', post.title),
189
+ onError: (err) => console.error('Failed:', err.message),
190
+ onSettled: (data, err) => console.log('Done', data, err),
191
+ },
192
+ );
193
+
194
+ // Calling mutate() while a previous request is in flight cancels the previous one.
195
+ await this.updatePost.mutate({ id: 1, title: 'New title' });
196
+ ```
197
+
198
+ ---
199
+
200
+ ## Interceptors
201
+
202
+ All hooks are optional and may return a `Promise`. They run in registration order.
203
+
204
+ ```typescript
205
+ provideSignalHttp({
206
+ baseUrl: 'https://api.example.com',
207
+ interceptors: [
208
+ {
209
+ // Attach an auth token to every request
210
+ request: async (config) => ({
211
+ ...config,
212
+ headers: { ...config.headers, Authorization: `Bearer ${getToken()}` },
213
+ }),
214
+
215
+ // Log every response
216
+ response: async (response) => {
217
+ console.log(response.status, response.url);
218
+ return response;
219
+ },
220
+
221
+ // Transform or react to errors
222
+ error: async (err) => {
223
+ if (err instanceof HttpError && err.isUnauthorized) {
224
+ await refreshToken();
225
+ }
226
+ return err;
227
+ },
228
+ },
229
+ ],
230
+ });
231
+ ```
232
+
233
+ ---
234
+
235
+ ## Error handling
236
+
237
+ Failed requests set the `error` signal to an `HttpError` with convenience getters:
238
+
239
+ ```typescript
240
+ import { HttpError, querySignal } from 'ng-signal-http';
241
+ import { effect } from '@angular/core';
242
+
243
+ const post = querySignal<Post>('/posts/1');
244
+
245
+ effect(() => {
246
+ const err = post.error();
247
+ if (!err) return;
248
+ if (err instanceof HttpError) {
249
+ if (err.isNotFound) console.log('Not found');
250
+ if (err.isUnauthorized) router.navigate(['/login']);
251
+ if (err.isServerError) console.error(`Server error ${err.status}`);
252
+ }
253
+ });
254
+ ```
255
+
256
+ ### Retry
257
+
258
+ ```typescript
259
+ // Retry up to 3 times immediately
260
+ querySignal('/data', { retry: 3 });
261
+
262
+ // Custom retry with exponential backoff
263
+ querySignal('/data', {
264
+ retry: {
265
+ count: 4,
266
+ delay: (attempt) => 1000 * 2 ** (attempt - 1),
267
+ shouldRetry: (err) => !(err instanceof HttpError && err.isClientError),
268
+ },
269
+ });
270
+ ```
271
+
272
+ `AbortError` (request cancellation or component destroy) is never retried.
273
+
274
+ ---
275
+
276
+ ## Full API reference
277
+
278
+ ### `provideSignalHttp(config?)`
279
+
280
+ Registers the library. Call once in `app.config.ts`.
281
+
282
+ ```typescript
283
+ provideSignalHttp(config?: SignalHttpConfig): EnvironmentProviders
284
+ ```
285
+
286
+ | `SignalHttpConfig` | Type | Description |
287
+ |---|---|---|
288
+ | `baseUrl` | `string` | Prefix prepended to all relative URLs |
289
+ | `headers` | `Record<string, string>` | Default headers sent with every request |
290
+ | `timeout` | `number` | Global request timeout in ms |
291
+ | `interceptors` | `HttpInterceptor[]` | Request / response / error hooks |
292
+
293
+ ---
294
+
295
+ ### `querySignal<T>(url, options?)`
296
+
297
+ ```typescript
298
+ querySignal<T>(url: string | UrlFactory, options?: HttpClientOptions<T>): HttpClientResult<T>
299
+ ```
300
+
301
+ `UrlFactory`: `() => string | RequestConfig`
302
+
303
+ **Options (`HttpClientOptions<T>`)**
304
+
305
+ | Option | Type | Default | Description |
306
+ |---|---|---|---|
307
+ | `initialValue` | `T` | `null` | Signal value before the first successful fetch |
308
+ | `lazy` | `boolean` | `false` | Skip the initial fetch; call `refetch()` manually |
309
+ | `retry` | `number \| RetryConfig` | — | Retry on failure |
310
+ | `staleTime` | `number` | — | Ms after which data is considered stale |
311
+ | `refetchInterval` | `number` | — | Poll interval in ms |
312
+ | `refetchOnFocus` | `boolean` | `false` | Refetch on window focus (only if stale) |
313
+ | `refetchOnReconnect` | `boolean` | `false` | Refetch when network reconnects |
314
+ | `onSuccess` | `(data: T) => void` | — | Called after a successful fetch |
315
+ | `onError` | `(error: Error) => void` | — | Called after a failed fetch |
316
+
317
+ **Return value (`HttpClientResult<T>`)**
318
+
319
+ | Property | Type | Description |
320
+ |---|---|---|
321
+ | `data` | `Signal<T \| null>` | Response data; `null` until first success |
322
+ | `loading` | `Signal<boolean>` | `true` while a request is in flight |
323
+ | `error` | `Signal<Error \| null>` | Last error; cleared when a new fetch starts |
324
+ | `status` | `Signal<'idle' \| 'loading' \| 'success' \| 'error'>` | Explicit state machine value |
325
+ | `isStale` | `Signal<boolean>` | `true` if data is older than `staleTime` |
326
+ | `refetch()` | `() => Promise<void>` | Manually trigger a new fetch |
327
+ | `invalidate()` | `() => void` | Mark data as stale without triggering a fetch |
328
+ | `reset()` | `() => void` | Abort in-flight request and restore initial state |
329
+
330
+ ---
331
+
332
+ ### `mutationSignal<TInput, TOutput>(factory, options?)`
333
+
334
+ ```typescript
335
+ mutationSignal<TInput, TOutput>(
336
+ requestFactory: (input: TInput) => RequestConfig,
337
+ options?: MutationOptions<TInput, TOutput>
338
+ ): MutationResult<TInput, TOutput>
339
+ ```
340
+
341
+ **Options (`MutationOptions<TInput, TOutput>`)**
342
+
343
+ | Option | Type | Description |
344
+ |---|---|---|
345
+ | `onSuccess` | `(data: TOutput, input: TInput) => void` | Called on success |
346
+ | `onError` | `(error: Error, input: TInput) => void` | Called on failure |
347
+ | `onSettled` | `(data: TOutput \| null, error: Error \| null, input: TInput) => void` | Called after either outcome |
348
+
349
+ **Return value (`MutationResult<TInput, TOutput>`)**
350
+
351
+ | Property | Type | Description |
352
+ |---|---|---|
353
+ | `isPending` | `Signal<boolean>` | `true` while the request is in flight |
354
+ | `data` | `Signal<TOutput \| null>` | Last successful response |
355
+ | `error` | `Signal<Error \| null>` | Last error |
356
+ | `mutate(input)` | `(input: TInput) => Promise<TOutput>` | Trigger the request |
357
+ | `reset()` | `() => void` | Clear all state |
358
+
359
+ ---
360
+
361
+ ### `SignalHttpClient`
362
+
363
+ Injectable service for imperative HTTP calls — guards, resolvers, one-off effects.
364
+
365
+ ```typescript
366
+ @Injectable({ providedIn: 'root' })
367
+ class SignalHttpClient {
368
+ get<T>(url: string, options?: Partial<RequestConfig>): Signal<T | null>
369
+ post<T>(url: string, body?: unknown, options?: Partial<RequestConfig>): Signal<T | null>
370
+ put<T>(url: string, body?: unknown, options?: Partial<RequestConfig>): Signal<T | null>
371
+ patch<T>(url: string, body?: unknown, options?: Partial<RequestConfig>): Signal<T | null>
372
+ delete<T>(url: string, options?: Partial<RequestConfig>): Signal<T | null>
373
+ executeRequest<T>(config: RequestConfig): Promise<T>
374
+ }
375
+ ```
376
+
377
+ `get` / `post` / `put` / `patch` / `delete` return a `Signal<T | null>` and must be called from an injection context. Use `executeRequest` for async/await patterns:
378
+
379
+ ```typescript
380
+ @Injectable({ providedIn: 'root' })
381
+ export class AuthService {
382
+ private http = inject(SignalHttpClient);
383
+
384
+ async login(credentials: Credentials): Promise<Token> {
385
+ return this.http.executeRequest<Token>({
386
+ url: '/auth/login',
387
+ method: 'POST',
388
+ body: credentials,
389
+ });
390
+ }
391
+ }
392
+ ```
393
+
394
+ ---
395
+
396
+ ### `HttpError`
397
+
398
+ ```typescript
399
+ class HttpError extends Error {
400
+ readonly status: number;
401
+ readonly response?: Response;
402
+
403
+ get isClientError(): boolean // 4xx
404
+ get isServerError(): boolean // 5xx
405
+ get isTimeout(): boolean // 408
406
+ get isNotFound(): boolean // 404
407
+ get isUnauthorized(): boolean // 401
408
+ get isForbidden(): boolean // 403
409
+ }
410
+ ```
411
+
412
+ ---
413
+
414
+ ### `RequestConfig`
415
+
416
+ ```typescript
417
+ interface RequestConfig {
418
+ url: string;
419
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
420
+ headers?: Record<string, string>;
421
+ body?: unknown;
422
+ params?: Record<string, string | number | boolean>;
423
+ timeout?: number; // overrides the global timeout for this request
424
+ signal?: AbortSignal; // merged with the internal AbortController
425
+ }
426
+ ```
427
+
428
+ ---
429
+
430
+ ### `RetryConfig`
431
+
432
+ ```typescript
433
+ interface RetryConfig {
434
+ count: number;
435
+ delay?: number | ((attempt: number) => number); // ms; defaults to 0
436
+ shouldRetry?: (error: Error, attempt: number) => boolean;
437
+ }
438
+ ```
439
+
440
+ ---
441
+
442
+ ## Migration from `HttpClient`
443
+
444
+ | Before (`@angular/common/http`) | After (`ng-signal-http`) |
445
+ |---|---|
446
+ | `imports: [HttpClientModule]` | `providers: [provideSignalHttp()]` |
447
+ | `inject(HttpClient).get<T>(url)` → `Observable<T>` | `querySignal<T>(url)` → `HttpClientResult<T>` |
448
+ | `async pipe` + manual loading flag | `result.data()` + `result.loading()` |
449
+ | `pipe(takeUntil(destroy$))` | automatic — cancelled on destroy |
450
+ | `pipe(switchMap(...))` for reactive deps | reactive factory: `` () => `/users/${id()}` `` |
451
+ | `pipe(retry(3))` | `{ retry: 3 }` option |
452
+ | `pipe(catchError(...))` | `result.error()` signal + `onError` callback |
453
+ | `http.post<T>(url, body)` → `Observable<T>` | `mutationSignal(...)` → `MutationResult<T>` |
454
+
455
+ ---
456
+
457
+ ## Browser support
458
+
459
+ Any browser with native `fetch` support: Chrome/Edge 90+, Firefox 88+, Safari 14+. No IE11.
460
+
461
+ SSR is fully supported — window events (`focus`, `online`) are skipped on the server.
462
+
463
+ ---
464
+
465
+ ## License
466
+
467
+ MIT