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

@incproctor/proctoring-sdk

v1.0.4

Published

Browser SDK for InCruiter proctoring. Drop it into your exam page, point it at a session your backend created, and it reports browser activity, camera/microphone/screen state and evidence to the InCruiter proctoring API.

Downloads

546

Readme

@incproctor/proctoring-sdk

Browser SDK for InCruiter proctoring. Add it to your exam page, point it at a session your backend created, and it reports browser activity, camera/microphone/screen state, and evidence to the InCruiter proctoring API.

  • Framework-agnostic — plain JavaScript. Works with React, Vue, Angular, Svelte, or no framework.
  • No database required on your side. Events, evidence and reports are stored and served by us.
  • REST over HTTPS only. No WebSocket, no persistent connection, no socket library.
  • ~15 KB gzipped (13.6 KB brotli), zero runtime dependencies.

The five-minute version

Throughout these examples, https://api.example.com is a placeholder for the base URL InCruiter issued to you. Substitute your own — example.com is reserved for documentation and resolves to nothing, so a copied snippet fails immediately rather than half-working.

1. Your backend creates a session (never the browser — see Security):

const response = await fetch('https://api.example.com/api/v1/proctoring/browser/sessions', {
  method: 'POST',
  headers: {
    'X-API-Key': process.env.INCRUITER_API_KEY,
    'X-API-Secret': process.env.INCRUITER_API_SECRET,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    origin: 'https://exams.acme.com',      // where your exam page is served
    reference_id: 'candidate-4821',        // your own id, echoed on every event
    capabilities: ['events', 'evidence'],
  }),
})
const { data } = await response.json()     // { session_id, session_token, ... }

Send data to your frontend. It is safe there: short-lived, origin-bound, single-session.

2. Your frontend starts proctoring:

import { createProctoringSession, ProctoringEvent } from '@incproctor/proctoring-sdk'

const proctoring = createProctoringSession({
  baseUrl: 'https://api.example.com',
  session: sessionFromYourBackend,
})

proctoring.on(ProctoringEvent.TAB_SWITCH, () => showWarning('Return to the exam'))
proctoring.onError((error) => console.warn(error.code, error.message))

await proctoring.start()

3. When the exam ends:

await proctoring.stop()

4. Your backend fetches the report:

GET /api/v1/proctoring/browser/sessions/{session_id}/report

That is the whole integration. You store nothing.


Installation

npm install @incproctor/proctoring-sdk

Pin the version you install. It is what stops your live exam page changing under you — a release of ours should reach your candidates when you deploy, not when we publish.

Ships ESM, CJS, an IIFE browser bundle, and TypeScript definitions. No runtime dependencies.


Configuration

createProctoringSession({
  baseUrl: 'https://api.example.com',
  session: sessionFromYourBackend,          // required

  camera:     { enabled: true,  required: false },
  microphone: { enabled: false, required: false },
  screen:     { enabled: false, required: false },

  evidence: {
    enabled: false,          // off by default — nothing is captured unless you ask
    intervalSec: 30,
    sources: ['screen'],     // or ['camera'], or both
    maxDimension: 1280,
    quality: 0.6,
    captureOnEvent: true,    // an extra frame when something high-severity happens
  },

  monitors: {
    activity: true,          // tab switching, focus, unload
    fullscreen: true,
    network: true,
    devices: true,
    clipboard: false,        // OFF by default — a privacy decision, see below
    print: false,
  },

  environment: {
    enabled: true,           // one snapshot at start(): OS, browser, screen, device counts
    deviceLabels: true,      // include device NAMES — read the note below before leaving this on
  },

  flushIntervalSec: 5,
  maxQueueSize: 500,
  heartbeatSec: 60,
  logLevel: 'warn',          // 'debug' | 'warn' | 'error' | 'silent'
})

The environment snapshot, and what deviceLabels collects

At start() the SDK sends one environment_reported event describing the machine: operating system, browser and version, screen and window size, and how many cameras, microphones and speakers the browser reports. It appears in your report as an environment block, and it is what lets a reviewer answer "was a microphone even attached, and what was this candidate sitting at?" without reading the timeline.

deviceLabels: true collects device names, and a device name is personal data. Operating systems name hardware after its owner, so "Jane's MacBook Pro Microphone" is an ordinary label — it carries a name your exam never asked for, and it lands in your report and your logs. Set deviceLabels: false to receive counts only ("2 cameras, 1 microphone"), which still answers whether hardware was present. If you process EU personal data, this is a decision to make deliberately rather than inherit from a default.

Two fields in the payload exist to stop a wrong reading, and you should check them before concluding anything:

  • labels_available — browsers blank every device name until the candidate grants media permission. false means we were not allowed to look, not the hardware has no name.
  • counts_provisional — true when the counts were taken before permission was granted. A microphones: 0 alongside counts_provisional: true means we could not see, never the candidate had no microphone.

Everything in the block is marked trust: "self_reported". A user agent is trivially spoofed and deliberately reduced by modern browsers, so this says what the machine claimed to be — useful as context and for support, never as proof of what a candidate used. Set environment: { enabled: false } and no snapshot is generated at all.

required: false is the default, deliberately

If a candidate denies the camera, the session continues and the refusal is recorded as a high-severity event. Set required: true only if you genuinely want a broken webcam to block the exam — that is an availability problem far more common than the cheating it prevents. With the default, the decision moves to whoever reviews the report, which is where it belongs.

Clipboard monitoring is off by default

Watching a candidate's clipboard is a privacy decision that belongs to you, not to us. Turn it on deliberately. Clipboard contents are never read — only that a copy or paste happened, and roughly how many characters.


The API

| Method | What it does | |---|---| | start() | Requests permissions, attaches monitors, begins reporting. Returns the status. | | stop(reason?) | Ends the session, flushes, releases everything. Idempotent. | | pause() / resume() | Suspends capture without ending the session. | | destroy() | Synchronous teardown for framework cleanup paths. | | getStatus() | Current state, media status, event and evidence counters. | | on(event, handler) | Subscribe. Returns an unsubscribe function. | | once / off / onError | The usual companions. | | requestScreenShare() | Must be called from a click handler. See below. | | requestFullscreen(el?) | Same. | | requestMicrophone() | Start the microphone later. | | captureEvidence(reason?) | Capture and upload one frame now. | | getCameraStream() | The live stream, if you want to show a self-view. | | reportCustomEvent(type, detail?) | Record something your app noticed. Namespaced app.*. |

Screen sharing and fullscreen need a user gesture

Every browser requires a click or key press for getDisplayMedia() and requestFullscreen(). The SDK cannot start them for you. Wire them to your own button:

<button id="begin">Begin exam</button>
<script>
  document.getElementById('begin').addEventListener('click', async () => {
    await proctoring.requestFullscreen()
    await proctoring.requestScreenShare()
  })
</script>

Events

proctoring.on(ProctoringEvent.TAB_SWITCH, (payload) => {
  payload.id          // unique event id
  payload.timestamp   // ISO-8601 wall clock
  payload.elapsedMs   // monotonic ms since start — immune to a clock change
  payload.sessionId
  payload.referenceId
  payload.detail      // event-specific context
})

| Event | Meaning | |---|---| | TAB_SWITCH / TAB_RETURN | The exam tab stopped/started being displayed. Strong signal. | | WINDOW_BLUR / WINDOW_FOCUS | Focus moved, page may still be visible. Weak signal — see below. | | FULLSCREEN_ENTER / FULLSCREEN_EXIT | | | PAGE_UNLOAD | Best effort — see Browser support. | | CAMERA_STARTED / STOPPED / INTERRUPTED | Interrupted = it went away without us asking. | | MICROPHONE_STARTED / STOPPED / INTERRUPTED | | | SCREEN_SHARE_STARTED / STOPPED / INTERRUPTED | | | DEVICES_CHANGED | A camera or microphone was plugged in or removed. Counts only, never labels. | | ENVIRONMENT_REPORTED | Fires once at start: OS, browser, screen, device counts and names. See the snapshot. | | CONNECTION_LOST / CONNECTION_RESTORED | | | SESSION_STARTED / PAUSED / RESUMED / STOPPED / EXPIRED | | | PERMISSION_DENIED | detail.device says which. | | PROCTORING_ERROR | payload.error is always a ProctoringError. | | EVIDENCE_CAPTURED / EVIDENCE_UPLOADED | | | COPY / PASTE / CONTEXT_MENU / PRINT | Opt-in only. | | '*' | Fires for every event above. |

Blur is not a tab switch

They are separate events because conflating them produces false accusations. A candidate on two monitors generates WINDOW_BLUR constantly without ever leaving the exam. TAB_SWITCH is scored high severity; WINDOW_BLUR is medium.


Error handling

proctoring.onError((error) => {
  error.code         // stable, branch on this
  error.message      // human-readable, may change — do not parse
  error.recoverable  // is retrying meaningful?
  error.requestId    // quote this in a support ticket
})

Codes: INVALID_CONFIG, ALREADY_INITIALIZED, NOT_STARTED, SESSION_ALREADY_STOPPED, BROWSER_UNSUPPORTED, INSECURE_CONTEXT, FEATURE_UNSUPPORTED, PERMISSION_DENIED, PERMISSION_DISMISSED, DEVICE_NOT_FOUND, DEVICE_IN_USE, MEDIA_FAILED, CAPTURE_FAILED, NETWORK_ERROR, UNAUTHORISED, SESSION_EXPIRED, SESSION_ENDED, RATE_LIMITED, SERVER_ERROR, UPLOAD_FAILED.

Handle SESSION_EXPIRED by having your backend mint a new session — it is terminal.


Reliability

The candidate's network will fail during some exams. The SDK is built for that:

  • Events are queued in memory and retried with exponential backoff and full jitter.
  • Every event carries a UUID. The server de-duplicates on it, so a retry after a lost response can never turn one tab switch into two violations. This is what makes retrying safe.
  • The queue is bounded (default 500). At the cap it drops the oldest low-severity event first, so a WINDOW_FOCUS from four minutes ago is discarded before a FULLSCREEN_EXIT from just now.
  • High-severity events skip the batch timer and are sent immediately.
  • navigator.sendBeacon carries the final flush when the tab closes — the one delivery channel the browser guarantees after a page is gone.
  • Nothing is written to localStorage. A candidate's activity log must not survive on a shared machine. A refresh is itself reported, so the gap is visible rather than silently filled.

Check the service is reachable before the interview (1.0.4)

Brave with Shields up, and some ad/tracker-blocking extensions, block the SDK's requests (net::ERR_BLOCKED_BY_CLIENT). Retrying cannot help — the request never leaves the browser — so check first and tell the candidate:

import { checkReachable } from '@incproctor/proctoring-sdk'

const reach = await checkReachable(session.ingest_base_url || 'https://api.example.com')
if (reach !== 'ok') {
  const isBrave = !!(navigator.brave && (await navigator.brave.isBrave()))
  showMessage(reach === 'offline'
    ? 'You appear to be offline. Check your connection and try again.'
    : isBrave
      ? 'Brave Shields is blocking the proctoring service. Turn Shields off for this site and reload, or use Chrome, Edge or Firefox.'
      : 'A browser extension is blocking the proctoring service. Disable it for this site or use another browser, then reload.')
  return   // do not start the interview
}

'ok' / 'blocked' / 'offline'. No session needed, nothing is charged. The browser console may show a 405 for the check itself — that is the expected answer and means the service was reached.


Security

Never put your API key or secret in the browser. The SDK refuses to start if it finds one in its config, because anything your JavaScript can read, the candidate can read — and a leaked key can spend your credits and read every report you have ever run.

The session token the SDK does hold is deliberately weak by comparison:

| Property | Effect | |---|---| | Short-lived (15 min, auto-refreshed) | A stolen token dies on its own | | Bound to one origin | Cannot be replayed from another site | | Scoped to one session and one tenant | Cannot reach another candidate's or customer's data | | Limited to the capabilities you named | An events-only token cannot upload screenshots | | Revocable | Ending the session kills it before expiry |

The SDK also: sends the token in a header (never a URL, which would land in access logs and Referer), never sends cookies, redacts tokens and image data from every log line at every level, and requires a secure context.

More detail: docs/SECURITY.md, included in the installed package.


What a browser genuinely cannot do

Read this before promising anything to a customer. The SDK ships the list programmatically:

import { UNSUPPORTED_DETECTIONS } from '@incproctor/proctoring-sdk'

A normal web page cannot detect a second physical monitor, which application the candidate switched to, a virtual machine, remote-desktop software, or screen recording. It cannot block a new tab, a copy, or a print. It cannot capture the screen without permission, and cannot force a candidate to share their whole screen rather than one window.

Browser events are advisory, not forensic — they are produced by JavaScript on the candidate's own machine and a determined candidate can suppress or fabricate them. They are excellent against casual cheating and worthless against a prepared adversary. The authoritative signal is the recorded-video analysis (POST /api/v1/proctoring/analyze), which runs on our compute over media we hold. Every stored browser event is stamped trust: "advisory" so no downstream consumer can forget.

Full detail, per browser: docs/BROWSER_SUPPORT.md, included in the installed package.


Framework integration

React — note the cleanup, which is what stops StrictMode double-rendering from doubling events:

useEffect(() => {
  const proctoring = createProctoringSession({ baseUrl, session })
  proctoring.start().catch(console.error)
  return () => proctoring.destroy()
}, [session.session_id])

Vue: onMounted / onUnmounted. Angular: ngOnInit / ngOnDestroy. Runnable examples for React, vanilla JS and a Node backend are in the examples/ folder of the installed package.


Browser support

| | Chrome/Edge 90+ | Firefox 90+ | Safari 15+ | iOS Safari | |---|---|---|---|---| | Activity monitoring | ✅ | ✅ | ✅ | ✅ | | Camera / microphone | ✅ | ✅ | ✅ | ✅ | | Screen sharing | ✅ | ✅ | ✅ | ❌ not available | | Permission revocation | ✅ | ✅ | ⚠️ no Permissions API | ⚠️ | | Fast frame capture | ✅ | ⚠️ fallback | ⚠️ fallback | ⚠️ |


Testing

Three layers, because none of them is sufficient alone. Each one catches what the layer below cannot.

Unit — 134 tests. State machine, retry, de-duplication, queue eviction, token lifetime, error mapping, log redaction. The fakes model behaviour, not just shape: a fake media track really transitions state and really fires ended / mute / unmute, and the fake transport implements the real response envelope including its error codes. A stub that always resolved would let the tests pass while every failure path went untested — and those paths are the product.

Browser — 12 tests, real Chromium. Only the things a simulated DOM cannot answer:

  • the shipped dist/ bundle loads and works — every unit test imports the source, so a broken build would pass all 134 of them
  • a real getUserMedia track opens, and stop() really ends it (the camera light goes out)
  • a real JPEG is produced — asserted on its ffd8ff magic bytes and on size staying in budget
  • the session token never appears in any real request URL
  • a real page reload does not double-count events
  • duplicate initialisation is refused in a real browser

Live — 4 tests, against a running proctoring API. The layers above prove the SDK sends the right shape; this one proves the shape survives the journey. Every assertion reads the report endpoint, so an event counts only once it has been authenticated with a real session token, de-duplicated, severity-stamped server-side and stored. That difference is not theoretical — two bugs lived happily against a mock backend and died here: the final unload beacon never arriving cross-origin, and a 60-second outage silently discarding seven queued events, one of them a tab switch.

What no automated layer covers

A genuine browser-initiated tab switch. Headless Chromium cannot background a tab for real, so the test drives the transition from inside the page. That still proves the listener is attached, that the high-severity path skips the batch timer, and that a real request lands — but that the browser fires the event when a person really switches tabs is behaviour we depend on rather than implement. Confirm it with a headed run before a release.

Real permission prompts, and Safari/Firefox, need a human. The checklist we run:

  1. Deny the camera at the prompt → the session continues, PERMISSION_DENIED fires, event recorded.
  2. Revoke the camera mid-exam in site settings → CAMERA_INTERRUPTED.
  3. Press the browser's own "Stop sharing" → SCREEN_SHARE_STOPPED, high severity.
  4. Unplug the webcam → CAMERA_INTERRUPTED.
  5. Run the whole thing in Safari → no Permissions API, so check revocation still surfaces via the track ending.

Support

Every API response carries X-Request-Id, surfaced as error.requestId. Quote it — it finds the exact server-side log line.