Ilhas de servidor

O sitelo gera HTML estático. As ilhas de servidor preenchem as partes que têm de estar frescas — um relógio, comentários, stock, tudo o que depende do pedido. Esta receita constrói uma página com uma ilha de hora e depois corre um pequeno servidor Node que serve dist/ e /_sitelo/islands.

Há uma cópia deste projeto no repositório do sitelo, em examples/islands/.

O que obténs

Estrutura do projeto

my-site/
  sitelo.config.js
  server.js              # host Node: dist estático + ilhas
  netlify.toml           # rewrite do Netlify → função
  vercel.json            # rewrite do Vercel → rota api
  package.json
  netlify/functions/
    islands.mjs          # handler de ilhas do Netlify
  api/islands/
    [...path].js         # handler de ilhas do Vercel
  src/
    index.ht.js          # página com um marcador de ilha
    js/
      islands.js         # carregador de cliente (incluído em dist/)
    islands/
      time.js            # módulo de fragmento só de servidor
    css/
      styles.css
export default {
  site: 'https://example.com',
}

1. Módulo de ilha

Um ficheiro .js normal (não .ht.js). Recebe { name, props, request } e devolve uma string HTML. Este usa a hora do pedido e o user-agent para veres que é renderizado a cada pedido.

export default function time({ props, request }) {
  const label = typeof props?.label === 'string' ? props.label : 'Hora do servidor'
  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">Renderizado a pedido para <code>${escapeHtml(ua.slice(0, 48))}</code></p>
  `
}

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

2. Coloca a ilha numa página

island() embute as props no marcador. A compilação publica a reserva; o carregador substitui-a quando o endpoint responde.

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

export default () =>
  html({ lang: 'pt' },
    head(
      title('Demonstração de ilhas de servidor'),
      link({ rel: 'stylesheet', href: '/styles.css' }),
    ),
    body(
      h1('Página estática, ilha ao vivo'),
      p('Este HTML foi compilado uma só vez. A caixa abaixo é preenchida no momento do pedido.'),
      island(
        'time',
        { label: 'Agora mesmo' },
        '<p>A carregar a hora do servidor…</p>',
      ),
      script({ type: 'module', src: '/islands.js' }),
    ),
  )

3. Carregador de cliente

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. Host Node

Depois de sitelo build, este processo serve dist/ e renderiza as ilhas com createIslandsNodeHandler de sitelo/islands/server. Os módulos de ilha ficam fora de dist/ — o host importa-os de 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('Não encontrado')
    }
  })
})

server.listen(port, () => {
  console.log(`À escuta em http://localhost:${port}`)
})

5. Compilar e executar

npm install
sitelo build
node server.js

Abre http://localhost:3000. Deves ver a reserva por um instante e depois a hora do servidor. Atualiza — a marca temporal muda. Em sitelo (dev) e sitelo preview não precisas do server.js: a CLI já serve /_sitelo/islands.

Implementação

Este exemplo traz esboços de host ao lado do servidor Node:

Para serverless ou edge noutro sítio, usa createIslandsHandler (Request web → Response) — vê a documentação de ilhas de servidor. Aponta mountIslands({ endpoint }) para o URL dessa função se não for da mesma origem.

Notas

Alojamentos só estáticos

O GitHub Pages, o S3 simples e alojamentos parecidos não têm processo de servidor. Sem um endpoint de ilhas, o HTML de reserva fica simplesmente lá — as páginas continuam a funcionar, só sem o fragmento ao vivo.

Mantém as props pequenas

As props viajam no atributo HTML e na query string do pedido. Não ponhas aí segredos nem cargas grandes — vai buscá-los dentro do módulo de ilha, no servidor.

Documentação de ilhas de servidor · Site básico / implementação · Todos os exemplos