π²
Introduction
@prsm/pine is designed to be used with Express.
It is a collection of decorators and request/response utilities that enhance the Express development experience while adding a few additional features.
It's not really a "framework", per-se, more than it is a collection of utilities to simplify the creation of the more redundant or complicated pieces of a typical backend service.
Also included is a powerful session-based authentication system with a simple, predictable API. Session-based authentication dramatically simplifies the frontend boilerplate that JWT-based authentication typically requires.
In the cases where JWT-based authentication is needed, @prsm/pine also provides tooling for generating and verifying tokens.
As with most Express-backed frameworks and/or extensions, this package is pretty opinionated. It's not going to work for everyone, and in the cases it doesn't it may just serve as a nice learning resource.
Decorators
Q: Why decorators?
The decorator proposal has advanced to Stage 3, indicating widespread consensus for integration into TypeScript. As of TypeScript 5, decorators in their Stage 3 form are fully supported and are unlikely to change substantially.
@router(rootPath: string)
This creates an Express Router.
@router("/auth")
export class AuthRouter {}
@route.get(path?: string)
@route.post(path?: string)
@route.put(path?: string)
@route.patch(path?: string)
@route.delete(path?: string)
@router("/auth")
export class AuthRouter {
@route.post("/login")
async login(c: Context) {}
@route.post()
async login(c: Context) {}
}
-
@from.body(key?: string|null|undefined, schemaExecutor?: SchemaExecutor)
An undefined, null, or empty string "" for key will return the entire body object.
You may use dot notation to access nested properties.
@from.body() value: object;
@from.body("a") value: number;
@from.body("c.d") value: number;
-
@from.path(key: string, schemaExecutor?: SchemaExecutor)
-
@from.query(key: string, schemaExecutor?: SchemaExecutor)
-
@from.header(key: string, schemaExecutor?: SchemaExecutor)
-
@from.cookie(key: string, schemaExecutor?: SchemaExecutor)
Get values from the request object.
@router("/auth")
export class AuthRouter {
@route.get("/check")
async check(c: Context, @from.header("Authorization") bearer: string) {
}
@route.post("/login")
async login(c: Context, @from.body() body: object) {
}
@route.get("/user/:id")
async getUser(c: Context, @from.path("id") id: string) {
}
@route.get("/do")
async getUser(
c: Context,
@from.query("action") action: string,
@from.query("id") id: string,
) {
}
}
Validation
For validation purposes, an optional SchemaExecutor can be provided to each of the from decorators. If the value provided does not match the schema, a BadRequest error is thrown, and the errors are stringified and sent to the client. The handler will not be called.
Using a SchemaExecutor:
import { createSchema, ... } from "@prsm/pine";
const registrationValidator = createSchema((v) => ({
email: v.string().notEmpty().max(100),
password: v.string().notEmpty().min(8).max(100),
username: v.string().notEmpty().min(3).max(20),
}));
@route.post("/register")
async register(c: Context, @from.body(null, registrationValidator) user: object) {
}
const result = registrationValidator({ ... });
if (!result.ok) {
}
This pattern of validating at the request level is nice. It means that your services don't need to take on this responsibility, resulting in cleaner and more focused code where it matters.
Here's a more complex and complete example of a SchemaExecutor, covering most of its API:
import { ensure, createExecutableSchema, Infer } from "@prsm/pine";
const Address = ensure.object({
street: ensure.string().notEmpty().max(100),
city: ensure.string().notEmpty().max(100),
state: ensure.string().notEmpty().max(100),
zip: ensure.string().notEmpty().max(100).nullable(),
});
type AddressType = Infer<typeof Address>;
const Person = ensure.object({
name: ensure.string().notEmpty().max(100),
age: ensure.number().min(0).max(100),
address: Address,
friends: ensure.array(Person).optional(),
});
const isPerson = createExecutableSchema(Person);
isPerson({ name: "John", age: 30, address: { ... } });
const Person = createSchema((v) => ({
name: ensure.string().notEmpty().max(100),
age: ensure.number().min(0).max(100),
address: Address,
friends: ensure.array(Person).optional(),
}));
Person({ name: "John", age: 30, address: { ... } });
HTTP and WS controllers
@dev
Only mount a router or route when process.NODE_ENV is not production.
import { router, dev, route } from "@prsm/pine";
@router("/dev")
@dev()
export class DevRouter {}
@router("/dev")
export class DevRouter {
@dev()
@route.get("/private")
async privateRoute(c: Context) {}
}
@auth
A collection of protective middleware decorators that can be used on either a router or a route.
@auth.isLoggedIn(): prevent access unless the user is logged in.
@auth.isNotLoggedIn(): prevent access if the user is logged in.
@auth.hasRole(role: AuthRole): prevent access unless the user has the specified role.
@auth.hasAnyRole(roles: AuthRole[]): prevent access unless the user has any of the specified roles.
@auth.isVerified(): prevent access unless the user has verified their email address.
@auth.isNotVerified(): prevent access if the user has verified their email address.
@auth.isNormal(): prevent access unless the user in good standing (not banned, locked, suspended, archived, etc).
@auth.isAdmin(): prevent access unless the user is an admin (AuthRole.Admin).
Context
A Context object is always provided as the first argument to each controller method. If you prefer to use the normal Express handler API, you can use the @expressCompat decorator:
import { Request, Response } from "express";
import { expressCompat } from "@prsm/pine";
@expressCompat()
async someHandler(req: Request, res: Response) { }
What is Context and where are req and res?
req and res are on the Context object as request and response. Here's the full Context interface:
interface Context {
request: Request;
response: Response;
next: NextFunction;
auth: Auth;
authAdmin: AuthAdmin;
render: { };
files: { };
respond: { };
}
This pattern simplifies the API of the handler and provides additional (very useful) APIs for common tasks such as file uploads, downloads, authentication, and responses.
context.files.serve
import { Context, router, route } from "@prsm/pine";
@router("/download")
export class DownloadRouter {
@route.get("")
async download(c: Context) {
try {
return await c.files.serve({ path: "package.json", asAttachment: false });
} catch (e) {
return c.respond.BadRequest(e);
}
}
}
context.files.upload
import { Context, router, route } from "@prsm/pine";
@router("/upload")
export class UploadRouter {
@route.post("")
async upload(c: Context) {
try {
await c.files.upload({ formFieldNames: ["file1", "file2"] });
return c.respond.OK();
} catch (e) {
return c.respond.BadRequest(e);
}
}
}
context.auth (Auth)
The interface for Auth is available on the Context object. It contains the following methods:
loginWithEmail(email: string, password: string, rememberDuration?: number): Promise<void> |
Login a user, using their email and password as credentials. login is a shorthand for this method. If rememberDuration is provided and is numeric, a remember me cookie is created. If the user visits within this expiration period, Auth automatically updates their session and preserves their login state. |
loginWithUsername(username: string, password: string, rememberDuration?: number): Promise<void> |
| Login a user, using their username and password as credentials. |
register(email: string, password: string, username?: string): void |
logout(): void |
Logs out the user. Destroys the session. Overwrites remember me cookie with an expired one. |
registerWithUniqueUsername(email: string, password: string, username: string): void |
| Throws if the username already exists. |
changeEmail(newEmail: string, oldEmail: string, callback: (selector: string, token: string) => void): void |
Tries to change the email address for the currently logged-in user. The callback is called with the selector and token, which you can email to the user to create a short-lived confirmation URL. |
A mostly complete example of Auth:
import { auth, duration, createSchema, router, route, from, respond } from "@prsm/pine";
const registerSchema = createSchema((v) => ({
email: v.string().notEmpty().max(100),
password: v.string().notEmpty().min(8).max(100),
username: v.string().notEmpty().min(3).max(20),
}));
@router("/auth")
export class AuthRouter {
@auth.isNotLoggedIn({ onFail: { redirect: "/" }})
@route.post("/register")
async register(c: Context, @from.body(null, registerSchema) user: object) {
try {
const user = c.auth.register(user.email, user.password, user.username);
return c.respond.OK({ user });
} catch (e) {
return c.respond.BadRequest(e);
}
}
@auth.isNotLoggedIn({ onFail: { redirect: "/" }})
@route.get("/login")
async login(c: Context, @from.body("email") email: string, @from.body("password") password: string) {
try {
const user = await c.auth.login(email, password, duration("1w"));
return c.respond.OK();
} catch (e) {
return c.respond.BadRequest(e);
}
}
@auth.isLoggedIn()
@route.post("/change-email")
async changeEmail(c: Context, @from.body("password") password: string, @from.body("email") email: string) {
if (c.auth.confirmPassword(password)) {
c.auth.changeEmail(email, c.auth.email, (selector, token) => {
const confirmationUrl = `/confirm/${selector}/${token}`;
});
}
}
@auth.isLoggedIn()
@route.get("/confirm/:selector/:token")
async confirmEmail(c: Context, @from.path("selector") selector: string, @from.path("token") token: string) {
c.auth.confirmEmail(selector, token);
const rememberDuration = duration("30d");
c.auth.confirmEmailAndSignIn(selector, token, rememberDuration);
}
}
Is the built-in authentication secure?
Yes! But...
You must properly configure your cookie, session, and CSRF middlewares. Don't use the defaults.
When using the built-in, session-based authentication, @prsm/pine takes measures to protect against fixation, hijacks, and replay attacks by re-synchronizing the session with authoritative data at a fixed interval.
CSRF support is not baked-in because that's something you should configure yourself for obvious reasons. (Given that the csurf package is deprecated, you should use tiny-csrf instead.)
Also, adding @prsm/pine to your project doesn't mean you need to use @prsm/pine's authentication tooling.
Error handling
import { router, route } from "@prsm/pine";
@router("/")
class MyRouter {
@route.get("/error")
async error(c: Context) {
throw new Error("Something went wrong");
}
@route.get("/error-string")
async errorString(c: Context) {
throw "Something went wrong";
}
@route.get("/error-next")
async errorNext(c: Context) {
c.next("Something went wrong");
}
@route.get("/error-respond")
async errorRespondBadRequest(c: Context) {
return c.respond.BadRequest("Something went wrong");
}
}
Getting started
- Give your controllers the
.controller.ts suffix and place them anywhere in your project.
- Give your WebSocket commands the
.ws.ts suffix and place them anywhere in your project.
- Call
initialize({ app, root: "dist/" }); where app is your Express app and root is the root directory of your built .js files. If you're using TypeScript and your tsconfig's outDir is dist/, then root should be dist/.
These suffixes are used to find your controllers and socket commands and automatically require them.
Next, define a normal Express application and call initialize with your Express app and the root directory of your built .js files.
import express from "express";
import { createServer } from "http";
import { initialize } from "@prsm/pine";
const app = express();
const server = createServer(app);
initialize({
app,
root: "dist",
});
server.listen(4000);
Sockets
@prsm/pine uses @prsm/keepalive-ws/server as the WebSocket communication layer, so it is recommended that you use @prsm/keepalive-ws/client to dramatically simplify this flow. It will format the messages for you in the way that the server expects to receive them, handle ping and pong, latency, automatic reconnection, and more.
All of the decorators for working with WebSockets are scoped behind the ws export from @prsm/pine:
import { ws } from "@prsm/pine";
@ws.ββββββββββββββββββββββ
β namespace β
β command β
β middleware β
β onClientConnect β
β onClientDisconnect β
ββββββββββββββββββββββ
Command handlers
Command handlers cannot be static.
import { WSContext, ws } from "@prsm/pine";
export class SocketAuth {
@ws.command("auth")
async auth(c: WSContext) {
const { token } = c.payload;
return { ok: true };
}
}
Events
onClientConnect and onClientDisconnect can be static, but they don't have to be.
import { Connection, WSContext, jwt, ws, getWss } from "@prsm/pine";
export class SocketAuth {
static active: Connection[] = [];
static authenticated: Connection[] = [];
@ws.onClientConnect()
static onClientConnect(c: Connection) {
SocketAuth.active.push(c);
const wss = getWss();
wss.addToRoom("lobby", c);
}
@ws.onClientDisconnect()
static onClientDisconnect(c: Connection) {
SocketAuth.active = SocketAuth.active.filter((conn) => conn.id !== c.id);
SocketAuth.authenticated = SocketAuth.authenticated.filter((conn) => conn.id !== c.id);
const wss = getWss();
wss.removeFromRoom("lobby", c);
}
static notifyRoom(room: string, command: string, payload: any) {
return getWss().broadcastRoom(room, command, payload);
}
static notifyOthers(c: Connection, command: string, payload: any) {
return getWss().broadcastExclude(c, command, payload);
}
}
Returning errors to the client
Throwing from a socket command handler will reply to the client with a JSON body that includes the error message in a payload.
@ws.command("throws")
async throws(c: WSContext) {
throw new Error("Oh, no...");
}
Response to client:
{ "command": "auth", "payload" :{ "error": "Oh, no..." } }
The same is true for middlewares. To fail from a middleware and return an error to the client, just throw:
class SocketAuth {
static throws(c: WSContext) {
throw new Error("Oh no!");
}
@ws.middleware(SocketAuth.throws)
@ws.command("hello")
async thisCommandAlwaysFails() {
}
}
Response to client:
{ "command": "hello", "payload": { "error": "Oh no!" } }
Namespacing commands
Commands can be namespaced by using the @ws.namespace decorator.
If the namespace is foo and the command is bar, the client can execute this command as foo.bar.
@ws.namespace("job")
class Job {
@ws.command("create")
async create(c: WSContext) {
return { created: true };
}
}
Middlewares
Sockets can have namespace-level middlewares and handler-level middlewares.
Namespace-level middlewares are invoked before handler-level middlewares.
Queues
Queues are not automatically imported like http and ws controllers are. They also don't need to have the .queue.ts extension, but it's nice to be consistent.
Queues can be in-memory or backed by Redis.
import { Queue } from "@prsm/pine";
export default new Queue({
delay: duration("10s"),
concurrency: 1,
timeout: duration("1m"),
redis: { host: "localhost", port: 6379, queueName: "mail" },
async handle(payload: { to: string; body: string }) {
console.log("Sending an email to", payload.to);
}
});
Now, use the queue somewhere:
import mailQueue from "@/queues/mail.queue.ts";
mailQueue.push({ to: "somebody@somewhere.com", body: "Hi" });
mailQueue.group("foo").push({ to: "foo@bar.com", body: "Hello" });
useCache
useCache
useCache(inputs, callback, cacheDuration)
This cache helper takes in a callback, some inputs that are used as the cache key,
and a cacheDuration, which is a string like "1m" or "30s", or a number of milliseconds.
The operation is stored in a CacheMap, where the key is a stringified version of the input arguments and the value is the result of callback(...inputs).
The cached result of the provided callback will be returned by the call to useCache until the lifetime defined by cacheDuration has passed.
An example of using this could be a route or middleware that caches the result of JWT signature verification using the client IP and/or use agent string and the JWT as the cache key:
import { type Context, jwt, useCache } from "@prsm/pine";
@router("/auth")
export class AuthController {
@route.post("/validate-token")
async validateToken(
c: Context,
@from.header("Authorization") bearer: string,
) {
if (!bearer) {
return c.respond.Unauthorized("No token provided.");
}
const inputs = [c.request.ip, c.request.headers["user-agent"], bearer];
const result = useCache(
inputs,
(_ip: string, _agent: string, _bearer: string) => {
const res = jwt.verify(_bearer, String(process.env.JWT_SIGNATURE), {
exp: true,
});
if (!res.sig) {
return { valid: false, reason: "signature" };
}
if (res.exp) {
return { valid: false, reason: "expired" };
}
return { valid: true, reason: "valid" };
},
"1hr",
);
if (!result.valid) {
return c.respond.Unauthorized(`Token is not valid: ${result.reason}`);
}
return c.respond.OK();
}
}