@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
|
+
[](https://www.npmjs.com/package/ng-signal-http)
|
|
4
|
+
[](https://bundlephobia.com/package/ng-signal-http)
|
|
5
|
+
[](https://github.com/assebc/ng-signal-http/blob/master/LICENSE)
|
|
6
|
+
[](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
|