Skip to content

fix(directus-client): restore the Nuxt/Nitro auth runtime boundary #295

Description

@remihuigen

Problem

@onderwijsin/nuxt-directus-client@0.11.0 regressed the Nuxt/Nitro runtime boundary and can make consuming applications fail during SSR with:

Package import specifier "#nitro-internal-virtual/storage" is not defined in package
...
ERR_PACKAGE_IMPORT_NOT_DEFINED

We have hit this class of failure before. Nitro runtime helpers such as useStorage() are valid inside Nitro-owned server runtime, but their implementation must not become part of the Nuxt application runtime graph. If a Nuxt SSR plugin statically imports Nitro-only implementation code, Nitro's generated virtual imports can leak into the consumer bundle and fail at runtime.

This is a release-blocking regression in 0.11.0.

Root cause

The regression was introduced in PR #286 / commit 1f5e6f5 while making SSR auth hydration resilient to transient refresh failures.

src/runtime/auth/app/ssr-session-plugin.ts now imports implementation code from auth/server/*:

import { isTransientDirectusRefreshError } from "../server/refresh";
import { getDirectusSessionSnapshot } from "../server/session";

The first import creates this dependency chain:

auth/app/ssr-session-plugin.ts
  -> auth/server/refresh.ts
  -> auth/server/refresh-coordinator/index.ts
  -> nitropack/runtime
  -> #nitro-internal-virtual/storage

refresh-coordinator/index.ts imports useStorage() from nitropack/runtime, so the Nuxt SSR application graph ends up pulling in Nitro's storage runtime.

The asset-cache/pruning code also legitimately uses useStorage(), but it is not the primary cause of this failure. The concrete leak is the Nuxt SSR auth plugin importing the Nitro-only refresh implementation graph.

The second import (auth/server/session.ts) does not currently trigger this exact storage error, but it crosses the same architectural boundary and should be removed as part of the fix. auth/app/** should not depend directly on auth/server/** implementation code.

Existing architecture we should preserve

We already have the correct boundary: Nitro owns request authentication and exposes it through event.context.directusAuth.

The Nitro plugin currently installs a lazy request-scoped resolver:

event.context.directusAuth = {
  resolve() {
    return ...;
  }
};

This boundary should remain the only way the Nuxt SSR plugin asks Nitro to resolve authentication/session state.

The important distinction is:

  • Nuxt application runtime may consume a typed request-context contract.
  • Nitro server runtime may import nitropack/runtime, useStorage(), refresh coordination, sealed-session utilities, etc.
  • Nuxt application runtime must not statically import those Nitro implementation files.

Proposed fix

Extend the existing DirectusRequestAuthContext with a hydration-specific operation rather than importing refresh/session helpers directly from the Nuxt plugin.

For example:

export interface DirectusRequestAuthContext {
  readonly resolve: () => Promise<DirectusRequestAuthState>;
  readonly resolveSnapshot: () => Promise<DirectusSessionSnapshot | null>;
}

resolve()

Keep the current strict semantics:

  • refresh when required;
  • return usable request credentials and the token-free snapshot;
  • propagate transient refresh failures;
  • clear/resolve terminal authentication failures according to the existing auth contract.

This is the boundary used by authenticated upstream Directus requests and must not silently fall back to expired credentials.

resolveSnapshot()

Implement this entirely inside the Nitro-owned auth plugin/runtime:

  1. Call the refresh-aware resolver.
  2. On success, return its snapshot.
  3. If and only if the error is an explicitly classified transient Directus refresh failure, read the trusted local sealed-session snapshot without refreshing.
  4. Propagate all other errors.

The Nuxt SSR plugin should then become conceptually:

if (event) {
  session.value = (await event.context.directusAuth?.resolveSnapshot()) ?? null;
}

It should no longer import either:

auth/server/refresh.ts
auth/server/session.ts

This keeps the transient fallback behavior introduced in #286 while restoring the runtime boundary previously fixed in #215/#250.

Why not just move isTransientDirectusRefreshError()?

Moving the classifier to a shared file would fix the immediate storage import chain, but it would leave the underlying architecture wrong: a Nuxt application plugin would still know about and import Nitro-side auth implementation details.

The fix should restore the boundary, not just make the current transitive import graph happen to be safe.

Likewise, replacing useStorage() with another Nitro import inside the refresh coordinator is not the right fix. useStorage() belongs there; the problem is that the file is reachable from the Nuxt application graph.

Regression coverage

The current test suite misses this for two reasons.

Unit test masks the import graph

ssr-session-plugin.test.ts mocks both auth/server/refresh and auth/server/session, so it never loads the transitive Nitro runtime graph that breaks real consumers.

Refactor this test to mock/test the request-context contract instead. The Nuxt SSR plugin test should not need to mock Nitro implementation modules at all.

Packed external consumer does not enable auth

The packed external-consumer Directus fixture currently has:

auth: { enabled: false }

Therefore auth/app/ssr-session-plugin.ts is never registered in the exact integration test designed to catch published-package runtime-boundary problems.

Add packed-consumer coverage with Directus auth enabled.

The fixture should configure at least:

  • a valid dummy sessionSecret;
  • a directus-auth-refresh memory storage mount;
  • an SSR page/request that causes the authenticated SSR plugin to load and render.

The packed consumer test must fail if the response/build/runtime contains either:

ERR_PACKAGE_IMPORT_NOT_DEFINED
#nitro-internal-virtual/storage

Do not limit the regression assertion to the asset route. We need coverage that actually loads the auth-enabled SSR application graph.

Repository-wide boundary guard

While fixing this issue, inspect the Directus client runtime for the same pattern:

runtime/**/app/** -> runtime/**/server/**

Do not broadly refactor unrelated code, but remove any equivalent direct application-to-Nitro implementation dependency that can make server-only Nitro runtime imports reachable from Nuxt app runtime.

It is acceptable and expected for Nitro handlers, Nitro plugins, tasks, and server utilities to use nitropack/runtime and useStorage().

The invariant is about graph ownership, not banning Nitro imports.

Acceptance criteria

  • Auth-enabled consuming Nuxt applications no longer throw ERR_PACKAGE_IMPORT_NOT_DEFINED / #nitro-internal-virtual/storage during SSR.
  • auth/app/ssr-session-plugin.ts has no direct imports from auth/server/* implementation files.
  • SSR hydration still uses refresh-aware auth under normal conditions.
  • SSR hydration falls back to the trusted local sealed-session snapshot only for explicitly classified transient refresh failures.
  • Terminal refresh failures preserve the existing logout/session-clear semantics.
  • Authenticated Directus server requests remain strict and never fall back to stale/expired access credentials.
  • The existing event.context.directusAuth boundary owns the hydration fallback behavior.
  • Unit tests cover the request-context contract rather than mocking Nitro implementation modules into the Nuxt app plugin.
  • Packed external-consumer coverage runs with Directus auth enabled and exercises SSR.
  • Packed-consumer coverage explicitly guards against ERR_PACKAGE_IMPORT_NOT_DEFINED and #nitro-internal-virtual/storage.
  • Existing refresh coordinator, Redis/memory coordination, auth E2E, asset cache and pruning tests remain green.

Non-goals

  • Replacing or removing Nitro useStorage() from legitimate Nitro-owned runtime code.
  • Redesigning refresh coordination.
  • Changing transient/terminal refresh semantics beyond relocating ownership behind the request-context boundary.
  • Refactoring unrelated Directus runtime organization.

Expected outcome

The public Nuxt package may still contain Nitro-specific implementation files, but Nuxt application runtime must never statically traverse into them. Nitro owns storage, refresh coordination and sealed-session internals; the Nuxt SSR layer consumes only the narrow request-scoped auth contract exposed on the H3 event.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions