SpaceNode
A full-stack app with SpaceNode serving both the API and the frontend (SPA mode).
my-spa/
├── app.js
├── package.json
├── public/
│ ├── index.html
│ ├── app.js
│ └── style.css
└── modules/
└── todos/
├── module.js
├── todos.controller.js
└── todos.service.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')
})
export default {
name: 'todos',
prefix: '/api/todos', // API under /api/ prefix
routes: [
['GET', '/', 'list'],
['POST', '/', 'create'],
['PUT', '/:id', 'toggle'],
['DELETE', '/:id', 'remove'],
],
}
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) },
}
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)
}
<!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>
/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.