Skip to content

Latest commit

 

History

History
277 lines (206 loc) · 8.86 KB

File metadata and controls

277 lines (206 loc) · 8.86 KB

Hooks

Hooks allow modules to react to events and extend functionality without modifying core code. They're the primary mechanism for inter-module communication in the Adapt authoring tool.

How hooks work

A hook is a point in the code where external observers can run their own functions. When a hook is invoked, all registered observers are called with the same arguments.

All hook observers must complete before the operation continues. For example, a document won't be inserted until all preInsertHook observers have finished executing.

Hook types

Hooks support three execution types:

  • Parallel (default): observers run at the same time. Observers receive a deep copy of arguments to prevent unintended modifications.
  • Series: observers run one after another. When mutable: true is set, observers can modify shared arguments in place.
  • Middleware: observers wrap a core function using a next() pattern (like Express middleware). Each observer receives (next, ...args) and must call next(...args) to continue the chain. This allows observers to run logic both before and after the core operation, with shared scope across both.

Basic usage

Creating hooks

import { Hook } from 'adapt-authoring-core'

class MyModule extends AbstractModule {
  async init () {
    // param data will be read only, observers will be run in parallel
    this.myBasicHook = new Hook()

    // allows param data to be modified, observers run in series
    this.myMutableHook = new Hook({ mutable: true })

    // force observers to run in series
    this.mySeriesHook = new Hook({ type: Hook.Types.Series })

    // middleware hook — observers wrap a core function
    this.myMiddlewareHook = new Hook({ type: Hook.Types.Middleware })
  }

  async doSomething () {
    // Invoke the hook, passing any relevant data
    await this.myBasicHook.invoke(someData)
  }

  async doSomethingWrapped (data) {
    // Invoke middleware hook — first arg is the core function, rest are passed through
    const coreFn = async (data) => {
      return await this.db.insert(data)
    }
    return this.myMiddlewareHook.invoke(coreFn, data)
  }
}

Listening to a hook

Use tap() to register an observer function.

Listeners can be async, and a second scope parameter can be passed to bind this:

const content = await this.app.waitForModule('content')

content.preInsertHook.tap(async data => {
  data.createdAt = new Date()
}, this)

Removing an observer

Use untap() to remove an observer:

const observer = data => console.log(data)
content.preInsertHook.tap(observer)

// Later...
content.preInsertHook.untap(observer)

Waiting for a hook

Use onInvoke() to get a promise that resolves when the hook is next invoked:

await server.listeningHook.onInvoke()
console.log('Server is now listening')

Error handling

If an observer throws an error, the hook stops executing and the error propagates to the caller. For mutable hooks running in series, any observers after the failing one won't be called.

content.preInsertHook.tap(data => {
  if (!data.title) {
    throw new Error('Title is required')
  }
})

try {
  await content.insert({ body: 'No title here' })
} catch (e) {
  console.log(e.message) // 'Title is required'
}

Best practices

  1. Keep observers focused — Each observer should do one thing well
  2. Handle errors gracefully — Don't let one observer break the entire flow (unless intended)
  3. Avoid side effects in non-mutable hooks — They receive copies of data, so modifications won't persist
  4. Use descriptive names — Try to name your hooks clearly, and try to follow established patterns (see below for examples)
  5. Consider execution order — For mutable hooks, observers run in the order they were registered. Keep this in mind both as the hook creator, and as the hook observer.

Common hooks

Below are some commonly used hooks, which you may find useful.

Module Hook Description Parameters Type
AbstractModule readyHook Module has initialised Parallel
AbstractApiModule requestHook API request received (req) Mutable
AbstractApiModule insertHook Wraps the insert operation (next, data, options, mongoOptions) Middleware
AbstractApiModule updateHook Wraps the update operation (next, query, data, options, mongoOptions) Middleware
AbstractApiModule deleteHook Wraps the delete operation (next, query, options, mongoOptions) Middleware
AbstractApiModule preInsertHook Before document insert (data, options, mongoOptions) Mutable
AbstractApiModule postInsertHook After document insert (doc) Parallel
AbstractApiModule preUpdateHook Before document update (originalDoc, newData, options, mongoOptions) Mutable
AbstractApiModule postUpdateHook After document update (originalDoc, updatedDoc) Parallel
AbstractApiModule preDeleteHook Before document delete (doc, options, mongoOptions) Parallel
AbstractApiModule postDeleteHook After document delete (doc) Parallel
AbstractApiModule accessCheckHook Check document access (req, doc) Parallel
AdaptFrameworkBuild preBuildHook Before course build starts Mutable
AdaptFrameworkBuild postBuildHook After course build completes Mutable
AdaptFrameworkModule preImportHook Before course import starts Mutable
AdaptFrameworkModule postImportHook After course import completes Parallel

Practical examples

Adding timestamps

async init () {
  await super.init()
  const content = await this.app.waitForModule('content')

  content.preInsertHook.tap(data => {
    data.createdAt = new Date()
  })

  content.preUpdateHook.tap((original, newData) => {
    newData.updatedAt = new Date()
  })
}

Enforcing data format

async init () {
  await super.init()

  this.preInsertHook.tap(this.forceLowerCaseEmail)
  this.preUpdateHook.tap(this.forceLowerCaseEmail)
}

forceLowerCaseEmail (data) {
  if (data.email) {
    data.email = data.email.toLowerCase()
  }
}

Access control

async init () {
  await super.init()
  const content = await this.app.waitForModule('content')

  content.accessCheckHook.tap((req, doc) => {
    // Only allow access to own documents
    if (doc.createdBy !== req.auth.user._id.toString()) {
      throw this.app.errors.UNAUTHORISED
    }
  })
}

Cascading deletes

async init () {
  await super.init()
  const assets = await this.app.waitForModule('assets')

  assets.preDeleteHook.tap(async doc => {
    // Remove all references to this asset
    await this.removeAssetReferences(doc._id)
  })
}

Registering schemas

async init () {
  await super.init()
  const jsonschema = await this.app.waitForModule('jsonschema')

  jsonschema.registerSchemasHook.tap(async () => {
    await jsonschema.registerSchema('/path/to/schema.json')
  })
}

Wrapping a CRUD operation (middleware)

Middleware hooks let you run logic both before and after the core operation, with shared scope. This is useful when you need pre-operation state to inform post-operation actions.

async init () {
  await super.init()
  const content = await this.app.waitForModule('content')

  content.deleteHook.tap(async (next, query, options, mongoOptions) => {
    // gather related data BEFORE the delete
    const item = await content.findOne(query)
    const related = await this.findRelated(item)

    // run the actual delete
    const result = await next(query, options, mongoOptions)

    // clean up related data AFTER — item and related are still in scope
    for (const r of related) {
      await this.cleanup(r)
    }

    return result
  })
}

You can also use middleware to guard operations. If you don't call next(), the operation is blocked:

content.insertHook.tap(async (next, data, options, mongoOptions) => {
  if (data._restricted) {
    throw new Error('Cannot insert restricted items')
  }
  return next(data, options, mongoOptions)
})

Choosing between pre/post hooks and middleware

Use pre/post hooks when you only care about one side of an operation — mutating data before a write, or reacting after one. Use middleware when you need to own the full lifecycle: gathering state before, acting after, with shared context across both.

Middleware wraps the entire operation including pre/post hooks:

middleware → pre-hook → validate → write → post-hook → middleware returns

Waiting for server startup

async init () {
  await super.init()
  const server = await this.app.waitForModule('server')

  await server.listeningHook.onInvoke()
  this.log('info', 'Server is ready, starting background tasks')
}