{"_id":"@cruzrojapuebla/plasma","name":"@cruzrojapuebla/plasma","dist-tags":{"latest":"1.0.0"},"versions":{"1.0.0":{"name":"@cruzrojapuebla/plasma","version":"1.0.0","publishConfig":{"access":"public"},"type":"module","main":"./dist/index.mjs","types":"./dist/index.d.mts","exports":{".":{"types":"./dist/index.d.mts","import":"./dist/index.mjs","default":"./dist/index.mjs"}},"scripts":{"build":"tsdown src/index.ts --format esm --dts --clean","test":"vitest run","check-types":"tsc --noEmit","lint":"biome lint"},"devDependencies":{"@biomejs/biome":"2.3.15","tsdown":"0.21.9","typescript":"5.9.3","vitest":"4.0.18","zod":"^4.3.6"},"dependencies":{"@kristall/try-catch":"2.0.0","object-to-formdata":"4.5.1"},"peerDependencies":{"zod":"^4.0.0"},"keywords":[],"author":{"name":"Cruz Roja Mexicana, Delegación Puebla"},"contributors":[{"name":"Jared Muñoz","url":"https://jared-mb.dev"}],"license":"MIT","packageManager":"pnpm@10.30.0","_id":"@cruzrojapuebla/plasma@1.0.0","gitHead":"ddd1f056ea7d0edf30dd099eab67ed2c22038e51","description":"Full type-safe HTTP client wrapper around `fetch`, designed for strict typing of API routes, parameters, and responses — with zero codegen.","_nodeVersion":"22.20.0","_npmVersion":"10.9.3","dist":{"integrity":"sha512-ZqxDOcjCEhg+NzEJVb1TYgRMOCpB8l2txQi6UuoWysyFHTtcdDnn5aJA+DKZ9C/qhcO+bHjE5nHrYqPkuy67Sw==","shasum":"14ab251030d7ffdbd7e8e4592adfcc8e727579fc","tarball":"https://registry.npmjs.org/@cruzrojapuebla/plasma/-/plasma-1.0.0.tgz","fileCount":17,"unpackedSize":44836,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIQCgJLAoIFPpWIGYpv1K7kTpEgXo22oy48UstiwQq8sFzAIgDWXLGljZ3R0dLOP1a3KLPVw4liZaeRWsJOEwh+AFYaE="}]},"_npmUser":{"name":"jahzeelcrm","email":"jahzeel.lopez@cruzrojapuebla.org"},"directories":{},"maintainers":[{"name":"jahzeelcrm","email":"jahzeel.lopez@cruzrojapuebla.org"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/plasma_1.0.0_1777059892639_0.17468535989678347"},"_hasShrinkwrap":false}},"time":{"created":"2026-04-24T19:44:52.549Z","1.0.0":"2026-04-24T19:44:52.806Z","modified":"2026-04-24T19:44:53.030Z"},"maintainers":[{"name":"jahzeelcrm","email":"jahzeel.lopez@cruzrojapuebla.org"}],"description":"Full type-safe HTTP client wrapper around `fetch`, designed for strict typing of API routes, parameters, and responses — with zero codegen.","keywords":[],"contributors":[{"name":"Jared Muñoz","url":"https://jared-mb.dev"}],"author":{"name":"Cruz Roja Mexicana, Delegación Puebla"},"license":"MIT","readme":"# `@crm/plasma`\r\n\r\nFull type-safe HTTP client wrapper around `fetch`, designed for strict typing of API routes, parameters, and responses — with zero codegen.\r\n\r\n> [!NOTE]\r\n> Primarily designed for **server-side** usage (server functions, API routes, loaders), but runs in the browser as well since it relies solely on standard Web APIs (`fetch`, `Headers`, `FormData`).\r\n\r\n## Installation\r\n\r\n```bash\r\npnpm add @crm/plasma\r\n# or\r\nnpm install @crm/plasma\r\n# or\r\nyarn add @crm/plasma\r\n# or\r\nbun add @crm/plasma\r\n```\r\n\r\n## Quick Start\r\n\r\n### 1. Define your routes\r\n\r\nCreate a `routes.ts` file describing your API endpoints. Use `z.object()` for any query parameters that need validation or coercion.\r\n\r\n```typescript\r\n// routes.ts\r\nimport type { ServerRoutes } from '@crm/plasma'\r\nimport { z } from 'zod'\r\n\r\nexport const APP_ROUTES = {\r\n  'get-users': {\r\n    url: '/api/users',\r\n    params: z.object({\r\n      page: z.coerce.number().optional(),\r\n      role: z.enum(['admin', 'user']).optional(),\r\n    }),\r\n    returns: {} as { id: number; name: string }[],\r\n  },\r\n  'create-user': {\r\n    url: '/api/users',\r\n    apiPayload: z.object({\r\n      name: z.string(),\r\n      email: z.string().email(),\r\n    }),\r\n    returns: {} as { id: number; name: string; email: string },\r\n  },\r\n} satisfies ServerRoutes\r\n```\r\n\r\n### 2. Create the client\r\n\r\nInstantiate `createHttpClient` once and export it for use across your app or create separate clients for different purposes.\r\n\r\n```typescript\r\n// client.ts\r\nimport { createHttpClient } from '@crm/plasma'\r\nimport { APP_ROUTES } from './routes'\r\n\r\nexport const client = createHttpClient({\r\n  serverUrl: process.env.API_URL,\r\n  routes: APP_ROUTES,\r\n  interceptors: {\r\n    request: [\r\n      async (req) => {\r\n        const token = localStorage.getItem('token')\r\n        if (token) req.headers.set('Authorization', `Bearer ${token}`)\r\n        return req\r\n      },\r\n    ],\r\n  },\r\n})\r\n```\r\n\r\n### 3. Make requests\r\n\r\nAll methods return a Go-style `[error, data]` tuple — no try/catch needed.\r\n\r\n```typescript\r\nconst [error, users] = await client.GET('get-users', {\r\n  params: { page: 1, role: 'admin' },\r\n})\r\n\r\nif (error) {\r\n  console.error(error)\r\n  return\r\n}\r\n\r\nconsole.log(users) // { id: number; name: string }[]\r\n```\r\n\r\n---\r\n\r\n## Core Concepts\r\n\r\n### Route Definitions\r\n\r\nRoutes are plain objects that satisfy the `ServerRoutes` type.\r\n\r\n| Field | Type | Description |\r\n|---|---|---|\r\n| `url` | `` `/${string}` `` | Endpoint path (must start with `/`) |\r\n| `params` | `z.ZodObject` | Query parameters schema — enables validation and coercion |\r\n| `apiPayload` | `z.ZodObject` | Request body schema — validated before the request is sent |\r\n| `clientInput` | `z.ZodObject` | Input schema for the UI layer (e.g. form validation) — not sent to the API |\r\n| `returns` | `unknown` | Shape of the API response (type-only, not runtime) |\r\n\r\n### Error Handling\r\n\r\nEvery method returns a `readonly [Error, null] | readonly [null, T]` tuple.\r\n\r\n```typescript\r\nconst [error, data] = await client.GET('get-users')\r\n\r\nif (error) return handleError(error)\r\n// data is fully typed and non-null here\r\n```\r\n\r\nErrors are **returned** (not thrown) for:\r\n- Network failures\r\n- Non-2xx HTTP responses\r\n- JSON parse failures\r\n- `apiPayload` Zod validation failures\r\n\r\nErrors are **thrown** for:\r\n- Missing `serverUrl`\r\n- Missing `Authorization` header on a protected route\r\n\r\n### Authentication\r\n\r\nAll routes are **protected by default**. The client verifies that an `Authorization` header is present after request interceptors run.\r\n\r\n```typescript\r\n// Protected (default) — interceptors must attach the Authorization header\r\nconst [error, data] = await client.GET('get-users')\r\n\r\n// Public — skips the Authorization check entirely\r\nconst [error, data] = await client.POST('login', credentials, { auth: false })\r\n```\r\n\r\n> [!CAUTION]\r\n> If `auth` is `true` (the default) and no `Authorization` header is present after interceptors run, the client **throws synchronously** — it does not return an error tuple.\r\n\r\n### Adapters\r\n\r\nAn adapter transforms the raw API response before it reaches your application.\r\n\r\n```typescript\r\nexport const client = createHttpClient({\r\n  serverUrl: process.env.API_URL,\r\n  routes: APP_ROUTES,\r\n  // API returns { data: [...] } — adapter extracts the array\r\n  adapter: (response) => response.data,\r\n})\r\n```\r\n\r\nWhen an adapter is provided, the `data` field of the result tuple is typed as the adapter's return type, not the raw `returns` type.\r\n\r\n---\r\n\r\n## API Reference\r\n\r\n### `createHttpClient(config)`\r\n\r\nCreates a typed HTTP client.\r\n\r\n| Option | Type | Description |\r\n|---|---|---|\r\n| `serverUrl` | `string \\| undefined` | Base URL for your API |\r\n| `routes` | `ServerRoutes` | Route definitions object |\r\n| `adapter` | `(data: any) => any` | (Optional) Transform the response body |\r\n| `interceptors` | `Interceptors` | (Optional) Request/response interceptors |\r\n\r\n### `client.GET(alias[, options])`\r\n\r\n- **`options` is omittable** when the route has no `params` schema\r\n- **`options` is required** (and must include `params`) when the route defines a `params: z.object(...)` schema\r\n\r\n```typescript\r\n// Route defines `params: z.object(...)` — options is required\r\nconst [error, users] = await client.GET('get-users', {\r\n  params: { page: 1, role: 'admin' }, // required\r\n  auth: true,                          // optional, default: true\r\n})\r\n\r\n// Route has no `params` schema — second argument can be omitted entirely\r\nconst [error, profile] = await client.GET('get-profile')\r\n\r\n// Or pass options to override auth when no params are needed\r\nconst [error, profile] = await client.GET('get-profile', { auth: false })\r\n```\r\n\r\n| Property | Required | Type | Default | Description |\r\n|---|---|---|---|---|\r\n| `params` | When route defines a `params` schema | `z.infer<Route[\"params\"]>` | — | Query parameters, validated and coerced by the route's Zod schema |\r\n| `auth` | No | `boolean` | `true` | Whether to enforce the `Authorization` header |\r\n\r\n### `client.POST(alias, body[, options])`\r\n\r\nIf the route defines an `apiPayload` schema, the body is **validated and coerced** before being sent. Invalid data returns `[ZodError, null]` without making a network request.\r\n\r\n**Options (all optional):**\r\n\r\n| Property | Required | Type | Default | Description |\r\n|---|---|---|---|---|\r\n| `params` | No | `object` | — | Query parameters appended to the URL |\r\n| `bodyType` | No | `'json' \\| 'form-data'` | `'json'` | Serialization format |\r\n| `auth` | No | `boolean` | `true` | Whether to enforce the `Authorization` header |\r\n\r\n### `client.PATCH(alias, body[, options])`\r\n\r\nIdentical signature to `client.POST`. Use for partial update requests.\r\n\r\n---\r\n\r\n## Interceptors\r\n\r\nInterceptors are async functions that run before the request is sent (request) or after the response is received (response). Both sync and async are supported, and they execute **in order** — each one receives the output of the previous.\r\n\r\n### Request Interceptors\r\n\r\n```typescript\r\ntype RequestInterceptor = (request: HttpRequest, context: HttpRequest) => HttpRequest | Promise<HttpRequest>\r\n```\r\n\r\n- **`request`** — the current request state (may already be modified by a previous interceptor)\r\n- **`context`** — a snapshot of the original request before any interceptors ran; useful for logging or error correlation\r\n\r\n```typescript\r\nimport type { RequestInterceptor } from '@crm/plasma'\r\n\r\nconst authInterceptor: RequestInterceptor = async (request, context) => {\r\n  const token = localStorage.getItem('token')\r\n  if (token) request.headers.set('Authorization', `Bearer ${token}`)\r\n  return request\r\n}\r\n```\r\n\r\nThe `HttpRequest` shape:\r\n\r\n| Property | Type | Description |\r\n|---|---|---|\r\n| `url` | `string` | Full resolved URL (serverUrl + path + query) |\r\n| `method` | `'GET' \\| 'POST' \\| 'PATCH' \\| 'PUT' \\| 'DELETE'` | HTTP method |\r\n| `headers` | `Headers` | Mutable headers object |\r\n| `body` | `BodyInit \\| null \\| undefined` | Serialized request body |\r\n\r\n### Response Interceptors\r\n\r\n```typescript\r\ntype ResponseInterceptor = (response: Response, context: HttpRequest) => Response | Promise<Response>\r\n```\r\n\r\n- **`response`** — the current response (may have been modified by a previous interceptor)\r\n- **`context`** — the original `HttpRequest` that generated this response; useful for logging, retries, or redirects\r\n\r\n```typescript\r\nimport type { ResponseInterceptor } from '@crm/plasma'\r\n\r\nconst unauthorizedInterceptor: ResponseInterceptor = async (response, context) => {\r\n  if (response.status === 401) {\r\n    console.warn(`Unauthorized on ${context.method} ${context.url}`)\r\n    localStorage.removeItem('token')\r\n    window.location.href = '/login'\r\n  }\r\n  return response\r\n}\r\n```\r\n\r\nUse the `context` parameter to act on the original request inside a response interceptor. The following example logs and redirects forbidden access attempts:\r\n\r\n```typescript\r\nimport type { ResponseInterceptor } from \"@crm/plasma\";\r\n\r\nimport { redirect } from \"@tanstack/react-router\";\r\n\r\nimport { getUserProfile } from \"@/core/users/services/get-user-profile\";\r\n\r\nexport const forbiddenInterceptor: ResponseInterceptor = async (response, context) => {\r\n    if (response.status === 403) {\r\n        const user = await getUserProfile({ data: { userId: \"\" } });\r\n\r\n        console.warn(\r\n            `User **${user.name}** with id **${user.id}** tried to access the resource [${context.method}] **${context.url}**. \\nLogged out by forbidden interceptor.`,\r\n        );\r\n\r\n        throw redirect({\r\n            to: \"/login\",\r\n        });\r\n    }\r\n\r\n    return response;\r\n};\r\n\r\n``` \r\n\r\n### Wiring interceptors\r\n\r\n```typescript\r\nexport const client = createHttpClient({\r\n  serverUrl: process.env.API_URL,\r\n  routes: APP_ROUTES,\r\n  interceptors: {\r\n    request: [authInterceptor],\r\n    response: [unauthorizedInterceptor, forbiddenInterceptor],\r\n  },\r\n})\r\n```\r\n\r\n---\r\n\r\n## Examples\r\n\r\n### Server Function (TanStack Start)\r\n\r\n```typescript\r\nimport { createServerFn } from '@tanstack/react-start'\r\nimport { client } from '../utils/http'\r\nimport { usersAdapter } from '../adapters/users.adapter'\r\n\r\nexport const getUsers = createServerFn().handler(async () => {\r\n  const [error, response] = await client.GET('get-users')\r\n\r\n  if (error) throw error\r\n\r\n  return usersAdapter(response)\r\n})\r\n```\r\n\r\n### File upload with `form-data`\r\n\r\n```typescript\r\nconst [error, result] = await client.POST('upload-avatar', formPayload, {\r\n  bodyType: 'form-data',\r\n})\r\n```\r\n\r\n### Public endpoint\r\n\r\n```typescript\r\nconst [error, session] = await client.POST('login', credentials, {\r\n  auth: false,\r\n})\r\n```\r\n\r\n### Advanced Usage\r\n\r\n```typescript\r\nimport { DATABASE_STATUS } from \"@/constants/status\";\r\nimport { createServerFn } from \"@tanstack/react-start\";\r\nimport { ZodError } from \"zod\";\r\nimport { format } from \"@/lib/time\";\r\nimport { vacationsClient } from \"../utils/http\";\r\nimport { VACATION_ROUTES } from \"../utils/routes\";\r\n\r\nexport const uploadVacation = createServerFn({ method: \"POST\" })\r\n    .inputValidator((data: FormData) => {\r\n        if (!(data instanceof FormData)) {\r\n            throw new Error(\"Expected FormData\");\r\n        }\r\n\r\n        const segment = data.get(\"segment\")?.toString().split(\" - \")[0];\r\n\r\n        const payload = {\r\n            segment,\r\n            employeeWhoCovers: data.get(\"employee-who-covers\") || undefined,\r\n            days: data.getAll(\"days\"),\r\n        };\r\n\r\n        return VACATION_ROUTES[\"upload-vacation\"].clientInput.parse(payload);\r\n    })\r\n    .handler(async ({ data: { days, segment, employeeWhoCovers } }) => {\r\n        const requestDate = new Date();\r\n        const thisYear = new Date().getFullYear();\r\n\r\n        const requestYear = `${thisYear}-12-31` as const;\r\n\r\n        const requestData = {\r\n            fechaSolicitud: requestDate,\r\n            numDias: days.length,\r\n            estatusVacacion: DATABASE_STATUS.PENDING,\r\n            observaciones: \"-\",\r\n            segmento: segment,\r\n            fk_cubre: employeeWhoCovers,\r\n            anio_solicitud: requestYear,\r\n            diasVacaciones: days.map((date) => format(date)).toString(),\r\n        };\r\n\r\n        const [error, response] = await vacationsClient.POST(\"upload-vacation\", requestData, {\r\n            bodyType: \"form-data\",\r\n        });\r\n\r\n        if (error) {\r\n            if (error instanceof ZodError) {\r\n                return {\r\n                    success: false,\r\n                    error: \"Invalid payload data\",\r\n                    code: \"\",\r\n                };\r\n            }\r\n\r\n            throw error;\r\n        }\r\n\r\n        if (response.status !== 201) {\r\n            return {\r\n                success: false,\r\n                error: \"No se pudieron procesar las vacaciones. Intenta más tarde.\",\r\n            };\r\n        }\r\n\r\n        return {\r\n            success: true,\r\n        };\r\n    });\r\n\r\n```\r\n\r\n---\r\n\r\n## Development & Testing\r\n\r\n```bash\r\n# Run the test suite\r\npnpm test\r\n\r\n# Type check\r\npnpm check-types\r\n\r\n# Lint\r\npnpm lint\r\n```\r\n\r\nWhen contributing:\r\n\r\n1. Run tests before committing — `pnpm test`\r\n2. Add or update tests when changing behavior\r\n3. Update this README and the developer docs when adding features\r\n4. New features must not break existing type inference or runtime behavior","readmeFilename":"README.md","_rev":"1-645339e4ccb6a0be300ea2dc4fbbe0de"}