Build Orshot

KINDAreplaces $39/mosaves $468/yrback to the verdict

0%0 of 17 items done

Saved on this device only. Tick prerequisites first, then work the phases in order · do not start one until the checks above it pass.

A template-driven render service whose hard problems are producing the same pixels on every machine, keeping a browser pool warm and bounded under burst, and never letting a caller's data turn into code the browser runs.

estimated effort weekendthe files for this build are in the project pack

RuntimeOne Node process with an HTTP API and a pool of reusable headless Chromium pagesTemplatesServer-side folders of HTML, CSS and a declared data schema, versioned in the repositoryFontsBundled beside the templates, loaded from disk, hashed into the cache keyOutputScreenshot for PNG and JPEG, the print path for PDF, dimensions declared per templateCacheContent hash of template version, payload and font set, on disk with a size ceilingIsolationAllowlisted remote assets, no file scheme, private ranges refused after DNS resolution

Before step 1

Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.

  1. installfree

    Why Runs the service and the browser driver.

    Get it nodejs.org, or nvm install 22. open ↗

    Verify node --version prints v22 or higher

  2. installfree

    Why The renderer. Installing the browser without its system libraries is the most common cause of a service that starts cleanly and renders nothing.

    Get it npm i -D playwright then npx playwright install --with-deps chromium. open ↗

    Verify A scripted screenshot of a local page produces a non-empty PNG inside the container

  3. have readyfree with open licences

    Why Bundling a font you have no licence to redistribute is a legal problem that arrives long after the code works.

    Get it Open-licensed families, or the files your brand licence permits you to embed in a server.

    Verify A written note records each family, its licence and where the file came from

  4. have readypennies per month

    Why The cache and the batch output both live on disk and both grow without a ceiling unless you set one.

    Get it A volume with a known size, separate from the system disk.

    Verify The cache ceiling is set below the volume size and eviction has been observed

  5. decidefree

    Why Each of these is asserted by a test, and choosing them after seeing failures means choosing whatever passes.

    Get it Write down which asset hosts are permitted, how long a render may take, and how much disk the cache may use.

    Verify All three appear in the configuration and are named in tests

Data model

Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.

- templates(name, version, width, height, default_format, schema_json, font_set, updated_at) · version is part of every cache key
- renders(id, template, template_version, payload_hash, format, scale, bytes, duration_ms, cache_hit, api_key_id, created_at)
- assets(pattern, kind, sha256, allowed) · every remote image a template may load, checked before a page is opened
- fonts(family, weight, style, file, sha256) · hashed into the cache key so replacing a font invalidates only what used it
- api_keys(id, label, hashed_key, rate_per_minute, monthly_render_cap, created_at, revoked_at)
- failures(id, template, payload_hash, stage, message, console_excerpt, at) · the page's console output, because most failures are a missing asset nobody saw
- The cache key is the hash of template version, payload and font set together, so an identical request never opens a browser and editing one template invalidates nothing else.

Environment variables

These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.

VariableNeededExampleWhere the value comes from
PORTrequired8080The HTTP API. Bind to localhost unless a proxy terminates TLS in front.
TEMPLATES_DIRrequired./templatesServer-side templates. The only markup the browser ever renders.
FONTS_DIRrequired./fontsBundled faces. Hashed into the cache key.
CACHE_DIRrequired./.cacheRendered output by content hash, evicted least recently used.
CACHE_MAX_BYTESrequired5368709120The disk ceiling. Eviction is tested against it, not hoped for.
RENDER_TIMEOUT_MSrequired15000After this the page is killed, not navigated away from.
POOL_SIZErequired4Warm contexts. Beyond this requests queue and then are refused.
ASSET_ALLOWLISTrequiredcdn.example.com,images.example.orgHostnames a template may load from. Resolved addresses are checked separately.

The build, in order

  1. One template that renders the same everywhere

    The same payload produces the same pixels on your laptop and in a container.

    1. The template states what it needs so the service can reject a payload before opening a browser.

      Files templates/og-card/meta.jsonsrc/templates.ts

    2. Load fires long before the page looks like the design.

      Files src/render.ts

    3. A rendering regression is invisible in a diff and obvious in a picture.

      Files tests/golden.test.tstests/golden/og-card.png

    done when · tick each as it passes
    watch out
    • Screenshotting as soon as the page loads captures text in the fallback font and images half decoded, and the result looks correct often enough that the bug ships; the fallback then differs between your machine and the container, so the golden test passes locally and the live output is wrong. Wait on fonts ready and image decoding, bundle every font, and assert the computed font family in the test rather than trusting it.
  2. Data in, never markup

    A hostile payload cannot make the browser fetch or run anything you did not choose.

    1. Unknown keys are rejected, not ignored, so a typo is an error rather than a blank.

      Files src/validate.tstests/schema.test.ts

    2. Raw insertion should not be reachable from a request at all.

      Files src/render.ts

    3. This is the difference between an allowlist and the appearance of one.

      Files src/assets.tstests/ssrf.test.ts

    done when · tick each as it passes
    watch out
    • Checking a remote image address against an allowlist and then handing the same address to the browser lets a name that passed the check resolve to 169.254.169.254 on the browser's own lookup, and your cloud credentials arrive inside a PNG. Resolve the name yourself, check the resulting address, and pass the address rather than the name, or route every fetch through a proxy that re-checks.
  3. PDFs that look like the design

    The invoice on screen and the invoice in the file are the same document.

    1. Print and screen are different documents and the template should say which it is.

      Files templates/invoice/meta.jsonsrc/pdf.ts

    2. The default is tuned for ink, and your design is not.

      Files templates/invoice/style.css

    3. Comparing bytes tells you nothing; comparing pixels tells you everything.

      Files tests/golden-pdf.test.ts

    done when · tick each as it passes
    watch out
    • Designing a template in a browser tab, then generating the PDF with the defaults, gives a document with backgrounds dropped and colours shifted, because the print path applies print media and adjusts colours for ink. The template looked right in every preview, so the bug is found by a customer holding a beige invoice. Choose the media explicitly, force the colour adjustment, and make the golden test render the PDF path rather than only the screenshot path.
  4. A browser pool that survives Monday

    Burst traffic queues instead of exhausting memory, and a hung page never holds a worker.

    1. Reuse the expensive part, discard the part that holds state.

      Files src/pool.ts

    2. Accepting work you cannot finish turns a slow minute into an outage.

      Files src/queue.tstests/load.test.ts

    3. Measure memory over a thousand renders and set the recycle point from the measurement.

      Files src/pool.tstests/timeout.test.ts

    done when · tick each as it passes
    watch out
    • A template that waits on an asset which never arrives leaves the page open forever, and after a handful of those the pool has no workers left while the process looks healthy and memory looks fine. Time out the render, kill the page rather than navigating away, and count consecutive timeouts as a reason to recycle the browser.
what this build does not replace
after v1, if you want it

Need the files? The project pack on the verdict page hands your agent the whole brief · more automation.