@vevedh/create-feathers-app 0.0.0-stage → 0.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +42 -2
  3. package/bin/create-feathers-app.mjs +33 -0
  4. package/lib/planner/options.mjs +74 -0
  5. package/lib/planner/plan.mjs +107 -0
  6. package/lib/scaffold.mjs +136 -0
  7. package/package.json +32 -4
  8. package/templates/common/dev-all.mjs +46 -0
  9. package/templates/daisyui-ssg/.env.example +6 -0
  10. package/templates/daisyui-ssg/README.md +21 -0
  11. package/templates/daisyui-ssg/api/server.mjs +45 -0
  12. package/templates/daisyui-ssg/api/services/health/health.class.mjs +3 -0
  13. package/templates/daisyui-ssg/api/services/health/health.service.mjs +4 -0
  14. package/templates/daisyui-ssg/api/services/notes/notes.class.mjs +25 -0
  15. package/templates/daisyui-ssg/api/services/notes/notes.service.mjs +10 -0
  16. package/templates/daisyui-ssg/gitignore.template +16 -0
  17. package/templates/daisyui-ssg/index.html +2 -0
  18. package/templates/daisyui-ssg/package.json +39 -0
  19. package/templates/daisyui-ssg/scripts/prerender.mjs +14 -0
  20. package/templates/daisyui-ssg/scripts/serve-static.mjs +32 -0
  21. package/templates/daisyui-ssg/src/App.vue +14 -0
  22. package/templates/daisyui-ssg/src/components/NotesPanel.vue +22 -0
  23. package/templates/daisyui-ssg/src/entry-client.ts +9 -0
  24. package/templates/daisyui-ssg/src/entry-server.ts +9 -0
  25. package/templates/daisyui-ssg/src/stores/notes.ts +46 -0
  26. package/templates/daisyui-ssg/src/style.css +3 -0
  27. package/templates/daisyui-ssg/src/vite-env.d.ts +1 -0
  28. package/templates/daisyui-ssg/tsconfig.json +4 -0
  29. package/templates/daisyui-ssg/vite.config.ts +6 -0
  30. package/templates/daisyui-ssr/.env.example +6 -0
  31. package/templates/daisyui-ssr/README.md +21 -0
  32. package/templates/daisyui-ssr/api/server.mjs +45 -0
  33. package/templates/daisyui-ssr/api/services/health/health.class.mjs +3 -0
  34. package/templates/daisyui-ssr/api/services/health/health.service.mjs +4 -0
  35. package/templates/daisyui-ssr/api/services/notes/notes.class.mjs +25 -0
  36. package/templates/daisyui-ssr/api/services/notes/notes.service.mjs +10 -0
  37. package/templates/daisyui-ssr/gitignore.template +16 -0
  38. package/templates/daisyui-ssr/index.html +2 -0
  39. package/templates/daisyui-ssr/package.json +39 -0
  40. package/templates/daisyui-ssr/scripts/serve-ssr.mjs +47 -0
  41. package/templates/daisyui-ssr/src/App.vue +14 -0
  42. package/templates/daisyui-ssr/src/components/NotesPanel.vue +22 -0
  43. package/templates/daisyui-ssr/src/entry-client.ts +9 -0
  44. package/templates/daisyui-ssr/src/entry-server.ts +9 -0
  45. package/templates/daisyui-ssr/src/stores/notes.ts +46 -0
  46. package/templates/daisyui-ssr/src/style.css +3 -0
  47. package/templates/daisyui-ssr/src/vite-env.d.ts +1 -0
  48. package/templates/daisyui-ssr/tsconfig.json +4 -0
  49. package/templates/daisyui-ssr/vite.config.ts +7 -0
  50. package/templates/spa/common/.env.example +7 -0
  51. package/templates/spa/common/api/server.mjs +45 -0
  52. package/templates/spa/common/api/services/health/health.class.mjs +3 -0
  53. package/templates/spa/common/api/services/health/health.service.mjs +4 -0
  54. package/templates/spa/common/api/services/notes/notes.class.mjs +25 -0
  55. package/templates/spa/common/api/services/notes/notes.service.mjs +10 -0
  56. package/templates/spa/common/gitignore.template +19 -0
  57. package/templates/spa/common/index.html +3 -0
  58. package/templates/spa/common/src/components/NotesPanel.vue +25 -0
  59. package/templates/spa/common/src/stores/notes.ts +46 -0
  60. package/templates/spa/common/src/vite-env.d.ts +1 -0
  61. package/templates/spa/common/tsconfig.json +4 -0
  62. package/templates/spa/daisyui/src/App.vue +2 -0
  63. package/templates/spa/daisyui/src/components/NotesPanel.vue +22 -0
  64. package/templates/spa/daisyui/src/main.ts +5 -0
  65. package/templates/spa/daisyui/src/style.css +3 -0
  66. package/templates/spa/daisyui/vite.config.ts +8 -0
  67. package/templates/spa/quasar2/src/App.vue +3 -0
  68. package/templates/spa/quasar2/src/components/NotesPanel.vue +23 -0
  69. package/templates/spa/quasar2/src/main.ts +7 -0
  70. package/templates/spa/quasar2/src/style.css +1 -0
  71. package/templates/spa/quasar2/vite.config.ts +8 -0
@@ -0,0 +1,16 @@
1
+ node_modules/
2
+ dist/
3
+ .env*
4
+ !.env.example
5
+ *.log
6
+ coverage/
7
+ test-results/
8
+ playwright-report/
9
+ .agents/
10
+ .ai/
11
+ .codex/
12
+ .cursor/
13
+ .claude/
14
+ .mcp.json
15
+ .vscode/mcp.json
16
+ docs-private/
@@ -0,0 +1,2 @@
1
+ <!doctype html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>DaisyUI Feathers — SSR/SSG</title><meta name="r17-example" content="daisyui-ssg"><link rel="icon" href="data:,"></head>
2
+ <body><div id="app"><!--app-html--></div><script type="module" src="/src/entry-client.ts"></script></body></html>
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "r17-daisyui-ssg",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "packageManager": "pnpm@10.30.2",
7
+ "engines": {
8
+ "node": ">=22.12"
9
+ },
10
+ "scripts": {
11
+ "dev:api": "node api/server.mjs",
12
+ "typecheck": "vue-tsc --noEmit",
13
+ "build": "vite build --outDir dist/client && vite build --ssr src/entry-server.ts --outDir dist/server && node scripts/prerender.mjs",
14
+ "dev": "vite --host 127.0.0.1 --port 5173",
15
+ "serve:production": "node scripts/serve-static.mjs"
16
+ },
17
+ "dependencies": {
18
+ "@vevedh/vite-plugin-feathersjs": "0.2.6",
19
+ "@feathersjs/feathers": "^5.0.50",
20
+ "@feathersjs/errors": "^5.0.50",
21
+ "@feathersjs/koa": "^5.0.50",
22
+ "@feathersjs/express": "^5.0.50",
23
+ "@feathersjs/socketio": "^5.0.50",
24
+ "vue": "^3.5.44",
25
+ "pinia": "^3.0.4",
26
+ "socket.io-client": "^4.8.4",
27
+ "@vue/server-renderer": "^3.5.44"
28
+ },
29
+ "devDependencies": {
30
+ "vite": "^7.3.7",
31
+ "@vitejs/plugin-vue": "^6.0.0",
32
+ "tailwindcss": "^4.1.0",
33
+ "@tailwindcss/vite": "^4.1.0",
34
+ "daisyui": "^5.0.0",
35
+ "vue-tsc": "^3.0.7",
36
+ "typescript": "^5.9.3",
37
+ "@types/node": "^22.0.0"
38
+ }
39
+ }
@@ -0,0 +1,14 @@
1
+ import { readFile, mkdir, writeFile, cp } from 'node:fs/promises'
2
+ import { resolve, dirname } from 'node:path'
3
+ import { fileURLToPath, pathToFileURL } from 'node:url'
4
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
5
+ const { render } = await import(pathToFileURL(resolve(root,'dist/server/entry-server.js')).href)
6
+ const template = await readFile(resolve(root,'dist/client/index.html'),'utf8')
7
+ for (const route of ['/', '/about']) {
8
+ const content = await render(route) // public markup only: no Feathers fetch or token
9
+ const directory = route === '/' ? resolve(root,'dist/ssg') : resolve(root,'dist/ssg/about')
10
+ await mkdir(directory,{recursive:true})
11
+ await writeFile(resolve(directory,'index.html'), template.replace('<!--app-html-->',content))
12
+ }
13
+ await cp(resolve(root,'dist/client/assets'), resolve(root,'dist/ssg/assets'),{recursive:true})
14
+ console.log('PASS: SSG generated / and /about without dynamic notes')
@@ -0,0 +1,32 @@
1
+ import { createServer } from 'node:http'
2
+ import { readFile } from 'node:fs/promises'
3
+ import { resolve, dirname, extname, sep } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'dist/ssg')
6
+ const mime = { '.html':'text/html; charset=utf-8', '.js':'text/javascript', '.css':'text/css', '.png':'image/png', '.svg':'image/svg+xml', '.woff2':'font/woff2' }
7
+ const server = createServer(async (req,res)=>{
8
+ try {
9
+ const pathname = new URL(req.url||'/', 'http://localhost').pathname
10
+ if (pathname.startsWith('/api/')) {
11
+ const target = new URL(pathname,'http://127.0.0.1:3030')
12
+ const method = req.method || 'GET'
13
+ const chunks = []
14
+ if (method !== 'GET' && method !== 'HEAD') for await(const chunk of req) {
15
+ chunks.push(chunk)
16
+ if (chunks.reduce((sum,item)=>sum+item.length,0)>65536) throw new Error('Request body too large')
17
+ }
18
+ const response = await fetch(target,{ method, headers: { accept:'application/json', 'content-type':req.headers['content-type']||'application/json' },body:chunks.length?Buffer.concat(chunks):undefined })
19
+ res.writeHead(response.status,{'content-type': response.headers.get('content-type')||'application/json'})
20
+ res.end(Buffer.from(await response.arrayBuffer()));return
21
+ }
22
+ if (!/^\/[a-zA-Z0-9._\/-]*$/.test(pathname) || pathname.includes('..')) {res.writeHead(400);res.end();return}
23
+ const rel = pathname === '/' ? 'index.html' : pathname === '/about' || pathname === '/about/' ? 'about/index.html' : pathname.slice(1)
24
+ const full=resolve(root,rel)
25
+ if (!full.startsWith(root+sep) && full !== resolve(root,'index.html')) {res.writeHead(403);res.end();return}
26
+ const body=await readFile(full)
27
+ res.writeHead(200, { 'content-type': mime[extname(full)] || 'application/octet-stream', 'cache-control': extname(full)==='.html' ? 'no-store' : 'public, max-age=60' })
28
+ res.end(body)
29
+ }
30
+ catch (error) {res.writeHead(error.code==='ENOENT'?404:500);res.end('Not found')}
31
+ })
32
+ server.listen(5173,'127.0.0.1',()=>console.log('SSG preview: http://127.0.0.1:5173'))
@@ -0,0 +1,14 @@
1
+ <script setup lang="ts">
2
+ import NotesPanel from './components/NotesPanel.vue'
3
+ defineProps<{ route: string }>()
4
+ </script>
5
+ <template>
6
+ <main class="min-h-screen bg-base-200 p-6"><div class="max-w-4xl mx-auto space-y-6">
7
+ <header class="navbar bg-base-100 rounded-box px-6 gap-4"><span class="font-bold">Feathers Console — daisyUI</span>
8
+ <a href="/" class="link">Accueil</a><a href="/about" class="link">À propos</a></header>
9
+ <h1 class="text-3xl font-semibold">{{ route === '/about' ? 'À propos de cet exemple' : 'Vue 3, Pinia et FeathersJS v5' }}</h1>
10
+ <p>Cette page publique ne contient aucune donnée personnelle. Les notes sont demandées au service Feathers après hydratation.</p>
11
+ <NotesPanel v-if="route !== '/about'" />
12
+ <p v-else>Rendu HTML public. API REST séparée et données de démonstration non prérendues.</p>
13
+ </div></main>
14
+ </template>
@@ -0,0 +1,22 @@
1
+ <script setup lang="ts">
2
+ import { onMounted, onUnmounted, ref } from 'vue'
3
+ import { useNotesStore } from '../stores/notes'
4
+ const store = useNotesStore()
5
+ const title = ref('')
6
+ onMounted(() => { void store.load(); store.connectRealtime() })
7
+ onUnmounted(() => store.disconnectRealtime())
8
+ async function submit() { await store.create(title.value); if (!store.error) title.value = '' }
9
+ </script>
10
+ <template>
11
+ <section class="card bg-base-100 border border-base-300 shadow-lg"><div class="card-body">
12
+ <h2 class="card-title">Notes FeathersJS</h2>
13
+ <span class="badge badge-outline" data-testid="realtime-status">{{ store.realtimeConnected ? 'Temps réel connecté' : 'REST actif' }}</span>
14
+ <div v-if="store.error" class="alert alert-error" role="alert">{{ store.error }}</div>
15
+ <form class="flex gap-3 flex-wrap" @submit.prevent="submit">
16
+ <label class="input flex-1"><span class="label">Titre</span><input v-model="title" aria-label="Titre de la nouvelle note" maxlength="120" required /></label>
17
+ <button class="btn btn-primary" type="submit" :disabled="store.loading || !title.trim()">Créer une note</button>
18
+ </form>
19
+ <p data-testid="notes-count">{{ store.notes.length }} note(s)</p>
20
+ <ul data-testid="notes" class="list bg-base-200 rounded-box"><li v-for="note in store.notes" :key="note.id" class="list-row">{{ note.title }}</li></ul>
21
+ </div></section>
22
+ </template>
@@ -0,0 +1,9 @@
1
+ import { createApp, createSSRApp } from 'vue'
2
+ import { createPinia } from 'pinia'
3
+ import App from './App.vue'
4
+ import './style.css'
5
+ const route = window.location.pathname.replace(/\/$/, '') || '/'
6
+ // Static HTML in production is hydrated. A dev-only empty shell is mounted normally.
7
+ const hydrated = Boolean(document.querySelector('#app')?.firstElementChild)
8
+ const app = hydrated ? createSSRApp(App, { route }) : createApp(App, { route })
9
+ app.use(createPinia()).mount('#app')
@@ -0,0 +1,9 @@
1
+ import { createSSRApp } from 'vue'
2
+ import { createPinia } from 'pinia'
3
+ import { renderToString } from 'vue/server-renderer'
4
+ import App from './App.vue'
5
+ export async function render(route: string): Promise<string> {
6
+ const app = createSSRApp(App, { route })
7
+ app.use(createPinia()) // fresh Pinia for every server-side render
8
+ return await renderToString(app)
9
+ }
@@ -0,0 +1,46 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref } from 'vue'
3
+ import { io, type Socket } from 'socket.io-client'
4
+ export interface Note { id: number; title: string }
5
+ const realtime = import.meta.env.VITE_FEATHERS_REALTIME === '1'
6
+ export const useNotesStore = defineStore('notes', () => {
7
+ const notes = ref<Note[]>([])
8
+ const loading = ref(false)
9
+ const error = ref('')
10
+ const realtimeConnected = ref(false)
11
+ let socket: Socket | undefined
12
+ async function load(): Promise<void> {
13
+ try {
14
+ const response = await fetch('/api/notes', { headers: { Accept: 'application/json' } })
15
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
16
+ const payload: unknown = await response.json()
17
+ if (!Array.isArray(payload)) throw new Error('Unexpected notes response')
18
+ notes.value = payload as Note[]
19
+ error.value = ''
20
+ }
21
+ catch (cause) { error.value = cause instanceof Error ? cause.message : 'API unavailable' }
22
+ }
23
+ async function create(title: string): Promise<void> {
24
+ if (loading.value || !title.trim()) return
25
+ loading.value = true
26
+ try {
27
+ const response = await fetch('/api/notes', {
28
+ method: 'POST', headers: { 'content-type': 'application/json' },
29
+ body: JSON.stringify({ title: title.trim() }),
30
+ })
31
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
32
+ await load()
33
+ }
34
+ catch (cause) { error.value = cause instanceof Error ? cause.message : 'Create failed' }
35
+ finally { loading.value = false }
36
+ }
37
+ function connectRealtime(): void {
38
+ if (!realtime || typeof window === 'undefined' || socket) return
39
+ socket = io(window.location.origin, { path: '/socket.io', transports: ['websocket'] })
40
+ socket.on('connect', () => { realtimeConnected.value = true })
41
+ socket.on('disconnect', () => { realtimeConnected.value = false })
42
+ socket.on('notes created', () => { void load() })
43
+ }
44
+ function disconnectRealtime(): void { socket?.disconnect(); socket = undefined; realtimeConnected.value = false }
45
+ return { notes, loading, error, realtimeConnected, load, create, connectRealtime, disconnectRealtime }
46
+ })
@@ -0,0 +1,3 @@
1
+ @import "tailwindcss";
2
+ @plugin "daisyui";
3
+ :root { font-family: ui-sans-serif, system-ui, sans-serif }
@@ -0,0 +1 @@
1
+ /// <reference types="vite/client" />
@@ -0,0 +1,4 @@
1
+ {
2
+ "compilerOptions": { "target": "ES2022", "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "Bundler", "types": ["vite/client"], "strict": true, "exactOptionalPropertyTypes": true, "noUncheckedIndexedAccess": true, "isolatedModules": true, "skipLibCheck": true, "noEmit": true, "jsx": "preserve" },
3
+ "include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
4
+ }
@@ -0,0 +1,6 @@
1
+ import { defineConfig } from 'vite'
2
+ import vue from '@vitejs/plugin-vue'
3
+ import tailwindcss from '@tailwindcss/vite'
4
+ export default defineConfig({ plugins: [vue(), tailwindcss()],
5
+ server: { host: '127.0.0.1', port: 5173, strictPort: true, proxy: { '/api': 'http://127.0.0.1:3030' } },
6
+ })
@@ -0,0 +1,6 @@
1
+ # Local demonstration only. No secrets or private addresses.
2
+ FEATHERS_ADAPTER=koa
3
+ FEATHERS_API_PORT=3030
4
+ FEATHERS_SOCKET_PORT=3031
5
+ FEATHERS_SOCKETIO=0
6
+ FEATHERS_PUBLIC_ORIGIN=http://127.0.0.1:5173
@@ -0,0 +1,21 @@
1
+ # daisyUI SSR — FeathersJS v5
2
+
3
+ Exemple de rendu SSR avec Vue 3, Pinia, Vite 7 et Tailwind CSS/daisyUI. Le service FeathersJS v5 est exécuté dans un processus indépendant. Les pages statiques ne contiennent pas de notes ni de secrets. Données de démonstration uniquement, sans authentification.
4
+
5
+ ```powershell
6
+ corepack pnpm install
7
+ # Terminal 1 : API Feathers
8
+ corepack pnpm run dev:api
9
+ # Terminal 2 : frontend
10
+ corepack pnpm run dev
11
+ ```
12
+
13
+ ```powershell
14
+ corepack pnpm run typecheck
15
+ corepack pnpm run build
16
+ corepack pnpm run serve:production
17
+ ```
18
+
19
+ Le serveur de développement et de production rend deux routes (`/`, `/about`) avec un état Pinia distinct à chaque requête.
20
+
21
+ La certification Windows complète reste à exécuter. Ne pas exposer publiquement l’API de démonstration en mémoire, sans authentification.
@@ -0,0 +1,45 @@
1
+ import { createServer } from 'node:http'
2
+ import { createFeathersRuntime } from '@vevedh/vite-plugin-feathersjs'
3
+ const adapter = process.env.FEATHERS_ADAPTER === 'express' ? 'express' : 'koa'
4
+ const port = Number(process.env.FEATHERS_API_PORT || 3030)
5
+ const wsPort = Number(process.env.FEATHERS_SOCKET_PORT || 3031)
6
+ const realtime = process.env.FEATHERS_SOCKETIO === '1'
7
+ const origin = process.env.FEATHERS_PUBLIC_ORIGIN || 'http://127.0.0.1:5173'
8
+ if (!Number.isInteger(port) || port < 1024 || port > 65535 || !Number.isInteger(wsPort) || wsPort === port) throw new Error('Invalid port configuration')
9
+ const modules = [
10
+ new URL('./services/health/health.service.mjs', import.meta.url),
11
+ new URL('./services/notes/notes.service.mjs', import.meta.url),
12
+ ]
13
+ const runtime = await createFeathersRuntime(modules.map(String), file => import(file), {
14
+ serviceFiles: [], adapter, prefix: '/api', cors: false,
15
+ socketio: realtime ? { port: wsPort, origins: [origin], host: '127.0.0.1' } : false,
16
+ })
17
+ await runtime.start()
18
+ const httpServer = createServer((req, res) => {
19
+ const pathname = new URL(req.url || '/', 'http://localhost').pathname
20
+ if (pathname !== '/api' && !pathname.startsWith('/api/')) {
21
+ res.writeHead(404, { 'content-type': 'application/json' }); res.end('{"error":"Not found"}'); return
22
+ }
23
+ // The Feathers transport is mounted at /api, while its services register as health/notes.
24
+ req.url = (req.url || '/').slice(4) || '/'
25
+ runtime.handler(req, res, error => {
26
+ if (res.headersSent) { res.end(); return }
27
+ res.writeHead(500, { 'content-type': 'application/json' })
28
+ res.end(JSON.stringify({ error: 'Internal server error' }))
29
+ if (error) console.error('[feathers] request failed')
30
+ })
31
+ })
32
+ await new Promise((resolve, reject) => {
33
+ httpServer.once('error', reject)
34
+ httpServer.listen(port, '127.0.0.1', resolve)
35
+ })
36
+ console.log(`Feathers ${adapter} API listening on http://127.0.0.1:${port}/api (socketio=${realtime})`)
37
+ let stopping = false
38
+ async function close() {
39
+ if (stopping) return
40
+ stopping = true
41
+ await new Promise(resolve => httpServer.close(resolve))
42
+ await runtime.close()
43
+ }
44
+ process.on('SIGINT', () => { void close().then(() => process.exit(0)) })
45
+ process.on('SIGTERM', () => { void close().then(() => process.exit(0)) })
@@ -0,0 +1,3 @@
1
+ export class HealthService {
2
+ async find() { return { status: 'ok', transport: 'feathers-v5' } }
3
+ }
@@ -0,0 +1,4 @@
1
+ import { HealthService } from './health.class.mjs'
2
+ export default function register(app) {
3
+ app.use('health', new HealthService(), { methods: ['find'] })
4
+ }
@@ -0,0 +1,25 @@
1
+ import { NotFound, BadRequest } from '@feathersjs/errors'
2
+ export class NotesService {
3
+ // Feathers v5 exposes service methods through its own service wrapper. The
4
+ // wrapper is not necessarily this class instance, so native #private slots
5
+ // cannot be read through an unbound method call. Bind to the owning instance.
6
+ // Keep this per-service-instance: never hoist records or IDs to module scope.
7
+ constructor() {
8
+ for (const name of ['find', 'get', 'create', 'patch', 'remove']) {
9
+ this[name] = this[name].bind(this)
10
+ }
11
+ }
12
+ #records = new Map([[1, { id: 1, title: 'Hello from FeathersJS v5' }]])
13
+ #nextId = 2
14
+ #id(value) { const n = Number(value); if (!Number.isSafeInteger(n) || n < 1) throw new BadRequest('Invalid note ID'); return n }
15
+ #title(value) {
16
+ const title = value?.title
17
+ if (typeof title !== 'string' || title.trim().length < 1 || title.length > 120) throw new BadRequest('Invalid note title')
18
+ return title.trim()
19
+ }
20
+ async find() { return [...this.#records.values()].map(note => ({ ...note })) }
21
+ async get(id) { const note = this.#records.get(this.#id(id)); if (!note) throw new NotFound('Note not found'); return { ...note } }
22
+ async create(data) { const note = { id: this.#nextId++, title: this.#title(data) }; this.#records.set(note.id, note); return { ...note } }
23
+ async patch(id, data) { const key = this.#id(id); if (!this.#records.has(key)) throw new NotFound('Note not found'); const note = { id: key, title: this.#title(data) }; this.#records.set(key, note); return { ...note } }
24
+ async remove(id) { const key = this.#id(id); const note = this.#records.get(key); if (!note) throw new NotFound('Note not found'); this.#records.delete(key); return { ...note } }
25
+ }
@@ -0,0 +1,10 @@
1
+ import { NotesService } from './notes.class.mjs'
2
+ export default function register(app) {
3
+ app.use('notes', new NotesService(), { methods: ['find', 'get', 'create', 'patch', 'remove'] })
4
+ const service = app.service('notes')
5
+ // Demo-only channel: keep the entire stack loopback-only.
6
+ if (typeof service.publish === 'function') {
7
+ app.on('connection', connection => app.channel('local-demo').join(connection))
8
+ service.publish(() => app.channel('local-demo'))
9
+ }
10
+ }
@@ -0,0 +1,16 @@
1
+ node_modules/
2
+ dist/
3
+ .env*
4
+ !.env.example
5
+ *.log
6
+ coverage/
7
+ test-results/
8
+ playwright-report/
9
+ .agents/
10
+ .ai/
11
+ .codex/
12
+ .cursor/
13
+ .claude/
14
+ .mcp.json
15
+ .vscode/mcp.json
16
+ docs-private/
@@ -0,0 +1,2 @@
1
+ <!doctype html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>DaisyUI Feathers — SSR/SSG</title><meta name="r17-example" content="daisyui-ssr"><link rel="icon" href="data:,"></head>
2
+ <body><div id="app"><!--app-html--></div><script type="module" src="/src/entry-client.ts"></script></body></html>
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "r17-daisyui-ssr",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "packageManager": "pnpm@10.30.2",
7
+ "engines": {
8
+ "node": ">=22.12"
9
+ },
10
+ "scripts": {
11
+ "dev:api": "node api/server.mjs",
12
+ "typecheck": "vue-tsc --noEmit",
13
+ "build": "vite build --outDir dist/client && vite build --ssr src/entry-server.ts --outDir dist/server",
14
+ "dev": "node scripts/serve-ssr.mjs",
15
+ "serve:production": "node scripts/serve-ssr.mjs"
16
+ },
17
+ "dependencies": {
18
+ "@vevedh/vite-plugin-feathersjs": "0.2.6",
19
+ "@feathersjs/feathers": "^5.0.50",
20
+ "@feathersjs/errors": "^5.0.50",
21
+ "@feathersjs/koa": "^5.0.50",
22
+ "@feathersjs/express": "^5.0.50",
23
+ "@feathersjs/socketio": "^5.0.50",
24
+ "vue": "^3.5.44",
25
+ "pinia": "^3.0.4",
26
+ "socket.io-client": "^4.8.4",
27
+ "@vue/server-renderer": "^3.5.44"
28
+ },
29
+ "devDependencies": {
30
+ "vite": "^7.3.7",
31
+ "@vitejs/plugin-vue": "^6.0.0",
32
+ "tailwindcss": "^4.1.0",
33
+ "@tailwindcss/vite": "^4.1.0",
34
+ "daisyui": "^5.0.0",
35
+ "vue-tsc": "^3.0.7",
36
+ "typescript": "^5.9.3",
37
+ "@types/node": "^22.0.0"
38
+ }
39
+ }
@@ -0,0 +1,47 @@
1
+ import { createServer } from 'node:http'
2
+ import { readFile, realpath } from 'node:fs/promises'
3
+ import { resolve, dirname, extname, sep } from 'node:path'
4
+ import { fileURLToPath, pathToFileURL } from 'node:url'
5
+ import { createServer as createViteServer } from 'vite'
6
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
7
+ const prod = process.env.NODE_ENV === 'production'
8
+ const vite = prod ? undefined : await createViteServer({ root, server: { middlewareMode: true }, appType: 'custom' })
9
+ const html = prod ? await readFile(resolve(root,'dist/client/index.html'),'utf8') : await readFile(resolve(root,'index.html'),'utf8')
10
+ const renderer = prod ? (await import(pathToFileURL(resolve(root,'dist/server/entry-server.js')).href)).render : undefined
11
+ const mime = { '.js':'text/javascript', '.css':'text/css', '.svg':'image/svg+xml', '.png':'image/png', '.woff2':'font/woff2' }
12
+ const server = createServer(async (req, res) => {
13
+ const pathname = new URL(req.url || '/', 'http://127.0.0.1').pathname
14
+ try {
15
+ if (pathname.startsWith('/api/')) {
16
+ const target = new URL(pathname, 'http://127.0.0.1:3030')
17
+ const body = req.method === 'GET' || req.method === 'HEAD' ? undefined : await (async () => { const chunks=[];for await(const chunk of req) { chunks.push(chunk); if(chunks.reduce((n,c)=>n+c.length,0)>65536) throw new Error('Request body too large') } return Buffer.concat(chunks) })()
18
+ const response = await fetch(target, { method: req.method, headers: { 'content-type': req.headers['content-type'] || 'application/json', accept: 'application/json' }, body })
19
+ res.writeHead(response.status, { 'content-type': response.headers.get('content-type') || 'application/json' })
20
+ res.end(Buffer.from(await response.arrayBuffer()));return
21
+ }
22
+ if (prod && pathname.startsWith('/assets/') && /^\/assets\/[a-zA-Z0-9._\/-]+$/.test(pathname)) {
23
+ const base = await realpath(resolve(root,'dist/client/assets'))
24
+ const pathnameOnDisk = resolve(root,'dist/client'+pathname)
25
+ if (!pathnameOnDisk.startsWith(base + sep)) {res.writeHead(403);res.end();return}
26
+ const bytes = await readFile(pathnameOnDisk)
27
+ res.writeHead(200,{'content-type': mime[extname(pathname)] || 'application/octet-stream'});res.end(bytes);return
28
+ }
29
+ if (pathname !== '/' && pathname !== '/about' && pathname !== '/about/') { res.writeHead(404);res.end('Not found');return }
30
+ const route = pathname.replace(/\/$/,'') || '/'
31
+ const page = prod ? html : await vite.transformIndexHtml(pathname, html)
32
+ const render = prod ? renderer : (await vite.ssrLoadModule('/src/entry-server.ts')).render
33
+ const content = await render(route)
34
+ res.writeHead(200,{'content-type':'text/html; charset=utf-8','cache-control':'no-store'})
35
+ res.end(page.replace('<!--app-html-->', content))
36
+ }
37
+ catch (error) { console.error('[ssr] request failed', error); if (!res.headersSent) res.writeHead(500);res.end('Internal server error') }
38
+ })
39
+ // Vite transforms assets in dev via its middleware, HTML is handled by our renderer.
40
+ const app = createServer()
41
+ app.on('request', (req,res) => {
42
+ if (vite && !new URL(req.url||'/', 'http://localhost').pathname.startsWith('/api/'))
43
+ vite.middlewares(req,res,()=>server.emit('request',req,res))
44
+ else server.emit('request',req,res)
45
+ })
46
+ app.listen(5173,'127.0.0.1',()=>console.log('SSR listening on http://127.0.0.1:5173'))
47
+ process.on('SIGINT', () => { app.close(); void vite?.close() })
@@ -0,0 +1,14 @@
1
+ <script setup lang="ts">
2
+ import NotesPanel from './components/NotesPanel.vue'
3
+ defineProps<{ route: string }>()
4
+ </script>
5
+ <template>
6
+ <main class="min-h-screen bg-base-200 p-6"><div class="max-w-4xl mx-auto space-y-6">
7
+ <header class="navbar bg-base-100 rounded-box px-6 gap-4"><span class="font-bold">Feathers Console — daisyUI</span>
8
+ <a href="/" class="link">Accueil</a><a href="/about" class="link">À propos</a></header>
9
+ <h1 class="text-3xl font-semibold">{{ route === '/about' ? 'À propos de cet exemple' : 'Vue 3, Pinia et FeathersJS v5' }}</h1>
10
+ <p>Cette page publique ne contient aucune donnée personnelle. Les notes sont demandées au service Feathers après hydratation.</p>
11
+ <NotesPanel v-if="route !== '/about'" />
12
+ <p v-else>Rendu HTML public. API REST séparée et données de démonstration non prérendues.</p>
13
+ </div></main>
14
+ </template>
@@ -0,0 +1,22 @@
1
+ <script setup lang="ts">
2
+ import { onMounted, onUnmounted, ref } from 'vue'
3
+ import { useNotesStore } from '../stores/notes'
4
+ const store = useNotesStore()
5
+ const title = ref('')
6
+ onMounted(() => { void store.load(); store.connectRealtime() })
7
+ onUnmounted(() => store.disconnectRealtime())
8
+ async function submit() { await store.create(title.value); if (!store.error) title.value = '' }
9
+ </script>
10
+ <template>
11
+ <section class="card bg-base-100 border border-base-300 shadow-lg"><div class="card-body">
12
+ <h2 class="card-title">Notes FeathersJS</h2>
13
+ <span class="badge badge-outline" data-testid="realtime-status">{{ store.realtimeConnected ? 'Temps réel connecté' : 'REST actif' }}</span>
14
+ <div v-if="store.error" class="alert alert-error" role="alert">{{ store.error }}</div>
15
+ <form class="flex gap-3 flex-wrap" @submit.prevent="submit">
16
+ <label class="input flex-1"><span class="label">Titre</span><input v-model="title" aria-label="Titre de la nouvelle note" maxlength="120" required /></label>
17
+ <button class="btn btn-primary" type="submit" :disabled="store.loading || !title.trim()">Créer une note</button>
18
+ </form>
19
+ <p data-testid="notes-count">{{ store.notes.length }} note(s)</p>
20
+ <ul data-testid="notes" class="list bg-base-200 rounded-box"><li v-for="note in store.notes" :key="note.id" class="list-row">{{ note.title }}</li></ul>
21
+ </div></section>
22
+ </template>
@@ -0,0 +1,9 @@
1
+ import { createApp, createSSRApp } from 'vue'
2
+ import { createPinia } from 'pinia'
3
+ import App from './App.vue'
4
+ import './style.css'
5
+ const route = window.location.pathname.replace(/\/$/, '') || '/'
6
+ // Static HTML in production is hydrated. A dev-only empty shell is mounted normally.
7
+ const hydrated = Boolean(document.querySelector('#app')?.firstElementChild)
8
+ const app = hydrated ? createSSRApp(App, { route }) : createApp(App, { route })
9
+ app.use(createPinia()).mount('#app')
@@ -0,0 +1,9 @@
1
+ import { createSSRApp } from 'vue'
2
+ import { createPinia } from 'pinia'
3
+ import { renderToString } from 'vue/server-renderer'
4
+ import App from './App.vue'
5
+ export async function render(route: string): Promise<string> {
6
+ const app = createSSRApp(App, { route })
7
+ app.use(createPinia()) // fresh Pinia for every server-side render
8
+ return await renderToString(app)
9
+ }
@@ -0,0 +1,46 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref } from 'vue'
3
+ import { io, type Socket } from 'socket.io-client'
4
+ export interface Note { id: number; title: string }
5
+ const realtime = import.meta.env.VITE_FEATHERS_REALTIME === '1'
6
+ export const useNotesStore = defineStore('notes', () => {
7
+ const notes = ref<Note[]>([])
8
+ const loading = ref(false)
9
+ const error = ref('')
10
+ const realtimeConnected = ref(false)
11
+ let socket: Socket | undefined
12
+ async function load(): Promise<void> {
13
+ try {
14
+ const response = await fetch('/api/notes', { headers: { Accept: 'application/json' } })
15
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
16
+ const payload: unknown = await response.json()
17
+ if (!Array.isArray(payload)) throw new Error('Unexpected notes response')
18
+ notes.value = payload as Note[]
19
+ error.value = ''
20
+ }
21
+ catch (cause) { error.value = cause instanceof Error ? cause.message : 'API unavailable' }
22
+ }
23
+ async function create(title: string): Promise<void> {
24
+ if (loading.value || !title.trim()) return
25
+ loading.value = true
26
+ try {
27
+ const response = await fetch('/api/notes', {
28
+ method: 'POST', headers: { 'content-type': 'application/json' },
29
+ body: JSON.stringify({ title: title.trim() }),
30
+ })
31
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
32
+ await load()
33
+ }
34
+ catch (cause) { error.value = cause instanceof Error ? cause.message : 'Create failed' }
35
+ finally { loading.value = false }
36
+ }
37
+ function connectRealtime(): void {
38
+ if (!realtime || typeof window === 'undefined' || socket) return
39
+ socket = io(window.location.origin, { path: '/socket.io', transports: ['websocket'] })
40
+ socket.on('connect', () => { realtimeConnected.value = true })
41
+ socket.on('disconnect', () => { realtimeConnected.value = false })
42
+ socket.on('notes created', () => { void load() })
43
+ }
44
+ function disconnectRealtime(): void { socket?.disconnect(); socket = undefined; realtimeConnected.value = false }
45
+ return { notes, loading, error, realtimeConnected, load, create, connectRealtime, disconnectRealtime }
46
+ })
@@ -0,0 +1,3 @@
1
+ @import "tailwindcss";
2
+ @plugin "daisyui";
3
+ :root { font-family: ui-sans-serif, system-ui, sans-serif }
@@ -0,0 +1 @@
1
+ /// <reference types="vite/client" />
@@ -0,0 +1,4 @@
1
+ {
2
+ "compilerOptions": { "target": "ES2022", "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "Bundler", "types": ["vite/client"], "strict": true, "exactOptionalPropertyTypes": true, "noUncheckedIndexedAccess": true, "isolatedModules": true, "skipLibCheck": true, "noEmit": true, "jsx": "preserve" },
3
+ "include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
4
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from 'vite'
2
+ import vue from '@vitejs/plugin-vue'
3
+ import tailwindcss from '@tailwindcss/vite'
4
+ export default defineConfig({ plugins: [vue(), tailwindcss()],
5
+ server: { host: '127.0.0.1', port: 5173, strictPort: true, proxy: { '/api': 'http://127.0.0.1:3030' } },
6
+ appType: 'custom',
7
+ })
@@ -0,0 +1,7 @@
1
+ # Local demonstration only. No secrets or private addresses.
2
+ FEATHERS_ADAPTER=koa
3
+ FEATHERS_API_PORT=3030
4
+ FEATHERS_SOCKET_PORT=3031
5
+ FEATHERS_SOCKETIO=0
6
+ VITE_FEATHERS_REALTIME=0
7
+ FEATHERS_PUBLIC_ORIGIN=http://127.0.0.1:5173