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

@enbox/api

v0.6.20

Published

Enbox SDK — high-level API for Decentralized Web Nodes, DIDs, and Verifiable Credentials

Downloads

5,026

Readme

@enbox/api

Research Preview -- Enbox is under active development. APIs may change without notice.

Coverage

The high-level SDK for building decentralized applications with protocol-first data management. This is the main consumer-facing package in the Enbox ecosystem -- most applications only need @enbox/api.

Table of Contents

Installation

bun add @enbox/api

Quick Start

import { defineProtocol, Web5 } from '@enbox/api';

// 1. Connect -- creates or loads a local identity and agent
const { web5, did: myDid } = await Web5.connect();

// 2. Define a protocol with typed data shapes
const NotesProtocol = defineProtocol({
  protocol  : 'https://example.com/notes',
  published : true,
  types     : {
    note: {
      schema      : 'https://example.com/schemas/note',
      dataFormats : ['application/json'],
    },
  },
  structure: {
    note: {},
  },
} as const, {} as {
  note: { title: string; body: string };
});

// 3. Scope all operations to the protocol
const notes = web5.using(NotesProtocol);

// 4. Install the protocol on the local DWN
await notes.configure();

// 5. Create a record -- path, data, and schema are type-checked
const { record } = await notes.records.create('note', {
  data: { title: 'Hello', body: 'World' },
});

// 6. Query records back -- data is typed automatically
const { records } = await notes.records.query('note');
for (const r of records) {
  const note = await r.data.json(); // { title: string; body: string }
  console.log(r.id, note.title);
}

// 7. Send to your remote DWN
await record.send(myDid);

Core Concepts

Web5.connect(options?)

Connects to a local identity agent. On first launch it creates an identity vault, generates a did:dht DID, and starts the sync engine. On subsequent launches it unlocks the existing vault.

const { web5, did, recoveryPhrase } = await Web5.connect({
  password: 'user-chosen-password',
});

// recoveryPhrase is returned on first launch only -- store it safely!

Options (all optional):

| Option | Type | Description | |--------|------|-------------| | password | string | Password to protect the local identity vault. Defaults to an insecure static value -- always set this in production. | | recoveryPhrase | string | 12-word BIP-39 phrase for vault recovery. Generated automatically on first launch if not provided. | | sync | string | Sync interval (e.g. '2m', '30s') or 'off' to disable. Default: '2m'. | | didCreateOptions.dwnEndpoints | string[] | DWN service endpoints for the created DID. Default: ['https://enbox-dwn.fly.dev']. | | connectedDid | string | Use an existing DID instead of creating a new one. | | agent | Web5Agent | Provide a custom agent instance. Defaults to a local Web5UserAgent. | | walletConnectOptions | ConnectOptions | Trigger an external wallet connect flow for delegated identity. | | registration | { onSuccess, onFailure } | Callbacks for DWN endpoint registration status. |

Returns { web5, did, recoveryPhrase?, delegateDid? }.

  • web5 -- the Web5 instance for all subsequent operations
  • did -- the connected DID URI (e.g. did:dht:abc...)
  • recoveryPhrase -- only returned on first initialization
  • delegateDid -- only present when using wallet connect

defineProtocol(definition, schemaMap?)

Creates a typed protocol definition that enables compile-time path autocompletion and data type checking when used with web5.using().

import type { ProtocolDefinition } from '@enbox/dwn-sdk-js';

const ChatProtocol = defineProtocol({
  protocol  : 'https://example.com/chat',
  published : true,
  types: {
    thread  : { schema: 'https://example.com/schemas/thread',  dataFormats: ['application/json'] },
    message : { schema: 'https://example.com/schemas/message', dataFormats: ['application/json'] },
  },
  structure: {
    thread: {
      message: {},   // messages are nested under threads
    },
  },
} as const satisfies ProtocolDefinition, {} as {
  thread  : { title: string; description?: string };
  message : { text: string };
});

The second argument is a phantom type -- it only exists at compile time. Pass {} as YourSchemaMap to map protocol type names to TypeScript interfaces. The runtime value is ignored.

This gives you:

  • Path autocompletion: 'thread', 'thread/message' are inferred from structure
  • Typed data payloads: write('thread', { data: ... }) type-checks against the schema map
  • Typed dataFormat: restricted to the formats declared in the protocol type

web5.using(protocol)

The primary interface for all record operations. Returns a TypedWeb5 instance scoped to the given protocol.

const chat = web5.using(ChatProtocol);

configure()

Installs the protocol on the local DWN. If already installed with an identical definition, this is a no-op. If the definition has changed, it reconfigures with the updated version.

await chat.configure();

records.create(path, request)

Create a new record at a protocol path. The protocol URI, protocolPath, schema, and dataFormat are automatically injected. Returns a TypedRecord<T> where T is inferred from the schema map.

const { record, status } = await chat.records.create('thread', {
  data: { title: 'General', description: 'General discussion' },
});

console.log(status.code);  // 202
console.log(record.id);    // unique record ID

// record is TypedRecord<{ title: string; description?: string }>
const data = await record.data.json(); // typed -- no cast needed

To mutate an existing record, use the instance method record.update():

const { record: updated } = await record.update({
  data: { title: 'Updated Title', description: 'New description' },
});

records.query(path, request?)

Query records at a protocol path. Returns TypedRecord<T>[] with optional pagination.

const { records, cursor } = await chat.records.query('thread', {
  dateSort   : 'createdDescending',
  pagination : { limit: 20 },
});

// records is TypedRecord<{ title: string; description?: string }>[]
for (const thread of records) {
  const data = await thread.data.json(); // typed automatically
  console.log(data.title);
}

// Fetch next page
if (cursor) {
  const { records: nextPage } = await chat.records.query('thread', {
    pagination: { limit: 20, cursor },
  });
}

records.read(path, request)

Read a single record by filter criteria.

const { record } = await chat.records.read('thread', {
  filter: { recordId: 'bafyrei...' },
});

const data = await record.data.json();

records.delete(path, request)

Delete a record by ID.

const { status } = await chat.records.delete('thread', {
  recordId: record.id,
});

records.subscribe(path, request?)

Subscribe to real-time changes. Returns a TypedLiveQuery<T> with an initial snapshot of TypedRecord<T>[] plus a stream of typed change events.

const { liveQuery } = await chat.records.subscribe('thread/message');

// Initial snapshot -- typed records
for (const msg of liveQuery.records) {
  const data = await msg.data.json(); // { text: string } -- no cast
  console.log(data.text);
}

// Real-time updates -- typed records in handlers
liveQuery.on('create', (record) => console.log('new:', record.id));
liveQuery.on('update', (record) => console.log('updated:', record.id));
liveQuery.on('delete', (record) => console.log('deleted:', record.id));

All methods also accept a from option to query a remote DWN:

const { records } = await chat.records.query('thread', {
  from: 'did:dht:other-user...',
});

Record Instances (TypedRecord<T>)

Methods like create, query, and read return TypedRecord<T> instances -- type-safe wrappers that preserve the data type T inferred from the schema map through the entire lifecycle (create, query, read, update, subscribe).

TypedRecord<T> exposes a typed data.json() that returns Promise<T> instead of Promise<unknown>, eliminating manual type casts. The underlying Record is accessible via record.rawRecord if needed.

Properties:

| Property | Description | |----------|-------------| | id | Unique record identifier | | contextId | Context ID (scopes nested records to a parent thread) | | protocol | Protocol URI | | protocolPath | Path within the protocol structure (e.g. 'thread/message') | | schema | Schema URI | | dataFormat | MIME type of the data | | dataCid | Content-addressed hash of the data | | dataSize | Size of the data in bytes | | dateCreated | ISO timestamp of creation | | timestamp | ISO timestamp of most recent write | | datePublished | ISO timestamp of publication (if published) | | published | Whether the record is publicly readable | | author | DID of the record author | | recipient | DID of the intended recipient | | parentId | Record ID of the parent record (for nested structures) | | tags | Key-value metadata tags | | deleted | Whether the record has been deleted |

Data accessors -- read the record payload in different formats:

const obj    = await record.data.json();   // T -- automatically typed from schema map
const text   = await record.data.text();   // string
const blob   = await record.data.blob();   // Blob
const bytes  = await record.data.bytes();  // Uint8Array
const stream = await record.data.stream(); // ReadableStream

Mutators:

// Update the record's data
const { record: updated } = await record.update({
  data: { title: 'Updated Title', body: '...' },
});

// Delete the record
const { status } = await record.delete();

Side-effect methods:

// Send the record to a remote DWN
await record.send(targetDid);

// Persist a remote record to the local DWN
await record.store();

// Import a record from a remote DWN into the local store
await record.import();

LiveQuery (Subscriptions) -- TypedLiveQuery<T>

records.subscribe() returns a TypedLiveQuery<T> that provides an initial snapshot of TypedRecord<T>[] plus a real-time stream of deduplicated, typed change events.

const { liveQuery } = await chat.records.subscribe('thread/message');

// Initial snapshot -- TypedRecord<MessageData>[]
for (const msg of liveQuery.records) {
  const data = await msg.data.json(); // MessageData -- typed
  renderMessage(data);
}

// Real-time changes -- handlers receive TypedRecord<MessageData>
const offCreate = liveQuery.on('create', (record) => appendMessage(record));
const offUpdate = liveQuery.on('update', (record) => refreshMessage(record));
const offDelete = liveQuery.on('delete', (record) => removeMessage(record));

// Catch-all event (receives { type: 'create'|'update'|'delete', record: TypedRecord<T> })
liveQuery.on('change', ({ type, record }) => {
  console.log(`${type}: ${record.id}`);
});

// Unsubscribe from a specific handler
offCreate();

// Close the subscription entirely
await liveQuery.close();

The underlying LiveQuery is accessible via liveQuery.rawLiveQuery if needed. Events are automatically deduplicated against the initial snapshot -- you won't receive a create event for records already in the records array.


Web5.anonymous(options?)

Creates a lightweight, read-only instance for querying public DWN data. No identity, vault, or signing keys are required.

const { dwn } = Web5.anonymous();

// Query published records from someone's DWN
const { records } = await dwn.records.query({
  from   : 'did:dht:alice...',
  filter : { protocol: 'https://example.com/notes', protocolPath: 'note' },
});

for (const record of records) {
  console.log(record.id, await record.data.text());
}

// Read a specific record
const { record } = await dwn.records.read({
  from   : 'did:dht:alice...',
  filter : { recordId: 'bafyrei...' },
});

// Count matching records
const { count } = await dwn.records.count({
  from   : 'did:dht:alice...',
  filter : { protocol: 'https://example.com/notes', protocolPath: 'note' },
});

// Query published protocols
const { protocols } = await dwn.protocols.query({
  from: 'did:dht:alice...',
});

Returns ReadOnlyRecord instances (no update, delete, send, or store methods). All calls require a from DID since the reader has no local DWN.


Cookbook

Nested Records

Protocols support hierarchical record structures. Child records reference their parent via parentContextId.

const ChatProtocol = defineProtocol({
  protocol  : 'https://example.com/chat',
  published : true,
  types: {
    thread  : { schema: 'https://example.com/schemas/thread',  dataFormats: ['application/json'] },
    message : { schema: 'https://example.com/schemas/message', dataFormats: ['application/json'] },
  },
  structure: {
    thread: {
      message: {},
    },
  },
} as const, {} as {
  thread  : { title: string };
  message : { text: string };
});

const chat = web5.using(ChatProtocol);
await chat.configure();

// Create a parent thread
const { record: thread } = await chat.records.create('thread', {
  data: { title: 'General' },
});

// Create a message nested under the thread
const { record: msg } = await chat.records.create('thread/message', {
  parentContextId : thread.contextId,
  data            : { text: 'Hello, world!' },
});

// Query messages within a specific thread
const { records: messages } = await chat.records.query('thread/message', {
  filter: { parentId: thread.id },
});

Querying with Filters and Pagination

// Date-sorted, paginated query
const { records, cursor } = await notes.records.query('note', {
  dateSort   : 'createdDescending',
  pagination : { limit: 10 },
});

// Fetch next page using the cursor
if (cursor) {
  const { records: page2 } = await notes.records.query('note', {
    dateSort   : 'createdDescending',
    pagination : { limit: 10, cursor },
  });
}

// Filter by recipient
const { records: shared } = await notes.records.query('note', {
  filter: { recipient: 'did:dht:bob...' },
});

// Query from a remote DWN
const { records: remote } = await notes.records.query('note', {
  from: 'did:dht:alice...',
});

Tags

Tags are key-value metadata attached to records, useful for filtering without parsing record data.

const { record } = await notes.records.create('note', {
  data : { title: 'Meeting Notes', body: '...' },
  tags : { category: 'work', priority: 'high' },
});

// Query by tag
const { records } = await notes.records.query('note', {
  filter: { tags: { category: 'work' } },
});

Note: tags must be declared in your protocol's type definition for the DWN engine to index them.

Publishing Records

Published records are publicly readable by anyone, including anonymous readers.

const { record } = await notes.records.create('note', {
  data      : { title: 'Public Note', body: 'Visible to everyone' },
  published : true,
});

Reading Public Data Anonymously

const { dwn } = Web5.anonymous();

const { records } = await dwn.records.query({
  from   : 'did:dht:alice...',
  filter : {
    protocol     : 'https://example.com/notes',
    protocolPath : 'note',
  },
});

for (const record of records) {
  const note = await record.data.json();
  console.log(note.title);
}

Sending Records to Remote DWNs

Records are initially written to the local DWN. Use send() to push them to a remote DWN, or rely on the automatic sync engine.

// Explicitly send to your own remote DWN
await record.send(myDid);

// Send to someone else's DWN (requires protocol permissions)
await record.send('did:dht:bob...');

The sync engine (enabled by default at 2-minute intervals) automatically synchronizes records between local and remote DWNs. For most use cases, you don't need to call send() manually.


Repository Pattern

The repository() factory provides a higher-level abstraction over TypedWeb5. Instead of passing path strings to every call, you get a structure-aware object with CRUD methods directly on each protocol type -- with automatic singleton detection.

import { defineProtocol, repository, Web5 } from '@enbox/api';

const { web5 } = await Web5.connect({ password: 'secret' });

const TaskProtocol = defineProtocol({
  protocol  : 'https://example.com/tasks',
  published : false,
  types: {
    project : { schema: 'https://example.com/schemas/project', dataFormats: ['application/json'] },
    task    : { schema: 'https://example.com/schemas/task',    dataFormats: ['application/json'] },
    config  : { schema: 'https://example.com/schemas/config',  dataFormats: ['application/json'] },
  },
  structure: {
    project: {
      task: {},   // collection -- many tasks per project
    },
    config: {
      $recordLimit: { max: 1, strategy: 'reject' },  // singleton
    },
  },
} as const, {} as {
  project : { name: string; color?: string };
  task    : { title: string; completed: boolean };
  config  : { defaultView: 'list' | 'board' };
});

const repo = repository(web5.using(TaskProtocol));
await repo.configure();

Collections vs Singletons

The repository automatically detects types with $recordLimit: { max: 1 } and provides different APIs:

Collections (default) -- create, query, get, delete, subscribe:

// Create
const { record } = await repo.project.create({
  data: { name: 'Website Redesign', color: '#3b82f6' },
});

// Query all
const { records } = await repo.project.query();

// Query with filters and pagination
const { records: recent, cursor } = await repo.project.query({
  dateSort   : 'createdDescending',
  pagination : { limit: 10 },
});

// Get by record ID
const { record: project } = await repo.project.get(recordId);

// Delete
await repo.project.delete(recordId);

// Subscribe to real-time changes
const { liveQuery } = await repo.project.subscribe();
liveQuery.on('create', (record) => console.log('new project:', record.id));

Singletons ($recordLimit: { max: 1 }) -- set, get, delete:

// Set (creates or updates)
await repo.config.set({
  data: { defaultView: 'board' },
});

// Get the single record
const { record: config } = await repo.config.get();
const { defaultView } = await config.data.json(); // 'board'

// Delete
await repo.config.delete(config.id);

Nested Records

Nested types take parentContextId as the first argument:

// Create a task under a project
const { record: task } = await repo.project.task.create(project.contextId, {
  data: { title: 'Design mockups', completed: false },
});

// Query tasks within a project
const { records: tasks } = await repo.project.task.query(project.contextId);

// Subscribe to tasks within a project
const { liveQuery } = await repo.project.task.subscribe(project.contextId);

Using Pre-built Protocols

The @enbox/protocols package provides production-ready protocol definitions. Combined with repository(), you get zero-boilerplate typed data access:

import { repository, Web5 } from '@enbox/api';
import {
  PreferencesProtocol,
  ProfileProtocol,
  SocialGraphProtocol,
} from '@enbox/protocols';

const { web5 } = await Web5.connect({ password: 'secret' });

// Social Graph -- friend, block, group, member
const social = repository(web5.using(SocialGraphProtocol));
await social.configure();

const { record } = await social.friend.create({
  data: { did: 'did:dht:alice...', alias: 'Alice' },
});

// Profile -- profile (singleton), avatar, hero, link, privateNote
const profile = repository(web5.using(ProfileProtocol));
await profile.configure();

await profile.profile.set({
  data: { displayName: 'Bob', bio: 'Building the decentralized web' },
});

// Add links nested under the profile
const { record: p } = await profile.profile.get();
await profile.profile.link.create(p.contextId, {
  data: { url: 'https://github.com/bob', title: 'GitHub' },
});

// Preferences -- theme, locale, privacy (singletons), notification (collection)
const prefs = repository(web5.using(PreferencesProtocol));
await prefs.configure();

await prefs.theme.set({ data: { mode: 'dark', accentColor: '#8b5cf6' } });
await prefs.locale.set({ data: { language: 'en', timezone: 'America/New_York' } });

See @enbox/protocols for the full catalog of 6 protocols and 19 typed data shapes.


Code Generation

For protocols defined externally (e.g. from a spec or shared JSON file), use @enbox/protocol-codegen to generate TypeScript types from a protocol definition and JSON Schemas:

bunx @enbox/protocol-codegen generate \
  --definition ./my-protocol.json \
  --schemas ./schemas/ \
  --name MyProtocol \
  --output ./my-protocol.generated.ts

This generates:

  • TypeScript interfaces for each type's JSON Schema (via json-schema-to-typescript)
  • A SchemaMap mapping type names to generated interfaces
  • A ready-to-use defineProtocol() call

See @enbox/protocol-codegen for full documentation.


Advanced Usage

Unscoped DWN Access

For power users who need direct DWN access without protocol scoping (e.g. cross-protocol queries, raw permission management), import from the @enbox/api/advanced sub-path:

import { DwnApi } from '@enbox/api/advanced';

The DwnApi class provides raw records, protocols, and permissions accessors without automatic protocol/path/schema injection. You must provide those fields manually in every call. Most applications should use web5.using() instead.

Permissions

The DWN permission system supports fine-grained access control through permission requests, grants, and revocations.

import { DwnApi } from '@enbox/api/advanced';

// Query existing permission grants
const grants = await web5._dwn.permissions.queryGrants();

// Request permissions from another DWN
const request = await web5._dwn.permissions.request({
  scope: {
    interface : 'Records',
    method    : 'Write',
    protocol  : 'https://example.com/notes',
  },
});

// Send the request to the target DWN
await request.send('did:dht:alice...');

DID Operations

// Resolve any DID
const { didDocument } = await web5.did.resolve('did:dht:abc...');

Browser Builds — Required Polyfills

The Enbox packages reference several Node.js globals that must be shimmed in browser environments. Most bundlers (Vite, webpack) handle the main bundle automatically, but secondary build targets (e.g., service workers, Web Workers) may need explicit configuration.

Required shims

| Global | Shim value | Used by | |--------|-----------|---------| | global | globalThis | @enbox/agent transitive deps | | process.env | {} | @enbox/agent, @enbox/dids | | process.browser | true | @enbox/agent transitive deps | | process.emitWarning | () => {} | @enbox/agent transitive deps |

Vite configuration

For the main app bundle, add to vite.config.ts:

import nodePolyfills from 'vite-plugin-node-stdlib-browser';

export default defineConfig({
  define: {
    global: 'globalThis',
  },
  plugins: [nodePolyfills()],
});

Service workers (VitePWA)

Service workers built via VitePWA run in a separate Vite build that does not inherit the main app's polyfill plugins. Two additional steps are needed:

  1. Build as IIFE to compile away import.meta.url references:

    VitePWA({
      strategies: 'injectManifest',
      injectManifest: {
        rollupFormat: 'iife',
      },
    })
  2. Prepend a process shim via a Rollup plugin:

    VitePWA({
      injectManifest: {
        rollupFormat: 'iife',
        buildPlugins: {
          rollup: [{
            name: 'sw-process-shim',
            renderChunk(code) {
              const shim = 'if(typeof process==="undefined"){self.process={env:{},browser:true,emitWarning:function(){}};};\n';
              return { code: shim + code, map: null };
            },
          }],
        },
      },
    })

API Reference

Main Exports (@enbox/api)

| Export | Description | |--------|-------------| | Web5 | Main entry point -- connect(), anonymous(), using() | | defineProtocol() | Factory for creating typed protocol definitions | | repository() | Factory for creating structure-aware CRUD repositories from TypedWeb5 | | TypedWeb5 | Protocol-scoped API returned by web5.using() -- create, query, read, delete, subscribe | | TypedRecord<T> | Type-safe record wrapper -- data.json() returns Promise<T> | | TypedLiveQuery<T> | Type-safe subscription with TypedRecord<T>[] snapshot and typed change events | | Record | Mutable record instance with data accessors and side-effect methods | | ReadOnlyRecord | Immutable record for anonymous/read-only access | | LiveQuery | Real-time subscription with initial snapshot and change events | | Protocol | Protocol metadata wrapper | | PermissionGrant | Permission grant record | | PermissionRequest | Permission request record | | PermissionGrantRevocation | Permission revocation record | | DidApi | DID resolution | | VcApi | Verifiable Credentials (not yet implemented) | | DwnReaderApi | Read-only DWN API for anonymous access |

Advanced Export (@enbox/api/advanced)

| Export | Description | |--------|-------------| | DwnApi | Full unscoped DWN API with records, protocols, permissions |

Key Types

| Export | Description | |--------|-------------| | TypedProtocol<D, M> | Typed protocol wrapper with definition and schema map | | ProtocolPaths<D> | Union of valid slash-delimited paths for a protocol definition | | SchemaMap | Maps protocol type names to TypeScript interfaces | | TypedCreateRequest<D, M, Path> | Options for records.create() | | TypedCreateResponse<T> | Response from records.create() -- { status, record: TypedRecord<T> } | | TypedQueryRequest | Options for records.query() | | TypedQueryResponse<T> | Response from records.query() -- { status, records: TypedRecord<T>[], cursor? } | | TypedSubscribeResponse<T> | Response from records.subscribe() -- { status, liveQuery: TypedLiveQuery<T> } | | Repository<D, M> | Repository type -- structure-aware Proxy object with CRUD methods | | DataForPath<D, M, Path> | Resolves TypeScript data type for a protocol path from the schema map | | Web5ConnectOptions | Options for Web5.connect() | | Web5ConnectResult | Return type of Web5.connect() | | RecordModel | Structured data model of a record | | RecordChangeType | 'create' \| 'update' \| 'delete' | | RecordChange | Change event payload { type, record } |

License

Apache-2.0