Example: Full-Stack SPA

A full-stack app with SpaceNode serving both the API and the frontend (SPA mode).

Project Structure

my-spa/
├── app.js
├── package.json
├── public/
│   ├── index.html
│   ├── app.js
│   └── style.css
└── modules/
    └── todos/
        ├── module.js
        ├── todos.controller.js
        └── todos.service.js

app.js

import { createApp } from 'SpaceNode'

const app = await createApp({
  static: './public',   // serve frontend
  spa: true,            // fallback to index.html for client routes
  pipe: ['cors', 'compress'],
})

app.listen(3000, () => {
  console.log('SPA + API running on http://localhost:3000')
})

modules/todos/module.js

export default {
  name: 'todos',
  prefix: '/api/todos',    // API under /api/ prefix
  routes: [
    ['GET',    '/',    'list'],
    ['POST',   '/',    'create'],
    ['PUT',    '/:id', 'toggle'],
    ['DELETE', '/:id', 'remove'],
  ],
}

modules/todos/todos.service.js

let nextId = 1
const todos = []

export const todoService = {
  list()           { return todos },
  create(text)     { const t = { id: nextId++, text, done: false }; todos.push(t); return t },
  toggle(id)       { const t = todos.find(x => x.id === id); if (t) t.done = !t.done; return t },
  remove(id)       { const i = todos.findIndex(x => x.id === id); if (i >= 0) todos.splice(i, 1) },
}

modules/todos/todos.controller.js

export function list({ send }, { todoService }) {
  send(todoService.list())
}

export function create({ body, send, check }, { todoService }) {
  check(body.text, 400, 'text is required')
  send(201, todoService.create(body.text))
}

export function toggle({ params, send, check }, { todoService }) {
  const todo = todoService.toggle(Number(params.id))
  check(todo, 404, 'Todo not found')
  send(todo)
}

export function remove({ params, send }, { todoService }) {
  todoService.remove(Number(params.id))
  send(204)
}

public/index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Todo App</title>
  <style>
    * { margin: 0; box-sizing: border-box; }
    body { font-family: system-ui, sans-serif; background: #0f0f1a; color: #e0e0e0;
           display: flex; justify-content: center; padding-top: 60px; }
    .app { width: 420px; }
    h1 { font-size: 1.6rem; margin-bottom: 20px; }
    .add-row { display: flex; gap: 8px; margin-bottom: 16px; }
    .add-row input { flex: 1; padding: 10px 14px; border: 1px solid #333;
                     border-radius: 6px; background: #1a1a2e; color: #fff;
                     font-size: 0.95rem; outline: none; }
    .add-row input:focus { border-color: #7c5cff; }
    .add-row button { padding: 10px 20px; border: none; border-radius: 6px;
                      background: #7c5cff; color: #fff; cursor: pointer;
                      font-weight: 600; }
    .stats { font-size: 0.85rem; color: #888; margin-bottom: 12px; }
    ul { list-style: none; padding: 0; }
    li { display: flex; align-items: center; gap: 10px; padding: 10px 12px;
         border-bottom: 1px solid #1e1e30; }
    li.done .text { text-decoration: line-through; opacity: 0.5; }
    .toggle { cursor: pointer; font-size: 1.2rem; user-select: none;
              width: 24px; text-align: center; }
    .text { flex: 1; }
    .del { cursor: pointer; opacity: 0.4; font-size: 0.9rem; }
    .del:hover { opacity: 1; color: #ff5c5c; }
    .empty { color: #555; text-align: center; padding: 40px; }
  </style>
</head>
<body>
  <div class="app">
    <h1>⚡ Todo App</h1>

    <div class="add-row">
      <input id="input" placeholder="What needs to be done?">
      <button onclick="addTodo()">Add</button>
    </div>
    <div id="stats" class="stats"></div>
    <ul id="list"></ul>
  </div>

  <script>
  let todos = []

  // ── Fetch all todos ──
  async function load() {
    const res = await fetch('/api/todos')
    todos = await res.json()
    render()
  }

  // ── Render list ──
  function render() {
    if (todos.length === 0) {
      list.innerHTML = '<li class="empty">No todos yet. Add one above!</li>'
      stats.textContent = ''
      return
    }
    const done = todos.filter(t => t.done).length
    stats.textContent = `${done}/${todos.length} completed`

    list.innerHTML = todos.map(t => `
      <li class="${t.done ? 'done' : ''}">
        <span class="toggle" onclick="toggleTodo(${t.id})">
          ${t.done ? '☑' : '☐'}
        </span>
        <span class="text">${t.text}</span>
        <span class="del" onclick="removeTodo(${t.id})">✕</span>
      </li>`
    ).join('')
  }

  // ── POST /api/todos ──
  async function addTodo() {
    const text = input.value.trim()
    if (!text) return
    await fetch('/api/todos', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ text }),
    })
    input.value = ''
    load()
  }

  // ── PUT /api/todos/:id  (toggle done) ──
  async function toggleTodo(id) {
    await fetch(`/api/todos/${id}`, { method: 'PUT' })
    load()
  }

  // ── DELETE /api/todos/:id ──
  async function removeTodo(id) {
    await fetch(`/api/todos/${id}`, { method: 'DELETE' })
    load()
  }

  // ── Enter key to add ──
  input.adorgentListener('keydown', (e) => {
    if (e.key === 'Enter') addTodo()
  })

  load()
  </script>
</body>
</html>
Key point — API routes under /api/ are handled by modules. All other GET paths serve index.html (SPA fallback). CSS, JS, images are served as static files. One server, full stack.