proctoring.js
Zero-dependency browser proctoring toolkit. Detects tab switching, right-click use, developer-tools shortcuts, camera tampering, missing or extra faces, room noise, and third-party capture software — virtual cameras, loopback audio cables and screen shares — through one small event API.
npm install proctoring.js
import { Proctor } from 'proctoring.js';
const proctor = new Proctor({
camera: { enabled: true, videoElement: '#preview' },
});
proctor.on('violation', (v) => console.log(v.type, v.severity, v.details));
await proctor.start();
// ...exam runs...
const report = proctor.stop();
console.log(report.score, report.violations);
Why this exists
Most proctoring libraries are either heavyweight SaaS SDKs that phone home, or
tutorial-grade snippets that break the moment a candidate unplugs their webcam.
proctoring.js sits in between: a small, auditable client-side library with a
stable event contract, no runtime dependencies, and no opinion about your
backend.
Design rules it follows:
- No hidden network calls. Nothing leaves the page unless you set
backend.endpoint. - Nothing is recorded. Audio is analysed in memory and discarded; no
MediaRecorder, and no screenshots or periodic snapshots unless you opt in. The clipboard is never read — copy/paste detection records that it happened, where, and how many characters, never the characters themselves. - A broken feature never kills the session. If the camera is denied, tab
monitoring keeps running and you get a
detector:errorevent. - Permissions are opt-in. Only tab detection is on by default, because it needs no permission prompt.
Detectors
| Detector | Default | Permissions | What it catches |
|---|---|---|---|
tabs |
on | none | Tab switch, window blur, page hidden, page torn down |
rightClick |
off | none | Right-click, keyboard Menu key, long-press menu |
shortcuts |
off | none | DevTools / view-source shortcuts (Ctrl+Shift+I, F12, Ctrl+U, …) |
clipboard |
off | none | Copy, cut and paste, with the element it happened in |
camera |
off | camera | Denied camera, muted track, covered lens, frozen feed, unplugged device |
face |
off | camera (shared) | No face, multiple faces, looking away |
audio |
off | microphone | Sustained loud noise, spectral busyness |
thirdParty |
off | camera (for labels) | Virtual cameras, loopback audio cables, screen shares |
Face detection reuses the camera detector's stream, so enabling both costs one
permission prompt, not two. The same is true of thirdParty.detectActiveCamera.
Periodic snapshots are not a detector: see Periodic snapshots.
Options
All options are optional. The shape is detector -> setting.
new Proctor({
sessionId: 'attempt-1234', // your own id, echoed in the report
metadata: { examId: 'math-01' }, // arbitrary JSON-safe data
logLevel: 'warn', // silent | error | warn | info | debug
tabs: {
enabled: true,
trackWindowBlur: true, // also flag focus loss, not just tab switch
minHiddenMs: 0, // ignore flicker shorter than this
throttleMs: 300,
reportOnClose: false, // report tab-closed as the page goes away
},
rightClick: {
enabled: false,
block: false, // suppress the browser menu (opt-in)
detectPointerDown: true, // catch it even if the page swallows the menu
dedupeMs: 400, // one gesture is one violation
throttleMs: 500, // minimum gap between reported right-clicks
captureTarget: true, // record tag/id/classes of the target
},
shortcuts: {
enabled: false,
combos: null, // null = the platform's devtools defaults
block: false, // preventDefault the shortcut (opt-in)
},
clipboard: {
enabled: false,
actions: ['copy', 'cut', 'paste'],
block: false, // preventDefault the operation (opt-in)
throttleMs: 250, // per action, not shared across them
ignoreSelectors: null, // CSS selectors for your own UI
},
camera: {
enabled: false,
videoElement: '#preview', // selector, element, or null for no preview
width: 640,
height: 480,
detectMuted: true, // lens covered / revoked
detectEnded: true, // device unplugged
throttleMs: 2000,
snapshotIntervalMs: 0, // periodic webcam stills; 0 = off
},
face: {
enabled: false,
videoElement: null, // defaults to the camera detector's element
provider: 'cdn', // cdn | module | custom
intervalMs: 500, // inference cadence; 500ms is a good default
minConfidence: 0.5,
requireFace: true,
awayGraceMs: 2000, // grace before "no face" is a violation
maxFaces: 1,
lookAwayTolerance: 0.25, // how far off-centre before it counts
throttleMs: 3000,
},
audio: {
enabled: false,
rmsThreshold: 0.08, // 0..1 loudness that counts as too loud
loudGraceMs: 1500, // must stay loud this long
voiceThreshold: 0.5,
detectMultipleVoices: false, // experimental
throttleMs: 3000,
},
thirdParty: {
enabled: false,
detectVirtualDevices: true, // scan enumerateDevices() for virtual devices
detectActiveCamera: true, // needs camera.enabled
detectScreenShare: true, // observe getDisplayMedia
devices: null, // extra lowercase substrings to flag
ignore: null, // substrings that suppress a match
scanIntervalMs: 15000, // rescan cadence; devicechange also triggers
checkIntervalMs: 3000, // active-camera poll
throttleMs: 2000,
},
pageCapture: {
enabled: false, // gates startPageCapture(); nothing starts itself
intervalMs: 30000, // snapshot cadence
maxWidth: 640,
quality: 0.6,
displaySurface: 'browser', // browser | window | monitor (a hint only)
},
backend: {
enabled: false,
endpoint: null, // POST target for violations
snapshotEndpoint: null, // separate target for snapshots; falls back
batchIntervalMs: 0, // 0 = send immediately
offlineQueue: true, // retry when the network returns
includeReport: false,
minSeverity: null, // only upload this severity and above
retries: 2,
timeoutMs: 10000,
},
report: {
persist: true, // survive a page reload
storageKey: 'proctoring.js:report',
maxViolations: 1000,
captureScreenshots: false, // attach a webcam frame to visual violations
screenshotMaxWidth: 320, // downscale target; 0 = native size
screenshotQuality: 0.6, // JPEG quality
screenshotTypes: null, // null = camera + face only
screenshotBudgetBytes: 8 * 1024 * 1024,
},
// Per-type severity overrides
severity: { 'tab-hidden': 'critical' },
})
Events
const off = proctor.on('violation', (v) => {
// v = { id, type, severity, detector, timestamp, elapsedMs, details }
if (v.severity === 'critical') alertSupervisor(v);
});
off(); // unsubscribe
| Event | Payload | When |
|---|---|---|
started |
{ startedAt, detectors } |
start() resolved |
violation |
Violation |
any detector fires |
snapshot |
Snapshot |
a periodic still was taken |
detector:ready |
{ detector } |
one detector initialised |
detector:error |
{ detector, error } |
one detector failed |
detector:enabled / detector:disabled |
{ detector } |
toggled at runtime |
stopped |
{ report } |
stop() called |
log |
{ level, message, meta } |
diagnostics |
error |
Error |
session-level failure |
Use once() for one-shot listeners and off() to remove one.
Violation types
| Type | Default severity | Meaning |
|---|---|---|
tab-hidden |
high | The page was hidden past minHiddenMs |
tab-closed |
high | The page is going away (close, reload or navigation) |
window-blur |
medium | Window lost focus while still visible |
right-click |
medium | Right-click, Menu key, or long-press inside the page |
shortcut-used |
high | A watched keyboard shortcut was pressed |
clipboard-copy |
medium | Text was copied out of the page |
clipboard-cut |
medium | Text was cut out of the page |
clipboard-paste |
medium | Text was pasted into the page |
camera-denied |
critical | Permission refused |
camera-disabled |
critical | Track ended — device gone or revoked |
camera-muted |
high | Track muted, or the image froze |
face-not-detected |
medium | No face for longer than awayGraceMs |
face-multiple |
high | More than maxFaces faces |
face-looking-away |
low | Face present but off-centre |
audio-too-loud |
low | RMS above threshold, sustained |
audio-multiple-voices |
medium | Spectral busyness above threshold — experimental, not a speaker count |
third-party-device |
medium | A known virtual/loopback capture device is installed |
virtual-camera-active |
high | The camera in use is one of those devices |
screen-share-started |
high | A screen share started, or the "camera" is a shared screen |
Override any of them with severity: { 'audio-too-loud': 'medium' }.
API
| Method | Description |
|---|---|
start(overrides?) |
Initialise every enabled detector. Resolves even if one fails. |
stop() |
Tear everything down; returns the final report. |
getReport() |
Current snapshot — safe to call any time. |
getViolations(minSeverity?) |
Violation list, optionally filtered. |
hasViolation(type) |
Convenience boolean. |
getDetectorState(name) |
Live status, e.g. current face count. |
enableDetector(name) / disableDetector(name) |
Toggle at runtime. |
clearViolations() |
Reset the trail, keep detectors running. |
reportViolation(type, details, meta) |
Log your own custom violations. |
destroy() |
Stop and release every listener. |
The report
const report = proctor.stop();
{
sessionId: 'attempt-1234',
startedAt: '2026-03-18T02:00:00.000Z',
endedAt: '2026-03-18T03:00:00.000Z',
durationMs: 3600000,
total: 4,
score: 76, // 100 = clean, decreases with severity
worstSeverity: 'high',
countsByType: { 'tab-hidden': 2, 'face-not-detected': 2 },
violations: [ /* full trail, newest last */ ],
detectorStates: { /* last known state per detector */ },
screenshotBytes: 0, // in-memory screenshot budget used
droppedScreenshots: 0, // frames discarded once the budget ran out
}
Formatting helpers:
import { formatReport, reportToCsv, downloadReport, mergeReports } from 'proctoring.js';
console.log(formatReport(report)); // human-readable summary
const csv = reportToCsv(report); // spreadsheet-ready
downloadReport(report, 'exam.json'); // trigger a browser download
const forClass = mergeReports([r1, r2]); // combine sessions
Screenshots
Off by default. When enabled, a downscaled webcam frame is attached to visual violations (camera and face), which turns "the camera was muted at 12:03" into evidence you can actually look at.
new Proctor({
camera: { enabled: true, videoElement: '#preview' },
report: {
captureScreenshots: true,
screenshotMaxWidth: 320, // ~3 KB per frame instead of ~40 KB
screenshotQuality: 0.6,
screenshotTypes: null, // null = camera + face only
},
});
proctor.on('violation', (v) => {
if (v.screenshot) img.src = v.screenshot; // data:image/jpeg;base64,...
});
Three behaviours worth knowing:
- Only visual violations get a frame. Attaching a webcam still to
audio-too-loudwould be misleading evidence, sotab-hiddenand audio violations never carry one. Narrow or widen this withscreenshotTypes. - Screenshots are memory-only. They are stripped before the report is
persisted to
sessionStorage, because a few hundred 3 KB frames would blow the ~5 MB quota and cost you the entire report. The persisted copy survives a reload; the images do not. - There is a budget. Once
screenshotBudgetBytesis exhausted, new frames are dropped andreport.droppedScreenshotscounts them. The violation itself is always kept — the text matters more than the picture.
If the frame cannot be read (no stream yet, a tainted canvas, a detached element), capture silently yields nothing rather than failing the violation.
Right-click detection
new Proctor({ rightClick: { enabled: true } });
A right-click inside the page produces one right-click violation, recording
where it happened:
{
type: 'right-click',
severity: 'medium',
detector: 'rightClick',
details: {
source: 'pointerdown', // or 'contextmenu'
blocked: false, // whether the menu was suppressed
x: 412, y: 268,
target: 'button#submit.primary',
at: '2026-03-18T02:14:09.000Z',
},
}
Two signals feed it, and they are deliberately redundant:
contextmenu, listened for ondocumentin the capture phase. It covers a mouse right-click, the keyboard Menu key,Shift+F10, and a long-press on Android. The capture phase matters: a page that callsstopPropagation()in a bubble-phase listener would otherwise silence a detector listening ondocument.- A secondary-button
pointerdown, which fires first and still fires when a page swallows the menu.
Both signals arrive for one click, so the detector collapses them: a contextmenu
within dedupeMs of a reported pointerdown is treated as the same gesture.
dedupeMs is separate from throttleMs on purpose — setting throttleMs: 0 to
opt out of rate limiting must not also start double-counting every click.
Blocking the menu
Off by default. A library should not silently change how your page behaves, and a right-click is normal on most pages — only you know whether it is suspicious in your context.
new Proctor({ rightClick: { enabled: true, block: true } });
With block: true the browser menu is suppressed on every right-click, even
ones whose report is dropped by the throttle — otherwise a second click inside
the throttle window would quietly open the menu.
Scope, honestly
This observes the page, not the machine. A right-click on the desktop, in
another app, or on browser chrome produces nothing here. Catching that is what
tabs and camera are for. It is also trivially defeated by devtools or a
second device — treat it as a signal for human review, not as prevention.
Keyboard shortcuts
new Proctor({ shortcuts: { enabled: true } });
Out of the box it watches the developer-tools and view-source combinations, which is the usual first move when someone wants to inspect or edit an exam page:
| Platform | Watched by default |
|---|---|
| Windows / Linux | Ctrl+Shift+I · Ctrl+Shift+J · Ctrl+Shift+K · Ctrl+Shift+C · Ctrl+U · F12 |
| macOS | Cmd+Opt+I · Cmd+Opt+J · Cmd+Opt+K · Cmd+Shift+C · Cmd+U · F12 |
The platform list is chosen at start-up and exposed as DEVTOOLS_COMBOS, so you
can build on it rather than retyping it:
import { DEVTOOLS_COMBOS, detectPlatform } from 'proctoring.js';
new Proctor({
shortcuts: {
enabled: true,
combos: [...DEVTOOLS_COMBOS[detectPlatform()], 'ctrl+p', 'mod+shift+v'],
},
});
{
type: 'shortcut-used',
severity: 'high',
detector: 'shortcuts',
details: {
combo: 'ctrl+shift+i', // the matched rule, canonicalised
label: 'devtools', // or null for a combo the library does not know
blocked: false,
key: 'I', // raw evidence, as the browser reported it
code: 'KeyI',
at: '2026-03-18T02:14:09.000Z',
},
}
Combo syntax
Modifiers ctrl, shift, alt, meta, plus mod — which resolves to meta
on macOS and ctrl elsewhere, the convention every editor uses — followed by one
key. Order does not matter; the matched combo is reported canonically:
parseCombo('Shift+Ctrl+I'); // { ctrl: true, shift: true, key: 'i', raw: 'ctrl+shift+i' }
parseCombo('mod+shift+i', 'mac'); // { meta: true, ctrl: false, ... }
parseCombo('ctrl+shift'); // null — modifiers with no key
A typo is logged as a warning and skipped rather than thrown, so one bad entry does not disable the rest — but it is never silent, because a rule that can never match is the worst kind of bug to debug.
Matching details that are easy to get wrong
Shiftuppercaseskey.Ctrl+Shift+Iarrives askey: 'I', so a naiveevent.key === 'i'comparison never fires. Bothevent.codeandevent.keyare accepted, which also means a Cyrillic or Greek layout still matches — the physical key is the same even when the character is not.- Modifier sets must match exactly. A subset check would accept
Ctrl+Shift+Alt+Ifor actrl+shift+irule. Exact matching also keeps AltGr out: on Windows it setsctrlKeyandaltKey, so a portablectrl+alt+ientry would fire every time a German or Polish candidate types an "i". No shipped default usesctrl+altfor that reason, and there is a test asserting it stays that way. - Auto-repeat and IME are ignored. Holding the key fires a stream of repeats and must not spam the report; a keydown during IME composition belongs to the input method, not to a shortcut.
Blocking shortcuts
Off by default — a library should not silently change how your page behaves, and
hijacking Ctrl+Shift+I on a page you do not control is user-hostile.
new Proctor({ shortcuts: { enabled: true, block: true } });
preventDefault() is called only on a match, never on unrelated keystrokes,
and it is the only thing that stops the browser acting on the shortcut — which
works in Chromium and Firefox for the DevTools and view-source keys.
Scope, honestly
This detects a keystroke, not the tool. Someone who opens DevTools from the
browser menu, with the mouse, from a second window, or with a browser extension
produces nothing here — and once DevTools has focus, the page stops receiving
keydown at all, so a candidate can open it and then do whatever they like
without a single further event. Treat shortcut-used as one weak signal among
several, never as proof that DevTools was not used.
Copy / paste
Detects copy, cut and paste inside the page, and records where it happened. No permission and no user gesture: these are ordinary DOM events.
| Signal | Violation | Details |
|---|---|---|
copy |
clipboard-copy |
target, textLength |
cut |
clipboard-cut |
target, textLength |
paste |
clipboard-paste |
target, textLength |
new Proctor({
clipboard: {
enabled: true,
actions: ['copy', 'cut', 'paste'], // any subset
block: false, // true also preventDefault()s the operation
throttleMs: 250, // per action, so a copy cannot swallow a paste
ignoreSelectors: ['#host-toolbar'], // your own UI, e.g. a "copy question" button
},
})
The listeners sit in the capture phase on document, so a page calling
stopPropagation() cannot hide the gesture — the same reasoning as the
right-click detector. block: true calls preventDefault() on every event,
including ones whose report is throttled: suppressing only the reported ones
would let the second copy inside the throttle window go through silently.
What it does not do
The clipboard is never read. textLength is measured from the event's own
clipboardData — data the browser has already handed the page by virtue of the
event firing — and the string is discarded on the same line it is measured.
navigator.clipboard.readText() is never called: it needs a clipboard-read
permission prompt, and it would turn a behavioural signal into content
surveillance. If you need the text, read it in your own copy handler and decide
for yourself what to store.
Only this page is observable. A paste into Word, or a copy made in another tab, never reaches these listeners. Silence means "not here", not "not happening".
Audio detection
Two signals, both from the same microphone stream:
- Loudness (RMS) — reports
audio-too-loudwhen the room stays abovermsThresholdforloudGraceMs. This path is well-behaved, and is the reason to enable the detector at all. - Spectral busyness — reports
audio-multiple-voiceswhen the spectrum looks "busy" enough. Opt-in viadetectMultipleVoices, and experimental.
The detector never records. It reads the live waveform on the main thread and
discards every sample immediately — no MediaRecorder, no buffer, nothing to
leak. That keeps it usable in places where recording a candidate's audio needs
separate consent.
It also asks getUserMedia for echoCancellation: false,
noiseSuppression: false and autoGainControl: false. Those stages exist to
remove exactly the background noise and distant voices you are trying to detect.
Scope, honestly
detectMultipleVoices cannot count speakers, and enabling it logs a warning
saying so. Three measurements, each pinned by a check in npm run verify:
| What was measured | Result |
|---|---|
| One voice at 0.3x vs 1.0x volume | 0.0020 → 0.0118 — 5.9x from volume alone |
| One voice vs two voices | only 1.3x apart |
| Room with background noise vs one voice | noise scores ~10x higher |
So the score tracks how loud the room is and how high its noise floor is, not how many people are talking. A single loud speaker reads as "busier" than two quiet ones, and no threshold separates "several people talking" from "one person in a noisy room".
The shipped voiceThreshold of 0.5 is also unreachable in practice —
realistic spectra measure 0.01–0.12, so the violation will not fire unless you
calibrate that value against your own audio.
It is kept because audio-multiple-voices is a published violation type, and a
host that calibrates the threshold may still find the ratio useful. Do not treat
its output as evidence that a second person is present.
These measurements come from synthetic spectra, not real recordings, so treat the exact numbers as indicative. The direction is not in doubt — the metric is gain-dependent by construction, since its floor is an absolute value.
Third-party capture software
Reports the screen-sharing, streaming and remote-access tooling that a web page can actually observe — and is explicit about the rest.
| Signal | Violation | How it is seen |
|---|---|---|
| A known virtual/loopback device is installed | third-party-device |
enumerateDevices() |
| The camera in use is one of those devices | virtual-camera-active |
the live MediaStreamTrack |
| The "camera" is really a shared screen | screen-share-started |
displaySurface on the track |
| This page started a screen share | screen-share-started |
getDisplayMedia, wrapped |
new Proctor({
camera: { enabled: true, videoElement: '#preview' },
thirdParty: {
enabled: true,
detectVirtualDevices: true, // scan enumerateDevices()
detectActiveCamera: true, // needs camera.enabled
detectScreenShare: true, // observe getDisplayMedia
devices: ['acme capture'], // extra lowercase substrings to flag
ignore: null, // suppress known-good lab hardware
scanIntervalMs: 15000, // rescan cadence; devicechange also triggers
checkIntervalMs: 3000, // active-camera poll
throttleMs: 2000,
},
})
The shipped device list is exported as KNOWN_THIRD_PARTY_DEVICES: OBS Virtual
Camera, ManyCam, XSplit VCam, Snap Camera, DroidCam, Iriun, EpocCam, NDI,
SplitCam, CamTwist, Logi Capture, VB-Audio cables, VoiceMeeter, BlackHole,
Soundflower, and the screen-capture "cameras". Matching is a case-insensitive
substring test, because vendors decorate labels freely. matchThirdPartyDevice(label, { extra, ignore })
is exported too, so you can run the same rules over your own device inventory.
ignore is checked first and beats every match. The list is heuristic, so a site
with known-good virtual hardware needs that escape hatch.
Scope, honestly
Remote-desktop software cannot be detected from a web page. No browser API
exposes other processes, and nothing about a TeamViewer, AnyDesk or RDP session is
visible to page script. If that is what worries you, this detector will not tell
you it is running — and neither will any other page-level library. Anything that
claims otherwise is guessing. That is why there is no detectRemoteSession option
here: there is no signal to build one from, and shipping a heuristic with no
signal would only produce noise that a supervisor learns to ignore.
What is observable, and what this detector reports:
- Synthetic devices. Third-party capture stacks install virtual cameras and
loopback audio cables, and
enumerateDevices()lists them by name. This is the main signal, and it is an observation rather than an inference. Note that it is a capability, not an act:third-party-devicemeans the software is installed, which is why it ismediumwhile actually using one ishigh. - The live camera track. Its label identifies the device, so a candidate
feeding a pre-recorded video through OBS is caught even when the virtual camera
was plugged in after the initial scan. A track carrying
displaySurfaceis a screen capture wearing a camera's label — the most conclusive signal available from inside a page. - Screen shares this page starts.
getDisplayMediais wrapped while the detector runs and restored ondestroy(). A share started in Zoom, Discord, or from the OS never passes through the page and is invisible here.
Two caveats worth designing around:
- The device scan is blind without permission. Browsers blank
labeluntil the user has granted access to that kind at least once. With no permission the scan runs, finds nothing, and logs adebugline saying so — silence here means "could not see", not "clean". Run it next tocamera, or callproctor.getDetector('thirdParty').scan()right after permission is granted. - No stream is opened and nothing is uploaded. This detector never calls
getUserMediaitself;detectActiveCamerareads the camera detector's existing track, so enabling both costs no extra permission prompt.
Periodic snapshots
Stills taken on a timer, emitted as snapshot. They are samples, not
violations: they never enter the report, the score, or the screenshot budget.
| Source | How to enable | What it captures |
|---|---|---|
webcam |
camera.snapshotIntervalMs > 0 |
The candidate's camera, read from the stream the camera detector already holds |
page |
pageCapture.enabled + proctor.startPageCapture() |
A screen the candidate chose to share |
const proctor = new Proctor({
camera: { enabled: true, videoElement: '#preview', snapshotIntervalMs: 30000 },
pageCapture: { enabled: true, intervalMs: 30000, maxWidth: 640 },
backend: {
enabled: true,
endpoint: 'https://api.example/violations',
snapshotEndpoint: 'https://api.example/snapshots', // falls back to endpoint
},
});
proctor.on('snapshot', (s) => console.log(s.source, s.bytes));
await proctor.start();
Each snapshot is { source, at, dataUrl, bytes, width, height }. It is emitted as
an event and, when a backend is configured, POSTed as
{ sessionId, snapshots: [ … ], sentAt } to backend.snapshotEndpoint — the same
sessionId the violation payloads carry, so a receiver can attribute a frame to
a session without guessing. Snapshots are not queued and not retried — a
backlog of stale frames arriving late is worse than a gap, and there is always
another one coming. A failed upload is swallowed.
Page capture needs a click, and cannot be faked
// From a click handler — never from start().
button.addEventListener('click', () => proctor.startPageCapture());
A page cannot rasterise its own DOM: there is no native DOM-to-image API, and
html2canvas would be a runtime dependency, which this library does not take. The
only zero-dependency route is getDisplayMedia(), and the browser gates that on
transient activation — so startPageCapture() has to be called from a real
user gesture and can never run unattended. pageCapture.enabled merely permits
the call; nothing switches it on by itself.
The candidate picks the surface, so displaySurface: 'browser' is a hint about
what to ask for, not a guarantee of what they choose. Capture also ends by itself
when they press "Stop sharing" in the browser's own UI.
It must also come after start(). A capture started on a session that is not
running would open a share, tick on schedule and silently produce nothing, so
startPageCapture() rejects with an error instead. stop() and destroy() both
release the surface, whatever state the session is in.
Watch out: with
thirdParty.detectScreenShareon, your own page capture is reported asscreen-share-started. That is correct — the page really is sharing its screen — but it means the two features flag each other. Filter out thesource: 'getDisplayMedia'event, or leavedetectScreenShareoff.
stopPageCapture() releases the surface and is safe to call when idle;
isPageCapturing() reports the current state.
Sending violations to a backend
new Proctor({
backend: {
enabled: true,
endpoint: 'https://api.example.com/proctoring',
includeReport: false,
minSeverity: 'medium', // don't report every glance away
},
});
Each request is POST with:
{
"sessionId": "attempt-42",
"violations": [ { "id": "vio_...", "type": "tab-hidden", ... } ],
"sentAt": "..."
}
sessionId is the same value you passed to the constructor or start(). The key
is always present, and is null when you did not set one — an absent key
could not be told apart from an older sender, so a receiver can rely on it being
there. It is read at send time, not at construction, so setting it through
start({ sessionId }) is reflected in every upload. Snapshot uploads carry it
too.
Reliability behaviour:
- Retries use exponential backoff, but only for network errors and
5xx. A4xxis treated as a bug in your endpoint and is not retried. navigator.sendBeaconis used on page unload, because a normalfetchis cancelled when the document goes away — which is exactly whentab-hiddenfires.offlineQueuekeeps unsent violations in memory and flushes on the next successful request.
Reporting while the page is dying
tabs.reportOnClose raises tab-closed from the pagehide handler — the last
reliable moment before a document is torn down. Delivery there is a special case,
and an easy one to get wrong:
- The transport's own
pagehidelistener is registered when the session is constructed, so it runs before the detector's and flushes an empty queue. Atab-closedqueued after that would die with the document. - So a terminal violation skips the queue entirely and goes straight out over
sendBeacon.proctor.reportViolation(type, details, { terminal: true })does the same for your own end-of-session reports.
Two honest caveats. pagehide fires for a close, a reload and a navigation
alike, and no API separates them, so tab-closed means "the page went away".
And delivery is best-effort: beforeunload may not run at all, and a beacon can
still be dropped. Treat a missing tab-closed as unknown, never as "clean" —
which is why it is off by default.
Face detection setup
By default (provider: 'cdn') the library loads @vladmandic/face-api 1.7.15
and its matching model weights from jsDelivr at runtime. Library and weights come
from the same published tarball, which avoids the version-mismatch bug that
silently produces garbage detections.
If you would rather self-host:
face: {
enabled: true,
provider: 'module', // use your own installed copy
providerOptions: {
modelUrl: '/static/face-models/', // must end with a slash
},
}
npm install @vladmandic/face-api
# then copy node_modules/@vladmandic/face-api/model/ into your public folder
You can also point the CDN provider at your own mirror:
face: {
enabled: true,
provider: 'cdn',
providerOptions: {
scriptUrl: '/vendor/face-api.esm.js',
modelUrl: '/models/face-api/',
},
}
Note: the
models/folder committed in this repository is ~18 MB and is not published to npm. Onlydist/ships. See.npmignore.
CDN / script tag usage
<script src="https://cdn.jsdelivr.net/npm/proctoring.js/dist/proctoring.umd.js"></script>
<script>
const proctor = new Proctoring.Proctor({ camera: { enabled: true } });
proctor.on('violation', console.log);
proctor.start();
</script>
The UMD build exposes the namespace as window.Proctoring.
A complete, self-contained example lives in netlify-demo/index.html
— one HTML file, no build step, loading the published bundle from jsDelivr. It is
deployed at https://proctoring-js-demo.netlify.app.
npm run deploy:demo # redeploy netlify-demo/ to Netlify
Browser support
Requires a secure context (https:// or http://localhost) — browsers only
expose getUserMedia there. Tab detection works everywhere.
| Feature | Requirement |
|---|---|
tabs |
Page Visibility API (universal) |
rightClick |
contextmenu event (universal) |
shortcuts |
keydown event (universal) |
camera |
navigator.mediaDevices.getUserMedia |
face |
WebGL (via TensorFlow.js) |
audio |
Web Audio API |
Node has no DOM, so start() rejects outside a browser. The module is still
safe to import at top level, which makes SSR and unit-test imports work.
Security and privacy notes
This library helps you observe a session. It cannot make a browser exam tamper-proof — a determined candidate with devtools or a second machine can defeat any client-side proctoring. Treat its output as a signal for human review, not as proof.
- Violations are stored in
sessionStorage, which the candidate can clear. Usebackend.endpointfor a trail you control. report.captureScreenshotsis off by default; enabling it captures webcam frames and may create a legal obligation to notify candidates. The frames are never persisted to storage and never uploaded unless you also enable the backend.- Periodic snapshots (
camera.snapshotIntervalMs,pageCapture) are off by default for the same reason. Page capture additionally requires the candidate to grant screen sharing from a click, so it can never start on its own. - The clipboard is never read. Copy/paste detection records the event, the element it happened in, and the character count — never the text.
- Audio is analysed in memory and discarded — the library never records it.
- Always tell candidates what is being monitored before the session starts.
Development
npm install
npm run dev # demo page at http://localhost:5173/demo.html
npm run build # emits dist/proctoring.js, .cjs, .umd.js + index.d.ts
npm test # build + 163 checks against the built artifact
Five suites, 260 checks in total:
| Command | Checks | What it proves |
|---|---|---|
npm run verify |
163 | Build output, exports, report math, screenshot helpers, webhook payload shape, and the decision logic of every detector including clipboard, tab-close and snapshot routing |
npm run verify:browser |
72 | Real Edge: tab switch, right-click, keyboard shortcuts, camera stream, audio sampler, device scan, live-camera-track match, real copy/cut/paste, tab-closed beaconing (body read off the blob), webcam and page snapshots, browser discovery (EDGE_PATH precedence and failures), getDisplayMedia wrap/restore, teardown, screenshots |
npm run verify:umd |
6 | The <script src> path via a plain static server |
npm run verify:face |
7 | face-api CDN + weights resolve and inference runs |
npm run verify:static |
12 | The deployed demo shape: one HTML file + the published CDN bundle |
npm test runs against dist/, not src/, so a build regression (a missing
export, broken CJS interop) fails here rather than in a consumer's app.
npm run verify:all chains the first four. verify:static is deliberately left
out: it loads the demo's pinned CDN bundle, so it can only pass after that
version is published — chaining it would make verify:all fail on every
pre-release run. Run it as the last step of a release instead.
verify:static also accepts a deployed URL, so a deploy can be checked rather
than assumed:
DEMO_URL=https://proctoring-js-demo.netlify.app npm run verify:static
The browser suites drive real Edge over CDP. They need Bun and a dev server:
npm run dev -- --port 5180 --strictPort # in one shell
npm run verify:browser # in another
npm run verify:face # downloads ~2MB of models
They launch Edge with --use-fake-device-for-media-stream, so the camera and
audio checks run with no physical hardware and no permission prompt.
The CDP driver is vendored at scripts/lib/cdp.mjs — a
dependency-free copy, so the suites run on any clone without installing a
browser-automation stack. It speaks DevTools Protocol over a WebSocket, which is
the same on every Chromium browser, so the only platform-specific part is finding
the executable. It probes a built-in list — Edge in both Windows Program Files
locations, microsoft-edge / google-chrome / chromium under /usr/bin and
/snap/bin on Linux, and the macOS app bundles — and takes the first that
exists. Missing entries are skipped, so one list covers all three platforms.
Set EDGE_PATH when the browser lives somewhere else (a portable install, a
version manager, a non-standard prefix):
EDGE_PATH=/opt/google/chrome/chrome npm run verify:browser
A path that is set but wrong stops the run and names it, rather than falling back to a different browser. A silent fallback would test something other than what you configured, and every check would still pass — the one failure mode that looks exactly like success.
The release version is read from package.json by
scripts/lib/pkg.mjs, so bumping the version is a
one-line change plus the pin in netlify-demo/index.html.
Two deliberate testing choices worth knowing:
- Detector decision rules are unit-tested with synthetic input, not through
a real face or microphone. A real camera cannot be made to show exactly two
faces on demand, so
_evaluate/_sampleare driven directly with stubbed detections and analyser buffers. That tests our thresholds and grace periods deterministically; face-api's detection accuracy is upstream's concern. - The synthetic microphone is silent, so the browser suite asserts the
absence of a false positive rather than a real
audio-too-loudevent. The loud path is covered by the stubbed-analyser tests. - The third-party camera path runs against a real track, because what needs
proving there is not the matching rule (unit-tested) but that the detector
reads the camera detector's own live track. The browser suite counts
getUserMediacalls and fails on more than one, which is what backs the claim that enabling both costs no second permission prompt. A clean machine must also stay silent, so the suite asserts the absence of a falsevirtual-camera-activebefore forcing a match by declaring the local label as a pattern — and asserts it fires once, not once per tick.
Right-click detection, keyboard shortcuts and clipboard detection get the
opposite treatment: verify:browser pushes a real right-click, a real
Ctrl+Shift+I and a real Ctrl+C / Ctrl+X / Ctrl+V through Edge's input
pipeline via Input.dispatchMouseEvent / Input.dispatchKeyEvent, because a
page-scripted dispatchEvent proves only that a listener exists — it cannot
trigger a browser shortcut, so it cannot prove the detector sees what a user
produces. That is how the double-count bug in throttleMs: 0 was found: the unit
tests were happy with a config the browser then disproved.
The genuine keystroke check runs with block: true, which serves two purposes at
once — it proves preventDefault reaches the browser before the shortcut is
acted on, and it stops Edge from actually opening DevTools in the middle of the
run. The same check proves clipboard.block suppresses a real copy, observed
through defaultPrevented on a listener registered after the detector's.
Two more things only a browser can prove. tab-closed is dispatched as a genuine
PageTransitionEvent on the real window, with navigator.sendBeacon stubbed —
which is what shows the violation leaves the page instead of dying in the queue.
And periodic snapshots are checked against real pixels: a live track for the
webcam source, and a genuine getDisplayMedia surface for the page source.
The UMD check spawns its own static server rather than using Vite, because Vite's dev server pipes every
.jsthrough its ESM transform and would inject animportinto the UMD wrapper. The same applies if you test the bundle by hand — serve it as a plain static file.
Project layout
| Path | What it is |
|---|---|
src/ |
Library source. core/ holds the orchestrator; detectors/ is the registry, where a new feature is one file plus one entry. |
scripts/ |
The verification suites and the Netlify deploy helper. Not published. lib/expected-detectors.mjs is the single source for the detector-name list that all four suites assert against — keep it in sync when adding a detector. |
netlify-demo/ |
The static demo site, hosted separately at proctoring-js-demo.netlify.app. |
demo.html |
The richer demo page served by npm run dev. |
models/ |
Local face-api weights for the module and custom providers (~18 MB). Tracked for convenience, excluded from the tarball. |
legacy/ |
Frozen snapshot of the first prototype — the plain vanilla-JS app this library was rewritten from, one modules/*.js file per detector idea. Not built, not tested, not published — see legacy/README.md. |
Environment variables
None are required. Every one has a working fallback, and npm test plus all
four verify:* suites run with nothing set. Copy .env.example
to .env if you want to override any of them:
cp .env.example .env
| Variable | Used by | Fallback when unset |
|---|---|---|
NETLIFY_AUTH_TOKEN |
npm run deploy:demo |
the token netlify login saved to %APPDATA%/netlify/Config/config.json |
DEMO_URL |
npm run verify:static |
verifies the local netlify-demo/ folder instead of the live site |
EDGE_PATH |
verify:browser, verify:umd, verify:face, verify:static |
a built-in candidate list covering Windows, Linux and macOS |
PYTHON |
verify:umd, verify:static |
python on PATH — set it if that is a Windows Store shim |
NODE_AUTH_TOKEN, NPM_TOKEN |
node scripts/check-npm-auth.mjs |
_authToken in ~/.npmrc |
Two details worth knowing:
- The library itself reads no environment variables. It is a browser package;
backend.endpointand every detector option are passed to theProctorconstructor at runtime. - Loading is uniform. Bun loads
.envon its own (so theverify:*suites already see it); the two scripts run with plainnodeimportscripts/load-env.mjs, which callsprocess.loadEnvFile. The real environment always wins over.env, under both runtimes, so a one-offDEMO_URL=… npm run verify:staticstill overrides the file.
.env is gitignored and excluded from the npm tarball. .env.example is
committed — never put a real secret in it.
License
MIT