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

@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.permissions and navigator.mediaDevices APIs — read a permission state, watch it, wait for a grant, or surface the prompt, without reaching for the raw browser API.

npm GitHub Workflow Status Codecov

Installation

yarn add @untemps/user-permissions-utils

Quick 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 = stream

API 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 its change event. They never surface a dialog. checkPermission, watchPermission, getPermission, and the push / clipboard getters 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 and getUserMediaStream are 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 / clipboard getters): on 'prompt' you must pass a timeout (rejects with a TimeoutError), a signal, or both. Provide neither and the promise rejects immediately with an InvalidStateError instead of hanging forever.
  • Active (dedicated getters, getUserMediaStream): the native prompt they surface settles the wait when the user responds, so signal / timeout are optional. Still pass a timeout for 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 getUserMediaStream catch the TypeError and 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 TypeError to a NotSupportedError DOMException, preserving the "always a DOMException" guarantee. (getPushPermission does the same when push can't be queried at all.)
  • checkPermission reports the raw state, so it propagates the original query() 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 getUserMediaStreamgetPermission 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 getUserMediaStream instead of getCameraPermission / getMicrophonePermission.
  • Some resolve without a dialog (persistent-storage, midi, screen-wake-lock, storage-access) — see Passive vs active.
  • Pass a timeout for unattended flowssignal / timeout stop 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 = videoStream

Cancel 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 install

Consumers 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