npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

ngx-eden

v0.2.0

Published

Eden Treaty alternative for Angular — end-to-end typesafe Elysia client running on Angular's HttpClient

Readme

ngx-eden

Eden Treaty alternative for Angular — end-to-end typesafe Elysia client running on Angular's HttpClient

ngx-eden turns the type of your Elysia app into a callable client, exactly like @elysiajs/eden's Treaty, but every call is an HttpClient.request() returning an Observable. Interceptors, HttpContext, withCredentials, SSR TransferState, toSignal() and httpResource() all work because nothing bypasses Angular's HTTP stack.

const users = await firstValueFrom(api.users.get({ query: { limit: 10 } }))
//    ^? User[]
api.users({ id: 1 }).patch({ name: 'Ada' })
//                          ^? { name?: string; email?: string }

ngx-eden vs @elysiajs/eden

| | ngx-eden | @elysiajs/eden (treaty) | | ----------------------- | --------------------------------------------------- | ------------------------------- | | Transport | Angular HttpClient.request() | fetch | | Return type | Observable<Success> (errors in the error channel) | Promise<{ data, error }> | | { data, error } style | opt-in via safe() | always | | HTTP interceptors | yes, all provideHttpClient() interceptors run | no (onRequest / onResponse) | | SSR TransferState | yes, via withHttpTransferCacheOptions() | no | | Signal support | toSignal(), edenResource() → httpResource() | manual | | DI / config | provideNgxEden() + createEden() | treaty(domain, config) | | Path params | api.users({ id }).get() | same | | Files | auto FormData | same | | SSE / streaming | not supported (v1) | AsyncGenerator | | WebSocket | not supported | subscribe() |

Install

bun add ngx-eden
# peer deps
bun add @angular/common @angular/core rxjs
bun add -d elysia   # optional peer, only needed to type your server's `App`

Requires Angular 20, 21 or 22 (>=20.0.0 <23.0.0; httpResource is still evolving, so newer majors are added after testing), Elysia 1.4+ (when you import the server's App type) and strict: true. The published declarations do not import elysia.

Setup

1. Export your app type from the Elysia server

// server/src/app.ts
export const app = new Elysia()
  .get('/users', () => users, { query: t.Object({ limit: t.Optional(t.Number()) }) })
  .get('/users/:id', ({ params }) => users.find(params.id), {
    params: t.Object({ id: t.Number() }),
    response: { 200: UserSchema, 404: t.Object({ message: t.String() }) },
  })

export type App = typeof app

2. Provide the config next to provideHttpClient()

// app.config.ts
import { provideHttpClient, withFetch } from '@angular/common/http'
import { provideNgxEden } from 'ngx-eden'

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(withFetch()), // add your interceptors here
    provideNgxEden({ baseUrl: 'https://api.example.com' }),
  ],
}

3. Create the client inside an injection context

// api.service.ts
import { Injectable } from '@angular/core'
import type { App } from 'server'
import { createEden } from 'ngx-eden'

@Injectable({ providedIn: 'root' })
export class Api {
  readonly client = createEden<App>()
}

createEden() calls inject() internally, so use it in a constructor, a field initializer, or runInInjectionContext(). Pass { baseUrl } to override the provided config per client.

Usage

Calls

const api = inject(Api).client

api.users.get() // Observable<User[]>
api.users.get({ query: { limit: 5 } }) // typed query
api.users.post({ name: 'Ada', email: '[email protected]' }) // typed body
api.users({ id: 1 }).get() // path param, required
api.users({ id: 1 }).delete() // Observable<void>
api.secure.get({ headers: { authorization: 'Bearer …' } }) // required when the schema says so
api.posts({ postId: 1 }).comments({ commentId: 2 }).get()
api.echo.get()
api.echo({ name: 'x' }).get() // optional param `/echo/:name?`

Rules mirror Eden:

  • get / head take (options?); everything else takes (body?, options?).
  • body and options are optional when every field in the schema is optional.
  • The success type is the 200 | 201 | 202 | 204 response, then any other 2xx, then the union of everything declared.
  • A body that contains a File, Blob or FileList is sent as FormData; other objects as JSON.
  • Query values: arrays repeat the key, Date becomes ISO, objects are JSON.stringifyed, null / undefined are skipped.

Options

Every call accepts, besides typed query and headers:

| option | forwarded to | | ----------------- | ----------------------------------------------------------------- | | withCredentials | HttpClient.request() | | context | HttpContext for interceptors | | reportProgress | HttpClient.request() | | responseType | 'auto' (default), 'json', 'text', 'blob', 'arraybuffer' |

'auto' reads the body as text and decodes it by Content-Type the way Eden does: JSON when the server says so, otherwise numbers / booleans / JSON-looking strings, else the raw string.

Errors

Non-2xx responses go to the RxJS error channel as Angular's HttpErrorResponse, with error already decoded. This includes responseType: 'blob' and 'arraybuffer': the error body is read as UTF-8 text and decoded by Content-Type like any other body, so the server's JSON error is available as error.error. For the Eden style, use safe():

import { safe } from 'ngx-eden'

safe(api.users({ id }).get()).subscribe((result) => {
  if (result.error) {
    result.error.status // 404 | 422
    result.error.value // { message: string } | ValidationError
    return
  }
  result.data // User
})

api.users.get().pipe(safe()) // operator form, error typed as EdenError<number, unknown>

The call form keeps the route's declared error union. Elysia adds 422 to every route with a schema.

For async code, unwrap() awaits the first value and rejects with an EdenRequestError (status, value, message API request failed with status <status>):

import { EdenRequestError, unwrap } from 'ngx-eden'

try {
  const user = await unwrap(api.users({ id }).get()) // User
} catch (error) {
  if (error instanceof EdenRequestError) {
    error.status // number
    error.value // unknown: narrow it before use
  }
}

EdenRequestError.value is unknown. TypeScript does not type promise rejections, so the route's error union cannot reach a catch clause, and value can also be a network or decode error the route never declared. When you need the typed union, use safe() instead.

Failures that never reached the server (network errors, interceptor errors) have status 0 and the original error as value.

A success response whose body does not decode (for example malformed JSON under Content-Type: application/json) is also an HttpErrorResponse. It keeps the real status (such as 200), and its error is { error, text }, the same shape Angular uses when responseType: 'json' fails to parse.

Response types and dates

Response types are mapped through Serialized<T>: JSON has no Date, so Date fields are typed string (ISO), recursively through arrays, tuples and objects, for success bodies and error values. Blob, File, ArrayBuffer and primitives are left alone. There is no date revival.

Signals

import { toSignal } from '@angular/core/rxjs-interop'
import { edenResource } from 'ngx-eden'

@Component({/* … */})
export class Users {
  private api = inject(Api).client

  users = toSignal(this.api.users.get(), { initialValue: [] })

  id = signal(1)
  // httpResource() under the hood; re-fetches when `id` changes; value typed as User | undefined
  user = edenResource(() => this.api.users({ id: this.id() }).get.$request())
}

The request's responseType picks the httpResource variant: json → httpResource, text → httpResource.text (raw string, no heuristics), blob → httpResource.blob, arraybuffer → httpResource.arrayBuffer, auto (default) → text decoded by Content-Type with the client's decoder, so both return the same value for the same response.

The variant is fixed when the resource is created, so responseType is static per resource. It is read once, untracked, from the first request; if that request can be undefined, pass { responseType } in the options (it wins over the request's). A later request with another responseType is sent with the original variant and logs a console error.

$request(...) takes the same arguments as the call and returns the request descriptor (method, url, params, headers, body, …) without sending it. '~path' gives the resolved path of any node.

Helper types

import type { EdenData, EdenFailure } from 'ngx-eden'

type User = EdenData<typeof api.users.get> // User[]
type Failure = EdenFailure<typeof api.users.get> // EdenError<422, …>

Recipe: monorepo with an auth interceptor

A typical Bun workspace: apps/server (Elysia) and apps/web (Angular) where an interceptor already rebases /api/... onto the API origin and attaches the bearer token.

Keep the interceptor in charge. Create the client with an empty base URL so requests leave as /api/... and flow through the same interceptor chain as plain HttpClient calls. Doing it in the service (not provideNgxEden()) also means every TestBed that already has provideHttpClient() keeps working:

@Injectable({ providedIn: 'root' })
export class Api {
  readonly client = createEden<App>({ baseUrl: '' })
}

Import the server type from declarations, not source. Server code usually compiles under its own options (bun types, .ts import extensions) that the Angular project does not share, and pulling the whole server graph into every front-end build is slow. Emit .d.ts once instead; skipLibCheck in the web project ignores the server's ambient types:

// apps/server/tsconfig.types.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "noEmit": false,
    "declaration": true,
    "emitDeclarationOnly": true,
    "outDir": "dist/types",
    "rootDir": "src",
  },
  "include": ["src"],
}
// apps/server/package.json
{
  "types": "dist/types/index.d.ts",
  "exports": { ".": { "types": "./dist/types/index.d.ts", "default": "./src/index.ts" } },
  "scripts": {
    "build:types": "tsc -p tsconfig.types.json",
    "dev:types": "tsc -p tsconfig.types.json --watch --preserveWatchOutput",
  },
}
// apps/web/package.json
{
  "devDependencies": { "server": "workspace:*", "elysia": "^1.4.30" },
  "scripts": {
    "pretypecheck": "bun run --filter server build:types",
    "prebuild": "bun run --filter server build:types",
  },
}

Run dev:types next to the dev servers and a route change on the server becomes a compile error in the web app a second later.

Replace hand-written response types.

export type Directory = EdenData<Api['client']['api']['directory']['get']>

SSR

Because requests flow through HttpClient, provideClientHydration(withHttpTransferCacheOptions()) caches server-side responses and replays them on the client with no extra work.

Limitations

  • No Server-Sent Events / streaming responses and no WebSocket subscribe() — HttpClient does not stream bodies.
  • No date revival: ISO strings stay strings (Angular parses JSON as-is); the types say string.
  • A path segment named like an HTTP method (get, post, put, patch, delete, head, options), e.g. /items/delete, is treated as a method call, so api.items.delete() sends DELETE /items. Such routes cannot be reached through the proxy; call HttpClient directly.
  • Path parameter values . and .. throw, because they would climb out of their segment.
  • Wildcard routes are reachable as api['*'].

Acknowledgements

ngx-eden is written from scratch, but its API and type-level design follow @elysiajs/eden (Treaty) by saltyAom, MIT licensed: the callable route tree, path-parameter calls, optional body / options rules, 2xx-first response selection, query serialization and FormData conversion all mirror Eden's behaviour so switching between the two feels familiar. Thank you for the excellent reference.

License

MIT