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/grove

v1.7.5

Published

This is a backend framework that employs a filesystem-based API structure.

Downloads

3

Readme

This is a backend framework that employs a filesystem-based API structure.

You can find an example project at github.com/node-prism/grove-example.

Table of contents

Brief overview

Handling HTTP requests

// /src/app/http/auth/login.ts:
import { Context, Respond } from "@prsm/grove/http";

export async function post(c: Context, { body: { email, password }}) {
  return Respond.OK(c, { ok: "Authorized" });
}

// POST /auth/login
// content-type: application/json
// {
//   "email": "[email protected]",
//   "password": "password"
// }

// response: 
// HTTP/1.1 200 OK
// {
//   "code": 200,
//   "data": {
//     "ok": "Authorized"
//   }
// }

Handling WebSocket messages

// /src/app/socket/auth/login.ts:
import { Context } from "@prsm/grove/ws";

export default async function (c: Context) {
  return { ok: "Authorized" };
}

// wscat -c ws://localhost:PORT/ -x '{"command": "/auth/login", "payload": {"token": "..."}}'
// response:
// {"command":"/auth/login","payload":{"ok":"Authorized"}}

Some additional (and completely optional) features to help you quickly get your project started:

Installation

npm i @prsm/grove

Quickstart

// /src/index.ts
import express from "express";
import { createServer } from "http";
import { createApi } from "@prsm/grove";

// Create an express server.
const app = express();
const server = createServer(app);

// Tell express to parse incoming requests with JSON payloads.
app.use(express.json());

// Travel the "app" folder, discovering modules and using them to create route handlers.
const grove = await createApi("app", app, server);
grove.server.listen(3000);

The typical folder structure looks something like this:

├── app
│   ├── errors.ts
│   ├── http
│   │   ├── _middleware.ts
│   │   ├── auth
│   │   │   └── login.ts
│   │   ├── mail
│   │   │   └── index.ts
│   │   └── user
│   │       ├── [id].ts
│   │       └── index.ts
│   ├── http_middlewares
│   ├── queues
│   │   └── mail.ts
│   ├── schedules
│   │   └── metrics.ts
│   ├── socket
│   │   └── _authorized
│   │       ├── _middleware.ts
│   │       └── jobs
│   │           └── start.ts
│   └── socket_middlewares
│       └── authorize.ts
├── index.ts
└── views
    └── 404.ejs

.env

If an .env file is detected next to the package.json file, dotenv will automatically load and insert the environment variables into process.env.

The only grove-specific environment variable is LOGLEVEL, which can be one of:

  • 4 (debug)
  • 3 (error)
  • 2 (warn)
  • 1 (info)
  • 0 (silent)
# /.env
LOGLEVEL=3
JWT_SECRET=ninjaturtles

HTTP route handlers

Place your HTTP route handlers under /src/app/http, for example:

// /src/app/http/user.ts OR /src/app/http/user/index.ts
export async function get(c: Context) {}   // GET /user
export async function post(c: Context) {}  // POST /user
export async function put(c: Context) {}   // PUT /user
export async function patch(c: Context) {} // PATCH /user
export async function del(c: Context) {}   // DELETE /user

Path parameters are supported:

// /src/app/http/user/[id].ts -> /user/:id
export async function get(c: Context, { path: { id } }) {
  return Respond.OK(c, { id });
}

Wildcards are supported:

// /src/app/http/user/[id]/[...rest].ts -> /user/:id/:rest*
export async function get(c: Context, { path: { id, rest } }) {
  // c.req.params will include whatever other path params exist
  return Respond.OK(c, { id, rest });
}

In order to handle what would normally be an uncaught Promise rejection or thrown error, handlers are wrapped and the resulting error is routed to the defined Express error middlewares.

function throws() {
  throw new Error("Whoops!");
}

export async function get(c: Context) {
  throws();
  return Respond.OK(c, { hello: "world" });
}

The result of the above is that the error is caught and passed to the first middleware defined in /src/app/errors.ts, if it exists.

Consequently, you do not have to enclose code that is prone to errors in a try/catch block or manually route errors to your error-handling middleware using next(e), as this process occurs automatically.

const grove = await createAPI("app", app, server);

// Assuming `/src/app/errors.ts` doesn't exist and this is the first error handling
// middleware that is defined, the above thrown error would be handled by the following
// middleware:

grove.app.use((err, req, res, next) => {
  res.status(500).send("Something went horribly wrong!");
});

Middleware

Suppose we have the following endpoints:

/user/ (/src/app/http/user/index.ts)
/user/profile/ (/src/app/http/user/profile.ts)
/user/profile/edit (/src/app/http/user/profile/edit.ts)

To create middleware that is applied to all of these endpoints (i.e. /user/*), create a file named _middleware.ts and place it at /src/app/http/user/_middleware.ts.

A _middleware.ts file is expected to have a default export which is an array of Express-compatible middleware handlers.

// /src/app/http/user/_middleware.ts
export default [
   async (c: Context) => {
    console.log(c.req.method, c.req.path, c.req.ip);
    c.next();
  },
];

Another way to define middleware is to export a middleware object from a handler file, where the keys of the object correspond to the request method to which the middleware will be applied, and the value is an array of middlewares.

// /src/app/http/user/profile.ts
export async function get(c: Context) {}
export async function post(c: Context) {}

export const middleware = {
  // GET /user/profile will hit this middleware first.
  "get": [
    async function validUser(c: Context) {
      // validate this user, or something
      c.next();
    }
  ],

  // ...
  "post": [],
  "put": [],
  "patch": [],
  "del": [],
};

Error middleware

You can also optionally put an errors.ts file at the root of your application (e.g., /app/errors.ts), which grove will detect and apply to Express as middleware. This file should have a default export that is an array of Express middleware handlers. These middlewares are registered with Express after your filesystem-based routes are registered.

For example:

// /src/app/errors.ts
import { NextFunction, Request, Response } from "express";

export default [
  // Handle errors by sending a 500 with the error message.
  (err: any, _req: Request, res: Response, next: NextFunction) => {
    if (res.headersSent) {
      return next(err);
    }

    if (err instanceof Error) {
      return res.status(500).send({ code: 500, error: String(err.message) });
    }

    return next(err);
  },

  // 404 handler
  (req: Request, res: Response, _next: NextFunction) => {
    res.render("404", { path: req.path });
  },
];

Flattened paths

You can "flatten" parts of a route path by prefixing corresponding filesystem folders with an underscore. In other words,

/src/app/http/user/_authorized/profile.ts

...is mounted at /user/profile because the _authorized folder is effectively ignored (i.e., flattened).

Middlewares defined inside of flattened folders are still recognized and applied to sibling and child files. This can be a very useful pattern.

Consider the following folder structure:

├── app
│   ├── http
│   │   └── user
│   │       ├── index.ts
│   │       └── _authorized
│   │           ├── _middleware.ts # some authorization middleware
│   │           └── profile.ts
└── index.ts

The above structure means that handlers at /user are not protected by authorization middleware, whereas handlers at /user/profile are.

Reading the request body / query / path / headers

Both route handlers and middlewares are passed an object of the following shape:

{
  path: {}, // Path params (/user/:id -> { path: { id } })
  query: {}, // Query params (/user/123?action=edit -> { query: { action: "edit" } })
  body: {}, // POST body
  headers: {}, // Request headers
  bearer: "", // Bearer token, if present in Authorization header
}

// sample usage:
export async function post(c: Context, { body: { email, password } }) {}
export async function get(c: Context, { query: { action }, path: { id }}) {}
export async function get(c: Context, { bearer }) {}

Schedules

You must place your schedule definitions in /src/app/schedules. For example:

src
└── app
    └── schedules
        ├── metrics.ts
        └── backups.ts

These are expected to export default an async function, which is the handler for the scheduled task.

Here's an example of a simple schedule that would collect daily user metrics from a database and deliver them via a task placed in a mail queue.

// /src/app/schedules/metrics.ts
import { Schedule } from "@prsm/grove/schedules";
import { queue as mailQueue } from "../queues/mail";

// The task to be executed according to the schedule below.
export default async function() {
  mailQueue.push({ recipient: "[email protected]", body: "" });
}

export const config: Schedule = {
  cron: "0 0 12 * *",
  timezone: "America/Los_Angeles",
}

Queues

You must place your queue definitions in /queues. For example:

src
└── app
    └── queues
        ├── mailer.ts
        └── llm-processor.ts

Each queue module is expected to have a default export that is an asynchronous function, which serves as the handler for each queued job. Additionally, a queue object must be exported that generates and configures the queue.

Here's an example queue definition.

// /src/app/queues/mail.ts
import Queue from "@prsm/queues";

interface MailPayload {
  recipient: string;
  subject: string;
  body: string;
}

// The task to be executed for each queued job when a worker becomes available.
export default async function ({ recipient, subject, body }: MailPayload) {
  // send an email
}

export const queue = new Queue<MailPayload>({
  // Number of concurrent queue workers.
  concurrency: 1,

  // Once a worker becomes available, it will wait delay ms before processing the next job.
  delay: 0,

  // After it is initiated, a task will fail if it does not resolve within timeout ms.
  timeout: 0,

  // Groups enable scoped queues based on a key, such as a recipient's address.
  // This can be useful, for instance, if we want to restrict the number of emails we send to a particular
  // recipient during a specific time period.
  //
  // By default, a group will inherit the queue's config above, but you can
  // override that config here.
  groups: {
    concurrency: 3,
    delay: "5s",
    timeout: "1m"
  },
});

Adding jobs to a queue

To add a job to the queue, import the queue and call queue.push.

import { queue as mailQueue } from "../queues/mail";
mailQueue.push({ recipient, subject, body });

To track task status, listen for events emitted by the queue. The queue will emit events when:

  1. A job is added to the queue
  2. A job is completed
  3. A job fails
// /src/app/http/mail/index.ts
import { queue as mailQueue } from "../queues/mail";

const onNew = ({ task }) => console.log("Task created:", task.uuid);
const onFailed = ({ task }) => console.log("Task failed:", task.uuid);
const onComplete = ({ task }) => console.log("Task complete:", task.uuid);

mailQueue.on("new", onNew);
mailQueue.on("failed", onFailed);
mailQueue.on("complete", onComplete);

// POST /mail
export async function post(c: Context, { body: { recipient, subject, body } }) {
  const uuid = mailQueue.push({ recipient, subject, body });
  return Respond.OK(c, { uuid });
}

// /src/app/queues/mail.ts
export default async function({ recipient, subject, body }) {
  // send a very important email
}

Sockets

/src/app/socket/jobs/send-email.ts is associated with a command named /jobs/send-email at the endpoint ws://localhost:PORT/. To run a command, send a message in this format:

{
  command: string;
  payload: object;
}

For example:

wscat -c ws://localhost:3000/ -x '{"command": "/jobs/send-email", "payload": { "recipient": "[email protected]" }}'

As with other modules, a socket command module should have a default export that is an asynchronous function that serves as the command's handler. The handler's return value will be sent back to the client.

Here's an example of what the /src/app/socket/jobs/send-email.ts module might look like:

// /src/app/socket/jobs/send-email.ts
import { MailQueueTask, queue as mailQueue } from "../../queues/email";

export default async function(c: Context) {
  const mail: MailQueueTask = {
    recipient: c.payload.recipient,
    subject: c.payload.subject,
    body: c.payload.body,
  };

  // Queued tasks can receive an optional callback. Here, we pass a callback to the
  // task so that when the task is complete, we can send a completion message to the
  // client that launched the task.
  const callback = () => {
    c.connection.send({ command: "job:status", payload: { status: "finished" } });
  };

  const uuid = mailQueue.group(mail.recipient).push(mail, { callback });

  return { message: "Task created.", uuid };
}

Socket middleware

Socket middlewares are quite similar to HTTP middlewares, but they are not Express middlewares. Therefore, they do not need to call next or return anything.

// /src/app/socket/jobs/_middleware.ts
import { Context } from "@prsm/grove/ws";

export default [
  (c: Context) => {
    // c.connection has the connecting client details and socket object.
    if (!c.payload.token) {
      throw new Error("Missing authentication token.");
    }
  },
];

To fail from a socket middleware, simply throw an Error. This will terminate command execution, and the command handler will not be invoked. The error message will be sent back to the client, and it will resemble the following:

{"id":0,"command":"start-job","payload":{"error":"Missing authentication token."}}