24 KiB
SSE Realtime Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace HTTP polling with Server-Sent Events (SSE) for real-time updates of chat messages, unread counters, order status changes, and admin notifications.
Architecture: New SSE route on server bridges existing EventBus events to SSE streams. Client-side SseProvider manages EventSource lifecycle (connect on login, close on logout) and invalidates React Query caches on incoming events.
Tech Stack: Fastify (raw SSE via reply.raw), EventSource API, Effector ($token store), React Query (invalidateQueries), vitest.
File Map
| File | Responsibility |
|---|---|
server/src/routes/sse.js |
SSE endpoint, EventBus listener bridge |
server/src/routes/__tests__/sse.test.js |
Server tests |
server/src/index.js |
Import and register SSE routes |
client/src/shared/lib/sse.ts |
EventSource factory |
client/src/app/providers/SseProvider.tsx |
SSE→ReactQuery invalidation bridge |
client/src/app/providers/__tests__/SseProvider.test.tsx |
Client tests |
client/src/app/providers/AppProviders.tsx |
Mount SseProvider |
client/src/pages/me/ui/MeLayoutPage.tsx |
Remove refetchInterval |
client/src/pages/admin-layout/ui/AdminLayoutPage.tsx |
Remove refetchInterval |
Task 1: Server — write SSE route tests (TDD red)
Files:
-
Create:
server/src/routes/__tests__/sse.test.js -
Step 1: Write the test file
import Fastify from 'fastify'
import { EventEmitter } from 'node:events'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { buildSseListeners, formatHeartbit, formatSSE, isAdminUser, registerSseRoutes } from '../sse.js'
describe('formatSSE', () => {
it('formats event with data', () => {
const result = formatSSE('message:new', { orderId: 'o1' })
expect(result).toBe('event: message:new\ndata: {"orderId":"o1"}\n\n')
})
it('formats event without data', () => {
const result = formatSSE('heartbit')
expect(result).toBe('event: heartbit\n\n')
})
})
describe('formatHeartbit', () => {
it('returns SSE comment', () => {
expect(formatHeartbit()).toBe(':heartbit\n\n')
})
})
describe('isAdminUser', () => {
it('returns false for non-matching email', () => {
expect(isAdminUser({ email: 'user@test.com' })).toBe(false)
})
it('returns true when email matches ADMIN_EMAIL', () => {
const adminEmail = process.env.ADMIN_EMAIL
if (adminEmail) {
expect(isAdminUser({ email: adminEmail })).toBe(true)
}
})
it('returns false for null/undefined user', () => {
expect(isAdminUser(null)).toBe(false)
expect(isAdminUser(undefined)).toBe(false)
})
})
describe('buildSseListeners', () => {
let eventBus
let write
beforeEach(() => {
eventBus = new EventEmitter()
eventBus.setMaxListeners(50)
write = vi.fn()
})
it('forwards orderMessage:adminReply to matching userId', () => {
const cleanup = buildSseListeners('user-1', false, eventBus, write)
eventBus.emit('orderMessage:adminReply', { orderId: 'o1', userId: 'user-1', messageId: 'm1', preview: 'Hi' })
expect(write).toHaveBeenCalledTimes(1)
expect(write.mock.calls[0][0]).toContain('event: message:new')
expect(write.mock.calls[0][0]).toContain('"orderId":"o1"')
cleanup()
})
it('ignores orderMessage:adminReply for non-matching userId', () => {
const cleanup = buildSseListeners('user-2', false, eventBus, write)
eventBus.emit('orderMessage:adminReply', { orderId: 'o1', userId: 'user-1', messageId: 'm1', preview: 'Hi' })
expect(write).not.toHaveBeenCalled()
cleanup()
})
it('forwards orderMessage:sent to admin', () => {
const cleanup = buildSseListeners('admin-1', true, eventBus, write)
eventBus.emit('orderMessage:sent', { orderId: 'o1', authorType: 'user', messageId: 'm1', preview: 'Hello' })
expect(write).toHaveBeenCalledTimes(1)
expect(write.mock.calls[0][0]).toContain('event: message:new')
cleanup()
})
it('ignores orderMessage:sent for non-admin', () => {
const cleanup = buildSseListeners('user-1', false, eventBus, write)
eventBus.emit('orderMessage:sent', { orderId: 'o1', authorType: 'user', messageId: 'm1', preview: 'Hello' })
expect(write).not.toHaveBeenCalled()
cleanup()
})
it('forwards order:statusChanged to matching userId', () => {
const cleanup = buildSseListeners('user-1', false, eventBus, write)
eventBus.emit('order:statusChanged', { orderId: 'o1', userId: 'user-1', oldStatus: 'PENDING_PAYMENT', newStatus: 'PAID' })
expect(write).toHaveBeenCalledTimes(1)
expect(write.mock.calls[0][0]).toContain('event: order:statusChanged')
expect(write.mock.calls[0][0]).toContain('"newStatus":"PAID"')
cleanup()
})
it('forwards payment:statusChanged to matching userId', () => {
const cleanup = buildSseListeners('user-1', false, eventBus, write)
eventBus.emit('payment:statusChanged', { orderId: 'o1', userId: 'user-1', paymentStatus: 'paid' })
expect(write).toHaveBeenCalledTimes(1)
expect(write.mock.calls[0][0]).toContain('event: order:statusChanged')
cleanup()
})
it('forwards order:deliveryFeeAdjusted to matching userId', () => {
const cleanup = buildSseListeners('user-1', false, eventBus, write)
eventBus.emit('order:deliveryFeeAdjusted', { orderId: 'o1', userId: 'user-1', totalCents: 50000 })
expect(write).toHaveBeenCalledTimes(1)
expect(write.mock.calls[0][0]).toContain('event: order:updated')
cleanup()
})
it('forwards order:created to admin', () => {
const cleanup = buildSseListeners('admin-1', true, eventBus, write)
eventBus.emit('order:created', { orderId: 'o1', userId: 'user-1', totalCents: 50000 })
expect(write).toHaveBeenCalledTimes(1)
expect(write.mock.calls[0][0]).toContain('event: order:new')
cleanup()
})
it('forwards order:created:admin to admin', () => {
const cleanup = buildSseListeners('admin-1', true, eventBus, write)
eventBus.emit('order:created:admin', { orderId: 'o1', userId: 'user-1', userEmail: 'user@test.com' })
expect(write).toHaveBeenCalledTimes(1)
expect(write.mock.calls[0][0]).toContain('event: order:new')
cleanup()
})
it('ignores order:created for non-admin', () => {
const cleanup = buildSseListeners('user-1', false, eventBus, write)
eventBus.emit('order:created', { orderId: 'o1', userId: 'user-1', totalCents: 50000 })
expect(write).not.toHaveBeenCalled()
cleanup()
})
it('cleanup removes all listeners', () => {
const cleanup = buildSseListeners('user-1', false, eventBus, write)
cleanup()
eventBus.emit('orderMessage:adminReply', { orderId: 'o1', userId: 'user-1', messageId: 'm1', preview: 'Hi' })
expect(write).not.toHaveBeenCalled()
})
})
describe('GET /api/sse/stream (integration)', () => {
let app
beforeAll(async () => {
app = Fastify({ logger: false })
app.decorate('authenticate', async function (request, reply) {
try {
const token = request.query?.token
if (!token) throw new Error('no token')
if (token === 'user-token') {
request.user = { sub: 'user-1', email: 'user@test.com' }
} else if (token === 'admin-token') {
request.user = { sub: 'admin-1', email: process.env.ADMIN_EMAIL || 'admin@test.com' }
} else {
throw new Error('bad token')
}
} catch {
return reply.code(401).send({ error: 'Unauthorized' })
}
})
app.decorate('eventBus', new EventEmitter())
await registerSseRoutes(app)
await app.ready()
})
afterAll(async () => {
await app.close()
})
it('returns 401 without token', async () => {
const res = await app.inject({ method: 'GET', url: '/api/sse/stream' })
expect(res.statusCode).toBe(401)
})
it('returns 200 and event-stream headers for authenticated user', async () => {
const res = await app.inject({ method: 'GET', url: '/api/sse/stream?token=user-token' })
expect(res.statusCode).toBe(200)
expect(res.headers['content-type']).toBe('text/event-stream')
expect(res.headers['cache-control']).toBe('no-cache')
expect(res.headers['connection']).toBe('keep-alive')
})
it('sends initial heartbit', async () => {
const res = await app.inject({ method: 'GET', url: '/api/sse/stream?token=user-token' })
expect(res.body).toContain(':heartbit')
})
})
- Step 2: Run tests — expect FAIL (sse.js does not exist)
Run: cd server && npx vitest run src/routes/__tests__/sse.test.js
Expected: FAIL — module ../sse.js not found
Task 2: Server — implement SSE route (TDD green)
Files:
-
Create:
server/src/routes/sse.js -
Step 1: Write sse.js with exported helpers
import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js'
const {
ORDER_CREATED,
ORDER_STATUS_CHANGED,
ORDER_MESSAGE_SENT,
ORDER_MESSAGE_ADMIN_REPLY,
PAYMENT_STATUS_CHANGED,
DELIVERY_FEE_ADJUSTED,
} = NOTIFICATION_EVENTS
export function isAdminUser(user) {
return user?.email === process.env.ADMIN_EMAIL
}
export function formatSSE(event, data) {
const lines = [`event: ${event}`]
if (data !== undefined) {
lines.push(`data: ${JSON.stringify(data)}`)
}
return lines.join('\n') + '\n\n'
}
export function formatHeartbit() {
return ':heartbit\n\n'
}
export function buildSseListeners(userId, admin, eventBus, write) {
const listeners = []
function on(eventName, filterFn, sseEvent, dataFn) {
function handler(payload) {
if (!filterFn(payload)) return
write(formatSSE(sseEvent, dataFn(payload)))
}
listeners.push({ eventName, handler })
eventBus.on(eventName, handler)
}
on(
ORDER_MESSAGE_ADMIN_REPLY,
(p) => p.userId === userId,
'message:new',
(p) => ({ orderId: p.orderId, messageId: p.messageId, preview: p.preview }),
)
on(
ORDER_MESSAGE_SENT,
() => admin,
'message:new',
(p) => ({ orderId: p.orderId, messageId: p.messageId, preview: p.preview }),
)
on(
ORDER_STATUS_CHANGED,
(p) => p.userId === userId,
'order:statusChanged',
(p) => ({ orderId: p.orderId, newStatus: p.newStatus }),
)
on(
PAYMENT_STATUS_CHANGED,
(p) => p.userId === userId,
'order:statusChanged',
(p) => ({ orderId: p.orderId }),
)
on(
DELIVERY_FEE_ADJUSTED,
(p) => p.userId === userId,
'order:updated',
(p) => ({ orderId: p.orderId }),
)
on(
ORDER_CREATED,
() => admin,
'order:new',
(p) => ({ orderId: p.orderId }),
)
on(
'order:created:admin',
() => admin,
'order:new',
(p) => ({ orderId: p.orderId }),
)
return function cleanup() {
for (const { eventName, handler } of listeners) {
eventBus.off(eventName, handler)
}
}
}
export async function registerSseRoutes(fastify) {
fastify.get('/api/sse/stream', { preHandler: [fastify.authenticate] }, async (request, reply) => {
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
})
const userId = request.user.sub
const admin = isAdminUser(request.user)
reply.raw.write(formatHeartbit())
const heartbitTimer = setInterval(() => {
reply.raw.write(formatHeartbit())
}, 30_000)
const cleanup = buildSseListeners(userId, admin, fastify.eventBus, (chunk) => {
reply.raw.write(chunk)
})
request.raw.on('close', () => {
clearInterval(heartbitTimer)
cleanup()
})
})
}
- Step 2: Run tests — expect PASS
Run: cd server && npx vitest run src/routes/__tests__/sse.test.js
Expected: all PASS
- Step 3: Commit both files
git add server/src/routes/sse.js server/src/routes/__tests__/sse.test.js
git commit -m "feat: add SSE route with EventBus bridge and tests"
Task 3: Server — register SSE routes in index.js
Files:
-
Modify:
server/src/index.js -
Step 1: Add import
After line 27 (import { registerUserMessageRoutes } from './routes/user-messages.js'), add:
import { registerSseRoutes } from './routes/sse.js'
- Step 2: Add registration
After line 94 (await registerUserMessageRoutes(fastify)), add:
await registerSseRoutes(fastify)
- Step 3: Verify server starts
Run: cd server && timeout 5 npm run dev 2>&1 || true
Expected: no crash, server starts listening
- Step 4: Commit
git add server/src/index.js
git commit -m "feat: register SSE routes in server"
Task 4: Client — EventSource factory
Files:
-
Create:
client/src/shared/lib/sse.ts -
Step 1: Write the factory
export function createEventStream(token: string): EventSource {
return new EventSource(`/api/sse/stream?token=${encodeURIComponent(token)}`)
}
- Step 2: Commit
git add client/src/shared/lib/sse.ts
git commit -m "feat: add EventSource factory for SSE"
Task 5: Client — write SseProvider tests (TDD red)
Files:
-
Create:
client/src/app/providers/__tests__/SseProvider.test.tsx -
Step 1: Write the test file
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { SseProvider } from '../SseProvider'
const mockInvalidateQueries = vi.fn()
vi.mock('@tanstack/react-query', async () => {
const actual = await vi.importActual('@tanstack/react-query')
return { ...actual, useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }) }
})
vi.mock('@/shared/model/auth', () => ({
$token: { defaultState: null, subscribe: () => () => {}, getState: () => null, watch: () => () => {}, on: () => {}, reset: () => {} },
}))
let mockToken: string | null = null
let mockEventHandlers: Record<string, (event: MessageEvent) => void> = {}
let mockCloseCalls = 0
class MockEventSource {
url: string
constructor(url: string) {
this.url = url
mockCloseCalls = 0
mockEventHandlers = {}
}
addEventListener(type: string, handler: (event: MessageEvent) => void) {
mockEventHandlers[type] = handler
}
removeEventListener(type: string, _handler: (event: MessageEvent) => void) {
delete mockEventHandlers[type]
}
close() { mockCloseCalls++ }
}
vi.mock('@/shared/lib/sse', () => ({
createEventStream: (token: string) => {
mockToken = token
return new MockEventSource(`/api/sse/stream?token=${token}`) as unknown as EventSource
},
}))
vi.mock('effector-react', async () => {
const actual = await vi.importActual('effector-react')
return { ...actual, useUnit: () => mockToken }
})
function renderSse() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(<QueryClientProvider client={qc}><SseProvider /></QueryClientProvider>)
}
describe('SseProvider', () => {
afterEach(() => {
mockToken = null
mockInvalidateQueries.mockReset()
mockCloseCalls = 0
mockEventHandlers = {}
})
it('renders nothing (returns null)', () => {
mockToken = null
const { container } = renderSse()
expect(container.innerHTML).toBe('')
})
it('does not create EventSource when token is null', () => {
mockToken = null
renderSse()
expect(mockToken).toBeNull()
})
it('creates EventSource when token is set', () => {
mockToken = 'test-jwt'
renderSse()
expect(mockToken).toBe('test-jwt')
})
it('closes EventSource on unmount', () => {
mockToken = 'test-jwt'
const { unmount } = renderSse()
expect(mockCloseCalls).toBe(0)
unmount()
expect(mockCloseCalls).toBe(1)
})
it('invalidates unread-count and conversations on message:new', () => {
mockToken = 'test-jwt'
renderSse()
const handler = mockEventHandlers['message:new']
expect(handler).toBeDefined()
handler(new MessageEvent('message:new', { data: JSON.stringify({ orderId: 'o1' }) }))
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'messages', 'unread-count'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'conversations'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o1'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'o1'] })
})
it('invalidates order queries on order:statusChanged', () => {
mockToken = 'test-jwt'
renderSse()
const handler = mockEventHandlers['order:statusChanged']
handler(new MessageEvent('order:statusChanged', { data: JSON.stringify({ orderId: 'o2' }) }))
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o2'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'o2'] })
})
it('invalidates order queries on order:updated', () => {
mockToken = 'test-jwt'
renderSse()
const handler = mockEventHandlers['order:updated']
handler(new MessageEvent('order:updated', { data: JSON.stringify({ orderId: 'o3' }) }))
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o3'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'o3'] })
})
it('invalidates admin queries on order:new', () => {
mockToken = 'test-jwt'
renderSse()
const handler = mockEventHandlers['order:new']
handler(new MessageEvent('order:new', { data: JSON.stringify({ orderId: 'o4' }) }))
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'summary'] })
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders'] })
})
it('handles invalid JSON gracefully', () => {
mockToken = 'test-jwt'
renderSse()
const handler = mockEventHandlers['message:new']
expect(() => { handler(new MessageEvent('message:new', { data: ':heartbit' })) }).not.toThrow()
expect(mockInvalidateQueries).not.toHaveBeenCalled()
})
})
- Step 2: Run tests — expect FAIL (SseProvider does not exist)
Run: cd client && npx vitest run src/app/providers/__tests__/SseProvider.test.tsx
Expected: FAIL — module ../SseProvider not found
Task 6: Client — implement SseProvider (TDD green)
Files:
-
Create:
client/src/app/providers/SseProvider.tsx -
Step 1: Write the SseProvider component
import { useEffect, useRef } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
import { createEventStream } from '@/shared/lib/sse'
import { $token } from '@/shared/model/auth'
export function SseProvider() {
const token = useUnit($token)
const queryClient = useQueryClient()
const sourceRef = useRef<EventSource | null>(null)
useEffect(() => {
if (!token) {
if (sourceRef.current) {
sourceRef.current.close()
sourceRef.current = null
}
return
}
const es = createEventStream(token)
sourceRef.current = es
function handleEvent(eventName: string) {
return function (event: MessageEvent) {
try {
const data = JSON.parse(event.data)
const orderId = data.orderId
switch (eventName) {
case 'message:new':
queryClient.invalidateQueries({ queryKey: ['me', 'messages', 'unread-count'] })
queryClient.invalidateQueries({ queryKey: ['me', 'conversations'] })
if (orderId) {
queryClient.invalidateQueries({ queryKey: ['me', 'orders', orderId] })
queryClient.invalidateQueries({ queryKey: ['admin', 'orders', orderId] })
}
break
case 'order:statusChanged':
if (orderId) {
queryClient.invalidateQueries({ queryKey: ['me', 'orders', orderId] })
queryClient.invalidateQueries({ queryKey: ['admin', 'orders', orderId] })
}
break
case 'order:updated':
if (orderId) {
queryClient.invalidateQueries({ queryKey: ['me', 'orders', orderId] })
queryClient.invalidateQueries({ queryKey: ['admin', 'orders', orderId] })
}
break
case 'order:new':
queryClient.invalidateQueries({ queryKey: ['admin', 'orders', 'summary'] })
queryClient.invalidateQueries({ queryKey: ['admin', 'orders'] })
break
}
} catch {
// ignore parse errors (e.g. heartbit comments)
}
}
}
const messageNewHandler = handleEvent('message:new')
const orderStatusHandler = handleEvent('order:statusChanged')
const orderUpdatedHandler = handleEvent('order:updated')
const orderNewHandler = handleEvent('order:new')
es.addEventListener('message:new', messageNewHandler)
es.addEventListener('order:statusChanged', orderStatusHandler)
es.addEventListener('order:updated', orderUpdatedHandler)
es.addEventListener('order:new', orderNewHandler)
return () => {
es.removeEventListener('message:new', messageNewHandler)
es.removeEventListener('order:statusChanged', orderStatusHandler)
es.removeEventListener('order:updated', orderUpdatedHandler)
es.removeEventListener('order:new', orderNewHandler)
es.close()
sourceRef.current = null
}
}, [token, queryClient])
return null
}
- Step 2: Run tests — expect PASS
Run: cd client && npx vitest run src/app/providers/__tests__/SseProvider.test.tsx
Expected: all PASS
- Step 3: Commit both files
git add client/src/shared/lib/sse.ts client/src/app/providers/SseProvider.tsx client/src/app/providers/__tests__/SseProvider.test.tsx
git commit -m "feat: add SseProvider — SSE to ReactQuery bridge with tests"
Task 7: Client — mount SseProvider in AppProviders
Files:
-
Modify:
client/src/app/providers/AppProviders.tsx -
Step 1: Add import and mount
import { SseProvider } from './SseProvider'
Inside the return, add <SseProvider /> as first child of QueryClientProvider:
<QueryClientProvider client={queryClient}>
<SseProvider />
<ThemeControllerProvider>
<AppThemeInner>{children}</AppThemeInner>
</ThemeControllerProvider>
</QueryClientProvider>
- Step 2: Verify build
Run: cd client && npx tsc --noEmit
Expected: no errors
- Step 3: Commit
git add client/src/app/providers/AppProviders.tsx
git commit -m "feat: mount SseProvider in AppProviders"
Task 8: Client — remove polling from MeLayoutPage
Files:
-
Modify:
client/src/pages/me/ui/MeLayoutPage.tsx -
Step 1: Remove refetchInterval and refetchOnWindowFocus
Find the unread query. Remove refetchInterval: 45_000 and refetchOnWindowFocus: true:
const unreadQuery = useQuery({
queryKey: ['me', 'messages', 'unread-count'],
queryFn: fetchUnreadMessageCount,
enabled: Boolean(user),
})
- Step 2: Verify TypeScript
Run: cd client && npx tsc --noEmit
Expected: no errors
- Step 3: Commit
git add client/src/pages/me/ui/MeLayoutPage.tsx
git commit -m "feat: remove polling from MeLayoutPage — replaced by SSE"
Task 9: Client — remove polling from AdminLayoutPage
Files:
-
Modify:
client/src/pages/admin-layout/ui/AdminLayoutPage.tsx -
Step 1: Remove refetchInterval and refetchOnWindowFocus
Find the orders summary query. Remove refetchInterval: 45_000 and refetchOnWindowFocus: true:
const ordersSummaryQuery = useQuery({
queryKey: ['admin', 'orders', 'summary'],
queryFn: fetchAdminOrdersSummary,
enabled: isAdmin,
})
- Step 2: Verify TypeScript
Run: cd client && npx tsc --noEmit
Expected: no errors
- Step 3: Commit
git add client/src/pages/admin-layout/ui/AdminLayoutPage.tsx
git commit -m "feat: remove admin polling from AdminLayoutPage — replaced by SSE"
Task 10: Final verification
Files: none (verification only)
- Step 1: Run server tests
Run: cd server && npm test -- --run
Expected: all tests pass
- Step 2: Run client tests
Run: cd client && npm test -- --run
Expected: all tests pass
- Step 3: Run client lint
Run: cd client && npm run lint
Expected: no errors
- Step 4: Run server lint
Run: cd server && npm run lint
Expected: no errors
- Step 5: Run client format check
Run: cd client && npm run format:check
Expected: no formatting issues
- Step 6: Run client build
Run: cd client && npm run build
Expected: successful build