Part II · Concepts · Chapter 4

Lifecycle events

How plugins coordinate — the SpellFrame event emitter, the pre-apply handshake, and the bootstrapping problem it solves.

SpellFrame extends Node’s EventEmitter. Plugins announce phases on it and listen for each other’s, which is how work gets ordered between plugins that know nothing about one another — including ones you did not write.

The problem this solves

Terraform cannot enable the API that a resource it is creating depends on. Enabling compute.googleapis.com and creating a Compute instance in one apply is a race the apply loses, because the provider needs the API live before it can plan against it.

The usual answer is a README that says “first, run this.” That step gets skipped, or run against the wrong project, or forgotten entirely on the second environment.

The handshake

The terraform node owns the Terraform lifecycle and announces itself before applying:

await spellframe.emitAsync('@c6fc/spellcraft-plugins:terraform.pre-apply');

The gcp.terraform node — which the terraform node has no reference to, and would work identically if it lived in someone else’s package — listens:

exports._spellcraft_metadata = {
  init: async (spellframe) => {
    spellframe.on('@c6fc/spellcraft-plugins:terraform.pre-apply', async () => {
      await flushPendingServices();
    });
  },
};

Meanwhile, anything in the manifest that needs a GCP service registers it during evaluation. By the time pre-apply fires, the registry holds every service the rendered configuration will need, and one call enables them all before Terraform starts.

Step zero became part of the program.

Emitting and awaiting

emit() is Node’s ordinary synchronous emit: it does not wait for async listeners. For anything a listener must finish before you continue, use emitAsync(), which awaits each listener in turn:

await spellframe.emitAsync('@you/your-plugin:pre-flight', context);

Listeners run sequentially, in registration order. If one throws, the emit rejects and the error propagates to whoever triggered the phase — which is usually what you want for a bootstrap step.

Naming

Namespace events with your package name, exactly like native functions:

@c6fc/spellcraft-plugins:terraform.pre-apply

An unprefixed pre-apply is a collision waiting for the second plugin that has a notion of applying.

Built-in events

The frame emits two of its own:

EventWhenArgument
renderafter a manifest evaluatesthe rendered object
writeafter files are writtenthe object that was written

Every native function also emits an event under its own registered name when it is called.

Events and memoisation

The event is emitted before the memoisation check, so it fires on every call — including the ones served from cache. The function body still runs once per (name, arguments). So the event counts call sites, and the cache decides how much work each one costs.

Registering listeners

Register in init, not at module scope. At module scope you have no frame to listen to; init receives one and runs once, before any render.