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 🙏

© 2026 – Pkg Stats / Ryan Hefner

@scopmx/markora-server

v1.3.0

Published

Backend for the Markora markup editor: Express routes and service functions on the host's MongoDB connection.

Readme

@scopmx/markora-server

The backend for the Markora markup editor (@scopmx/markora-client): an Express router and a few service functions. They run on your MongoDB connection, your auth, and your file storage.

Each drawing keeps one main copy of its markup (workingSnapshot) plus one record per mark and per comment, the Bluebeam model. Saves are guarded by a revision number, so two people never silently overwrite each other. @scopmx/markora-client merges their work in the browser.

Install

npm install @scopmx/markora-server

Peer dependencies: express ^4.17, mongoose ^8. The package never loads its own Mongoose; it uses the instance that owns the connection you pass.

Mount

import { createMarkoraServer } from "@scopmx/markora-server";

const markora = createMarkoraServer({
  connection: (req) => req.user.conn,   // your MongoDB connection for this request
  user: (req) => ({ id: String(req.user._id), name: req.user.name }),
  files: {
    copy: (url) => copyFile(url),       // copy a file you own → new URL
    remove: (url) => deleteFile(url),   // delete a file you own
    isOwn: (url) => isOwnS3Url(url),    // true for URLs you may later delete
  },
  // Optional: use your own collections and foreign key.
  models: { drawing: "jobDefect", version: "jobDefectVersion", activity: "jobDefectActivity",
            markup: "jobDefectMarkup", comment: "jobDefectComment", presence: "jobDefectPresence",
            tool: "jobDefectTool" },
  foreignKey: "defectId",
  copyToChildren: ["jobId"],
  // Optional: turns GET /search on. A filter on the foreign key naming the drawings THIS person may search.
  searchScope: async (req) => ({ defectId: { $in: await defectIdsFor(req.user) } }),
  // Optional: what THIS person may do with THIS drawing: "edit", "comment" or "view" (default: everyone edits).
  access: async (req, drawing) => (await canEditDefect(req.user, drawing.foreignKey) ? "edit" : "view"),
});

app.use("/api/markora", requireLogin, markora.router);

If a model with one of those names is registered on the connection, it is used as-is, so your hooks run. Otherwise the package registers its own schema.

A tool model (default markoraTool) holds each person's tool sets, one document per person. A model you register yourself for it must declare ownerId as a unique String index ({ ownerId: { type: String, required: true, unique: true } }); without the unique index, two first saves at once can create two documents for one person.

Revisions. A new file for the same drawing is kept next to the old one: the drawing gains an append-only sources array ({ n, sourceType, sourceFileUrl, backgroundUrl, pdfPageCount, name, uploadedBy, at }), and every version carries a sourceRef of the file it was made on. The package writes both even when your own drawing schema is strict; declare sources: [Mixed] on it as well if other code of yours reads the drawing through Mongoose documents. Nothing is migrated: a drawing with no sources reads as one source, and remove deletes the files of every source.

Access. With the access option the package enforces what the host decides, on every route of one drawing (not on /tools and /search). The hook gets the request and { id, foreignKey } (the drawing's own record id) once per request, after the drawing is found and before anything is written:

| Answer | Reads and presence | Saves, versions, restore, background, revisions, HTML state, activity | |---|---|---| | "edit" | yes | yes | | "comment" | yes | a save only if nothing but the comments differs from the stored drawing (every mark in order, the measure state, page turns, PDF imports and the canvas size must be equal) and the comments pass the author rule; activity only for entries whose action starts with comment; everything else 403 comment_only | | "view" | yes | 403 read_only |

A refusal writes nothing and does not bump the revision. A save on a stale revision is still a 409, so a commenter's editor can merge. GET /drawings/:id returns access next to the drawing ("edit" when you set no hook), and the editor takes the stricter of that and its own mode prop. Throw a MarkoraError from the hook to refuse a whole request with your own code; any other failure, or an answer that is not one of the three, is a 500 (never an open door). Guests are ordinary users: build user(req) from the share token and answer "comment" or "view" for the one drawing it names. commentOnlyChange(current, incoming) is exported for hosts that want the same check.

Your own create, duplicate and delete routes call:

| Call | When | |---|---| | markora.service.attach(req, id, { sourceType, sourceFileUrl, backgroundUrl, pdfPageCount?, pdfCurrentPage?, htmlLayoutViewport?, snapshot?, summary? }) | right after you create the record | | markora.service.duplicate(req, sourceId, targetId, { copyAnnotations, summary? }) | after you create the copy's record | | markora.service.remove(req, id) | before you delete the record | | markora.service.reindex(req, id) | to rebuild mark and comment records |

Routes

Success: { data }. Failure: { error: { code, message } }.

| Route | Purpose | |---|---| | GET /drawings/:id | the record with workingSnapshot, revision, editors, and access (edit, comment or view) next to it | | PUT /drawings/:id/state | autosave { baseRevision, snapshot } | | POST /drawings/:id/versions | manual save { baseRevision, snapshot, label? }, or { source: "conflict-copy", snapshot, label } | | GET /drawings/:id/versions | history, newest first, without snapshots | | POST /versions/:versionId/restore | { createCheckpoint?, currentSnapshot? }; brings the version's file back when the drawing still has it; sourceUnknown: true when it cannot (a version from before revisions) | | GET / POST /drawings/:id/activity | the History feed ({ entries }, 200 per call) | | PUT /drawings/:id/background | new image, PDF page, HTML page or 3D model | | POST /drawings/:id/revisions | upload a new file for the drawing { sourceType: "image" \| "pdf", sourceFileUrl, backgroundUrl, pdfPageCount?, pdfCurrentPage?, name }; answers { revision, drawing }; deletes nothing | | GET /drawings/:id/revisions | { revisions: [{ n, name, uploadedBy, at, pdfPageCount?, current }] }, oldest first | | PUT /drawings/:id/html-state | HTML screen routes file | | POST /drawings/:id/presence | check in; returns who else is here and who changed it since since | | GET /search?q=&limit= | words people typed (a mark's text, the subject and author name they gave it, comments and their authors) across the drawings searchScope names: { hits: [{ foreignKey, kind: "mark" \| "comment", markupId, commentId?, page, subject?, snippet, author?, status?, at }] }, newest first. q 2 to 100 characters (regex characters are plain characters), limit 50 by default and 100 at most. No searchScope, or one returning null, is a 403 search_disabled: the package never decides who sees what | | GET / PUT /tools | the signed-in person's Tool Chest { sets } (null = never saved); 400 over 50 sets, 200 tools, 256 KB a tool or 4 MB in all, and over a style or template key starting with $ or containing . |

Error codes: bad_request 400, foreign_file 400 (a revision's file is not one this backend owns), search_disabled 403, read_only 403, comment_only 403, comment_forbidden 403, not_found 404, revision_conflict 409, background_changed 409, snapshot_too_large 413, internal 500.

Only a comment's author can change or remove it (comment_forbidden). Removing a mark removes its thread.

Tests

The database tests need a disposable MongoDB. They are skipped without one:

mongod --dbpath ./.mongo --port 27099
MARKORA_TEST_MONGO_URL=mongodb://127.0.0.1:27099 npm test