# @aseansc-admin/sea-http

Angular HTTP client for the ASEAN SC API — envelope wrapper, interceptors, auth storage, and error handling.

## Installation

```bash
npm install @aseansc-admin/sea-http
```

**Peer dependencies:** `@angular/core`, `@angular/common`, `@angular/router` ≥ 20.

---

## Quick Setup

Add to `app.config.ts`:

```ts
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import {
  provideAscHttp,
  ascAuthInterceptor,
  ascErrorInterceptor,
  ascLoadingInterceptor,
} from '@aseansc-admin/sea-http';

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(
      withInterceptors([
        ascAuthInterceptor,
        ascErrorInterceptor,
        ascLoadingInterceptor,
      ]),
    ),
    provideAscHttp({
      baseUrl: 'https://api.aseansc.com.vn/api',
      apiKey:  'your-api-key',
      api:     'sea-meetings',
    }),
  ],
};
```

### `provideAscHttp` options

| Option        | Type                     | Required | Default     | Description                              |
|--------------|--------------------------|----------|-------------|------------------------------------------|
| `baseUrl`    | `string`                 | ✅        | —           | Default API endpoint                     |
| `endpoints`  | `Record<string, string>` | —        | —           | Named endpoints for multi-URL apps       |
| `apiKey`     | `string`                 | ✅        | —           | API key injected into every header       |
| `api`        | `string`                 | ✅        | —           | Service name, e.g. `sea-meetings`        |
| `channel`    | `string`                 | —        | `'ASEANSC'` | Request channel                          |
| `subChannel` | `string`                 | —        | `'ASEANSC'` | Request sub-channel                      |
| `context`    | `string`                 | —        | `'WEB'`     | Request context                          |
| `priority`   | `string`                 | —        | `'1'`       | Request priority                         |

### Multiple base URLs

If your app talks to more than one API endpoint, declare them in `endpoints` and pass the key via the `endpoint` option to `post()` / `login()`. Falls back to `baseUrl` when the key is omitted or not found.

```ts
provideAscHttp({
  baseUrl: 'https://api.aseansc.com.vn/main',
  endpoints: {
    auth: 'https://api.aseansc.com.vn/auth',
  },
  apiKey: 'your-api-key',
  api:    'sea-meetings',
})
```

```ts
// → hits baseUrl
this.api.post({ authenType: 'getAllMeetings', data })

// → hits endpoints.auth
this.api.post({ authenType: 'doSomething', data, endpoint: 'auth' })
this.api.login({ ...credentials, endpoint: 'auth' })
```

---

## Making API Calls

### General API — `AscApiService.post()`

Wraps calls in the standard `{ header, body: { authenType, data } }` envelope and unwraps `body.data` from the response automatically.

```ts
import { inject, Injectable } from '@angular/core';
import { AscApiService } from '@aseansc-admin/sea-http';

interface MeetingListData { /* ... */ }

@Injectable({ providedIn: 'root' })
export class MeetingService {
  private api = inject(AscApiService);

  getAll(pageNumber = 0, pageSize = 25) {
    return this.api.post<MeetingListData>('getAllMeetingAsean', {
      pagination: { pageNumber, pageSize },
      search: '',
    });
  }
}
```

**Request envelope sent:**
```json
{
  "header": { "reqType": "REQUEST", "api": "sea-meetings", "..." },
  "body": {
    "authenType": "getAllMeetingAsean",
    "data": { "pagination": { "pageNumber": 0, "pageSize": 25 }, "search": "" }
  }
}
```

### Login API — `AscApiService.login()`

The login endpoint uses a different envelope (`command` instead of `authenType`). Use the dedicated `login()` method.

`login()` is generic — sea-http only needs a `token` field to make auth storage/`ascAuthInterceptor` work; every other field in the login response is up to your app. Define your own response interface extending `AscAuthUser` and pass it as the type argument:

```ts
import { inject, Injectable } from '@angular/core';
import { AscApiService, AscSessionStorageAuthService, AscAuthUser } from '@aseansc-admin/sea-http';
import { tap } from 'rxjs';

interface MyLoginResponse extends AscAuthUser {
  fullName: string;
  userCode: string;
  permissionList: { functionId: string; rightIdList: string[] }[];
}

@Injectable({ providedIn: 'root' })
export class AuthService {
  private api     = inject(AscApiService);
  // or AscLocalStorageAuthService / AscCookieAuthService — see Auth Storage
  private storage = inject(AscSessionStorageAuthService);

  login(username: string, password: string) {
    return this.api.login<MyLoginResponse>({
      username,
      password,
      authenType: 'getLogin',
      type: 'INHOUSE',
    }).pipe(
      tap(user => this.storage.save(user)),
    );
  }

  logout() {
    this.storage.clear();
  }
}
```

**Request envelope sent:**
```json
{
  "header": { "reqType": "REQUEST", "..." },
  "body": {
    "command": "GET_ENQUIRY",
    "data": { "username": "...", "password": "...", "authenType": "getLogin", "type": "INHOUSE" }
  }
}
```

---

### File Upload — `AscApiService.postFormData()`

Gọi API envelope kèm file upload (`multipart/form-data`). Cùng unwrap `body.data` / throw `AscApiError` như `post()`, nhưng **không dùng chung cơ chế inject `userID`** của `ascAuthInterceptor`: interceptor chỉ inject `userID` bằng cách mutate JSON body, còn với `FormData` thì nó không "nhìn" được vào bên trong — nên `userID` sẽ luôn rỗng nếu để mặc định. `postFormData()` tự lấy `userID` thật trực tiếp qua `ASC_HTTP_USER_ID_FN` ngay lúc build header, không phụ thuộc interceptor. Bearer token vẫn được interceptor tự inject bình thường (không phân biệt body type).

Envelope `{ header, body: { authenType, data } }` được đóng gói thành 1 field multipart tên `request` (`JSON.stringify`); mỗi file trong `files` được append riêng theo `field` tương ứng.

```ts
import { inject, Injectable } from '@angular/core';
import { AscApiService } from '@aseansc-admin/sea-http';

interface UploadResult { url: string; }

@Injectable({ providedIn: 'root' })
export class ProfileService {
  private api = inject(AscApiService);

  uploadAvatar(userId: string, file: File) {
    return this.api.postFormData<UploadResult>({
      authenType: 'uploadAvatar',
      data: { userId },
      files: [{ field: 'avatar', file }],
    });
  }

  uploadWithAttachments(data: Record<string, unknown>, attachments: File[]) {
    return this.api.postFormData<UploadResult>({
      authenType: 'submitTicket',
      data,
      files: attachments.map((file, i) => ({ field: `attachment${i}`, file })),
    });
  }

  // file: null → xoá avatar hiện có (theo quy ước BE: field multipart rỗng = xoá).
  // Không đưa field vào `files` nếu muốn giữ nguyên file cũ.
  removeAvatar(userId: string) {
    return this.api.postFormData<UploadResult>({
      authenType: 'updateProfile',
      data: { userId },
      files: [{ field: 'avatar', file: null }],
    });
  }
}
```

**`AscPostFormDataOptions`:**

| Option        | Type                                    | Required | Description                                                |
|---------------|-------------------------------------------|----------|--------------------------------------------------------------|
| `authenType`  | `string`                                   | ✅        | Tên action/operation, vd `'uploadAvatar'`                    |
| `data`        | `TBody`                                    | ✅        | Payload nghiệp vụ — đóng gói cùng header vào field `request`  |
| `files`       | `{ field: string; file: File \| null }[]`  | ✅        | Danh sách file — mỗi phần tử tạo 1 field multipart riêng. `file: null` → gửi field rỗng (`''`), theo quy ước BE nghĩa là xoá file đó khỏi record |
| `endpoint`    | `string`                                   | —        | Named endpoint trong `AscHttpConfig.endpoints`                |
| `skipLoading` | `boolean`                                  | —        | Bỏ qua global loading indicator                               |

---

### Plain REST — `AscApiService.request()`

Dùng cho endpoint BE trả thẳng chuẩn HTTP (không bọc envelope `{ header, body }`). Đi qua **cùng interceptor chain** với `post()`/`login()` — Bearer token vẫn tự inject, lỗi vẫn tự xử lý qua `ascErrorInterceptor` + `ASC_HTTP_ERROR_HANDLER`, loading indicator vẫn tự đếm. Khác biệt duy nhất: không build/unwrap envelope, và lỗi luôn là `HttpErrorResponse` chuẩn (không có `AscApiError`).

```ts
import { inject, Injectable } from '@angular/core';
import { AscApiService } from '@aseansc-admin/sea-http';

interface User { id: string; name: string; }
interface CreateUserDto { name: string; email: string; }

@Injectable({ providedIn: 'root' })
export class UserService {
  private api = inject(AscApiService);

  getById(id: string) {
    return this.api.request<User>({ path: `/users/${id}` });
  }

  list(status: 'active' | 'inactive') {
    return this.api.request<User[]>({ path: '/users', params: { status } });
  }

  create(dto: CreateUserDto) {
    return this.api.request<User, CreateUserDto>({ method: 'POST', path: '/users', body: dto });
  }

  delete(id: string) {
    return this.api.request<void>({ method: 'DELETE', path: `/users/${id}` });
  }
}
```

**`AscRestOptions`:**

| Option        | Type                                                    | Default | Description                                              |
|---------------|----------------------------------------------------------|---------|------------------------------------------------------------|
| `method`      | `'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'`         | `'GET'` | HTTP method                                               |
| `path`        | `string`                                                  | —       | Nối vào `baseUrl`/`endpoints[endpoint]`, vd `/users/123`  |
| `body`        | `TBody`                                                   | —       | Request body — POST/PUT/PATCH/DELETE                      |
| `params`      | `Record<string, string \| number \| boolean \| null \| undefined>` | —       | Query params — `null`/`undefined` bị bỏ qua               |
| `headers`     | `Record<string, string>`                                  | —       | Header bổ sung (không ghi đè `Authorization`)             |
| `endpoint`    | `string`                                                  | —       | Named endpoint trong `AscHttpConfig.endpoints`             |
| `skipLoading` | `boolean`                                                 | `false` | Bỏ qua global loading indicator                            |

> **Khi nào dùng `post()`/`login()`/`postFormData()` vs `request()`?** Dùng `post()`/`login()`/`postFormData()` cho BE theo chuẩn envelope cũ (`sea-meetings`, `sea-auth`...) — `postFormData()` là biến thể của `post()` khi cần gửi kèm file. Dùng `request()` cho BE mới trả thẳng REST chuẩn HTTP — các flow chạy song song, không xung đột, dùng chung 1 `AscApiService`/interceptor chain.

---

## Auth Storage

Three ready-made services for persisting the token and user session. All implement the abstract `AscAuthStorageService`.

### `AscLocalStorageAuthService` — localStorage

No size limits. Recommended when `permissionList` is large.

```ts
import { inject } from '@angular/core';
import { AscLocalStorageAuthService } from '@aseansc-admin/sea-http';

const storage = inject(AscLocalStorageAuthService);

storage.save(loginResponseData);       // saves token + full user data
storage.getToken();                    // string | null
storage.getUser<MyLoginResponse>();    // MyLoginResponse | null — pass your own type
storage.clear();                       // removes both keys
```

### `AscCookieAuthService` — Cookie

Useful when the token needs to be sent automatically by the browser. Note: each cookie is limited to ~4 KB — if `permissionList` is large, use `AscLocalStorageAuthService` instead.

```ts
import { inject } from '@angular/core';
import { AscCookieAuthService } from '@aseansc-admin/sea-http';

const storage = inject(AscCookieAuthService);

storage.save(loginResponseData, 7); // expires in 7 days (default: 1)
storage.getToken();
storage.getUser<MyLoginResponse>();
storage.clear();
```

Cookies are set with `SameSite=Strict; path=/`.

### `AscSessionStorageAuthService` — sessionStorage

Cleared automatically when the tab/browser is closed — use when the session shouldn't outlive the tab.

```ts
import { inject } from '@angular/core';
import { AscSessionStorageAuthService } from '@aseansc-admin/sea-http';

const storage = inject(AscSessionStorageAuthService);

storage.save(loginResponseData);
storage.getToken();
storage.getUser<MyLoginResponse>();
storage.clear();
```

### Using the abstract token for DI

If you want to swap implementations without changing consuming services, provide one via `AscAuthStorageService`:

```ts
// app.config.ts
import {
  AscAuthStorageService,
  AscLocalStorageAuthService,
  // or AscCookieAuthService / AscSessionStorageAuthService
} from '@aseansc-admin/sea-http';

providers: [
  { provide: AscAuthStorageService, useExisting: AscLocalStorageAuthService },
]
```

```ts
// any service
private storage = inject(AscAuthStorageService);
```

---

## Interceptors

| Interceptor            | What it does                                                                 |
|------------------------|------------------------------------------------------------------------------|
| `ascAuthInterceptor`   | Injects `userID` into the request header from `ASC_HTTP_USER_ID_FN`         |
| `ascErrorInterceptor`  | Handles 401 (redirect to `/login`); every other error status (400, 403, 404, network, 5xx...) goes through `ASC_HTTP_ERROR_HANDLER` |
| `ascLoadingInterceptor`| Auto-increments/decrements `AscLoadingService` counter per active request   |

All three are opt-in — add only what you need to `withInterceptors([...])`.

### Configure `userID` injection

```ts
// app.config.ts
import { ASC_HTTP_USER_ID_FN } from '@aseansc-admin/sea-http';

{
  provide:    ASC_HTTP_USER_ID_FN,
  useFactory: (auth: AuthService) => () => auth.currentUser()?.userCode ?? '',
  deps:       [AuthService],
}
```

### Configure global error handler

```ts
import { ASC_HTTP_ERROR_HANDLER, AscApiError } from '@aseansc-admin/sea-http';

{
  provide:    ASC_HTTP_ERROR_HANDLER,
  useFactory: (toast: ToastService) =>
    (err: unknown) => toast.error(err instanceof AscApiError ? err.message : 'Connection error'),
  deps:       [ToastService],
}
```

### Global loading indicator

`ascLoadingInterceptor` tự động đếm request đang active qua `AscLoadingService`. Mặc định **BẬT** cho mọi request.

Dùng `AscLoadingBarComponent` từ `@aseansc-admin/ui` để hiển thị thanh loading đầu trang:

```ts
// app.component.ts
import { AscLoadingService } from '@aseansc-admin/sea-http';

loading = inject(AscLoadingService);
```

```html
<!-- app.component.html -->
<asc-loading-bar [visible]="loading.isLoading()" />
<p-toast />
<p-confirmdialog />
<router-outlet />
```

### Tắt loading cho request cụ thể

Dùng `skipLoading: true` trong `AscApiService.post()` để bỏ qua loading indicator — hữu ích cho background polling, auto-refresh, silent call:

```ts
// Loading bật — thanh loading hiện khi gọi API này
this.api.post({ authenType: 'getData', data: {} })

// Loading tắt — call âm thầm, không ảnh hưởng UI
this.api.post({ authenType: 'silentPoll', data: {}, skipLoading: true })

// Tương tự với request() (REST thuần)
this.api.request({ path: '/status', skipLoading: true })
```

Hoặc dùng `SKIP_LOADING` token trực tiếp với `HttpClient`:

```ts
import { HttpContext }  from '@angular/common/http';
import { SKIP_LOADING } from '@aseansc-admin/sea-http';

this.http.post(url, body, {
  context: new HttpContext().set(SKIP_LOADING, true),
})
```

---

## Error Handling

API-level errors (when `body.status !== 'OK'`, only for `post()`/`login()`) are thrown as `AscApiError`:

```ts
import { AscApiError } from '@aseansc-admin/sea-http';

this.api.post('someAction', data).subscribe({
  error: (err) => {
    if (err instanceof AscApiError) {
      console.log(err.status);     // e.g. 'UNAUTHORIZED'
      console.log(err.authenType); // the operation name
      console.log(err.data);       // raw error payload from server
      console.log(err.message);    // human-readable message — see below
    }
  }
});
```

`err.message` tries to surface the real message the backend sent, since `body.data`'s shape isn't standardized across endpoints. It looks for a string field named `messageVn`, `message`, `msg`, `errorMessage`, `desc`, `description`, or `messageEn` (in that order — Vietnamese fields first, matching the app's default locale) and uses the first match. If none of those fields exist, it falls back to a debug string: `` `[${authenType}] API responded with status: ${status}` ``.

Some backends (e.g. bos-api) don't put error details in `body.data` at all — they return a sibling `error` field instead:

```json
{
  "header": { "reqType": "RESPONSE", "api": "bos-api", "...": "..." },
  "body":   { "status": "FAILE", "authenType": "manualMatchStatement" },
  "error":  { "code": "RECON001", "desc": "Không tìm thấy bản ghi đối chiếu", "messageVn": "Không tìm thấy bản ghi đối chiếu", "messageEn": null }
}
```

`AscApiError` handles this too — when `body.data` is absent, it falls back to `response.error` for both `err.message` extraction and `err.data`.

Either way, `err.data` holds the raw, untouched payload (`body.data`, or `response.error` when `body.data` is absent) for cases where the app needs more than just the message.

HTTP-level errors (any non-2xx status, network errors) are handled automatically by `ascErrorInterceptor` for **both** `post()`/`login()` and `request()` — since it works purely off `HttpErrorResponse.status`, not the envelope shape:

- `401` → clears session (localStorage + cookie + sessionStorage), redirects to `/login` (does **not** call the error handler)
- everything else (`400`, `403`, `404`, `0` network error, `5xx`...) → passed to `ASC_HTTP_ERROR_HANDLER`
- `500` retries up to 3× (1s/2s/3s delay) before falling through to the error handler

`request()` never throws `AscApiError` — its errors are always plain `HttpErrorResponse`.

---

## Testing with MSW (Mock Service Worker)

Import từ secondary entry point `@aseansc-admin/sea-http/testing` — tách biệt hoàn toàn với production bundle, MSW không bao giờ được ship.

### Install

```bash
npm install msw@2.14.6 --save-dev
```

### 1. Khởi tạo MSW service worker (browser)

```bash
npx msw init public/ --save
```

Tạo file `src/mocks/browser.ts`:

```ts
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';

export const worker = setupWorker(...handlers);
```

Khởi động worker trong `main.ts` khi dev:

```ts
// main.ts
if (isDevMode()) {
  const { worker } = await import('./mocks/browser');
  await worker.start({ onUnhandledRequest: 'bypass' });
}
bootstrapApplication(AppComponent, appConfig);
```

### 2. Khởi tạo MSW server (Jest / Vitest)

```ts
// jest.setup.ts  (hoặc vitest.setup.ts)
import { setupServer } from 'msw/node';
import { handlers } from './mocks/handlers';

export const server = setupServer(...handlers);

beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
```

### 3. Tạo handlers

```ts
// src/mocks/handlers.ts
import { createAscHandler, ascOperation } from '@aseansc-admin/sea-http/testing';

export const handlers = [
  createAscHandler('https://api.example.com/api', [

    // ── OK response ───────────────────────────────────────────────────────
    ascOperation('getAllMeeting', () => ({
      meetings: [
        { id: '1', title: 'Kickoff Q3', date: '2026-07-01' },
        { id: '2', title: 'Sprint Review', date: '2026-07-08' },
      ],
      total: 2,
    })),

    // ── Đọc payload từ request ────────────────────────────────────────────
    ascOperation('getMeetingById', ({ data }) => {
      const req = data as { id: string };
      return { id: req.id, title: `Meeting ${req.id}`, status: 'ACTIVE' };
    }),

    // ── Login ─────────────────────────────────────────────────────────────
    // sea-http dùng body.command thay vì authenType cho login
    ascOperation('getLogin', ({ data }) => {
      const creds = data as { username: string };
      return { token: 'mock-jwt-token', userCode: creds.username, stateType: 'LOGIN' };
    }),

  ]),
];
```

### 4. Error cases

#### 4a. Business error — `errorStatus`

sea-http ném `AscApiError` khi `body.status !== 'OK'`. HTTP status vẫn là 200.

```ts
// AscApiError { status: 'USER_NOT_FOUND', data: { id: '99' } }
ascOperation(
  'getUserById',
  ({ data }) => ({ id: (data as any).id }),
  { errorStatus: 'USER_NOT_FOUND' },
)

// AscApiError { status: 'VALIDATION_ERROR', data: { field: 'email', msg: 'Invalid email' } }
ascOperation(
  'createUser',
  () => ({ field: 'email', msg: 'Invalid email' }),
  { errorStatus: 'VALIDATION_ERROR' },
)

// Dùng trực tiếp ascFail nếu cần tuỳ chỉnh ngoài ascOperation
import { ascFail } from '@aseansc-admin/sea-http/testing';
return HttpResponse.json(ascFail('LOCKED', { retryAfter: 60 }));
```

#### 4b. HTTP 401 Unauthorized

`ascErrorInterceptor` bắt 401 → xoá session + redirect `/login` tự động.

```ts
ascOperation('getSecureData', () => null, { httpStatus: 401 })
```

> **Lưu ý:** Resolver KHÔNG được gọi khi `httpStatus` được đặt.

#### 4c. HTTP 403 Forbidden

`ascErrorInterceptor` bắt 403 → gọi `ASC_HTTP_ERROR_HANDLER`.

```ts
ascOperation('deleteRecord', () => null, { httpStatus: 403 })
```

#### 4d. HTTP 500 Internal Server Error

`ascErrorInterceptor` tự **retry tối đa 3 lần** (delay 1 s / 2 s / 3 s), sau đó gọi `ASC_HTTP_ERROR_HANDLER`.  
Mock này hữu ích để kiểm tra xem app có hiện đúng trạng thái loading và thông báo lỗi sau khi hết retry không.

```ts
ascOperation('getReport', () => null, { httpStatus: 500 })

// Kết hợp với delay để thấy rõ retry effect
ascOperation('getReport', () => null, { httpStatus: 500, delayMs: 300 })
```

#### 4e. HTTP 404 Not Found

`ascErrorInterceptor` gọi `ASC_HTTP_ERROR_HANDLER` (giống mọi status khác ngoài 401/0), sau đó vẫn ném `HttpErrorResponse` về subscriber.

```ts
ascOperation('getAttachment', () => null, { httpStatus: 404 })
```

#### 4f. Network error (status 0)

Giả lập mất kết nối mạng (connection refused, timeout). Angular nhận `HttpErrorResponse { status: 0 }`.  
`ascErrorInterceptor` gọi `ASC_HTTP_ERROR_HANDLER` với `statusText: 'Network Error — Không kết nối được server'`.

```ts
ascOperation('getData', () => null, { networkError: true })
```

> **Lưu ý:** `networkError` và `httpStatus` đều bỏ qua resolver. Nếu đặt cả hai cùng lúc, `networkError` được ưu tiên.

### 5. Kết hợp delay + error để test loading UI

```ts
// Thấy loading spinner, sau 2 giây hiện lỗi 500 → retry 3 lần → hiện toast error
ascOperation('heavyReport', () => null, { httpStatus: 500, delayMs: 2000 })

// Thấy loading, sau 800 ms hiện lỗi nghiệp vụ
ascOperation('submitForm', () => ({ msg: 'Duplicate entry' }), {
  errorStatus: 'DUPLICATE',
  delayMs: 800,
})
```

### 6. Override trong từng test

Dùng `server.use()` để override handler tạm thời trong một test case — không ảnh hưởng test khác nhờ `server.resetHandlers()` sau mỗi test.

```ts
import { server } from '../jest.setup';
import { createAscHandler, ascOperation } from '@aseansc-admin/sea-http/testing';

it('hiện thông báo lỗi khi API trả về UNAUTHORIZED', async () => {
  server.use(
    createAscHandler('https://api.example.com/api', [
      ascOperation('getProfile', () => null, { errorStatus: 'UNAUTHORIZED' }),
    ]),
  );

  // ... render component, kiểm tra toast / error message
});

it('redirect về /login khi server trả về 401', async () => {
  server.use(
    createAscHandler('https://api.example.com/api', [
      ascOperation('getProfile', () => null, { httpStatus: 401 }),
    ]),
  );

  // ... kiểm tra router.navigate đã được gọi với ['/login']
});

it('hiện banner lỗi mạng khi mất kết nối', async () => {
  server.use(
    createAscHandler('https://api.example.com/api', [
      ascOperation('getProfile', () => null, { networkError: true }),
    ]),
  );

  // ... kiểm tra error handler được gọi với status 0
});
```

### 7. Nhiều endpoints

Nếu app dùng nhiều `endpoints` trong `provideAscHttp`, tạo nhiều handler riêng:

```ts
import { createAscHandler, ascOperation } from '@aseansc-admin/sea-http/testing';

export const handlers = [
  // baseUrl
  createAscHandler('https://api.example.com/api', [
    ascOperation('getAllMeeting', () => ({ meetings: [] })),
  ]),

  // endpoints.auth
  createAscHandler('https://auth.example.com/api', [
    ascOperation('getLogin', () => ({ token: 'mock-token', userCode: 'admin' })),
  ]),
];
```

Hoặc dùng wildcard để khớp mọi origin (hữu ích khi base URL thay đổi theo môi trường):

```ts
createAscHandler('*/api', [...operations])
```

### 8. Bảng tham chiếu

| Option | Type | Resolver gọi? | Mô tả |
|---|---|---|---|
| _(mặc định)_ | — | ✅ | Trả OK envelope, resolver result vào `body.data` |
| `errorStatus` | `string` | ✅ | Trả error envelope với `body.status = errorStatus` → sea-http ném `AscApiError` |
| `httpStatus` | `number` | ❌ | Trả HTTP error response (401/403/500...) — bypass envelope |
| `networkError` | `boolean` | ❌ | Giả lập mất kết nối — Angular nhận `HttpErrorResponse.status = 0` |
| `delayMs` | `number` | — | Thêm delay (ms) trước khi trả response, kết hợp được với mọi option trên |

| HTTP Status | Xử lý bởi `ascErrorInterceptor` |
|---|---|
| `401` | Redirect `/login` (không gọi error handler) |
| `500` | Retry 3× (1s/2s/3s) → gọi `ASC_HTTP_ERROR_HANDLER` |
| `0` (network) | Gọi `ASC_HTTP_ERROR_HANDLER` với 'Network Error' |
| `403`, `404`, `400`, khác | Gọi `ASC_HTTP_ERROR_HANDLER` |

---

## Server-Sent Events (SSE)

`AscSseService` wraps the browser's `EventSource` API. Since `EventSource` does not support custom headers, the JWT token is passed automatically as a URL query param (`?token=<jwt>`).

No extra setup needed — the service reads token from localStorage / cookie (see [Auth Storage](#auth-storage)) and appends it to every URL.

### Unnamed events

The server sends lines in the format:
```
data: {"meetingId":"123","status":"STARTED"}\n\n
```

```ts
import { inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { AscSseService } from '@aseansc-admin/sea-http';

interface NotificationDto {
  type:    string;
  message: string;
}

@Component({ ... })
export class NotificationBell {
  private sse = inject(AscSseService);

  constructor() {
    this.sse.connect<NotificationDto>('/notifications/stream')
      .pipe(takeUntilDestroyed())
      .subscribe({
        next:     event => console.log(event.type, event.data),
        error:    err   => console.error('SSE error', err),
        complete: ()    => console.log('SSE closed'),
      });
  }
}
```

### Named events

The server sends lines in the format:
```
event: meeting-update
data: {"meetingId":"123","agenda":"Updated agenda"}\n\n
```

Use `sse.on()` to filter by event type — the returned Observable emits the parsed `data` directly:

```ts
import { inject, Component, OnInit } from '@angular/core';
import { takeUntilDestroyed }        from '@angular/core/rxjs-interop';
import { AscSseService }             from '@aseansc-admin/sea-http';

interface MeetingUpdate {
  meetingId: string;
  agenda:    string;
}

@Component({ ... })
export class MeetingDetailPage implements OnInit {
  private sse = inject(AscSseService);

  ngOnInit() {
    // Chỉ nhận events có type = 'meeting-update'
    this.sse.on<MeetingUpdate>('/notifications/stream', 'meeting-update')
      .pipe(takeUntilDestroyed())
      .subscribe(data => this.applyUpdate(data));

    // Có thể subscribe nhiều event type cùng lúc từ cùng 1 path
    this.sse.on<ParticipantUpdate>('/notifications/stream', 'participant-joined')
      .pipe(takeUntilDestroyed())
      .subscribe(data => this.addParticipant(data));
  }

  private applyUpdate(update: MeetingUpdate) { /* ... */ }
  private addParticipant(p: ParticipantUpdate) { /* ... */ }
}
```

### SSE options

| Option            | Type      | Default  | Description                                    |
|-------------------|-----------|----------|------------------------------------------------|
| `tokenParam`      | `string`  | `'token'`| Tên query param chứa JWT token                 |
| `params`          | `Record`  | `{}`     | Query params thêm vào URL                      |
| `withCredentials` | `boolean` | `false`  | Gửi cookie theo request                        |
| `reconnect`       | `boolean` | `true`   | Tự động reconnect khi mất kết nối              |
| `reconnectDelay`  | `number`  | `3000`   | Delay giữa các lần reconnect (ms)              |

```ts
// Ví dụ: thêm query params, đổi tên token param
this.sse.connect('/stream', {
  tokenParam: 'access_token',
  params:     { roomId: '42' },
  reconnect:  true,
  reconnectDelay: 5000,
});
```

---

## WebSocket

`AscWebSocketService.connect()` trả về một `AscWsConnection` handle. Outgoing và incoming messages đều dùng cùng envelope protocol:

```json
{
  "header": { "api": "sea-meetings", "apiKey": "...", "userID": "u01", "channel": "ASEANSC", "subChannel": "ASEANSC" },
  "body":   { "authenType": "meetingUpdate", "data": { ... } }
}
```

### Basic usage

```ts
import { inject, Component, OnInit, OnDestroy } from '@angular/core';
import { AscWebSocketService, AscWsConnection } from '@aseansc-admin/sea-http';

interface MeetingEvent  { meetingId: string; status: string; }
interface ChatMessage   { from: string; text: string; }

@Component({ ... })
export class MeetingRoomPage implements OnInit, OnDestroy {
  private ws   = inject(AscWebSocketService);
  private conn!: AscWsConnection;

  ngOnInit() {
    this.conn = this.ws.connect('/ws/meetings');

    // Theo dõi trạng thái kết nối
    this.conn.status$.subscribe(status => {
      // 'connecting' | 'connected' | 'disconnected' | 'error'
      console.log('WS status:', status);
    });

    // Nhận tất cả messages
    this.conn.messages$.subscribe(msg => {
      console.log(msg.body.authenType, msg.body.data);
    });

    // Lọc theo authenType
    this.conn.on<MeetingEvent>('meetingUpdate').subscribe(data => {
      console.log('Meeting updated:', data.status);
    });

    this.conn.on<ChatMessage>('chatMessage').subscribe(msg => {
      this.appendChat(msg);
    });

    // Gửi message
    this.conn.send('subscribeMeeting', { meetingId: '123' });
  }

  sendChat(text: string) {
    this.conn.send('sendChat', { text });
  }

  ngOnDestroy() {
    this.conn.close();
  }

  private appendChat(msg: ChatMessage) { /* ... */ }
}
```

### Với `takeUntilDestroyed` (Angular 16+)

```ts
import { Component, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { AscWebSocketService } from '@aseansc-admin/sea-http';

@Component({ ... })
export class ChatComponent {
  private ws   = inject(AscWebSocketService);
  private conn = this.ws.connect('/ws/chat');

  constructor() {
    this.conn.on<ChatMessage>('chatMessage')
      .pipe(takeUntilDestroyed())
      .subscribe(msg => this.messages.push(msg));
  }
}
```

> **Lưu ý:** Dùng `takeUntilDestroyed()` thì không cần gọi `conn.close()` để cleanup subscription, nhưng vẫn nên gọi `conn.close()` trong `ngOnDestroy` để đóng WebSocket connection.

### Multiple connections

Mỗi lần gọi `connect()` tạo một WebSocket connection riêng biệt:

```ts
// Kết nối đến meeting room
const meetingConn = this.ws.connect('/ws/meetings');

// Kết nối riêng đến notification channel
const notifConn = this.ws.connect('/ws/notifications', { reconnect: true });

// Kết nối đến URL khác hoàn toàn
const externalConn = this.ws.connect('wss://other.service.com/ws');
```

### WebSocket options

| Option           | Type             | Default  | Description                                        |
|------------------|------------------|----------|----------------------------------------------------|
| `tokenParam`     | `string \| false`| `'token'`| Tên query param chứa JWT. `false` = không gửi token|
| `reconnect`      | `boolean`        | `true`   | Tự động reconnect khi mất kết nối                  |
| `maxRetries`     | `number`         | `5`      | Số lần retry tối đa                                |
| `reconnectDelay` | `number`         | `3000`   | Delay cơ bản giữa các lần retry (ms), tăng dần     |

```ts
this.ws.connect('/ws/meetings', {
  tokenParam:     'access_token',
  reconnect:      true,
  maxRetries:     10,
  reconnectDelay: 2000,
});
```

### Retry back-off

Delay giữa các lần reconnect tăng tuyến tính theo số lần thử:

| Lần retry | Delay           |
|-----------|-----------------|
| 1         | 1× reconnectDelay |
| 2         | 2× reconnectDelay |
| 3         | 3× reconnectDelay |
| …         | …               |

Sau `maxRetries` lần thất bại, `messages$` sẽ complete và không reconnect nữa.

---

## Types Reference

```ts
import type {
  // Config
  AscHttpConfig,

  // Request / Response envelope
  AscApiRequest,
  AscApiRequestHeader,
  AscApiResponse,
  AscApiResponseHeader,

  // Plain REST
  AscHttpMethod,
  AscRestOptions,

  // File upload
  AscPostFormDataOptions,
  AscFormDataFile,

  // Login
  LoginRequestData,
  AscAuthUser,

  // SSE
  AscSseOptions,
  AscSseEvent,

  // WebSocket
  AscWsOptions,
  AscWsMessage,
  AscWsHeader,
  AscWsStatus,
} from '@aseansc-admin/sea-http';

import { AscApiError, isAscApiRequest } from '@aseansc-admin/sea-http';
```
