Skip to content

Commit 0ec6d24

Browse files
docs: expand API reference to address issue #49 (#221)
1 parent edb1d12 commit 0ec6d24

File tree

1 file changed

+218
-24
lines changed

1 file changed

+218
-24
lines changed

README.md

Lines changed: 218 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,76 @@ The Hook will compile a method with the most efficient way of running your plugi
140140
- The number of arguments
141141
- Whether interception is used
142142

143-
This ensures fastest possible execution.
143+
This ensures fastest possible execution. See [Code generation](#code-generation) for more details on the runtime compilation.
144+
145+
## Plugin API
146+
147+
A plugin registers a callback on a hook using one of the `tap*` methods. The hook type determines which of these are valid (see [Hook classes](#hook-classes)):
148+
149+
- `hook.tap(nameOrOptions, fn)` — register a synchronous callback.
150+
- `hook.tapAsync(nameOrOptions, fn)` — register a callback-based async callback. The last argument passed to `fn` is a node-style callback `(err, result)`.
151+
- `hook.tapPromise(nameOrOptions, fn)` — register a promise-returning async callback. If `fn` returns something that is not thenable, the hook throws.
152+
153+
The first argument can be either a string (the plugin name) or an options object that also allows influencing the order in which taps run:
154+
155+
```js
156+
hook.tap(
157+
{
158+
name: "MyPlugin",
159+
stage: -10, // lower stages run earlier, default is 0
160+
before: "OtherPlugin" // run before a named tap (string or string[])
161+
},
162+
(...args) => {
163+
/* ... */
164+
}
165+
);
166+
```
167+
168+
| Option | Type | Description |
169+
| -------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
170+
| `name` | `string` | Required. Identifies the tap for debugging, interceptors, and the `before` option. |
171+
| `stage` | `number` | Defaults to `0`. Taps with a lower stage run before taps with a higher stage. Taps with the same stage run in registration order. |
172+
| `before` | `string` \| `string[]` | The tap is inserted before the named tap(s). Unknown names are ignored. Combined with `stage`, `before` wins for the taps it targets; other taps are still ordered by `stage`. |
173+
174+
The `name` is also used by some ecosystems (like webpack) for profiling and error messages. Within a single tap registration, later interceptors' `register` hooks may still replace the tap object (see [Interception](#interception)).
175+
176+
### `hook.withOptions(options)`
177+
178+
`withOptions` returns a facade around the hook whose `tap*` methods automatically merge `options` into every registration. It is useful for libraries that want to pre-configure a `stage` or `before` for all the taps they add:
179+
180+
```js
181+
const lateHook = myCar.hooks.accelerate.withOptions({ stage: 10 });
182+
lateHook.tap("LogAfterOthers", (speed) => console.log("final speed", speed));
183+
// equivalent to: myCar.hooks.accelerate.tap({ name: "LogAfterOthers", stage: 10 }, ...)
184+
```
185+
186+
The returned object does not expose the `call*` methods, so it is safe to hand out to plugins.
187+
188+
A runnable example showing how `withOptions` influences tap ordering:
189+
190+
```js
191+
const { SyncHook } = require("tapable");
192+
193+
const hook = new SyncHook(["value"]);
194+
195+
hook.tap("Default", (v) => console.log("default", v));
196+
197+
// Pre-configure stage: 10 so all taps registered through `late` run last.
198+
const late = hook.withOptions({ stage: 10 });
199+
late.tap("RunLast", (v) => console.log("last", v));
200+
201+
// Pre-configure stage: -10 so these taps run first. Each facade can also
202+
// be further narrowed via `withOptions`.
203+
const early = hook.withOptions({ stage: -10 });
204+
early.tap("RunFirst", (v) => console.log("first", v));
205+
206+
hook.call(1);
207+
// first 1
208+
// default 1
209+
// last 1
210+
```
211+
212+
Per-tap options override values from `withOptions`. For example, `late.tap({ name: "Override", stage: 0 }, fn)` ignores the facade's `stage: 10` and registers `fn` at stage `0`.
144213

145214
## Hook types
146215

@@ -355,28 +424,41 @@ const output = await hook.promise("./input.txt");
355424

356425
## Interception
357426

358-
All Hooks offer an additional interception API:
427+
All hooks expose an `intercept(interceptor)` method. An interceptor is a plain object whose methods are invoked at specific points during the lifetime of the hook. Interceptors are invoked in registration order before the taps, and are useful for logging, tracing, profiling, or re-mapping tap options.
359428

360429
```js
361430
myCar.hooks.calculateRoutes.intercept({
431+
name: "LoggingInterceptor",
362432
call: (source, target, routesList) => {
363433
console.log("Starting to calculate routes");
364434
},
435+
tap: (tapInfo) => {
436+
// tapInfo = { type: "promise", name: "GoogleMapsPlugin", fn: ..., stage: 0 }
437+
console.log(`${tapInfo.name} is running`);
438+
},
365439
register: (tapInfo) => {
366-
// tapInfo = { type: "promise", name: "GoogleMapsPlugin", fn: ... }
367-
console.log(`${tapInfo.name} is doing its job`);
368-
return tapInfo; // may return a new tapInfo object
440+
// Called once per tap (and for each tap already registered when the
441+
// interceptor is added). Return a new tapInfo object to replace it.
442+
console.log(`${tapInfo.name} is registered`);
443+
444+
return tapInfo;
369445
}
370446
});
371447
```
372448

373-
**call**: `(...args) => void` Adding `call` to your interceptor will trigger when hooks are triggered. You have access to the hooks arguments.
374-
375-
**tap**: `(tap: Tap) => void` Adding `tap` to your interceptor will trigger when a plugin taps into a hook. Provided is the `Tap` object. `Tap` object can't be changed.
449+
| Handler | Signature | When it runs |
450+
| ---------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
451+
| `call` | `(...args) => void` | Before the hook starts executing its taps. Receives the arguments passed to `call` / `callAsync` / `promise`. |
452+
| `tap` | `(tap: Tap) => void` | Before each tap runs. The `tap` object is a snapshot — mutations are ignored. |
453+
| `loop` | `(...args) => void` | At the start of each iteration of a `SyncLoopHook` / `AsyncSeriesLoopHook`. |
454+
| `error` | `(err: Error) => void` | Whenever a tap throws, rejects, or calls its callback with an error. |
455+
| `result` | `(result: any) => void` | When a bail or waterfall hook produces a value, or when a tap produces one for a loop hook. |
456+
| `done` | `() => void` | When the hook finishes successfully (no error, no early bail). |
457+
| `register` | `(tap: Tap) => Tap \| undefined` | Once per tap at registration time (including taps that existed before the interceptor was added). Return a new `Tap` object to replace it. |
458+
| `name` | `string` | Optional label used by ecosystems for debugging. |
459+
| `context` | `boolean` | Opt into the shared `context` object. See [Context](#context). |
376460

377-
**loop**: `(...args) => void` Adding `loop` to your interceptor will trigger for each loop of a looping hook.
378-
379-
**register**: `(tap: Tap) => Tap | undefined` Adding `register` to your interceptor will trigger for each added `Tap` and allows to modify it.
461+
Adding an interceptor invalidates the hook's compiled call function — the next `call` / `callAsync` / `promise` recompiles it so that the new interceptor is woven in.
380462

381463
## Context
382464

@@ -415,12 +497,14 @@ myCar.hooks.accelerate.tap(
415497

416498
## HookMap
417499

418-
A HookMap is a helper class for a Map with Hooks
500+
A `HookMap` is a helper class that lazily creates hooks per key. The constructor takes a factory function; the first time a key is requested via `for(key)`, the factory is called and the resulting hook is cached.
419501

420502
```js
421503
const keyedHook = new HookMap((key) => new SyncHook(["arg"]));
422504
```
423505

506+
Plugins use `for(key)` to obtain the hook for a specific key (creating it on demand) and then `tap` on it as usual:
507+
424508
```js
425509
keyedHook.for("some-key").tap("MyPlugin", (arg) => {
426510
/* ... */
@@ -433,6 +517,8 @@ keyedHook.for("some-key").tapPromise("MyPlugin", (arg) => {
433517
});
434518
```
435519

520+
The owner of the `HookMap` uses `get(key)` to look up an existing hook without creating one. This is typically preferred on the calling side so that keys no plugin cares about are never materialized:
521+
436522
```js
437523
const hook = keyedHook.get("some-key");
438524
if (hook !== undefined) {
@@ -442,9 +528,32 @@ if (hook !== undefined) {
442528
}
443529
```
444530

531+
A `HookMap` can also be intercepted. `intercept({ factory })` wraps the factory so you can customize or replace the hook returned for each new key.
532+
533+
## Code generation
534+
535+
Tapable does not iterate over taps at call time. Instead, the first time `call`, `callAsync` or `promise` is invoked after the hook has been modified, the hook compiles a specialized function using `new Function(...)` and caches it on the instance. This is what the README means by "evals in code": the hook's dispatch logic is generated as a string and turned into a real JavaScript function the engine can inline and optimize.
536+
537+
The generated function is tailored to:
538+
539+
- **Call type** — whether the owner called `call` (sync), `callAsync` (callback), or `promise`. Each produces a different skeleton — e.g. `promise()` wraps the body in `new Promise((_resolve, _reject) => { ... })`.
540+
- **Tap types** — for each tap, the generator emits the right invocation pattern: direct call for `tap`, node-style callback wrapping for `tapAsync`, and `.then(...)` chaining for `tapPromise`.
541+
- **Hook class**`SyncHook` emits a straight-line sequence of calls; `SyncBailHook` emits early-return checks; `SyncWaterfallHook` threads a value through calls; loop hooks wrap the body in a re-entry loop; `AsyncParallel*` fans the taps out and counts completions; `AsyncSeries*` chains them.
542+
- **Interceptors** — if interceptors are attached, calls to their `call`/`tap`/`loop`/`error`/`result`/`done` handlers are spliced into the generated body; otherwise they cost nothing.
543+
- **Context** — a `_context` object is only created when at least one tap or interceptor opts into it with `context: true`.
544+
- **Arity** — the generated code hard-codes the number of arguments declared when the hook was constructed, so no `arguments`/rest handling happens at runtime.
545+
546+
The compiled function is invalidated (reset back to a one-shot "recompile then call" trampoline) whenever the hook's shape changes — i.e. on any new `tap*` or `intercept` call. Steady-state calls therefore run straight through the cached function with no per-tap branching.
547+
548+
### Why this matters
549+
550+
- You only pay for features you use. An interceptor-free, sync-only hook compiles down to a short sequence of direct function calls.
551+
- Debugging a hook means reading the generated source. If you need to see it, `Hook.prototype.compile` returns the `new Function(...)` result — log `hook._createCall("sync").toString()` (or `"async"` / `"promise"`) to inspect the body.
552+
- Because the dispatch is code-generated, a hook's behavior is fully determined at compile time. Mutating tap options after registration (for example, changing `stage` on an existing `Tap` object) will not reorder taps until you cause a recompile.
553+
445554
## Hook/HookMap interface
446555

447-
Public:
556+
Public (callable by anyone holding a reference to the hook, i.e. the plugins):
448557

449558
```ts
450559
interface Hook {
@@ -462,14 +571,21 @@ interface Hook {
462571
fn: (context?, ...args) => Promise<Result>
463572
) => void;
464573
intercept: (interceptor: HookInterceptor) => void;
574+
withOptions: (
575+
options: TapOptions
576+
) => Omit<Hook, "call" | "callAsync" | "promise">;
465577
}
466578

467579
interface HookInterceptor {
468-
call: (context?, ...args) => void;
469-
loop: (context?, ...args) => void;
470-
tap: (context?, tap: Tap) => void;
471-
register: (tap: Tap) => Tap;
472-
context: boolean;
580+
name?: string;
581+
call?: (context?, ...args) => void;
582+
loop?: (context?, ...args) => void;
583+
tap?: (context?, tap: Tap) => void;
584+
error?: (err: Error) => void;
585+
result?: (result: any) => void;
586+
done?: () => void;
587+
register?: (tap: Tap) => Tap | undefined;
588+
context?: boolean;
473589
}
474590

475591
interface HookMap {
@@ -483,15 +599,15 @@ interface HookMapInterceptor {
483599

484600
interface Tap {
485601
name: string;
486-
type: string;
602+
type: "sync" | "async" | "promise";
487603
fn: Function;
488604
stage: number;
489605
context: boolean;
490-
before?: string | Array;
606+
before?: string | Array<string>;
491607
}
492608
```
493609

494-
Protected (only for the class containing the hook):
610+
Protected (only for the class containing the hook — it owns the right to trigger it):
495611

496612
```ts
497613
interface Hook {
@@ -510,12 +626,90 @@ interface HookMap {
510626
}
511627
```
512628

629+
`isUsed()` returns `true` when the hook has at least one tap or interceptor registered. Hook owners can use it to skip expensive argument preparation when no plugin is listening:
630+
631+
```js
632+
class Car {
633+
// ...
634+
setSpeed(newSpeed) {
635+
if (this.hooks.accelerate.isUsed()) {
636+
this.hooks.accelerate.call(newSpeed);
637+
}
638+
}
639+
// ...
640+
}
641+
```
642+
513643
## MultiHook
514644

515-
A helper Hook-like class to redirect taps to multiple other hooks:
645+
A `MultiHook` is a Hook-like facade that forwards `tap`, `tapAsync`, `tapPromise`, `intercept`, and `withOptions` to several underlying hooks at once. It does not expose `call*` methods — only the owners of the wrapped hooks decide when each of them runs. It is the typical way a class exposes a "happens on any of these events" listening surface without having the plugin wire itself up to every hook individually.
646+
647+
### Fan out a tap to several hooks
516648

517649
```js
518-
const { MultiHook } = require("tapable");
650+
const { MultiHook, SyncHook } = require("tapable");
651+
652+
class Car {
653+
constructor() {
654+
const accelerate = new SyncHook(["newSpeed"]);
655+
const brake = new SyncHook();
656+
this.hooks = {
657+
accelerate,
658+
brake,
659+
// `anyMovement` is not a real hook — it simply re-registers taps
660+
// on both `accelerate` and `brake`.
661+
anyMovement: new MultiHook([accelerate, brake])
662+
};
663+
}
664+
}
665+
666+
const car = new Car();
667+
car.hooks.anyMovement.tap("Telemetry", () => console.log("car moved"));
668+
669+
car.hooks.accelerate.call(42); // "car moved"
670+
car.hooks.brake.call(); // "car moved"
671+
```
519672

520-
this.hooks.allHooks = new MultiHook([this.hooks.hookA, this.hooks.hookB]);
673+
The `MultiHook` has no state of its own: the tap above ends up inside `accelerate.taps` and `brake.taps`.
674+
675+
### Forwarding async taps
676+
677+
`tapAsync` / `tapPromise` forward to every wrapped hook — it is the plugin's job to make sure they are all compatible. Registering a `tapPromise` on a `MultiHook` that wraps a `SyncHook` will throw at registration time for that hook.
678+
679+
```js
680+
const build = new AsyncSeriesHook(["stats"]);
681+
const rebuild = new AsyncSeriesHook(["stats"]);
682+
const anyBuild = new MultiHook([build, rebuild]);
683+
684+
anyBuild.tapPromise("Report", async (stats) => report.send(stats));
685+
```
686+
687+
### Shared interceptors and options
688+
689+
`intercept` and `withOptions` are also forwarded, so a `MultiHook` can be used to attach the same interceptor or pre-configured options to a group of hooks:
690+
691+
```js
692+
const anyBuild = new MultiHook([build, rebuild]);
693+
694+
anyBuild.intercept({
695+
call: () => console.log("build started"),
696+
done: () => console.log("build done")
697+
});
698+
699+
// Every tap added through `late` is staged late on both underlying hooks.
700+
const late = anyBuild.withOptions({ stage: 10 });
701+
late.tap("RunLast", () => {
702+
/* ... */
703+
});
704+
```
705+
706+
### `isUsed`
707+
708+
`isUsed()` returns `true` if any of the wrapped hooks has at least one tap or interceptor, which lets the owner cheaply skip work when no one is listening on any of them:
709+
710+
```js
711+
if (this.hooks.anyMovement.isUsed()) {
712+
// expensive telemetry payload is only built when a plugin actually cares
713+
this.hooks.accelerate.call(computeSpeed());
714+
}
521715
```

0 commit comments

Comments
 (0)