@untemps/user-permissions-utils
v2.2.12
Published
Collection of utility functions to manage user permissions
Downloads
2,723
Readme
@untemps/user-permissions-utils
Tiny, typed wrappers around the browser
navigator.permissionsandnavigator.mediaDevicesAPIs — read a permission state, watch it, wait for a grant, or surface the prompt, without reaching for the raw browser API.
Installation
yarn add @untemps/user-permissions-utilsQuick start
import { checkPermission, getUserMediaStream } from '@untemps/user-permissions-utils'
// Read the current state without prompting the user
const state = await checkPermission('camera') // 'granted' | 'denied' | 'prompt'
// Surface the prompt and get the resulting stream once granted
const stream = await getUserMediaStream('camera', { video: true })
document.querySelector('video').srcObject = streamAPI at a glance
Two families of functions: those that only observe a permission state (they never surface a dialog) and those that request one (they surface the browser prompt). See Concepts for the mental model.
Inspect the current state — read only, never prompts:
| Function | What it does |
| ------------------------------------- | ------------------------------ |
| checkPermission | Read the current state once |
| watchPermission | Observe the state continuously |
Wait for a grant — never prompts; something else must trigger the real request:
| Function | Permission name |
| ----------------------------------------------------------------- | ------------------- |
| getPermission | any |
| getPushPermission | 'push' |
| getClipboardReadPermission | 'clipboard-read' |
| getClipboardWritePermission | 'clipboard-write' |
Request a permission — surfaces the prompt, then resolves once granted:
| Function | Permission name |
| --------------------------------------------------- | ---------------------- |
| getCameraPermission | 'camera' |
| getMicrophonePermission | 'microphone' |
| getGeolocationPermission | 'geolocation' |
| getNotificationsPermission | 'notifications' |
| getMidiPermission | 'midi' |
| getPersistentStoragePermission | 'persistent-storage' |
| getScreenWakeLockPermission | 'screen-wake-lock' |
| getStorageAccessPermission | 'storage-access' |
Acquire a media stream — requests camera/microphone and returns the MediaStream:
| Function | What it does |
| ------------------------------------------- | ----------------------------------------------------- |
| getUserMediaStream | Surface the prompt and resolve with the MediaStream |
Feature-detect an API — synchronous, no side effects, never prompts:
| Function | What it does |
| ------------------------------------------------------- | -------------------------------------------------- |
| isMediaDevicesSupported | Test for navigator.mediaDevices.getUserMedia |
| isPermissionsSupported | Test for navigator.permissions.query |
Concepts
Read this once and the API reference is mostly self-explanatory.
Passive vs active
- Passive functions only observe the state through
navigator.permissions.query()and itschangeevent. They never surface a dialog.checkPermission,watchPermission,getPermission, and thepush/clipboardgetters are all passive. - Active functions request a permission: they read the state and, on
'prompt', fire the matching native API (getUserMedia,geolocation.getCurrentPosition,Notification.requestPermission, …) — which is what surfaces the real browser dialog. The dedicated camera/microphone/geolocation/… getters andgetUserMediaStreamare active.
Some getters can't be active: triggering a prompt from a permission name alone would require consumer-owned infrastructure (push needs a service worker and a VAPID key) or a privacy-sensitive side effect (the only way to prompt clipboard-read is to read the user's clipboard). Those stay passive — you trigger the real request yourself, then let the getter resolve.
Conversely, a few active getters resolve without ever showing a dialog: persistent-storage, midi, screen-wake-lock and storage-access are granted heuristically or by policy rather than through an explicit prompt. They still fire their native API and resolve with 'granted' all the same.
The bounded-wait contract
query() never transitions a 'prompt' state on its own — it only reports it. So a passive wait on 'prompt' would hang forever unless something else triggers the real request.
- Passive (
getPermission,push/clipboardgetters): on'prompt'you must pass atimeout(rejects with aTimeoutError), asignal, or both. Provide neither and the promise rejects immediately with anInvalidStateErrorinstead of hanging forever. - Active (dedicated getters,
getUserMediaStream): the native prompt they surface settles the wait when the user responds, sosignal/timeoutare optional. Still pass atimeoutfor unattended flows — if the user neither accepts nor dismisses, the wait lasts as long as the prompt does.
signal / timeout bound the wait, not the prompt. Aborting or timing out rejects the returned promise, but the browser can't cancel a dialog it already surfaced: the prompt stays open and a late grant shows up only in the live state (read it via checkPermission), not in the rejected promise. The exception is getCameraPermission / getMicrophonePermission / getUserMediaStream, which forward the signal and tear down any stream that resolves after the abort, so the camera/microphone is never left active.
Errors and feature detection
The permission getters report unsupported APIs by throwing, not by returning a boolean. Every function throws a NotSupportedError DOMException when the API it relies on is unavailable: the Permissions API (every function except getUserMediaStream), MediaDevices (getUserMediaStream), or — for active getters — the native trigger API (e.g. getMidiPermission when navigator.requestMIDIAccess is missing). Every getter is guaranteed to reject with a DOMException; even a missing trigger API is normalized rather than leaking a raw TypeError.
getUserMediaStream is the exception: it requires only MediaDevices and treats the Permissions API as best-effort. The query only lets it short-circuit a previously denied permission; it isn't required to acquire a stream. On browsers that expose navigator.mediaDevices.getUserMedia but not navigator.permissions (e.g. older Safari), it skips the query and goes straight to getUserMedia.
For upfront feature detection the library ships two pure, synchronous, SSR-safe predicates — isPermissionsSupported() and isMediaDevicesSupported() — that test whether navigator.permissions.query and navigator.mediaDevices.getUserMedia exist. Use them as synchronous precondition gates (a constructor, a computed property, conditional rendering) without touching navigator yourself or triggering a prompt.
Detecting that the API exists is a different question from reading a permission state. A state ('granted' / 'denied' / 'prompt') is irreducibly asynchronous — it lives outside the renderer (browser process, persistent storage, Permissions Policy) — so checkPermission(name) stays the only way to read it. checkPermission also makes a poor presence proxy: a non-queryable name (camera/microphone on Firefox) rejects with a TypeError even though navigator.permissions is present, so a rejection can't tell "API absent" from "this name isn't queryable." It does, however, surface the raw query() error — every other function normalizes it.
For MediaDevices specifically, checkPermission can't substitute even in principle: it probes navigator.permissions, not navigator.mediaDevices, and the device permission names aren't queryable on Firefox/Safari — which is exactly why a synchronous property-presence predicate is the right (and only) shape there.
Permission name or descriptor
getPermission, checkPermission and watchPermission accept either a permission name string or a full PermissionQueryDescriptor. The descriptor form lets you read permissions that need extra query members — notably push, which Chromium only queries with userVisibleOnly: true (silent push isn't allowed):
await checkPermission({ name: 'push', userVisibleOnly: true })clipboard-read and clipboard-write are valid permission names at runtime but aren't yet part of the DOM PermissionName type, so those two wrappers assert the name internally.
Cross-browser fallthrough
Some browsers support a device but reject navigator.permissions.query() for its name with a TypeError (e.g. Firefox / Safari for camera, microphone, midi).
- Active getters and
getUserMediaStreamcatch theTypeErrorand surface the prompt through the native API anyway (getUserMedia,requestMIDIAccess, …), so they work cross-browser. - Passive getters have no native trigger to fall back on, so they normalize the
TypeErrorto aNotSupportedErrorDOMException, preserving the "always aDOMException" guarantee. (getPushPermissiondoes the same whenpushcan't be queried at all.) checkPermissionreports the raw state, so it propagates the originalquery()error for callers to inspect.
API reference
Inspect the current state
checkPermission
Resolves immediately with the current permission state ('granted', 'denied' or 'prompt'). Unlike getPermission, it never waits for user interaction and never rejects on 'denied'. It rejects when the Permissions API is unsupported, and otherwise propagates any error from navigator.permissions.query() (e.g. an unrecognized permission name). Useful to read the current state upfront — show a banner, disable a button, branch UI logic — without triggering a prompt.
Accepts a permission name or a full PermissionQueryDescriptor (see Permission name or descriptor).
import { checkPermission } from '@untemps/user-permissions-utils'
const init = async () => {
try {
const state = await checkPermission('microphone') // 'granted' | 'denied' | 'prompt'
if (state === 'granted') {
// permission already available — start straight away
} else if (state === 'prompt') {
// show a button that calls getPermission/getUserMediaStream on click
}
} catch (error) {
// Thrown when the Permissions API is unsupported or the query fails
console.error(error)
}
}
// Permissions that need extra query members use the descriptor form:
const pushState = await checkPermission({ name: 'push', userVisibleOnly: true })watchPermission
Subscribes to a permission's live state and calls onChange on every transition. Where checkPermission reads the state once and getPermission waits a single time for 'granted', this is a continuous observer wrapping navigator.permissions.query() and its change event — so you never reach for the raw browser API. Like query(), it never displays a dialog; it only reports the state as it changes (e.g. once getUserMediaStream or an active getter surfaces the real prompt and the user responds).
By default it emits the current state immediately (so a single call replaces a checkPermission read followed by a manual subscription), then on every change. Pass emitImmediately: false to receive transitions only. Accepts a permission name or a full PermissionQueryDescriptor (see Permission name or descriptor).
The subscription lives until the optional signal aborts, at which point the change listener is removed. Omit the signal for a watch that lasts the page's lifetime. If your onChange throws on the upfront emit, the returned promise rejects and the change listener is removed first, so a throwing emit never leaves a subscription behind.
import { watchPermission } from '@untemps/user-permissions-utils'
const controller = new AbortController()
await watchPermission(
'microphone',
(state) => {
// 'granted' | 'denied' | 'prompt' — keep a banner/button in sync
updateUI(state)
},
{ signal: controller.signal }
)
// Stop watching (removes the underlying change listener)
controller.abort()Wait for a grant
getPermission
Watches a permission and resolves with 'granted' once it is granted. It is a passive watcher built on navigator.permissions.query(), so it never displays a permission dialog: an already-'granted' state resolves right away, while a 'denied' state rejects with a NotAllowedError DOMException.
When the state is 'prompt', getPermission waits for the change event — which only fires once something else triggers the real request (e.g. getUserMediaStream, geolocation.getCurrentPosition) and the user responds. The wait must therefore be bounded (see The bounded-wait contract): pass a timeout, a signal, or both.
To actually surface a permission dialog, use one of the active getters or getUserMediaStream — getPermission itself only observes the state.
import { getPermission } from '@untemps/user-permissions-utils'
// Resolves immediately if already granted, otherwise waits up to 5s
await getPermission('microphone', { timeout: 5000 })import { getPermission } from '@untemps/user-permissions-utils'
const controller = new AbortController()
const init = async () => {
try {
await getPermission('microphone', { signal: controller.signal })
} catch (error) {
if (error.name === 'AbortError') return
console.error(error)
}
}
// Cancel while still waiting for the permission to be granted
controller.abort()Passive getters (push & clipboard)
These getters only watch the state (exactly like getPermission) and never surface a dialog, because the library can't trigger them from a permission name alone (see Passive vs active). The bounded-wait requirement on 'prompt' therefore applies — pass signal and/or timeout. All forward the same { signal?, timeout? } options and resolve with 'granted'.
| Function | Permission name | Why it stays passive |
| ----------------------------- | ------------------- | ----------------------------------------------------------- |
| getPushPermission | 'push' | needs a registered service worker and a VAPID key |
| getClipboardReadPermission | 'clipboard-read' | the only way to prompt is to read the user's clipboard |
| getClipboardWritePermission | 'clipboard-write' | the only way to prompt is to overwrite the user's clipboard |
Trigger the real request yourself (e.g. registration.pushManager.subscribe(...), navigator.clipboard.read()), then let the passive getter resolve. getPushPermission queries push with userVisibleOnly: true, as Chromium requires.
Request a permission
Active getters
For permissions with a fixed name, these wrappers spare you from typing (and mistyping) the permission string — and surface the prompt for you, so you never reach for the native browser API. Each reads the current state and, on 'prompt', fires the matching native API to acquire the permission, resolving once granted (or rejecting on denial / timeout / abort). All forward the same { signal?, timeout? } options and resolve with 'granted'.
| Function | Permission name | Acquired via |
| -------------------------------- | ---------------------- | ----------------------------------------------- |
| getCameraPermission | 'camera' | getUserMediaStream |
| getMicrophonePermission | 'microphone' | getUserMediaStream |
| getGeolocationPermission | 'geolocation' | navigator.geolocation.getCurrentPosition |
| getNotificationsPermission | 'notifications' | Notification.requestPermission |
| getMidiPermission | 'midi' | navigator.requestMIDIAccess({ sysex: false }) |
| getPersistentStoragePermission | 'persistent-storage' | navigator.storage.persist |
| getScreenWakeLockPermission | 'screen-wake-lock' | navigator.wakeLock.request('screen') |
| getStorageAccessPermission | 'storage-access' | document.requestStorageAccess |
import { getCameraPermission } from '@untemps/user-permissions-utils'
// Surfaces the camera prompt and resolves once granted (times out after 20s)
await getCameraPermission({ timeout: 20000 })A few things to keep in mind, all detailed in Concepts:
- Need the stream, not just the grant? Use
getUserMediaStreaminstead ofgetCameraPermission/getMicrophonePermission. - Some resolve without a dialog (
persistent-storage,midi,screen-wake-lock,storage-access) — see Passive vs active. - Pass a
timeoutfor unattended flows —signal/timeoutstop the wait, not the prompt; see The bounded-wait contract. - They work cross-browser by falling through to the native API on non-queryable names — see Cross-browser fallthrough.
Acquire a media stream
getUserMediaStream
Resolves with a MediaStream once the permission is granted and the stream is retrieved. Accepts an optional signal to cancel the entire operation and an optional timeout (in milliseconds) that rejects with a TimeoutError once it elapses — the same bounded-wait ergonomics the active getters offer, handy for unattended flows. If the signal aborts or the timeout fires while acquisition is still in flight, a stream that resolves afterwards is torn down automatically (its tracks are stopped), so the camera or microphone is never left active.
It requires only MediaDevices and treats the Permissions API as best-effort (see Errors and feature detection). The permissionName and mediaStreamConstraints must match the same media device:
| permissionName | mediaStreamConstraints |
| ---------------- | ------------------------ |
| 'microphone' | { audio: true } |
| 'camera' | { video: true } |
import { getUserMediaStream } from '@untemps/user-permissions-utils'
// Microphone
const stream = await getUserMediaStream('microphone', { audio: true })
const audioContext = new AudioContext()
const streamNode = audioContext.createMediaStreamSource(stream)
// Camera
const videoStream = await getUserMediaStream('camera', { video: true })
document.querySelector('video').srcObject = videoStreamCancel a pending acquisition (permission wait or stream retrieval) with a signal:
import { getUserMediaStream } from '@untemps/user-permissions-utils'
const controller = new AbortController()
const init = async () => {
try {
const stream = await getUserMediaStream('microphone', { audio: true }, { signal: controller.signal })
} catch (error) {
if (error.name === 'AbortError') return
console.error(error)
}
}
controller.abort()Or time-box an unattended acquisition with a timeout (combinable with a signal):
import { getUserMediaStream } from '@untemps/user-permissions-utils'
const init = async () => {
try {
// Reject with a `TimeoutError` if the stream isn't acquired within 10s
const stream = await getUserMediaStream('camera', { video: true }, { timeout: 10000 })
} catch (error) {
if (error.name === 'TimeoutError') return // no response in time — the camera is never left active
console.error(error)
}
}Feature detection
isMediaDevicesSupported
Returns whether navigator.mediaDevices.getUserMedia is available — a pure, synchronous, side-effect-free predicate. Unlike checkPermission (which reads a permission state asynchronously through navigator.permissions), this is the right shape for MediaDevices: there's no async browser API to await, just a property-presence test. It never touches the device nor surfaces a prompt, so it's safe to call eagerly to gate camera/microphone UI. SSR-safe — it reads through globalThis.navigator?.…, returning false cleanly when there's no navigator global instead of throwing.
import { isMediaDevicesSupported } from '@untemps/user-permissions-utils'
if (isMediaDevicesSupported()) {
// safe to offer camera/microphone features — call getUserMediaStream on user intent
} else {
// hide the control or show a fallback; no prompt, no navigator access required
}For reading a permission state (rather than detecting API support), use checkPermission; to detect the Permissions API itself, see isPermissionsSupported below.
isPermissionsSupported
Returns whether navigator.permissions.query is available — a pure, synchronous, side-effect-free predicate, symmetric to isMediaDevicesSupported. Use it as an upfront precondition gate ("is the Permissions API here at all?") from a constructor, a computed property, or conditional rendering, without wrapping checkPermission in a try/catch or poking navigator yourself. It tests .query — the method the library actually calls — rather than the mere presence of the permissions object. SSR-safe — it reads through globalThis.navigator?.…, returning false cleanly when there's no navigator global instead of throwing.
import { isPermissionsSupported } from '@untemps/user-permissions-utils'
if (isPermissionsSupported()) {
// safe to call checkPermission / getPermission / watchPermission
} else {
// the Permissions API is unavailable — skip or show a fallback
}Detecting that the API exists is a different question from reading a permission state: a state is asynchronous by nature, so use checkPermission for that — isPermissionsSupported only answers "is the API present?" (see Errors and feature detection).
TypeScript
This package is written in TypeScript and ships its own type declarations — no extra @types/... package is required. The option types are exported for convenience:
import {
getPermission,
getUserMediaStream,
type GetPermissionOptions,
type GetUserMediaStreamOptions,
type WatchPermissionOptions,
type PermissionQueryDescriptor,
} from '@untemps/user-permissions-utils'permissionName is typed as the DOM PermissionName (e.g. 'microphone', 'camera') and mediaStreamConstraints as the DOM MediaStreamConstraints.
Development
The development toolchain requires Node >= 22.22.1 — a hard floor imposed by the pinned tooling (notably lint-staged, used by the pre-commit hook) — and targets Node 24, pinned via .nvmrc:
nvm use
yarn installConsumers only need Node >= 20; the higher floor applies only to working on the project itself, so it lives in .nvmrc rather than in package.json's engines field (which states the runtime requirement for consumers).
To launch the interactive demo (Vite dev server) and exercise the utilities against real browser permission prompts:
yarn dev