- Get started
- Creating hooks
- Other frameworks
- Advanced usage
- Dependency Injection framework
- Applications
- Contribute
Backhook is a type safe, plug and play and funny way of injecting dependencies and manage context through a NodeJS application.
It's compatible with major frameworks like ExpressJS, Fastify, and can also be run standalone in a very simple way.
This project allows you to choose between two packages.
@backhooks/core
: For a minimalist dependency injection framework@backhooks/hooks
: For a complete set of builtin hooks that can help you with your daily work. (WIP)
npm install @backhooks/hooks
npm install @backhooks/express @backhooks/hooks
import express from "express";
import middleware from "@backhooks/express";
import { useHeaders } from "@backhooks/hooks";
const app = express();
app.use(middleware());
app.get("/", (req, res) => {
const headers = useHeaders(); // <- This is a hook
res.send(headers);
});
app.listen(3000, () => {
console.log("Listening on http://localhost:3000");
});
import { runHookContext } from "@backhooks/core";
import { useCount } from "./useCount"; // <- We'll see how to build a hook in a minute.
runHookContext(() => {
console.log(useCount()); // 1
console.log(useCount()); // 2
console.log(useCount()); // 3
});
runHookContext(() => {
console.log(useCount()); // 1
console.log(useCount()); // 2
console.log(useCount()); // 3
});
A hook holds a state
that lives through the entire life of a hook context. For the Express application, this context lives through the entire request lifecycle thanks to the express (or other runtime) middleware
. For a standalone app, this context lives in all the functions called (even deeper function calls) from the runHookContext
function.
This is made possible thanks to the AsyncLocalStorage
nodeJS API
Backhook's createHook
function allows you to create a piece of state, that will live through an entire hook context.
Let's see how to create a useCount
hook. This hook will return an incremental value while you call it.
- Define an initial state function
import { createHook } from "@backhooks/core";
const [useCount] = createHook({
// Here, we define a `data` function that will return
// the initial state of the hook. This function must
// be synchronous.
data() {
return {
count: 0,
};
},
});
- Define the return value of your hook
import { createHook } from "@backhooks/core";
const [useCount] = createHook({
data() {
return {
count: 0,
};
},
// The `execute` function uses the state, can mutate the state
// and returns a value. This function can be asynchronous.
execute(state) {
state.count++;
return state.count;
},
});
- Use the hook in a hook context
import { createHook, runHookContext } from "@backhooks/core";
const [useCount] = createHook({
data() {
return {
count: 0,
};
},
// The `execute` function uses the state, can mutate the state
// and returns a value. This function can be asynchronous.
execute(state) {
state.count++;
return state.count;
},
});
runHookContext(() => {
console.log(useCount()); // 1
console.log(useCount()); // 2
...
});
Hook states can be updated at runtime. If the counter has to start at 50, it can be useful to use the state updater returned by the createHook
function:
import { createHook, runHookContext } from "@backhooks/core";
const [useCount, setCount] = createHook({
...
});
runHookContext(() => {
setCount(() => {
return {
count: 50,
};
});
console.log(useCount()); // 51
});
This is exactly how the middleware
works. It runs a context for the current request, uses the state updater of the useHeaders
hook and attach the values from the req
express object.
The useHeaders()
function can now magically return the values of the request headers through the entire request lifecycle.
Don't hesitate to open an issue if you want to use hooks with another framework.
npm install @backhooks/fastify @backhooks/hooks
import Fastify from "fastify";
import hooksPreHandler from "@backhooks/fastify";
import { useHeaders } from "@backhooks/hooks";
const fastify = Fastify();
fastify.addHook("preHandler", hooksPreHandler());
// Declare a route
fastify.get("/", () => {
const headers = useHeaders();
return headers;
});
// Run the server!
fastify.listen({ port: 3000 }, () => {
// Server is now listening on ${address}
});
The library allows hooks to live in the global context and to run without the need of a hook context.
You also have the possibility to reset the global state at runtime. This can be very useful to reduce overhead in tests:
import { resetGlobalContext } from "@backhooks/core";
beforeEach(() => {
resetGlobalContext();
});
test("it should increment", () => {
expect(useCount()).toBe(1);
expect(useCount()).toBe(2);
});
test("it should also increment", () => {
expect(useCount()).toBe(1);
expect(useCount()).toBe(2);
});
The entire life of the node global process will be bound to a single instance of each called hooks.
Defining a hook dependency is very straightforward. As you know, each hook holds a state
for a specific context.
Let's try out to write a useAuthorizationHeader
hook:
import { createHook } from "@backhooks/core";
import { useHeaders } from "@backhooks/hooks";
const [useAuthorizationHeader, updateAuthorizationHeader] = createHook({
data() {
// This function is called the first time the hook
// is used to create the hook state within a specific
// context
// Note that in that function, you can use other hooks like `useHeaders`
const headers = useHeaders();
// this function MUST be synchronous.
return {
header: headers["authorization"],
};
},
execute(state) {
// This function is called every time the application
// calls the hook. It must return the value for this hook
// Note that you can also call other hooks in this function.
// This function CAN be asynchronous. You will have to await
// the response of the hook if you make it asynchronous.
return state.header;
},
});
Being able to update the hook state, makes it very easy to unit test our hooks or functions.
import { runHookContext } from "@backhooks/core";
import { setHeaders } from "@backhooks/hooks";
import { useAuthorizationHeader } from "./hooks/useAuthorizationHeader";
test("it should return the authorization header", () => {
return runHookContext(() => {
setHeaders(() => {
return {
headers: {
authorization: "def",
},
};
});
const authorizationHeader = useAuthorizationHeader();
expect(authorizationHeader).toBe("def"); // true
});
});
You can create a useRequestId
to trace a unique id down the function calls:
import { createHook } from "@backhooks/core";
import * as crypto from "node:crypto";
export const [useRequestId] = createHook({
data() {
return {
requestId: crypto.randomUUID(),
};
},
execute(state) {
return state.requestId;
},
});
Create a logger that prepends the useRequestId()
value in output:
import { createHook } from "@backhooks/core";
import { useRequestId } from "./useRequestId";
export const [useLogger] = createHook({
data() {
return {
requestId: useRequestId(),
};
},
execute(state) {
return {
debug(...args: Parameters<typeof console.debug>) {
return console.debug(state.requestId, ...args);
},
};
},
});
In that way, in all your codebase you can use:
useLogger().debug("Hello World!");
, and all the calls of the same request to that debug
function will prepend the same requestId.
Hooks makes it natural to inject dependencies. You can even use classes if you want:
import { useLogger } from "./useLogger";
export class MyProvider {
constructor(private readonly logger = useLogger());
foo() {
this.logger.debug("bar!");
}
}
Register a hook for your provider in order to provide dependency injection:
import { createHook } from "@backhooks/core";
import { MyProvider } from "./MyProvider";
export const [useMyProvider] = createHook({
data() {
return new MyProvider();
},
});
And then use your provider where you want in your app:
app.use("/", (req, res) => {
const myProvider = useMyProvider();
myProvider.foo(); // Logs "bar!"
res.send("ok!");
});
Hooks can have a variety of applications that are yet to be discovered. Here are some examples:
export default function () {
const logger = useLogger();
logger.debug("I'm a log entry"); // {"requestId": "abcd", "message": "I'm a log entry"}
}
export default function () {
const user = await useUserOrThrow();
// Do something with the authenticated user
}
export default function () {
const zodSchema = z.object({
foo: z.string().min(3),
});
const parsedBody = useValidatedBody(zodSchema);
console.log(parsedBody.foo); // Type safe, returns the foo property of the body
}
This is really, really early stage. Everything is subject to change. The best way to help me with that is just to communicate me in some way that you are interested in this. You can open an issue or send a message in discussions, or just leave a star on the repo to tell me that I should continue to work on that, I'll be happy to interact!