# Server-Teil: Resolver, Fetcher und Jobs

Der Server-Teil einer App ist ein Modul aus Klassen, die die Plattform beim Start in einen eigenen Container lädt. Drei Decorator machen daraus Verhalten — `@WidgetResolver`, `@Fetcher` und `@JobHandler` —, und alles, was der Server-Teil von der Plattform braucht, bezieht er über Token. Diese Seite nennt die Verträge, Regeln und Grenzwerte.

> **Typ:** nachschlagen
> **Für wen:** Entwickler, die ein Widget mit Daten versorgen, fremde Quellen abrufen oder einen wiederkehrenden Job schreiben.
> **Voraussetzung:** das SDK `@szenera/apps-sdk` aus dem Quellbaum der Plattform (nicht öffentlich veröffentlicht); ein Widget mit `noResolver: false`.

## Was der Server-Teil ist

Nur Daten-Apps exportieren einen Server-Teil; Apps ohne Verhalten lassen ihn weg, statt ein leeres Modul zu pflegen. Die Plattform importiert das Modul nicht, sondern hebt seine Klassen in einen Container je App, in dem die Token gebunden sind. Eigene Routen des Server-Teils liegen unter `/v1/apps/<id>/…`. Zwei Apps können sich nie gegenseitig Klassen injizieren; ein Server-Teil sieht nur die Fläche des SDK.

Alle drei Decorator hinterlegen nur eine Markierung an der Klasse. Die Plattform liest sie beim Start, registriert Resolver, legt Worker für Fetcher und Jobs an und verdrahtet sie mit dem Snapshot-Bau. Je Klasse gilt genau eine Markierung je Sorte. Die Klasse trägt zusätzlich die Injektions-Markierungen des Plattform-Containers (`@Injectable` an der Klasse, `@Inject(Token)` an jedem Konstruktor-Parameter) — ohne sie kann der Container die Klasse nicht erzeugen.

## @WidgetResolver

Je Widget-Typ registriert eine App genau einen Resolver. Beim Snapshot-Bau ruft die Plattform `resolve(ctx)` mit der bereits validierten Konfiguration und erhält eine render-fertige Nutzlast — oder `null`.

Der Resolver der Wetter-App, gekürzt um Fehlerbehandlung und Zeitfenster-Rechnung. Er ruft die Quelle nie selbst: Er bestimmt den Ort (fest gewählt oder der Standort des Displays aus `ctx.location`), liest über den Fetcher-Cache (`APP_FETCHER_CACHE`) und reicht den Fetcher nur als Lader für den Fehltreffer hinein.

```ts
// gekürzt — ohne Fehlerprotokoll und Zeitfenster-Rechnung
@WidgetResolver({ widgetType: 'weather' })
@Injectable()
export class WeatherResolver implements IWidgetResolver<ResolvedWeatherData> {
  readonly widgetType = 'weather';

  constructor(
    @Inject(APP_FETCHER_CACHE) private readonly cache: AppFetcherCache,
    @Inject(APP_TENANT_DATA) private readonly tenant: AppTenantData,
    @Inject(APP_CLOCK) private readonly clock: AppClock,
    @Inject(APP_LOGGER) private readonly log: AppLogger,
    private readonly fetcher: WeatherFetcherService,
  ) {}

  async resolve(ctx: WidgetResolverContext): Promise<ResolvedWidgetPayload<ResolvedWeatherData> | null> {
    const parsed = WeatherConfigSchema.safeParse(ctx.config);
    if (!parsed.success) {
      // Autoren-Zustand — kein Betriebsfehler: das Widget zeigt seinen Hinweis.
      this.log.debug({ widgetInstanceId: ctx.widgetInstanceId }, 'weather.resolve.invalid-config');
      return null;
    }
    const config = parsed.data;
    // Fester Ort, Standort des Displays oder Ersatzort; ohne Ort still `null`.
    const place = resolveWeatherPlace(config.location, ctx.location);
    if (!place) return null;

    const tenant = await this.tenant.profile(ctx.tenantId);
    const locale = tenant?.defaultLocale ?? 'de-DE';
    const citySlug = computeCitySlug(place.latitude, place.longitude, place.countryCode);
    const ttlMs = WEATHER_VALIDITY_MS;
    const staleWindowMs = 3 * ttlMs;

    const { data: snapshot, cached, cachedAt } = await this.cache.getOrFetch<WeatherSnapshot>(
      WeatherFetcherService.fetchKey(citySlug),
      async () => {
        const result = await this.fetcher.fetchWeather({
          citySlug,
          latitude: place.latitude,
          longitude: place.longitude,
          locale,
        });
        return result.payload;
      },
      { ttl: ttlMs, staleWindow: staleWindowMs },
    );

    const anchor = cached && cachedAt ? cachedAt : this.clock.now();
    return {
      payload: mapToResolved(snapshot, place, config.units),
      source: cached ? `cache:api:${snapshot.provider}` : `api:${snapshot.provider}`,
      resolvedAt: this.clock.now(),
      ...zeitzusagen(anchor, ttlMs, staleWindowMs, this.clock.now()),
    };
  }
}
```

Zwei Details aus der ungekürzten Fassung: Ein Fehler des Anbieters oder der Infrastruktur wird als `weather.resolve.failed` protokolliert und weitergeworfen, damit der Snapshot-Bau ihn als Resolver-Fehler ausweist statt als „kein Ergebnis“. Und bei einem Cache-Treffer verankern die Zeitfenster am ursprünglichen Abrufzeitpunkt (`cachedAt`), nicht an „jetzt“ — `zeitzusagen` aus `@szenera/apps-sdk/backend` rechnet `validUntil` und `cacheUntil` daraus.

**Der Kontext (`WidgetResolverContext`):**

| Feld | Typ | Bedeutung |
|---|---|---|
| `tenantId` | string | Die Organisation, für die aufgelöst wird. |
| `playerId` | string oder `null` | Der Player; `null` bei der Vorschau auf Anfrage (etwa im Editor), solange sie für kein bestimmtes Display auflöst. |
| `widgetInstanceId` | string | Die Instanz in der Szene. |
| `widgetType` | string | Der Widget-Typ. |
| `appVersionPin` | string | Die in der Szenen-Version fixierte App-Version, z. B. `1.2.0`. |
| `config` | unknown | Bereits gegen das Konfigurationsschema validiert. |
| `traceId` | string | Kennung für die Nachverfolgung. |
| `mapTiles` | `{ light, dark }` oder fehlt | Adress-Vorlagen der Grundkarte in Leaflet-Schreibweise (`{z}/{x}/{y}{r}`). Die Kacheln kommen über die Schnittstelle der Plattform, die den Schlüssel des Kartenanbieters anhängt. Ein Resolver, dessen Widget eine Karte zeichnet, reicht die passende Vorlage in sein Payload — nur so findet die Anzeige dieselbe Adresse im Editor, im Browser und am Gerät. Fehlt das Feld, bleibt es auch im Payload weg. |
| `location` | Standort, `null` oder fehlt | Der Standort des Displays mit `name`, `city`, `countryCode`, `latitude`, `longitude` und `timeZone`. Fehlt, wenn die App `player-location` nicht unter `dataAccess.reads` nennt oder die Organisation dem nicht zugestimmt hat; `null` ohne Display, ohne Standort am Display oder ohne Koordinaten am Standort. Siehe [Der Standort des Displays](/de/apps-sdk/data-access#der-standort-des-displays-player-location). |

**Die Antwort (`ResolvedWidgetPayload`):** `payload`, `source`, `resolvedAt`, `validUntil`, `cacheUntil`. Die beiden Zeitpunkte wandern in den Snapshot und steuern, wann die Plattform neu rechnet. Der Snapshot gilt bis zum frühesten `validUntil` seiner Widgets, und genau dann baut die Plattform ihn neu. Ein Resolver, dessen Anzeige zu einem bekannten Zeitpunkt wechselt (ein Dienstfenster endet, ausverkauft gilt bis 5 Uhr, eine Karte ab einem Stichtag), setzt `validUntil` auf diesen Zeitpunkt. Liegt er weniger als 30 Sekunden voraus, fängt ihn der Durchlauf alle fünf Minuten auf.

**Die Regeln:**

| Regel | Warum |
|---|---|
| Idempotent | Derselbe Kontext ergibt dieselbe Nutzlast; der Snapshot-Bau darf wiederholen. |
| Kein Abruf nach außen | Daten kommen aus dem Fetcher-Cache; externe Latenz gehört in den Fetcher. |
| Keine Rechteprüfung | Der Snapshot-Bau ist Systemkontext ohne handelnden Nutzer. |
| Keine Ereignisse | Der Snapshot-Bau ist seiteneffektfrei. |
| `null` bei fehlender oder gelöschter Referenz | Das Widget rendert seinen Platzhalter, statt den Bau zu reißen. Auch eine ungültige Konfiguration ergibt `null`. |
| `playerId: null` verkraften | Derselbe Resolver bedient die Vorschau ohne Player. |
| p99 unter 50 ms | Das Zeitbudget des Snapshot-Baus. |

Beim Start prüft die Plattform, dass jeder registrierte Widget-Typ im Manifest steht, keine zwei Resolver denselben Typ beanspruchen und jedes Widget mit `noResolver: false` einen Resolver hat.

## @Fetcher

`@Fetcher` markiert eine Methode, die eine fremde Quelle abruft. Die Plattform legt dafür einen eigenen Worker an, der genau einen Lauf gleichzeitig ausführt, damit begrenzte fremde Quellen geschont bleiben.

| Option | Format | Bedeutung |
|---|---|---|
| `key` | Muster mit App-Präfix, z. B. `weather:{citySlug}` | Schlüssel im Cache; das erste Segment vor dem Doppelpunkt ist die App-Kennung. |
| `defaultValidity` | ISO-8601-Dauer, z. B. `PT30M` | Wie lange die Daten als frisch gelten. |
| `defaultCacheUntil` | ISO-8601-Dauer, z. B. `PT2H` | Wie lange der Cache sie im Notfall weiter ausliefert. |

Die Methode gibt `FetcherResult<TPayload>` zurück: `payload`, `validUntil`, `cacheUntil` und `source` (Herkunft, z. B. `api:openweather`).

```ts
@Fetcher({ key: 'weather:{citySlug}', defaultValidity: 'PT30M', defaultCacheUntil: 'PT2H' })
async fetchWeather(args: FetchArgs): Promise<FetcherResult<WeatherSnapshot>> {
  const snapshot = await this.router.fetch(args);
  const now = this.clock.now();
  return {
    payload: snapshot,
    validUntil: new Date(now.getTime() + 30 * 60 * 1000),
    cacheUntil: new Date(now.getTime() + 2 * 60 * 60 * 1000),
    source: `api:${snapshot.provider}`,
  };
}
```

Geplante Läufe und das Auffrischen über die API rufen eine Fetcher-Methode **ohne Argumente** auf. Deklariert sie Pflichtparameter, führt die Plattform sie als reinen Cache-Lader: geplante Läufe und Auffrischen lehnen sie ab, aufgerufen wird sie nur von der App selbst — aus `getOrFetch` im Resolver oder aus einem Job, der die Argumente kennt. Periodisches Abrufen ist deshalb Sache eines `@JobHandler`.

`getOrFetch` prüft auf jedem Weg, auch beim Treffer im Cache, ob die Organisation die App aktiv installiert hat; sonst antwortet die Plattform mit `App.NotInstalled` (HTTP 409). Die beiden Zeitfenster ergeben drei Lagen:

| Lage | Was passiert |
|---|---|
| jetzt ≤ `validUntil` | ausliefern, sonst nichts |
| `validUntil` < jetzt ≤ `cacheUntil` | sofort ausliefern und im Hintergrund auffrischen |
| jetzt > `cacheUntil` | der Aufrufer wartet synchron auf die Quelle |

Scheitert die Auffrischung, bleibt der gespeicherte Eintrag bis `cacheUntil` unangetastet — eine gestörte Quelle nimmt dem Bildschirm nicht auch das Bild.

## @JobHandler

`@JobHandler` markiert eine Methode als wiederkehrenden, plattformweiten Job. Die Plattform legt einen Zeitplan an, den ein Cron abarbeitet, und ruft die Methode ohne Organisation im Kontext auf; der Handler fächert selbst über die installierten Organisationen.

| Option | Format | Bedeutung |
|---|---|---|
| `key` | string, z. B. `pharmacies-on-duty-crawl-all-locations` | Eindeutig innerhalb der App; ohne App-Präfix. |
| `cron` | 5 Felder (`min hour dom month dow`), z. B. `*/30 * * * *` | Der Zeitplan. |
| `namespace` | string, Vorgabe App-Kennung | Aus `namespace` leitet die Plattform die App ab — die mitgelieferten Apps setzen ihn gleich der App-Kennung. |
| `timeout` | ISO-8601-Dauer, z. B. `PT5M` | Hartes Limit je Lauf. |
| `concurrency` | Zahl, Vorgabe `1` | Gleichzeitige Läufe. |

Die Organisationen liefert `listTenantsForApp()` am Job-Queue-Token — nur die mit aktiver Installation **dieser** App; eine fremde App lässt sich nicht aufzählen. Ein `key`, der bereits als Fetcher registriert ist, wird mit Warnung übersprungen.

## Die Token

Der Server-Teil bezieht jede übergreifende Fähigkeit über ein Token; die Plattform bindet dahinter eine Umsetzung, die alles automatisch mit der App-Kennung abgrenzt. Drei Token tragen Kontext, die übrigen Dienste.

| Token | Was Sie bekommen | Abgrenzung durch die Plattform |
|---|---|---|
| `APP_ID` | Die App-Kennung | — |
| `APP_MANIFEST` | Das geparste Manifest | — |
| `APP_VERSION` | Die Version aus dem Manifest | — |
| Token der App-Datenbank (im SDK nach dem Datenbankwerkzeug benannt) | Die App-Datenbank: `models`, `$transaction(fn)` | Nur die eigenen Tabellen im Schema `apps_<id>`; ohne eigenes Schema schlägt der Zugriff fehl. |
| `APP_PERMISSION_GUARD` | `assert(actor, action, resource?)`, `canAct(…)` | Rechteschlüssel mit der App-Kennung vorangestellt. |
| `APP_REQUEST_SCOPE` | `traceId`, `correlationId`, `actorId`, `tenantId`, `isPlatformContext` | Nur lesend. |
| `APP_ERROR_FACTORY` | `businessException`, `notFound`, `validationFailed`, `conflict`, `integrationFailed` | Liefert den Fehler, den Sie werfen. |
| `APP_EVENT_BUS` | `emit(tx, eventName, payload)`, `contentChanged(tx, { tenantId, locationIds? })` | Ereignisname `app.<id>.<eventName>`; die Transaktion übergeben Sie explizit. `contentChanged` ist das feste Ereignis `app.<id>.content.changed`, siehe [Geänderte Daten an die Displays melden](#geänderte-daten-an-die-displays-melden). |
| `APP_LOGGER` | `info`, `warn`, `error`, `debug`, `child` | Namensraum `app.<id>`. |
| `APP_CLOCK` | `now()`, `nowMs()` | Zeitquelle, in Tests austauschbar. |
| `APP_CACHE` | `get`, `set`, `invalidate` | Schlüssel `app:<id>:…`. |
| `APP_FETCHER_CACHE` | `getOrFetch(key, fetcher, { ttl, staleWindow? })`, `invalidate` | Read-Through-Cache; die Organisation kommt aus dem Aufruf-Kontext. |
| `APP_FILE_STORAGE` | `put`, `get`, `delete`, `signedUrl` — jeweils mit `tenantId` | Ablage unter `tenants/<tenantId>/apps/<id>/`. `put` nimmt eine Bedingung (`ifMatch` mit dem `etag` aus `get`, `ifNoneMatch: '*'`), ist sie verletzt, wirft es einen Konflikt (`Conflict.OptimisticLock`): neu lesen und noch einmal schreiben. |
| `APP_JOB_QUEUE` | `enqueue`, `enqueueBulk`, `listTenantsForApp()` | Warteschlangen mit App-Präfix. |
| `APP_TENANT_DATA` | `profile`, `branding`, `userDisplayName`, `asset`, `assetVariantUrl` — jeweils mit `tenantId` | Nur gebunden, wenn `dataAccess.reads` einen Bestand nennt; siehe [Datenzugriff](/de/apps-sdk/data-access). |
| `APP_PLATFORM_CONFIG` | `get`, `getSecret`, `has`, `getAllowlist` | Vom Betreiber gepflegte Einstellungen und Geheimnisse, nur lesend. |
| `APP_GUARDED_FETCH` | `fetchText(url, options?)` | Abgesicherter Abruf von Adressen, die ein Nutzer eingegeben hat. |

### AppGuardedFetch ist Pflicht für Nutzeradressen

Sobald ein Server-Teil eine Adresse abruft, die jemand im Portal eingetragen hat (Feed, Kalender, Nachrichtenquelle aus einer Widget-Konfiguration), muss er `fetchText` nehmen — nie das globale `fetch`. Der Guard lässt nur `http:` und `https:` durch, prüft alle aufgelösten Adressen gegen die Sperrliste privater und reservierter Bereiche, wiederholt die Prüfung bei jeder Weiterleitung, kappt die Antwort im Strom und erzwingt ein Zeitlimit. Vorgaben: `timeoutMs: 10000`, `maxBytes: 2097152` (2 MiB), `maxRedirects: 3`.

Jeder Fehlschlag trägt einen maschinenlesbaren `reason`: `invalid-url`, `blocked-scheme`, `blocked-address`, `dns-failure`, `too-many-redirects`, `too-large`, `timeout`, `http-error`, `network-error`. `isGuardedFetchError` erkennt ihn strukturell, sodass ein Resolver den Grund als Klartext in seine Nutzlast heben kann. Für einen fest im Code verdrahteten Anbieter (etwa die Wetter-API) ist der Guard nicht gedacht — dort wählt keine Nutzereingabe das Ziel.

## Geänderte Daten an die Displays melden

Ändert sich etwas, das ein Widget schon zeigt, meldet der Server-Teil das mit `contentChanged`. Die Plattform baut dann die Displays der Organisation neu, deren Programm die App enthält, und stößt die an, deren Inhalt sich wirklich geändert hat. Die Displays zeigen die Änderung nach wenigen Sekunden. Ohne Meldung kommt sie erst an, wenn der Snapshot aus einem anderen Grund neu entsteht, im schlimmsten Fall nach Stunden.

```ts
await this.prisma.$transaction(async (tx) => {
  await (tx as MyPrismaClient).dish.update({ where: { id }, data: { soldOut: true } });
  await this.events.contentChanged(tx, { tenantId });
});
```

| Regel | Warum |
|---|---|
| In derselben Transaktion wie die Änderung | Die Meldung geht über die Outbox: Sie kommt mindestens einmal an, aber nie für eine Änderung, die zurückgerollt wurde. |
| Ohne eigene Datenbank `null` statt der Transaktion | Eine App, die ihre Daten im Dateispeicher hält, meldet gleich nach dem Schreiben mit `contentChanged(null, { tenantId })`. Die Meldung geht dann allein in die Outbox. Scheitert sie, bleibt die Änderung, und die Displays sehen sie mit der nächsten Frist ihrer Nutzlast. |
| Nach Ändern und Löschen, nicht nach dem Anlegen | Ein Resolver liest Daten über die Kennung, die ein Widget gewählt hat. Auf einen neuen Eintrag zeigt noch keines, und wer ihn in eine Szene nimmt, veröffentlicht sie, was den Neubau ohnehin auslöst. |
| `tenantId` immer angeben | Jobs laufen ohne Organisation im Kontext. Im Kontext einer Organisation lehnt die Plattform eine fremde ab. |
| `locationIds` nur, wenn die Änderung ausschließlich an diesen Standorten sichtbar wird | Die Kennungen sind Standorte der Organisation, nicht Datensätze der App. Kann ein Widget die Daten auch auf einem Display anderswo zeigen (fester Standort in seiner Einstellung), lassen Sie das Feld weg. Eine leere Liste gilt wie keine. |
| Ein Abruf meldet einmal am Ende | Ein Job, der in vielen Transaktionen schreibt, meldet einmal, sobald er etwas geschrieben oder geräumt hat. Eine Meldung je Eintrag brächte nur zusätzliche Neubau-Anläufe. |
| Zeitgesteuerte Wechsel brauchen keine Meldung | Was zu einem bekannten Zeitpunkt wechselt, steht im `validUntil` des Resolvers. |

Ein Neubau mit unverändertem Inhalt stößt kein Display an, eine Meldung zu viel kostet also wenig. Eine fehlende kostet eine veraltete Anzeige. `emit(tx, 'content.changed', …)` nimmt denselben Weg wie `contentChanged`. Im Test zeichnet `createMockAppContainer` die Meldung in `emittedEvents` auf.

## Installieren und Deinstallieren: die Lebenszyklus-Punkte

Eine mit `@AppLifecycle()` markierte Klasse des Server-Teils darf `onInstall(tenantId)` und `onUninstall(tenantId)` tragen — höchstens eine Klasse je App, und beide Punkte müssen idempotent sein, weil eine Neuinstallation ihren Bestand wieder vorfindet. Wirft `onInstall`, gilt die Installation als gescheitert und die Organisation sieht den Fehler. Wirft `onUninstall`, bleibt die Deinstallation wirksam und der Fehler landet im Protokoll.

## Ohne laufende Plattform testen

`createMockAppContainer` aus `@szenera/apps-sdk/testing` liefert je Token eine In-Memory-Umsetzung samt Aufzeichnung; `mockWidgetResolverContext` baut einen synthetischen Kontext, zunächst ohne `location`. Eine App mit `player-location` setzt ihn mit `mockWidgetLocation()` oder `null`.

```ts
import { createMockAppContainer, mockWidgetResolverContext } from '@szenera/apps-sdk/testing';
import { APP_CLOCK, APP_FETCHER_CACHE, APP_LOGGER, APP_TENANT_DATA } from '@szenera/apps-sdk/backend';

const c = createMockAppContainer({
  appId: 'weather',
  clockNow: new Date('2026-06-23T00:00:00Z'),
  tenantMock: { id: 'tnt_a', slug: 'apo', displayName: 'Apo', defaultLocale: 'en-US', defaultTimezone: 'Europe/Berlin' },
});

// Eigene Klassen der App (hier der Fetcher) kommen als Stummel von Hand.
const resolver = new WeatherResolver(
  c.get(APP_FETCHER_CACHE),
  c.get(APP_TENANT_DATA),
  c.get(APP_CLOCK),
  c.get(APP_LOGGER),
  fetcherStub,
);
const result = await resolver.resolve(mockWidgetResolverContext({ widgetType: 'weather' }));
expect(c.emittedEvents).toEqual([]);
```

## Weiterführendes

- [Datenzugriff, Rechte und Bedingungen](/de/apps-sdk/data-access) — was `dataAccess.reads` an `APP_TENANT_DATA` bindet.
- [Widget, Konfiguration und Vorschau](/de/apps-sdk/widget-and-preview) — das Widget, das der Resolver bedient.
- [Die Beispiel-Apps](/de/apps-sdk/example-apps) — welche App welche Bausteine trägt.
