Spoosh
Plugin DevelopmentAdvanced

Plugin Architecture

How plugins work internally

Internal Details

This page explains how plugins work under the hood. You don't need to understand this to build plugins — it's here for those who want to know the internals.

This page explains how plugins work under the hood, their execution flow, and the PluginContext object.

Plugin Registration Flow

When you register plugins with .use([...]), Spoosh processes them in this sequence:

Registration

Priority Sorting

Plugin Executor Initialization

Request/Response Flow

Component Lifecycle (onMount, onUpdate, onUnmount)

What Happens When .use([...]) is Called

const spoosh = new Spoosh<ApiSchema, Error>("/api").use([
  cachePlugin(),
  retryPlugin(),
  devtool(),
]);
  1. Priority Sorting - Plugins are sorted by priority (lower numbers run first, default: 0)
  2. Plugin Storage - Sorted plugins are stored in the Spoosh instance
  3. Executor Creation - A PluginExecutor is created to manage middleware chains
  4. Framework Adapter - When you call create(spoosh), plugins' api functions execute

The PluginContext Object

The PluginContext object is passed to every plugin function and contains all information about the current request.

Complete Property Reference

interface PluginContext {
  readonly operationType: "read" | "write" | "pages";
  readonly path: string;
  readonly method: HttpMethod;
  readonly queryKey: string;
  readonly tags: string[];
  readonly requestTimestamp: number;
  readonly instanceId?: string;

  request: PluginRequestOptions;
  temp: Map<string, unknown>;
  stateManager: StateManager;
  eventEmitter: EventEmitter;
  plugins: PluginAccessor;
  pluginOptions?: unknown;
  forceRefetch?: boolean;
}

Critical Distinction: instanceId vs queryKey

queryKey - Identifies a unique query configuration (path + method + options)

instanceId - Identifies a unique hook instance in your component tree

Why This Matters

Multiple component instances can share the same queryKey:

@Component({
  selector: "user-profile",
  template: "<div>{{ profile.data()?.name }}</div>",
})
export class UserProfile {
  userId = input.required<string>();
  profile = injectRead((api) =>
    api("users/:id").GET({ params: { id: this.userId() } })
  );
}

@Component({
  selector: "user-avatar",
  template: '<img [src]="profile.data()?.avatar" />',
})
export class UserAvatar {
  userId = input.required<string>();
  profile = injectRead((api) =>
    api("users/:id").GET({ params: { id: this.userId() } })
  );
}

// Both components request the same data
// queryKey: '{"path":"users/123","method":"GET",...}'
// instanceId: "unique-id-1" and "unique-id-2"

When to Use Each

Use CaseUse
Cache operationsqueryKey - All components share the same cache
Per-component state trackinginstanceId - Track which components are mounted
Event listenersinstanceId - Each component has its own listeners
Timers/IntervalsinstanceId - Cleanup when specific component unmounts

request vs temp vs stateManager

request - Modify the actual HTTP request options:

middleware: async (context, next) => {
  context.request.headers["Authorization"] = `Bearer ${token}`;
  context.request.retries = 3;
  return next();
};

temp - Share data between plugins during a single request:

// Plugin A sets data
middleware: async (context, next) => {
  context.temp.set("my-plugin:timestamp", Date.now());
  return next();
}

// Plugin B reads data
afterResponse(context, response) {
  const timestamp = context.temp.get("my-plugin:timestamp");
  console.log(`Request took ${Date.now() - timestamp}ms`);
}

stateManager - Access and modify cache:

middleware: async (context, next) => {
  const cached = context.stateManager.getCache(context.queryKey);

  if (cached?.state.data) {
    return { data: cached.state.data, status: 200 };
  }

  return next();
};

Execution Order

Middleware Chain

Plugins execute in registration order, creating an "onion" pattern:

const spoosh = new Spoosh<ApiSchema, Error>("/api").use([
  pluginA(),
  pluginB(),
  pluginC(),
]);

// Request flow:
// pluginA.middleware() start
//   pluginB.middleware() start
//     pluginC.middleware() start
//       [ACTUAL FETCH]
//     pluginC.middleware() end
//   pluginB.middleware() end
// pluginA.middleware() end

Call Stack Example

// Plugin A
middleware: async (context, next) => {
  console.log("A: before");
  const result = await next();
  console.log("A: after");
  return result;
};

// Plugin B
middleware: async (context, next) => {
  console.log("B: before");
  const result = await next();
  console.log("B: after");
  return result;
};

// Output:
// A: before
// B: before
// [FETCH]
// B: after
// A: after

Plugin Priority

The priority property controls plugin execution order (lower numbers run first):

function cachePlugin(): SpooshPlugin {
  return {
    name: "spoosh:cache",
    operations: ["read"],
    priority: -10,

    middleware: async (context, next) => {
      const cached = context.stateManager.getCache(context.queryKey);
      if (cached?.state.data) {
        return { data: cached.state.data, status: 200 };
      }
      return next();
    },
  };
}

Priority Sorting: Plugins are automatically sorted by priority (stable sort):

const spoosh = new Spoosh<ApiSchema, Error>("/api").use([
  retryPlugin(),
  throttlePlugin(),
  cachePlugin(),
  devtool(),
]);

// Spoosh sorts to:
// 1. devtool (priority: -100)
// 2. cachePlugin (priority: -10)
// 3. retryPlugin (priority: 0)
// 4. throttlePlugin (priority: 100)

When Priority Matters

Cache should be early (priority: -10) - Check cache before other operations

Throttle should be last (priority: 100) - Block all requests including force fetches

Most plugins use default priority (0) - Retry, debug, transform, etc.

Type System

PluginTypeConfig

The PluginTypeConfig generic parameter defines what types your plugin contributes:

interface PluginTypeConfig {
  readOptions?: object;
  writeOptions?: object;
  pagesOptions?: object;
  readResult?: object;
  writeResult?: object;
  api?: object;
}

Example: Full Type Safety

interface CachePluginConfig {
  staleTime?: number;
}

interface CacheReadOptions {
  staleTime?: number;
}

interface CacheReadResult {
  isFromCache?: boolean;
}

interface CacheInstanceApi {
  clearCache: (options?: { refetchAll?: boolean }) => void;
}

export function cachePlugin(config: CachePluginConfig = {}): SpooshPlugin<{
  readOptions: CacheReadOptions;
  readResult: CacheReadResult;
  api: CacheInstanceApi;
}> {
  return {
    name: "spoosh:cache",
    operations: ["read"],

    middleware: async (context, next) => {
      const pluginOptions = context.pluginOptions as
        | CacheReadOptions
        | undefined;
      const staleTime = pluginOptions?.staleTime ?? config.staleTime ?? 0;
      // ...
    },

    api(context) {
      return {
        clearCache: (options) => {
          context.stateManager.clear();
          if (options?.refetchAll) {
            context.eventEmitter.emit("refetchAll", undefined);
          }
        },
      };
    },
  };
}

Type Inference Flow

const spoosh = new Spoosh<ApiSchema, Error>("/api").use([
  cachePlugin({ staleTime: 5000 }),
]);

const read = injectRead((api) => api("users").GET(), {
  staleTime: 10000,
});

// Access properties
const data = read.data();
const meta = read.meta();
read.clearCache();

console.log(meta.isFromCache);
clearCache({ refetchAll: true });

PluginMiddleware Type

type PluginMiddleware = (
  context: PluginContext,
  next: () => Promise<SpooshResponse<any, any>>
) => Promise<SpooshResponse<any, any>>;

Key points:

  • context - Contains all request info and utilities
  • next - Continues to the next middleware or actual fetch
  • Return value - Must be a SpooshResponse
  • Async - Always returns a Promise

On this page