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

@onderwijsin/directus-extension-utils

v0.3.0

Published

Reusable utilities for Onderwijs in Directus extensions

Downloads

814

Readme

@onderwijsin/directus-extension-utils

Small, reusable utilities for Directus extensions. The package is runtime-portable across Directus setups, but is intended to run inside Directus—not as a framework-agnostic utility library.

The public surface includes:

  • runtime guards (including Directus primary-key narrowing), attempt/retry helpers, object helpers, MIME classification, and UUIDs;
  • server-only async Express adapters, locks, debounced auto-task handlers, task storage, logging, extension setup helpers, and the Studio Docs article seeding contract; and
  • reusable Directus extension types.

Install

pnpm add @onderwijsin/directus-extension-utils

ensureDirectusDocumentation is the server-only contract for extensions that contribute articles to the fixed studio_docs collection. It validates stable article input, honors the docs seed gate, and reconciles changed content using either the main item or the reserved incoming content version. Call it from a startup documentation callback; the coordinator runs documentation independently of the ordinary extension schema and data gates.

Import

Use the root entry point for common helpers:

import { isRecord, uuid } from '@onderwijsin/directus-extension-utils'

Use /server for server-only utilities:

import {
  asyncHandler,
  createAutoTaskHandler,
  initializeCache,
  withCache,
  createRedisTaskHandlerStorage,
  createRedisLockProvider,
  ensureDirectusDocumentation,
} from '@onderwijsin/directus-extension-utils/server'

Use /hook when defining an API hook with asynchronous action handlers. This subpath contains the corrected Directus hook types and keeps @directus/extensions-sdk isolated from consumers that only import server utilities:

import { defineHook } from '@onderwijsin/directus-extension-utils/hook'

Import only the corrected hook types from /types when a utility needs the compile-time contract without the hook runtime:

import type { RegisterFunctions } from '@onderwijsin/directus-extension-utils/types'

Use the server policy utilities when an extension must resolve Directus policy assignments, including nested roles and ip_access filtering. The first accountability controls whose policy assignments are resolved; by default it also controls CRUD filtering while reading directus_access and directus_policies:

import {
  fetchPolicies,
  hasPolicies,
  initializePolicyCache,
} from '@onderwijsin/directus-extension-utils/server'

const policyCache = initializePolicyCache(env)
const policies = await fetchPolicies(accountability, services, schema, policyCache)
const allowed = await hasPolicies(accountability, policyId, services, schema, policyCache)

Pass a final null read accountability only for a trusted server-side consumer that must resolve assignments without CRUD filtering. This bypass reads policy metadata with system accountability and must not be used before returning policies to a client unless exposing all matching policy metadata is intentional:

const policies = await fetchPolicies(accountability, services, schema, policyCache, null)
const allowed = await hasPolicies(accountability, policyId, services, schema, policyCache, null)

The policies endpoint uses the default user-scoped mode. Its callers therefore need read access to the relevant directus_access and directus_policies records, directly or through a role.

Initialize a policy cache once during extension startup with initializePolicyCache. It returns null when caching is disabled or Redis configuration is invalid. Pass that cache to policy helpers; they never initialize Redis clients on the request path. The Redis-only policy cache uses the shared directus:policies namespace so separate extension entrypoints can invalidate it.

Create a local or Redis-backed cache from validated Directus environment values:

import { initializeCache, withCache } from '@onderwijsin/directus-extension-utils/server'

const cache = initializeCache(context.env, {
  ttl: 60_000,
  namespace: 'directus:extensions:my-extension:summary',
})
const summaryCacheKey = (collection: string): string => `summary:${collection}`

const summary = await withCache({ cache, key: summaryCacheKey('orders') }, () =>
  loadSummary('orders'),
)

withCache accepts a Cache | null, an explicit cache key, and an asynchronous cache-miss handler:

withCache<TResult>(
  options: { cache: Cache | null; key: string },
  handler: () => Promise<TResult>,
): Promise<TResult>

The handler runs only on a cache miss, and its resolved value is stored under the explicit key. A null cache bypasses cache reads and writes while still invoking the handler. Keep key construction next to the cached operation and use stable extension-specific prefixes such as fields:<collection> or summary:<collection>; key construction has no implicit extension prefix.

For mutation-driven invalidation, use registerCollectionCacheInvalidation with the same key function used by withCache:

registerCollectionCacheInvalidation(
  'articles',
  { cache, key: (collection) => `fields:${collection}` },
  hook,
  context,
)

Invalidation deletes the exact key associated with the mutated collection. It is non-blocking and logs deletion failures; use Redis when invalidation must be visible across Directus processes. Pass a string for regular item events, an array for explicit event targets, or an object to select create/update/delete events and system-collection handling.

The related public types are CacheEnv, CacheOptions (ttl, a finite positive number), WithCacheOptions (cache plus key), CollectionInput, and CollectionCacheInvalidationOptions.

Publishable extensions must provide an explicit extension-specific CacheOptions.namespace for extension-owned caches. Use separate subsystem namespaces when targeted invalidation is useful. CacheOptions.namespace isolates Redis keys and makes clear() namespace-scoped. Local caches have a private store per initialized instance; a namespace does not make separate local instances share data. cacheConfigSchema validates Directus cache and Redis settings. REDIS takes precedence over REDIS_HOST, REDIS_PORT, REDIS_USERNAME, and REDIS_PASSWORD; component configuration requires REDIS_ENABLED=true and all four values. Disabled caching returns null; an unset CACHE_STORE uses memory, even though initializeCache maps memory to the library's local backend. TTL values must be finite and positive. Policy caching is the exception: it deliberately ignores memory and requires valid Redis configuration.

The server entrypoint also exports emailConfigSchema, requiredEmailConfigSchema, and isEmailConfigured. The base email schema supplies Directus defaults without requiring a transport; the required schema validates the minimum shared prerequisites for the selected transport. For SMTP, that means only EMAIL_SMTP_HOST; the SMTP port, username, password, and other transport options are left to Directus and the consumer to configure and validate.

Wrap asynchronous endpoint handlers and middleware with asyncHandler so rejected promises reach Directus's Express 4 error handling:

The callback receives an Express request typed with Directus's accountability property, which is Accountability | null.

router.post(
  '/route',
  asyncHandler(async (request, response) => {
    const result = await doSomething(request)
    response.json(result)
  }),
)

Middleware can call next() explicitly after asynchronous work:

router.use(
  asyncHandler(async (_request, _response, next) => {
    await checkAccess()
    next()
  }),
)

Use /sentry when an extension explicitly needs the Sentry helpers. This separate entry point keeps Sentry out of consumers that only import /server utilities:

import { captureException } from '@onderwijsin/directus-extension-utils/sentry'

The shared Directus extension build configuration is available as an unbundled package subpath:

import config, {
  createExtensionConfig,
} from '@onderwijsin/directus-extension-utils/extension.config.js'

export default createExtensionConfig({ externals: ['oxfmt'] })
// Or: export default config

Use /constants for shared deployment-environment values:

import { DEPLOYMENT_ENV, deploymentEnvs } from '@onderwijsin/directus-extension-utils/constants'

const environment: DEPLOYMENT_ENV = 'development'
console.log(deploymentEnvs, environment)

deploymentEnvs is the readonly tuple ['development', 'staging', 'production'], and DEPLOYMENT_ENV is its corresponding TypeScript union.

Use the accountability factories when a service needs admin permissions. The system variant identifies the operation as owned by Directus system code:

import {
  createAdminAccountability,
  createSystemAdminAccountability,
} from '@onderwijsin/directus-extension-utils'

const admin = createAdminAccountability()
const systemAdmin = createSystemAdminAccountability()

Create a logger from a Pino-compatible runtime logger, or use the console-backed fallback:

import { createLogger } from '@onderwijsin/directus-extension-utils/server'

const logger = createLogger(context.logger)
logger.info({ msg: 'Extension started', extension: 'orders' })

When a logger is provided, it is returned unchanged. Without one, the fallback exposes the same info, warn, error, debug, and trace methods and forwards messages plus optional fields to the corresponding console methods.

The /app and /shared entry points expose the common browser-safe surface. Do not import locks, tasks, task storage, logging, or setup helpers from those paths.

Use /server setup helpers at an API extension boundary:

import {
  extensionSetup,
  validateExtensionOptions,
} from '@onderwijsin/directus-extension-utils/server'
import { envSchema } from './env.schema'

const setup = extensionSetup('my-extension', env, logger)
setup.start()
if (!setup.isEnabled()) return
const options = validateExtensionOptions(env, envSchema, logger)
// Register routes or other API behavior using options.
setup.end()

For extensions that modify Directus schema, compose the entrypoint environment schema with the shared server-side schema-change settings:

import { directusStartupSchema } from '@onderwijsin/directus-extension-utils/server'
import { z } from 'zod'

const envSchema = directusStartupSchema.extend({
  MY_EXTENSION_SCHEMA_CHANGES_ENABLED: z.boolean().default(true),
})

directusStartupSchema validates DIRECTUS_EXTENSIONS_SCHEMA_CHANGES_ENABLED, which defaults to true, and supports DIRECTUS_EXTENSIONS_LOCK_PROVIDER (memory, redis, or fs). When unset, the lock provider falls back to SYNCHRONIZATION_STORE. redis uses DIRECTUS_EXTENSIONS_LOCK_REDIS_URL when set, otherwise the resolved Directus Redis configuration; fs requires DIRECTUS_EXTENSIONS_LOCK_FS_DIRECTORY. It also exposes the shared DIRECTUS_EXTENSIONS_RATE_LIMITER_STORE setting. When unset, it falls back to SYNCHRONIZATION_STORE.

Use ensureDirectusSchema from the same /server subpath to apply portable collection, field, and relation definitions. Use withCollectionIdentity(name, schema) when a bundled portable schema supports a configurable collection name. Every collection definition must include a non-blank schema.name and its primary-key field in the collection's nested fields array; do not repeat that primary-key field in the top-level fields array. This prevents Directus from creating an implicit integer primary key before the extension's intended field is applied. Pass the Directus hook context's database, getSchema, and services, and provide a logger plus an extension identifier. Existing compatible resources are preserved; incompatible structural resources are logged loudly and left unchanged rather than being silently modified. The validated environment options select the provider automatically. Set options.lockProvider to override that selection programmatically. Redis providers created from environment options are disposed after the ensure operation; explicitly supplied providers remain owned by the consumer.

Use validateSchemaDefinition(...) for bundled schema JSON before passing it to ensureDirectusSchema; no type cast is required.

Use validatePolicyDefinition(...) for bundled policy JSON with nested permissions, then pass the validated definition to ensureDirectusPolicy. The policy ensure operation processes the nested permissions into directus_permissions rows linked to the policy; permission IDs are generated by Directus as integers and must not be included in bundled definitions. Idempotency is based on the natural key policy + collection + action; matching existing rows are preserved and are not updated.

extensionSetup logs lifecycle messages and supports an environment-based enabled flag. validateExtensionOptions parses a complete extension environment with Zod, logs validation details, and throws when the configuration is invalid.

Schema configuration and operation options:

| Option | Scope | Default | Purpose | | -------------------------------------------------------------- | ----------- | ---------------- | ------------------------------------------------------------------------------------ | | DIRECTUS_EXTENSIONS_SCHEMA_CHANGES_ENABLED | global | true | Master switch for schema setup. | | DIRECTUS_EXTENSIONS_DATA_SEED_ENABLED | global | true | Enables policy and future data seeds. | | SYNCHRONIZATION_STORE | Directus | memory | Global fallback for synchronization-related extension stores. | | DIRECTUS_EXTENSIONS_LOCK_PROVIDER | global | unset | Selects memory, redis, or fs; otherwise falls back to SYNCHRONIZATION_STORE. | | DIRECTUS_EXTENSIONS_LOCK_REDIS_URL | global | — | Optional override; otherwise uses resolved Redis settings. | | DIRECTUS_EXTENSIONS_LOCK_FS_DIRECTORY | global | — | Required for the filesystem provider. | | DIRECTUS_EXTENSIONS_RATE_LIMITER_STORE | global | unset | Selects the limiter store; otherwise falls back to SYNCHRONIZATION_STORE. | | REDIS_ENABLED | Directus | false | Enables component-based Redis configuration. | | REDIS | Directus | — | Complete Redis URL; takes precedence over components. | | REDIS_HOST, REDIS_PORT, REDIS_USERNAME, REDIS_PASSWORD | Directus | — | Required together when constructing a Redis URL. | | lockProviderConfig | operation | — | Uses validated environment config to create a provider. | | lockProvider | operation | — | Supplies a consumer-owned provider directly. | | autoRenew | coordinator | true | Renews the startup lease while callbacks run. | | abortOnError | coordinator | true | Rethrows provider, callback, and lost-lease failures after cleanup. | | lockLeaseMs | operation | provider default | Overrides one lock acquisition lease. |

ensureDirectusSchema always coordinates the operation with a lock and returns { changed, skipped }. It creates missing resources, skips compatible resources, and logs incompatible collections, fields, or relations without changing them. Structural compatibility is deliberately narrow: collection identity, field identity/type, and relation endpoints are authoritative; interfaces, displays, labels, icons, visibility, notes, and similar UI metadata are left under the site's control. It logs an info-level pre-operation plan and post-operation summary; per-resource and lock lifecycle details use debug-level logging. Bundled extension definitions are trusted data and do not need a second runtime Zod schema.

Use getDirectusStartupStatus to check the shared startup lock from another code path without acquiring, renewing, releasing, or repairing it:

import { getDirectusStartupStatus } from '@onderwijsin/directus-extension-utils/server'

const status = await getDirectusStartupStatus({
  id: 'orders',
  options: { lockProviderConfig: options },
})

if (status.isLocked) {
  // Schema and data startup work is still in progress.
}

The status query must use the same provider configuration and extension identifier as the startup coordinator. It is read-only and disposes only providers created from configuration. Use Redis or a shared filesystem provider for separate processes.

Use ensureDirectusPolicy for policy data seeds. It creates a policy with its configured UUID and name, preserves compatible policies, and idempotently creates its nested permission rows. It logs UUID/name conflicts without modifying existing policies. Role assignments and user assignments are separate future seeds.

Register startup work through createDirectusStartupCoordinator. It coordinates each phase with a lock, registers schema callbacks on app.before, and registers data and documentation callbacks on the awaited middlewares.before lifecycle event. Documentation callbacks are independent of the ordinary global schema/data gates:

const startup = createDirectusStartupCoordinator(hook, logger, {
  id: 'orders',
  name: 'Orders',
  disabled: false,
  disabledGlobally: !options.DIRECTUS_EXTENSIONS_SCHEMA_CHANGES_ENABLED,
  dataDisabledGlobally: !options.DIRECTUS_EXTENSIONS_DATA_SEED_ENABLED,
  lockProviderConfig: options,
})

startup.schema(async ({ lockProvider }) => {
  await ensureDirectusSchema({
    id: 'orders',
    database,
    getSchema,
    logger,
    services,
    definition: ordersDefinition,
    options: { lockProvider },
  })
})

Schema callbacks always register on Directus's awaited app.before lifecycle event, while data callbacks register on Directus's awaited middlewares.before lifecycle event. Provider and callback failures are logged and then rethrown by default after the coordinator releases its lease and disposes any provider it created. Set abortOnError: false only for deliberate best-effort startup.

const startup = createDirectusStartupCoordinator(hook, logger, {
  id: 'studio-docs',
  name: 'Studio Docs',
  disabled: false,
  disabledGlobally: false,
  dataDisabledGlobally: false,
})

Contribute a stable article from a startup documentation callback. The helper is server-only and targets the fixed studio_docs collection owned by @onderwijsin/directus-studio-docs-bundle:

import { ensureDirectusDocumentation } from '@onderwijsin/directus-extension-utils/server'

startup.documentation(async ({ lockProvider }) => {
  await ensureDirectusDocumentation(
    {
      id: '7b8b3a1e-38f3-4ab7-9b37-5e4c5d7f1234',
      navigation_label: 'Getting started',
      body: '# Getting started\n\nWrite the article in Markdown.',
    },
    context,
    { lockProvider, extensionName: 'Orders' },
  )
})

ensureDirectusDocumentation honors DIRECTUS_DOCS_ENABLED and DIRECTUS_DOCS_SEED_ENABLED. Documentation callbacks are not blocked by DIRECTUS_EXTENSIONS_SCHEMA_CHANGES_ENABLED or DIRECTUS_EXTENSIONS_DATA_SEED_ENABLED; those gates continue to control ordinary extension work. Pass extensionSeedEnabled: false to opt out one contributor. Stable UUIDs in the id field identify the article; icon and archived have defaults. The default versioning strategy updates the reserved incoming version, while override replaces the main item. Incoming content is never promoted automatically.

The coordinator renews its startup lease by default while callbacks run. Set autoRenew: false only when every callback is guaranteed to finish within the configured lease. Nested schema and data ensures receive a borrowed provider and cannot release the coordinator-owned lease. If renewal is lost, the coordinator stops before running the next callback and logs the failure. If release returns false, ownership was lost and the coordinator reports that failure instead of claiming a successful release.

All lock providers use the same tryAcquire/isLocked/lease contract and defaultLeaseMs option. Choose the memory provider for one process, the filesystem provider for processes sharing a directory, or the Redis provider for shared coordination across replicas. When creating memory providers directly, use the same providerId for callers that must coordinate and different IDs for isolated lock namespaces.

Auto-task handlers clear a marker only after the task succeeds. Task failures and lost leases are reported through onError and leave the marker pending for a later trigger; failed tasks are not automatically retried. Tasks should honor the supplied AbortSignal and be safe to run again.

Documentation

Start with the extension-utils cookbook article for usage examples and the utility glossary for coordination terminology. Maintainers can use the API reference for the complete export and option surface.