@prsm/pine
v2.5.3
Published
Downloads
210
Readme
🌲
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) {}
// 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"
}
}
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";
// 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: "" }
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";
// 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
).
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; // 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.
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 {
// curl -F [email protected] -F [email protected] 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);
}
}
}
context.auth
(Auth
)
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);
}
}
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 {
// 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");
}
}
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/" });
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);
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 {
// 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 };
}
}
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);
}
// See documentation for @prsm/keepalive-ws for more detailed usage examples.
}
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 {
// 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 };
}
}
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.
// src/queues/mail.queue.ts
import { Queue } from "@prsm/pine";
export default new Queue({
delay: duration("10s"),
concurrency: 1,
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: "[email protected]", body: "Hi" });
// This creates a scoped queue (a group) named "foo".
// Scoped queues delay, concurrency and timeout parameters
// are isolated and unaffected by any other queue.
// For example, in the case of this mail queue,
// we only want to allow sending a particular individual
// an email once every 10s to prevent spam, while still
// sending emails to other addresses.
// In other words, we can send an email to [email protected]
// while simultaneously sending one to [email protected].
// We don't need to wait `delay` between these two queue tasks.
mailQueue.group("foo").push({ to: "[email protected]", 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();
}
}