@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
npx @screenly/edge-apps create my-edge-app
cd my-edge-app
npm run devWith 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.ymlmanifest with amessagesetting and asentry_dsnsetting index.htmlwith<auto-scaler>and<app-header>already on the pagesrc/main.ts, which applies the theme, reads themessagesetting, and signals ready- Tailwind CSS set up for styling
- scripts for
dev(with the CORS proxy running alongside),build,lint,format,type-check,screenshots, anddeploy - 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:
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:
{
"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:
@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:
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:
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:
settings:
sentry_dsn:
type: secret
title: Sentry Client Key
optional: true
is_global: trueimport { 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:
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.