npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

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

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