# The Edge Apps library


> Scaffold an edge app with 1 command, build it with npm or Bun, and use the library's helpers for theming, error reporting, caching, and screenshots.

`@screenly/edge-apps` is the library behind most edge apps: a build setup, a local CORS proxy, web components such as `<auto-scaler>` and `<app-header>`, and helpers for the things every app on a screen has to handle. Its commands run as `edge-apps-scripts`.

It runs on Node.js 20.6.0 or later with npm, or on Bun. Both read the same `package.json` scripts.

## Start a project

```bash
npx @screenly/edge-apps create my-edge-app
cd my-edge-app
npm run dev
```

With Bun, run `bunx @screenly/edge-apps create my-edge-app`. The command detects which package manager started it and writes the scripts to match.

The new directory has:

- a `screenly.yml` manifest with a `message` setting and a `sentry_dsn` setting
- `index.html` with `<auto-scaler>` and `<app-header>` already on the page
- `src/main.ts`, which applies the theme, reads the `message` setting, and signals ready
- Tailwind CSS set up for styling
- scripts for `dev` (with the CORS proxy running alongside), `build`, `lint`, `format`, `type-check`, `screenshots`, and `deploy`
- a Playwright spec under `e2e/` for screenshots at every supported resolution

Dependencies are installed for you.

| Option | What it does |
| --- | --- |
| `--description <text>` | Sets the description in `package.json`, `screenly.yml`, and `README.md` |
| `--author <text>` | Sets the author in `package.json` |
| `--pm <npm\|bun>` | Picks the package manager instead of detecting it |
| `--force` | Writes into a directory that already has files in it |
| `--skip-install` | Skips installing dependencies |

`edge-apps-scripts create` with no directory fills in the `{{APP_NAME}}`-style placeholders in the current project instead, for a template repository you've already cloned.

## Scripts

The `edge-apps-scripts` commands are `dev`, `build`, `build:dev`, `lint`, `type-check`, `screenshots`, `cors-proxy`, and `create`. `dev`, `build`, and `build:dev` run on Vite 8, so a `vite.config.ts` or Vite plugins of your own need to work with Vite 8.

The `deploy` script builds and then deploys the `dist/` folder:

```bash
npm run build && screenly edge-app deploy --path=dist/
```

## The CORS proxy

During development, an API that blocks browser requests can be reached through a local proxy:

```json
{
  "scripts": {
    "cors-proxy-server": "edge-apps-scripts cors-proxy"
  }
}
```

It listens on `0.0.0.0:8080`, and `HOST` and `PORT` change that. Put the target URL after the proxy's address: `http://localhost:8080/https://api.example.com/feed`.

For `GET` and `HEAD` requests the proxy follows redirects, up to 10 deep, and then returns a `508` with `Too many redirects`. Other methods get the redirect response itself.

## Styling with Tailwind CSS

The Tailwind plugin is part of the build. To use it in an app you didn't create with `create`, put these at the top of your stylesheet:

```css
@layer theme, base, utilities;

@import 'tailwindcss/theme.css' layer(theme);
@import '@screenly/edge-apps/styles' layer(base);
@import 'tailwindcss/utilities.css' layer(utilities);
```

Keep the layer order line and the `layer(base)`, or the library's base styles win over your utility classes. Don't use `@import 'tailwindcss';` on its own: its reset strips the borders and padding that components like `<app-header>` set for themselves.

Inside `<auto-scaler>`, size things with `h-full` and `w-full` rather than `h-screen` and `w-screen`. `<auto-scaler>` draws your app at a reference size and scales it to fit the display, so viewport units measure the wrong box.

`<app-header>` shows the logo without the screen name. Use `<brand-logo show-name>` where an app needs the name on screen.

## Helpers

### Readable text on any brand color

Each workspace sets its own accent color, so the same app has to put readable text on a pale brand and a deep one. `isLightColor()` returns `true` when a color needs dark text:

```typescript
import { isLightColor, setupTheme } from '@screenly/edge-apps'

const { primary } = setupTheme()
document.body.classList.toggle('on-light-brand', isLightColor(primary))
```

It uses WCAG relative luminance. It accepts hex (`#abc`, `#aabbcc`, `#aabbccdd`) and numeric `rgb()` or `rgba()`, and ignores alpha. Anything it can't read returns `false` instead of throwing.

### Keeping the last good data

`writeEdgeAppCache()` stores the last value an app fetched successfully, and `readEdgeAppCache()` returns it when the next fetch fails:

```typescript
import { readEdgeAppCache, writeEdgeAppCache } from '@screenly/edge-apps'

async function loadWeather() {
  try {
    const weather = await fetchWeather()
    writeEdgeAppCache('weather-app', 'forecast', weather)
    return weather
  } catch (error) {
    return readEdgeAppCache('weather-app', 'forecast')
  }
}
```

Values are stored as JSON in the player's `localStorage` under `namespace:key`. Nothing expires, and the cache can be empty after the player restarts, so read it only after a real failure. `readEdgeAppCache()` returns `null` when there is nothing usable, and `writeEdgeAppCache()` does nothing when storage is full or unavailable.

### Error reporting to Sentry

`setupSentry()` and `reportError()` send an app's errors to your own Sentry project. Add a `sentry_dsn` setting to `screenly.yml`, set it to your project's DSN in Screenly, and call `setupSentry()` once when the app starts:

```yaml
settings:
  sentry_dsn:
    type: secret
    title: Sentry Client Key
    optional: true
    is_global: true
```

```typescript
import { setupSentry, reportError } from '@screenly/edge-apps'

setupSentry('menu-board')

try {
  await refreshPrices()
} catch (error) {
  reportError(error, { source: 'price-feed' })
}
```

Every event is tagged with `edge_app` (the name you pass) and the screen's `hostname`. The value of any setting whose key contains `token`, `secret`, `password`, or `credential` is replaced with `[REDACTED]` before an event leaves the screen, and an event that can't be checked isn't sent. `scrubSensitiveData()` is the same filter, for use as `beforeSend` if you initialize Sentry yourself.

With `sentry_dsn` empty, `setupSentry()` does nothing. It reports errors only, with no performance tracing.

### Screenshot tests

`captureScreenshot()` opens a page at a given size, fixes the clock, mocks `screenly.js`, waits for the network to go quiet, and saves the image:

```typescript
import { test } from '@playwright/test'
import {
  captureScreenshot,
  createMockScreenlyForScreenshots,
  RESOLUTIONS,
} from '@screenly/edge-apps/test/screenshots'

const { screenlyJsContent } = createMockScreenlyForScreenshots()

for (const { width, height } of RESOLUTIONS) {
  test(`screenshot ${width}x${height}`, async ({ browser }) => {
    await captureScreenshot(browser, {
      width,
      height,
      filenamePrefix: 'menu-board',
      screenlyJsContent,
    })
  })
}
```

Each run writes 1 file per resolution, such as `screenshots/menu-board-1920x1080.png`. Pass `setupMocks` to add routes of your own before the page loads. Keep `test()` in your own spec file, so Playwright reports failures against your file and line.

## Versions

The library is numbered by year and month from 26.8.0, the release after 1.5.0. A range like `^1.5.0` in your `package.json` never matches a release again. Use `^26.8.0`, which covers releases through the end of 2026.