Server-Islands

sitelo baut statisches HTML. Server-Islands füllen die Stellen, die frisch sein müssen — eine Uhr, Kommentare, Lagerbestände, alles, was die Anfrage braucht. Dieses Rezept baut eine Seite mit einer Zeit-Island und startet dann einen kleinen Node-Server, der dist/ und /_sitelo/islands ausliefert.

Eine Kopie dieses Projekts liegt im sitelo-Repository unter examples/islands/.

Was du bekommst

Projektstruktur

my-site/
  sitelo.config.js
  server.js              # Node-Host: statisches dist + Islands
  netlify.toml           # Netlify-Rewrite → Funktion
  vercel.json            # Vercel-Rewrite → api-Route
  package.json
  netlify/functions/
    islands.mjs          # Netlify-Island-Handler
  api/islands/
    [...path].js         # Vercel-Island-Handler
  src/
    index.ht.js          # Seite mit einem Island-Platzhalter
    js/
      islands.js         # Client-Loader (wird nach dist/ gebündelt)
    islands/
      time.js            # reines Server-Fragmentmodul
    css/
      styles.css
export default {
  site: 'https://example.com',
}

1. Island-Modul

Eine schlichte .js-Datei (kein .ht.js). Sie erhält { name, props, request } und gibt einen HTML-String zurück. Diese hier nutzt Anfragezeit und User-Agent, damit du siehst, dass sie pro Anfrage gerendert wird.

export default function time({ props, request }) {
  const label = typeof props?.label === 'string' ? props.label : 'Serverzeit'
  const now = new Date().toISOString()
  const ua = request?.headers?.get?.('user-agent') ?? 'unknown'

  return `
    <p><strong>${label}:</strong> <time datetime="${now}">${now}</time></p>
    <p class="muted">Auf Anfrage gerendert für <code>${escapeHtml(ua.slice(0, 48))}</code></p>
  `
}

function escapeHtml(value) {
  return value
    .replaceAll('&', '&amp;')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;')
    .replaceAll('"', '&quot;')
}

2. Die Island auf einer Seite platzieren

island() bettet die Props in den Platzhalter ein. Der Build liefert das Fallback aus; der Loader ersetzt es, sobald der Endpunkt antwortet.

import { html, head, title, link, body, h1, p, script } from 'javascript-to-html'
import { island } from 'sitelo/islands'

export default () =>
  html({ lang: 'de' },
    head(
      title('Server-Islands-Demo'),
      link({ rel: 'stylesheet', href: '/styles.css' }),
    ),
    body(
      h1('Statische Seite, lebendige Island'),
      p('Dieses HTML wurde einmal gebaut. Der Kasten unten wird zur Anfragezeit gefüllt.'),
      island(
        'time',
        { label: 'Gerade jetzt' },
        '<p>Serverzeit wird geladen…</p>',
      ),
      script({ type: 'module', src: '/islands.js' }),
    ),
  )

3. Client-Loader

import { mountIslands } from 'sitelo/islands/client'

mountIslands()
body {
  font-family: system-ui, sans-serif;
  max-width: 36rem;
  margin: 2rem auto;
  padding: 0 1rem;
  line-height: 1.5;
}

[data-sitelo-island] {
  margin: 1.5rem 0;
  padding: 1rem 1.25rem;
  border: 1px solid #ccc;
}

[data-sitelo-island-state='loading'] {
  opacity: 0.7;
}

.muted {
  color: #666;
  font-size: 0.9rem;
}

4. Node-Host

Nach sitelo build liefert dieser Prozess dist/ aus und rendert Islands mit createIslandsNodeHandler aus sitelo/islands/server. Die Island-Module bleiben außerhalb von dist/ — der Host importiert sie aus src/.

import fs from 'node:fs'
import http from 'node:http'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { createIslandsFromDirectory, createIslandsNodeHandler } from 'sitelo/islands/server'

const root = path.dirname(fileURLToPath(import.meta.url))
const dist = path.join(root, 'dist')
const port = Number(process.env.PORT) || 3000

const handleIslands = createIslandsNodeHandler({
  islands: createIslandsFromDirectory(path.join(root, 'src/islands')),
})

const MIME = {
  '.html': 'text/html; charset=utf-8',
  '.js': 'text/javascript; charset=utf-8',
  '.css': 'text/css; charset=utf-8',
  '.svg': 'image/svg+xml',
  '.png': 'image/png',
  '.ico': 'image/x-icon',
  '.xml': 'application/xml',
  '.json': 'application/json',
}

function sendFile(res, filePath) {
  const ext = path.extname(filePath)
  res.statusCode = 200
  res.setHeader('Content-Type', MIME[ext] ?? 'application/octet-stream')
  fs.createReadStream(filePath).pipe(res)
}

function resolveStatic(urlPath) {
  const clean = decodeURIComponent(urlPath.split('?')[0])
  const relative = clean === '/' ? 'index.html' : clean.replace(/^\/+/, '')
  const candidate = path.normalize(path.join(dist, relative))

  if (!candidate.startsWith(dist + path.sep) && candidate !== dist) {
    return null
  }
  if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
    return candidate
  }

  const asIndex = path.join(candidate, 'index.html')
  if (fs.existsSync(asIndex) && fs.statSync(asIndex).isFile()) {
    return asIndex
  }

  return null
}

const server = http.createServer(async (req, res) => {
  await handleIslands(req, res, () => {
    const file = resolveStatic(req.url ?? '/')
    if (file) {
      sendFile(res, file)
      return
    }

    const notFound = path.join(dist, '404.html')
    res.statusCode = 404
    if (fs.existsSync(notFound)) {
      sendFile(res, notFound)
    } else {
      res.setHeader('Content-Type', 'text/plain; charset=utf-8')
      res.end('Nicht gefunden')
    }
  })
})

server.listen(port, () => {
  console.log(`Lauscht auf http://localhost:${port}`)
})

5. Bauen und starten

npm install
sitelo build
node server.js

Öffne http://localhost:3000. Du solltest kurz das Fallback sehen, dann die Serverzeit. Lade neu — der Zeitstempel ändert sich. In sitelo (dev) und sitelo preview brauchst du server.js nicht: die CLI liefert /_sitelo/islands bereits aus.

Deployment

Dieses Beispiel liefert neben dem Node-Server auch Host-Vorlagen mit:

Für Serverless oder Edge anderswo nimm createIslandsHandler (Web-RequestResponse) — siehe die Server-Islands-Doku. Richte mountIslands({ endpoint }) auf die URL dieser Funktion, wenn sie nicht auf derselben Origin liegt.

Hinweise

Rein statische Hoster

GitHub Pages, schlichtes S3 und ähnliche Hoster haben keinen Serverprozess. Ohne Island-Endpunkt bleibt einfach das Fallback-HTML stehen — die Seiten funktionieren weiterhin, nur ohne das lebendige Fragment.

Halte die Props klein

Props reisen im HTML-Attribut und im Query-String der Anfrage mit. Pack dort weder Geheimnisse noch große Datenmengen hinein — hole die im Island-Modul auf dem Server.

Server-Islands-Doku · Basis-Website / Deployment · Alle Beispiele