1.2.19 • Published 30 days ago

@prsm/pine v1.2.19

Weekly downloads
-
License
ISC
Repository
-
Last release
30 days ago

🌲

@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.

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.

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 "" for key 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 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: "" }

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 { 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 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);
    }
  }

}

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);
  }
}

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

  1. Give your controllers the .controller.ts suffix and place them anywhere in your project.
  2. Give your WebSocket commands the .ws.ts suffix and place them anywhere in your project.
  3. 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", // 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("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" });
1.2.16

1 month ago

1.2.17

1 month ago

1.2.18

1 month ago

1.2.19

30 days ago

1.2.15

1 month ago

1.2.13

3 months ago

1.2.11

3 months ago

1.2.8

7 months ago

1.2.7

7 months ago

1.2.5

9 months ago

1.2.4

9 months ago

1.2.3

9 months ago

1.2.2

9 months ago

1.2.1

10 months ago

1.2.0

10 months ago

1.1.94

10 months ago

1.1.93

10 months ago

1.1.92

10 months ago

1.1.91

10 months ago

1.1.90

10 months ago

1.1.89

10 months ago

1.1.88

10 months ago

1.1.87

10 months ago

1.1.86

10 months ago

1.1.85

10 months ago

1.1.84

10 months ago

1.1.83

10 months ago

1.1.82

10 months ago

1.1.81

10 months ago

1.1.80

10 months ago

1.1.79

10 months ago

1.1.78

10 months ago

1.1.77

10 months ago

1.1.76

10 months ago

1.1.75

10 months ago

1.1.74

10 months ago

1.1.73

10 months ago

1.1.72

10 months ago

1.1.71

10 months ago

1.1.70

10 months ago

1.1.69

10 months ago

1.1.68

10 months ago

1.1.67

10 months ago

1.1.66

10 months ago

1.1.65

10 months ago

1.1.64

10 months ago

1.1.63

10 months ago

1.1.62

10 months ago

1.1.61

10 months ago

1.1.60

10 months ago

1.1.59

10 months ago

1.1.58

10 months ago

1.1.57

10 months ago

1.1.56

10 months ago

1.1.55

10 months ago

1.1.54

10 months ago

1.1.53

10 months ago

1.1.52

10 months ago

1.1.51

10 months ago

1.1.50

10 months ago

1.1.49

10 months ago

1.1.48

10 months ago

1.1.47

10 months ago

1.1.46

10 months ago

1.1.45

10 months ago

1.1.43

10 months ago

1.1.42

10 months ago

1.1.41

10 months ago

1.1.40

10 months ago

1.1.39

10 months ago

1.1.38

10 months ago

1.1.37

10 months ago

1.1.36

10 months ago

1.1.35

10 months ago

1.1.34

10 months ago

1.1.33

10 months ago

1.1.32

10 months ago

1.1.31

10 months ago

1.1.30

10 months ago

1.1.29

10 months ago

1.1.28

10 months ago

1.1.27

10 months ago

1.1.26

10 months ago

1.1.25

10 months ago

1.1.24

10 months ago

1.1.23

10 months ago

1.1.22

10 months ago

1.1.21

10 months ago

1.1.20

10 months ago

1.1.19

10 months ago

1.1.17

10 months ago

1.1.16

10 months ago

1.1.15

10 months ago

1.1.14

10 months ago

1.1.13

10 months ago

1.1.12

10 months ago

1.1.11

10 months ago

1.1.10

10 months ago

1.1.9

10 months ago

1.1.8

10 months ago

1.1.7

10 months ago

1.1.6

10 months ago

1.1.5

10 months ago

1.1.4

10 months ago

1.1.3

10 months ago

1.1.2

10 months ago

1.1.1

10 months ago

1.1.0

10 months ago

1.0.49

10 months ago

1.0.48

10 months ago

1.0.47

10 months ago

1.0.46

10 months ago

1.0.45

10 months ago

1.0.44

10 months ago

1.0.43

10 months ago

1.0.42

10 months ago

1.0.41

10 months ago

1.0.40

10 months ago

1.0.39

10 months ago

1.0.38

10 months ago

1.0.37

10 months ago

1.0.36

10 months ago

1.0.35

10 months ago

1.0.34

10 months ago

1.0.33

10 months ago

1.0.32

10 months ago

1.0.31

10 months ago

1.0.30

10 months ago

1.0.29

10 months ago

1.0.28

10 months ago

1.0.27

10 months ago

1.0.26

10 months ago

1.0.25

10 months ago

1.0.24

10 months ago

1.0.23

10 months ago

1.0.22

10 months ago

1.0.21

10 months ago

1.0.20

10 months ago

1.0.19

10 months ago

1.0.18

10 months ago

1.0.17

10 months ago

1.0.16

10 months ago

1.0.15

10 months ago

1.0.14

10 months ago

1.0.13

10 months ago

1.0.12

10 months ago

1.0.11

10 months ago

1.0.10

10 months ago

1.0.9

10 months ago

1.0.8

10 months ago

1.0.7

10 months ago

1.0.6

10 months ago

1.0.5

10 months ago

1.0.4

10 months ago

1.0.3

10 months ago

1.0.2

10 months ago