@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.
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.
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) {}
// When the path is empty like this, the name of the method is used
// as the route path. This route will be mounted at /auth/login.
@route.post()
async login(c: Context) {}
}
-
@from.body(key?: string|null|undefined, schemaExecutor?: SchemaExecutor)
An
undefined
,null
, or empty string""
forkey
will return the entire body object.You may use dot notation to access nested properties.
// if request.body is { "a": 1, "b": 2, c: { d: 3 } } @from.body() value: object; // -> `value` is { a: 1, b: 2 } @from.body("a") value: number; // -> `value` is 1 @from.body("c.d") value: number; // -> `value` is 3
-
@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) {
// `bearer` is the value of c.request.headers["Authorization"]
}
@route.post("/login")
async login(c: Context, @from.body() body: object) {
// `body` is the parsed JSON body
}
@route.get("/user/:id")
async getUser(c: Context, @from.path("id") id: string) {
// `id` is the value of the id parameter
}
@route.get("/do")
async getUser(
c: Context,
@from.query("action") action: string,
@from.query("id") id: string,
) {
// assuming the request is GET /do?action=login&id=123 ...
// `action` is "login"
// `id` is "123"
}
}
For validation purposes, an optional SchemaExecutor
can be provided to each of these decorators. If the value of the key does not match the schema, a BadRequest
error is thrown, and the errors are stringified and sent to the client.
Using a SchemaExecutor
:
import { createSchema, ... } from "@prsm/pine";
// Define a SchemaExecutor:
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) {
// `user` is validated and contains the email, password, and username.
// If the object contained any additional properties, it will have failed validation.
// If any properties defined in the schema were missing, it will have failed validation.
}
// You can also use a SchemaExecutor without a decorator, anywhere in your code.
// Calling the validator returns an object that looks like this:
// { ok: boolean; errors: object[], message: string }
const result = registrationValidator({ ... });
if (!result.ok) {
// result.errors contains an array of errors and can safely be returned to the user.
}
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(),
});
// ------------------------------------------------------
// You can create a type from this schema with `Infer`:
type AddressType = Infer<typeof Address>;
// type AddressType = {
// street: string;
// city: string;
// state: string;
// zip?: string | null;
// }
// ------------------------------------------------------
// Using the `createExecutableSchema` API:
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: { ... } }); // -> { ok: true, errors: [], message: "" }
// ------------------------------------------------------
// Using the `createSchema` API, which is just a
// shortcut for `createExecutableSchema(new ObjectHandler(schema))`:
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: { ... } }); // -> { ok: true, errors: [], message: "" }
@dev
Only mount a router or route when process.NODE_ENV
is not production
.
import { router, dev, route } from "@prsm/pine";
// Applied to a router:
@router("/dev")
@dev()
export class DevRouter {}
// Applied to a route:
@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
).
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 { expressCompat } from "@prsm/pine";
@expressCompat()
async someHandler(req: Request, res: Response) { }
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; // docs below
authAdmin: AuthAdmin; // docs below
render: { /* */ }; // docs below
files: { /* */ }; // docs below
respond: { /* */ }; // docs below
}
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.
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);
}
}
}
import { Context, router, route } from "@prsm/pine";
@router("/upload")
export class UploadRouter {
// curl -F file1=@tsconfig.json -F file2=@package.json http://localhost:4000/upload/
@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);
}
}
}
The interface for Auth
is available on the Context
object. It contains the following methods:
Login (with email) |
---|
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. |
Login (with username) |
---|
loginWithUsername(username: string, password: string, rememberDuration?: number): Promise<void> |
Login a user, using their username and password as credentials. |
Register without unique username |
---|
register(email: string, password: string, username?: string): void |
Logout |
---|
logout(): void |
Logs out the user. Destroys the session. Overwrites remember me cookie with an expired one. |
Register, forcing a unique username |
---|
registerWithUniqueUsername(email: string, password: string, username: string): void |
Throws if the username already exists. |
Change email |
---|
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: "/" }}) // redirect to / if the user is already logged in
@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: "/" }}) // redirect to / if the user is already logged in
@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}`;
// send this URL in an email to the user
});
}
}
@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);
// or..
const rememberDuration = duration("30d");
c.auth.confirmEmailAndSignIn(selector, token, rememberDuration);
}
}
import { router, route } from "@prsm/pine";
@router("/")
class MyRouter {
// code: 500
// content-type: application/json
// output: { code: 500, error: "Something went wrong" }
@route.get("/error")
async error(c: Context) {
throw new Error("Something went wrong");
}
// exactly the same as above
@route.get("/error-string")
async errorString(c: Context) {
throw "Something went wrong";
}
// pass to next error middleware
@route.get("/error-next")
async errorNext(c: Context) {
c.next("Something went wrong");
}
// code: 400
// content-type: application/json
// output: { code: 400, error: "Something went wrong" }
@route.get("/error-respond")
async errorRespondBadRequest(c: Context) {
return c.respond.BadRequest("Something went wrong");
}
}
- 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/" });
whereapp
is your Express app androot
is the root directory of your built.js
files. If you're using TypeScript and your tsconfig'soutDir
isdist/
, thenroot
should bedist/
.
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", // or maybe "src"
});
server.listen(4000);
@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 cannot be static.
import { WSContext, ws } from "@prsm/pine";
export class SocketAuth {
// wscat -c ws://localhost:4000/ws/ -x '{"command":"auth", "payload":{"token":"my.secret.jwt"}}'
@ws.command("auth")
async auth(c: WSContext) {
const { token } = c.payload;
// ...
return { ok: true };
}
}
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);
}
// See documentation for @prsm/keepalive-ws for more detailed usage examples.
}
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!" } }
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 {
// With the namespace "job" and command name "create",
// the fully-qualified command name is "job.create",
// and can be called like this:
// wscat -c ws://localhost:4000/ -x '{"command":"job.create", "payload":{}}'
@ws.command("create")
async create(c: WSContext) {
return { created: true };
}
}
Sockets can have namespace-level middlewares and handler-level middlewares.
Namespace-level middlewares are invoked before handler-level middlewares.
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.
// src/queues/mail.queue.ts
import { Queue } from "@prsm/pine";
export default new Queue({
delay: duration("1s"),
concurrency: 2,
timeout: duration("1m"),
// Leave `redis` undefined to use an in-memory queue.
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:
// src/somewhere/else.ts
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" });