npm package: @jam.dev/recording-links
Description: Capture bug reports from your users with the Jam recording links SDK
Latest version: 0.3.1

# @jam.dev/recording-links

A TypeScript SDK for loading & initializing Jam Recording Links scripts across browser tabs and windows.

[![npm version](https://badge.fury.io/js/%40jam.dev%2Frecording-links.svg)](https://www.npmjs.com/package/@jam.dev/recording-links)
[![TypeScript](https://img.shields.io/badge/%3C%2F%3E-TypeScript-%230074c1.svg)](http://www.typescriptlang.org/)
[![Bundle Size](https://img.shields.io/bundlephobia/min/%40jam.dev/recording-links?style=flat-square&label=bundle%20size)](https://bundlephobia.com/package/@jam.dev/recording-links)
[![Gzipped Size](https://img.shields.io/bundlephobia/minzip/%40jam.dev/recording-links?style=flat-square&label=gzipped)](https://bundlephobia.com/package/@jam.dev/recording-links)

## Overview

This SDK provides a lightweight interface for coordinating Jam recording sessions across multiple browser contexts. It handles dynamic script loading, cross-tab state synchronization, and recording lifecycle management.

## Benefits

- ⚡ **Dynamic script loading:** uses dynamic imports for just-in-time script loading
- 🚀 **Auto-initialization:** uses URL state and cross-tab reference counting to auto-load when necessary
- 🌐 **Cross-browser support:** supports all modern ES2020+ browsers
- 🔧 **Bundler compatibility:** pre-declares dynamic imports as external for popular bundlers
- 📡 **Event system:** subscribe to SDK events like script loading
- 📦 **TypeScript support:** full type definitions included

## Installation

```bash
npm install @jam.dev/recording-links
```

## Usage

### Basic Setup

```typescript
import * as jam from "@jam.dev/recording-links/sdk";

// Initialize the SDK
jam.initialize();

// Load the recorder when needed
const Recorder = await jam.loadRecorder();
Recorder.open("recording-id");
```

### Auto-loading from URL

The SDK automatically detects `jam-recording` and `jam-title` URL parameters and loads the recorder:

```typescript
// On a page with URL: https://example.com?jam-recording=abc123&jam-title=My%20Recording
jam.initialize(); // Automatically loads recorder and opens recording "abc123"
```

### Custom Configuration

```typescript
jam.initialize({
  teamId: "team-123,team-456", // Manually provide team ID(s); default: reads `<meta name="jam:team">` from the page
  openImmediately: false, // Don't auto-open recorder from URL params
  //           OR: "recording-123" to auto-open a recording by ID
  parseJamData: (href) => {
    // Custom logic to extract recording data from URL
    const url = new URL(href);
    return {
      recordingId: url.searchParams.get("jam-recording"),
      jamTitle: url.searchParams.get("jam-title")
    };
  },
  applyJamData: (data) => {
    // Custom logic to apply recording data to URL
    const url = new URL(window.location.href);

    url.searchParams.set("jam-recording", data.recordingId);
    url.searchParams.set("jam-title", data.jamTitle);

    return url.href;
  }
});
```

### Event Listeners

```typescript
// Listen for SDK events
jam.addEventListener("loaded", (event) => {
  console.log(`Script loaded: ${event.detail.name}`); // "capture" or "recorder"
});
```

## API Reference

### `initialize(options?)`

Initializes the SDK with optional configuration.

**Parameters:**
- `options.teamId?: string` - Team ID for recording validation
- `options.openImmediately?: boolean | string` - Whether to auto-open from URL (default: `true`), or a recording ID string to open immediately
- `options.parseJamData?: (input: string) => SerializableJamData | null` - Custom URL parser
- `options.applyJamData?: (data: SerializableJamData) => string` - Custom URL applier
- `options.recorderRefCounter?: RefCounter` - Custom reference counter (advanced)

**Throws:**
- Error if SDK is already initialized

### `loadRecorder(options?)`

Loads the Jam recorder module and returns a promise that resolves to the recorder interface.

**Parameters:**
- `Omit<InitializeOptions, "recorderRefCounter">`

**Returns:**
- `Promise<RecorderSingleton>` - The Recorder singleton

**Throws:**
- Error if SDK is not initialized

### `isInitialized()`

Returns whether the SDK has been initialized.

**Returns:**
- `boolean` - True if the SDK has been initialized, false otherwise

### `addEventListener(type, listener, options?)`

Add an event listener for SDK events.

**Parameters:**
- `type: "loaded"` - Event type
- `listener: (event: CustomEvent) => void` - Event handler
- `options?: AddEventListenerOptions` - Standard event listener options

### `removeEventListener(type, listener, options?)`

Remove an event listener for SDK events.

### `Recorder`

The loaded recorder singleton (null until `loadRecorder()` is called).

**Methods:**
- `open(recordingId, params?)` - Open recorder for specific recording
  - `recordingId: string` - The recording ID to open
  - `params.jamTitle?: string` - Optional title for the recording
  - `params.removeOnEscape?: boolean` - Whether to close recorder on Escape (default: true)

## Types

The SDK exports several TypeScript types:

### `ScriptName`
```typescript
type ScriptName = "capture" | "recorder";
```

### `SerializableJamData`
```typescript
type SerializableJamData = {
  recordingId: string | null;
  jamTitle: string | null;
};
```

### `InitializeOptions`
```typescript
type InitializeOptions = {
  teamId?: string;
  openImmediately?: boolean | string | undefined | null;
  parseJamData?(input: string): SerializableJamData | null;
  applyJamData?(data: SerializableJamData): string;
  recorderRefCounter?: RefCounter;
};
```

### `RecorderSingleton`
```typescript
type RecorderSingleton = {
  open(recordingId: string, params?: {
    jamTitle?: string | null;
    removeOnEscape?: boolean;
  }): unknown;
};
```

## Electron

Jam Recording Links can be integrated into your Electron app to enable seamless
bug reporting with screen recordings.

### 1. Initialize Jam Electron (Required)

Initialize the SDK in your main process before `app.whenReady()`:

```typescript
import { app, BrowserWindow } from "electron";
import * as jam from "@jam.dev/recording-links/electron";

jam.initialize({
  // Optional: Customize the session if using non-default or multiple sessions.
  // A Recorder can only capture logs from windows belonging to the same session.
  // Defaults to session.defaultSession if not provided.
  // defaultSession: session.defaultSession,

  // Optional: Customize the display media request handler.
  // By default, Jam installs a handler that auto-selects capture sources.
  // Set to null if you want to handle display media requests yourself.
  // defaultDisplayMediaRequestHandler: null,

  openRecorderWindow(session) {
    return new BrowserWindow({
      width: 1000,
      height: 700,
      webPreferences: {
        // Required to allow loading Jam's remote recorder scripts from *.jam.dev.
        // Alternative: Use session.webRequest.onHeadersReceived() to set CSP headers
        // allowing *.jam.dev for default-src, script-src, and worker-src.
        allowRunningInsecureContent: true,
        nodeIntegration: false,
        contextIsolation: true,
        sandbox: true,
      },
    });
  },
  async loadRecorderPage(win, data) {
    // Load your app with jam-* query parameters
    return win.loadURL(`https://my-app.com${data.search}`);
    // or: return win.loadFile("index.html", data);
  },
});
```

**What this does:**
- Installs display media request handler for screen capture on the default session
- Configures window creation and content loading for recorder
- Manages recorder window lifecycle per session

---

### 2. Handle Incoming Jam Links (Recommended)

**Register your app's protocol** (if not already done):

Add to your `package.json` electron-builder config:

```json
{
  "build": {
    "protocols": {
      "name": "my-app",
      "schemes": ["myapp"]
    }
  }
}
```

**Wire up protocol handlers** to open Jam recordings:

```typescript
import { app } from "electron";
import * as jam from "@jam.dev/recording-links/electron";

// macOS: Handle protocol URLs
app.on("open-url", (event, url) => {
  event.preventDefault();
  const [cleanUrl, recorderWindow] = jam.openUrl(url);

  if (recorderWindow) {
    recorderWindow.focus();
  }
  // cleanUrl has jam-* params removed for your main window
});

// Windows/Linux: Handle second instance
// Note: Remember to implement app.requestSingleInstanceLock() in your app
app.on("second-instance", (event, commandLine) => {
  const url = commandLine.find((arg) => arg.startsWith("myapp://"));
  if (url) {
    const [cleanUrl, recorderWindow] = jam.openUrl(url);
    if (recorderWindow) {
      recorderWindow.focus();
    }
  }
});
```

**Result:** Links like `myapp://open?jam-recording=abc123` will launch your app and open the recorder.

---

### 3. Enable In-App Recording (Optional)

Add a menu item to trigger recordings from within your app:

```typescript
import { Menu } from "electron";
import * as jam from "@jam.dev/recording-links/electron";

const template = [
  {
    label: "Help",
    submenu: [
      {
        label: "Report a Bug",
        click: () => {
          jam.openRecorder("your-recording-id-here");
        },
      },
    ],
  },
];

Menu.setApplicationMenu(Menu.buildFromTemplate(template));
```

**Test:** Click "Report a Bug" → Recorder window opens and screen capture starts.

---

### API Reference

#### `initialize(config)`

Initializes the Jam SDK for Electron. Must be called before using other Jam functions.

**Parameters:**

- `config.defaultSession?: Session` - The session to install display media handler on. Defaults to `session.defaultSession`. If using multiple sessions, install handlers on each session.

- `config.defaultDisplayMediaRequestHandler?: DisplayMediaRequestHandler | null` - The display media request handler to install. In an upcoming version, this will introduce a "screen or window picker" UI to the Recorder window. Right now, it uses the OS picker on Mac, and default-selects the first window it sees in Windows + Linux. Set to `null` to handle display media requests yourself (see advanced example below).

- `config.openRecorderWindow(session: Session): BrowserWindow` **(required)** - Function that creates and returns a BrowserWindow for the recorder. Called once per session when opening a recorder.

- `config.loadRecorderPage(win: BrowserWindow, data: IJamData): Promise<void>` **(required)** - Function that loads your app with Jam recording parameters into the recorder window.

**Advanced Example with Custom Display Media Handler:**

```typescript
import { BrowserWindow, desktopCapturer, session, webContents } from "electron";
import * as jam from "@jam.dev/recording-links/electron";

jam.initialize({
  defaultDisplayMediaRequestHandler: null, // Don't install default handler
  // ... other config
});

// Install custom handler per session
session.defaultSession.setDisplayMediaRequestHandler(
  async (request, callback) => {
    const frame = request.frame;
    const contents = frame ? webContents.fromFrame(frame) : null;
    const win = contents ? BrowserWindow.fromWebContents(contents) : null;

    if (jam.isJamRecorder(win)) {
      const sources = await desktopCapturer.getSources({
        types: ["screen", "window"],
      });
      jam.handleDisplayMediaRequest(sources, callback, win);
    } else {
      // Your app's own display media handling
      callback({});
    }
  },
  { useSystemPicker: true },
);
```

#### `openRecorder(init, session?)`

Opens a Jam recorder window. If a recorder window is already open for the session, focuses it and loads the new recording.

**Parameters:**

- `init: string | { recordingId: string; jamTitle?: string } | URLSearchParams` - Recording ID, data object, or URL parameters
- `session?: Session` - Optional session (defaults to `defaultSession` from initialize)

**Returns:** `BrowserWindow` - The recorder window (returns immediately, page loads asynchronously)

**Examples:**

```typescript
// Open by recording ID
jam.openRecorder("abc123");

// Open with title
jam.openRecorder({ recordingId: "abc123", jamTitle: "Bug Report" });

// Open from URL parameters
const params = new URLSearchParams("jam-recording=abc123&jam-title=Bug");
jam.openRecorder(params);
```

#### `openUrl(url, session?)`

Parses a URL, extracts Jam recording parameters, and opens the recorder if parameters are found. Returns the cleaned URL (with Jam params removed) and the recorder window if opened.

**Parameters:**

- `url: string | URL` - URL to parse (e.g., from protocol handler)
- `session?: Session` - Optional session (defaults to `defaultSession` from initialize)

**Returns:** `[string, BrowserWindow | null]` - Tuple of cleaned URL and recorder window (or null)

**Example:**

```typescript
app.on("open-url", (event, url) => {
  event.preventDefault();
  const [cleanUrl, recorderWindow] = jam.openUrl(url);

  if (recorderWindow) {
    recorderWindow.focus();
  } else {
    // Open your main window with cleanUrl
  }
});
```

#### `isJamRecorder(win)`

Checks if a BrowserWindow is a Jam recorder window.

**Parameters:**

- `win: BrowserWindow | null` - Window to check

**Returns:** `boolean` - True if window is a Jam recorder

**Example:**

```typescript
import { BrowserWindow } from "electron";
import * as jam from "@jam.dev/recording-links/electron";

const win = BrowserWindow.getFocusedWindow();
if (jam.isJamRecorder(win)) {
  console.log("This is a Jam recorder window");
}
```

#### `handleDisplayMediaRequest(sources, callback, requester?)`

Helper function for custom display media request handlers. Filters sources (excludes recorder window and DevTools), selects the best source, and calls the callback.

In an upcoming version, this function will implement a handshake with the Recorder window to display a "screen or window picker" UI. Right now, it auto-selects the first app window (or first screen if no windows are available).

**Parameters:**

- `sources: DesktopCapturerSource[]` - Sources from `desktopCapturer.getSources()`
- `callback: DisplayMediaRequestHandlerCallback` - Callback from display media request handler
- `requester?: BrowserWindow | null` - The requesting window (to exclude from sources)

**Example:**

```typescript
import { BrowserWindow, desktopCapturer, session, webContents } from "electron";
import * as jam from "@jam.dev/recording-links/electron";

session.defaultSession.setDisplayMediaRequestHandler(
  async (request, callback) => {
    const sources = await desktopCapturer.getSources({
      types: ["screen", "window"],
    });

    const frame = request.frame;
    const win = frame
      ? BrowserWindow.fromWebContents(webContents.fromFrame(frame))
      : null;

    jam.handleDisplayMediaRequest(sources, callback, win);
  },
);
```

#### `IJamData` Interface

Data structure representing Jam recording parameters.

```typescript
interface IJamData {
  recordingId: string; // Jam recording ID
  jamTitle: string | undefined | null; // Optional recording title
  state: string | undefined | null; // Optional JWT state
  searchParams: URLSearchParams; // jam-* query parameters
  search: string; // Query string with '?' prefix (or empty)
}
```

---

## What Jam Recording Links Enables for Your Electron App

Users can report bugs with one click:

- From your app menu → Instant screen recording starts
- From Jam Recording Links (via Slack, email) → App launches and recording begins
- Zero configuration of screen capture or window management

## What the SDK Provides

### Window Architecture

- Creates separate recorder window when `jam-*` query parameters detected
- Maintains main window alongside recorder (multi-window pattern)
- Auto-focuses or reuses existing recorder window if already open
- Prevents recorder window from closing while recording

### Screen Capture

- Filters capture sources to exclude recorder window (prevents recursive capture)
- Auto-excludes DevTools windows from source list
- Configures `desktopCapturer` with appropriate permissions
- TEMPORARY: Implements source selection strategy: prefer app windows, then screens
    - WILL BE REPLACED with user source picker in future release

### Protocol Integration

- Parses `yourapp://open?jam-recording=ID` format URLs
- Routes protocol requests to dual-window opener
- Handles cross-platform differences (macOS `open-url` vs Windows/Linux `second-instance`)
- Validates and extracts `jam-*` query parameters

### Developer Configuration

**You provide:**

- **Protocol scheme**: Your app's custom URL scheme (e.g., "notion", "slack")
- **Web URLs**: Dev server (e.g., `http://localhost:3000`) and production path
- **Recording IDs**: Which Jam recording to open for bug reports (coming soon: dynamic values via API)
- **Trigger UI**: Where to place menu items or buttons in your app

**SDK handles everything else.**

## Contributing

See [CONTRIBUTING.md](./CONTRIBUTING.md) for development setup, build commands, and release process.