@cytario/web
v2.3.1
Published
Cytario Web — scientific imaging data browser and viewer for OME-TIFF, OME-Zarr, Parquet and GeoTIFF on S3-compatible storage.
Readme
Cytario Web
A web-based file browser and viewer for scientific imaging data. Cytario Web lets you explore, visualize, and manage large-scale datasets (OME-TIFF, GeoTIFF, Parquet) stored in S3-compatible object storage.
For the hosted product, see cytario.com.

Architecture
| Layer | Technology | | ------------- | -------------------------------------------------------------------------- | | Framework | React Router v7 (SSR), React 19, Vite 6 | | Language | TypeScript (strict mode) | | Visualization | deck.gl, Viv, DuckDB-WASM, Apache Arrow | | Styling | Tailwind CSS, @cytario/design | | Auth | OAuth 2.0 via Keycloak, STS for S3 credentials | | Database | PostgreSQL (Prisma ORM), Redis/Valkey (sessions) | | Cloud | AWS SDK v3 (S3, STS) | | CI/CD | GitHub Actions, semantic-release, GHCR |
Plugin model
Cytario Web supports third-party plugins that contribute additional image
formats to the viewer. A plugin is a regular npm package whose default
export satisfies the @cytario/plugin-api contract (see
packages/plugin-api/src/):
import type { CytarioPlugin } from "@cytario/plugin-api";
const plugin: CytarioPlugin = {
name: "@vendor/my-loader",
apiVersion: "^1.0.0",
register(ctx) {
// `extension` accepts a string, a string[] of aliases, or a RegExp
// tested against the URL. See `FormatExtension` in @cytario/plugin-api.
ctx.formats.register(["myext", "myext.gz"], {
load: async (url, opts) => {
const res = await opts.signedFetch(url, { signal: opts.signal });
// …parse and return { data: Loader, metadata: Image }
},
fileTypeMeta: { label: "My Format", icon: "Microscope" },
});
},
};
export default plugin;Loading. The set of plugins is fixed at build time. The
CYTARIO_PLUGINS env var (comma-separated npm package names) drives a
Vite codegen step that writes app/plugins.generated.ts; the host
imports that module at startup, calls plugin.register(ctx) for each
entry, and hands the plugin a PluginContext containing a scoped
FormatRegistry and a Logger. A plugin can only register handlers
under its own name — the registry rejects cross-plugin extension
collisions.
Compatibility gate. Each plugin declares an apiVersion semver
range, checked against the host's bundled @cytario/plugin-api version
on bootstrap. Mismatched plugins are logged and skipped — the host keeps
running.
Security boundary. All S3 traffic flows through the host-supplied
signedFetch. Plugin-supplied headers pass through sanitizeHeaders
(allowlist: Range, If-None-Match, Accept, Cache-Control;
always denied: Authorization, Host, Cookie, x-amz-*) before
being merged behind the signed headers, so a plugin cannot override the
SigV4 signature or smuggle credentials.
Reference. Built-in OME-TIFF and OME-Zarr handlers in
app/components/.client/ImageViewer/state/formats/builtins.ts are
implemented against the same contract. A minimal stub plugin lives in
__tests__/fixtures/noop-plugin/ and exercises the registry, the
apiVersion gate, and the FILE_TYPE_REGISTRY auto-derivation.
Using @cytario/web as a package
@cytario/web can be installed as an npm dependency and assembled into
a deployable container together with one or more format-handler
plugins. This is how Cytario Enterprise Edition is built: the
AGPL-licensed open core (@cytario/web) is bundled with proprietary
plugins (for example, vendor-specific microscopy format loaders) to
produce a single deployable image. Anyone with a plugin that satisfies
the @cytario/plugin-api contract can follow
the same pattern.
License obligation.
@cytario/webis licensed under AGPL-3.0. Distributing or operating an assembly that includes it — including over a network as a service — triggers the AGPL's source-disclosure requirement: the complete corresponding source of the assembly (including any proprietary plugins linked into it) must be made available to its users under AGPL-3.0. If that is incompatible with your distribution model, a commercial license is available — contact us at cytario.com.
The consumer's job is to install the packages, set CYTARIO_PLUGINS,
and invoke the bundled CLI:
# .npmrc — only required if any of the plugins ship from a registry
# other than public npm. The example below routes a hypothetical
# closed plugin to GitHub Packages while keeping @cytario/web and
# @cytario/plugin-api on public npm.
@your-org/closed-plugin:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GH_TOKEN}npm install @cytario/web @cytario/plugin-api @your-org/my-plugin
# Production build with the bundled plugin set.
CYTARIO_PLUGINS=@your-org/my-plugin npx cytario-web build
# Dev server with HMR.
CYTARIO_PLUGINS=@your-org/my-plugin npx cytario-web dev
# Production server against the existing build/ output.
npx cytario-web startCYTARIO_PLUGINS is a comma-separated list of npm package names. Each
package's default export must satisfy the
@cytario/plugin-api contract.
CLI
| Command | Behaviour |
| ------------------- | -------------------------------------------------------------------------------------------------------- |
| cytario-web build | Codegen (Vite plugin reads CYTARIO_PLUGINS) + react-router build against the installed package root. |
| cytario-web dev | Codegen + react-router dev. Extra args (--port, --host, …) are forwarded. |
| cytario-web start | NODE_ENV=production node server.ts against the bundled build/server/index.js. |
All subcommands operate against @cytario/web's own install directory;
the consumer never needs to know the on-disk layout.
Reference Dockerfile
# syntax=docker/dockerfile:1.7
FROM node:24-slim AS build
WORKDIR /app
COPY .npmrc package.json package-lock.json ./
# BuildKit secret: GH_TOKEN is exposed as an env var only for this
# RUN. It is never written to a layer and never appears in `docker
# history` or `docker inspect`. Do NOT add `ARG GH_TOKEN` — that
# would bake the value into image metadata.
RUN --mount=type=secret,id=gh_token,env=GH_TOKEN \
npm ci
ENV CYTARIO_PLUGINS=@your-org/my-plugin
RUN npx cytario-web build
FROM node:24-slim
WORKDIR /app
COPY --from=build /app .
ENV NODE_ENV=production
EXPOSE 3000
CMD ["npx", "cytario-web", "start"]Build the image with the BuildKit secret bound from your environment (or a file):
# Docker — environment source (e.g. CI runner with a GH_TOKEN secret):
GH_TOKEN=… docker build --secret id=gh_token,env=GH_TOKEN -t my-cytario-image .
# Docker — file source:
docker build --secret id=gh_token,src=./gh_token.txt -t my-cytario-image .
# Podman — only file source is supported (no env= flavor); write the
# token to a temp file first:
printf '%s' "$GH_TOKEN" > /tmp/gh_token && \
podman build --secret id=gh_token,src=/tmp/gh_token -t my-cytario-image . && \
rm -f /tmp/gh_tokenThe # syntax=docker/dockerfile:1.7 parser directive at the top of
the Dockerfile is honored by Docker's BuildKit and silently ignored by
Podman's native parser — the rest of the file (multi-stage,
--mount=type=cache, --mount=type=secret) works on both engines.
Mixed-registry note.
@cytario/weband@cytario/plugin-apipublish to public npm. Plugins are free to publish wherever they like — public npm, GitHub Packages, or a private registry. Because a single scope-wide@cytario:registrydirective cannot route both public-npm and GitHub-Packages packages under the same scope, consumers pin individual plugin packages with a per-package<pkg>:registry=…override as shown above.
License
This project is licensed under AGPL-3.0. The source code is publicly available to provide full transparency and ensure long-term access for our users, independent of Cytario as a company.
Local Development
Prerequisites
The application requires several backend services. A local cluster is provided via Podman:
cd devenv
podman kube play local-deployment.yaml| Service | Port | Description | | ---------- | ---------- | -------------------------------- | | Keycloak | 8080 | Identity provider (admin/admin) | | MinIO | 9000, 9001 | S3-compatible object storage | | PostgreSQL | 5433 | Application database | | Valkey | 6379 | Session cache (Redis-compatible) |
To stop: podman kube down devenv/local-deployment.yaml
Getting Started
npm install
cp .env.template .env # Pre-configured for the Podman cluster
npm run devSession Cache (Redis/Valkey)
Sessions hold OAuth access/refresh/ID tokens and short-lived STS credentials. TLS is required in production. The app refuses to boot when NODE_ENV !== "development" unless one of the following is true:
| Env var | Value | Meaning |
| -------------------------------- | -------- | ---------------------------------------------------------------------------------------------------- |
| REDIS_TLS | "true" | Wrap the ioredis connection in TLS (recommended). |
| REDIS_CA_CERT | PEM | Optional CA bundle for self-signed deployments. Multi-line PEM string. |
| REDIS_TLS_SERVER_NAME | hostname | Optional SNI / certificate hostname override. |
| REDIS_INSECURE_ALLOW_PLAINTEXT | "true" | Explicit opt-out for trusted private networks. Logs a warning. Not for use on shared infrastructure. |
The local Podman cluster runs Valkey without TLS, which is allowed because NODE_ENV=development. Managed Valkey deployments (helm chart, AWS ElastiCache, etc.) should set REDIS_TLS=true. Valkey reuses the standard 6379 port for TLS when tls.enabled is set — it does not move the listener to 6380 and refuses plaintext on the same port — so leave REDIS_PORT at 6379 unless your provider explicitly publishes a separate TLS endpoint.
In the production cluster (see cytario-infrastructure, C-212) the Valkey leaf cert is signed by a cluster-internal CA managed by cert-manager. The CA's public cert is distributed to every namespace as a cytario-internal-ca ConfigMap by trust-manager, and the cytario-web helm chart's redis.caCertConfigMap.{name,key} wires it into the pod as REDIS_CA_CERT via valueFrom.configMapKeyRef. The app sees the PEM through the normal env var path — no code-side knowledge of the trust source is required.
Database
PostgreSQL with Prisma ORM. Connection configured via DATABASE_URL in .env.
npx prisma migrate dev --name <migration-name> # Create + apply migration
npx prisma migrate deploy # Apply pending migrations
npx prisma studio # Database GUI
npx prisma generate # Regenerate clientTesting
npm test # Unit & component tests (vitest, watch mode)
npm run coverage # Unit tests with coverage reportE2E tests (Playwright) live in a sibling repository and are triggered automatically on every PR via cross-repo dispatch.
Design System
To develop @cytario/design components locally and see changes reflected in cytario-web, run a single command. Assumes both repos are cloned as siblings (../cytario-design):
npm run dev:designThis links @cytario/design via npm link, starts tsup --watch in the design repo, and runs the cytario-web dev server — all in one process. Changes to design system source are rebuilt by tsup and picked up by Vite's HMR automatically.
The vite.config.ts is configured to handle the symlink: optimizeDeps.exclude skips pre-bundling, ssr.noExternal processes it through Vite's pipeline, and server.watch picks up changes in node_modules.
Note: Switching back to
npm run devautomatically unlinks@cytario/designand restores the published version (via thepredevscript). No manualnpm installneeded.
Debugging
The app uses Zustand for state management with the devtools middleware. Install the Redux DevTools browser extension to inspect store state and actions.
Commits
Conventional Commits enforced via commitlint (feat, fix, docs, refactor, test, build, ci, chore, etc.).
Deployment
npm run build # Outputs build/server and build/client
npm start # Production serverFor containerized deployments, see the Dockerfile. Database migrations run automatically on startup via docker-entrypoint.sh.
Acknowledgements
Built on Viv, a library for multiscale visualization of high-resolution, highly multiplexed bioimaging data on the web, developed by the HIDIVE Lab at Harvard Medical School.
