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

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 (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +79 -2
  3. package/bin/create-feathers-app.mjs +44 -0
  4. package/lib/planner/options.mjs +74 -0
  5. package/lib/planner/plan.mjs +107 -0
  6. package/lib/scaffold.mjs +300 -0
  7. package/lib/services.mjs +110 -0
  8. package/package.json +32 -4
  9. package/templates/common/dev-all-quasar-ssr.mjs +37 -0
  10. package/templates/common/dev-all.mjs +46 -0
  11. package/templates/daisyui-ssg/.env.example +6 -0
  12. package/templates/daisyui-ssg/README.md +21 -0
  13. package/templates/daisyui-ssg/api/server.mjs +45 -0
  14. package/templates/daisyui-ssg/api/services/health/health.class.mjs +3 -0
  15. package/templates/daisyui-ssg/api/services/health/health.service.mjs +4 -0
  16. package/templates/daisyui-ssg/api/services/notes/notes.class.mjs +25 -0
  17. package/templates/daisyui-ssg/api/services/notes/notes.service.mjs +10 -0
  18. package/templates/daisyui-ssg/gitignore.template +36 -0
  19. package/templates/daisyui-ssg/index.html +2 -0
  20. package/templates/daisyui-ssg/package.json +39 -0
  21. package/templates/daisyui-ssg/scripts/prerender.mjs +14 -0
  22. package/templates/daisyui-ssg/scripts/serve-static.mjs +32 -0
  23. package/templates/daisyui-ssg/src/App.vue +14 -0
  24. package/templates/daisyui-ssg/src/components/NotesPanel.vue +22 -0
  25. package/templates/daisyui-ssg/src/entry-client.ts +9 -0
  26. package/templates/daisyui-ssg/src/entry-server.ts +9 -0
  27. package/templates/daisyui-ssg/src/stores/notes.ts +46 -0
  28. package/templates/daisyui-ssg/src/style.css +5 -0
  29. package/templates/daisyui-ssg/src/vite-env.d.ts +1 -0
  30. package/templates/daisyui-ssg/tsconfig.json +4 -0
  31. package/templates/daisyui-ssg/vite.config.ts +6 -0
  32. package/templates/daisyui-ssr/.env.example +6 -0
  33. package/templates/daisyui-ssr/README.md +21 -0
  34. package/templates/daisyui-ssr/api/server.mjs +45 -0
  35. package/templates/daisyui-ssr/api/services/health/health.class.mjs +3 -0
  36. package/templates/daisyui-ssr/api/services/health/health.service.mjs +4 -0
  37. package/templates/daisyui-ssr/api/services/notes/notes.class.mjs +25 -0
  38. package/templates/daisyui-ssr/api/services/notes/notes.service.mjs +10 -0
  39. package/templates/daisyui-ssr/gitignore.template +36 -0
  40. package/templates/daisyui-ssr/index.html +2 -0
  41. package/templates/daisyui-ssr/package.json +39 -0
  42. package/templates/daisyui-ssr/scripts/serve-ssr.mjs +47 -0
  43. package/templates/daisyui-ssr/src/App.vue +14 -0
  44. package/templates/daisyui-ssr/src/components/NotesPanel.vue +22 -0
  45. package/templates/daisyui-ssr/src/entry-client.ts +9 -0
  46. package/templates/daisyui-ssr/src/entry-server.ts +9 -0
  47. package/templates/daisyui-ssr/src/stores/notes.ts +46 -0
  48. package/templates/daisyui-ssr/src/style.css +5 -0
  49. package/templates/daisyui-ssr/src/vite-env.d.ts +1 -0
  50. package/templates/daisyui-ssr/tsconfig.json +4 -0
  51. package/templates/daisyui-ssr/vite.config.ts +7 -0
  52. package/templates/quasar-ssr/.env.example +11 -0
  53. package/templates/quasar-ssr/api/server.mjs +45 -0
  54. package/templates/quasar-ssr/api/services/health/health.class.mjs +3 -0
  55. package/templates/quasar-ssr/api/services/health/health.service.mjs +4 -0
  56. package/templates/quasar-ssr/api/services/notes/notes.class.mjs +25 -0
  57. package/templates/quasar-ssr/api/services/notes/notes.service.mjs +10 -0
  58. package/templates/quasar-ssr/gitignore.template +36 -0
  59. package/templates/quasar-ssr/index.html +12 -0
  60. package/templates/quasar-ssr/quasar.config.ts +27 -0
  61. package/templates/quasar-ssr/scripts/edge-proxy.mjs +30 -0
  62. package/templates/quasar-ssr/scripts/edge-routing.mjs +14 -0
  63. package/templates/quasar-ssr/src/App.vue +1 -0
  64. package/templates/quasar-ssr/src/css/app.css +20 -0
  65. package/templates/quasar-ssr/src/pages/IndexPage.vue +146 -0
  66. package/templates/quasar-ssr/src/pages/NotFoundPage.vue +11 -0
  67. package/templates/quasar-ssr/src/router/index.ts +8 -0
  68. package/templates/quasar-ssr/src/router/routes.ts +6 -0
  69. package/templates/quasar-ssr/src/stores/index.ts +4 -0
  70. package/templates/quasar-ssr/src/stores/notes.ts +73 -0
  71. package/templates/quasar-ssr/src-ssr/middlewares/render.ts +15 -0
  72. package/templates/quasar-ssr/src-ssr/server.ts +42 -0
  73. package/templates/quasar-ssr/tsconfig.json +4 -0
  74. package/templates/spa/common/.env.example +8 -0
  75. package/templates/spa/common/api/server.mjs +46 -0
  76. package/templates/spa/common/api/services/health/health.class.mjs +3 -0
  77. package/templates/spa/common/api/services/health/health.service.mjs +4 -0
  78. package/templates/spa/common/api/services/notes/notes.class.mjs +25 -0
  79. package/templates/spa/common/api/services/notes/notes.service.mjs +10 -0
  80. package/templates/spa/common/gitignore.template +39 -0
  81. package/templates/spa/common/index.html +3 -0
  82. package/templates/spa/common/src/components/NotesPanel.vue +25 -0
  83. package/templates/spa/common/src/stores/notes.ts +105 -0
  84. package/templates/spa/common/src/vite-env.d.ts +1 -0
  85. package/templates/spa/common/tsconfig.json +4 -0
  86. package/templates/spa/daisyui/src/App.vue +2 -0
  87. package/templates/spa/daisyui/src/components/NotesPanel.vue +39 -0
  88. package/templates/spa/daisyui/src/main.ts +5 -0
  89. package/templates/spa/daisyui/src/style.css +5 -0
  90. package/templates/spa/daisyui/vite.config.ts +8 -0
  91. package/templates/spa/quasar2/src/App.vue +3 -0
  92. package/templates/spa/quasar2/src/components/NotesPanel.vue +41 -0
  93. package/templates/spa/quasar2/src/main.ts +7 -0
  94. package/templates/spa/quasar2/src/style.css +1 -0
  95. package/templates/spa/quasar2/vite.config.ts +8 -0
@@ -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,5 @@
1
+ @import "tailwindcss" source(none);
2
+ @source "./";
3
+ @source "../index.html";
4
+ @plugin "daisyui";
5
+ :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,11 @@
1
+ # Do not copy production secrets here. HTTP API and edge listen on loopback.
2
+ # WARNING: the unmodified plugin 0.2.6 Socket.IO app.listen(port) may bind all interfaces.
3
+ # Use Socket.IO only on an isolated/firewalled lab until host binding is added.
4
+ FEATHERS_ADAPTER=koa
5
+ FEATHERS_SOCKETIO=0
6
+ FEATHERS_API_PORT=3030
7
+ FEATHERS_SOCKET_PORT=3031
8
+ FEATHERS_EDGE_PORT=8080
9
+ QUASAR_SSR_PORT=9100
10
+ FEATHERS_PUBLIC_ORIGIN=http://127.0.0.1:8080
11
+ VITE_FEATHERS_REALTIME=0
@@ -0,0 +1,45 @@
1
+ import { createServer } from 'node:http'
2
+ import { createFeathersRuntime } from '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:8080'
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,36 @@
1
+ node_modules/
2
+ .quasar/
3
+ dist/
4
+ .env
5
+ .env.local
6
+ *.log
7
+ .DS_Store
8
+
9
+ # Editor MCP configuration; local tooling, never committed
10
+ .vscode/mcp.json
11
+ .cursor/
12
+
13
+ # Playwright Edge reports, traces, recordings and local screenshots
14
+ playwright-report/
15
+ test-results/
16
+ docs-private/
17
+
18
+ # Development-only configuration and internal artifacts
19
+ .agents/
20
+ .agent/
21
+ .ai/
22
+ .codex/
23
+ .claude/
24
+ .cursor/
25
+ .gemini/
26
+ .windsurf/
27
+ .ecc/
28
+ .mcp.json
29
+ .vscode/mcp.json
30
+ AGENTS.md
31
+ SKILLS.md
32
+ CLAUDE.md
33
+ GEMINI.md
34
+ *.prompt.md
35
+ docs-private/
36
+ .npmrc
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="fr">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="description" content="Console de certification Quasar SSR, FeathersJS v5 et Pinia">
7
+ <title>Quasar SSR / FeathersJS v5</title>
8
+ </head>
9
+ <body>
10
+ <!-- quasar:entry-point -->
11
+ </body>
12
+ </html>
@@ -0,0 +1,27 @@
1
+ import { defineConfig } from '#q-app/wrappers'
2
+
3
+ // Quasar CLI v2 (Vite): SSR owns its HTTP rendering process.
4
+ // Feathers is a separate Node runtime using createFeathersRuntime from the plugin.
5
+ export default defineConfig(() => ({
6
+ css: ['app.css'],
7
+ extras: [],
8
+ boot: [],
9
+ build: {
10
+ target: { browser: ['es2022', 'chrome115', 'firefox115'], node: 'node22' },
11
+ vueRouterMode: 'history',
12
+ typescript: { strict: true, vueShim: true },
13
+ },
14
+ devServer: { host: '127.0.0.1', port: 9100, open: false },
15
+ framework: { config: {}, plugins: [] },
16
+ ssr: {
17
+ prodPort: 9100,
18
+ middlewares: ['render'],
19
+ pwa: false,
20
+ extendPackageJson(pkg) {
21
+ // The Quasar-compiled dist/ssr/index.js imports serialize-javascript.
22
+ // pnpm does not hoist this transitive CLI dependency into this runtime.
23
+ pkg.dependencies ||= {}
24
+ pkg.dependencies['serialize-javascript'] ||= '^6.0.2'
25
+ },
26
+ },
27
+ }))
@@ -0,0 +1,30 @@
1
+ import { createServer, request } from 'node:http'
2
+ import { connect } from 'node:net'
3
+ import { selectTarget, targetConfig } from './edge-routing.mjs'
4
+ const edgePort = Number(process.env.FEATHERS_EDGE_PORT || 8080)
5
+ if (!Number.isInteger(edgePort) || edgePort < 1024 || edgePort > 65535) throw new Error('Invalid edge port')
6
+ const proxy = createServer((req, res) => {
7
+ const path = new URL(req.url || '/', 'http://localhost').pathname
8
+ const upstream = targetConfig(selectTarget(path))
9
+ const forwarded = request({ ...upstream, method: req.method, path: req.url, headers: { ...req.headers, host: `127.0.0.1:${upstream.port}` } }, response => {
10
+ res.writeHead(response.statusCode || 502, response.headers)
11
+ response.pipe(res)
12
+ })
13
+ forwarded.on('error', () => { if (!res.headersSent) res.writeHead(502); res.end('Upstream unavailable') })
14
+ req.pipe(forwarded)
15
+ })
16
+ proxy.on('upgrade', (req, socket, head) => {
17
+ const path = new URL(req.url || '/', 'http://localhost').pathname
18
+ const upstream = targetConfig(selectTarget(path))
19
+ const peer = connect(upstream.port, upstream.hostname)
20
+ peer.on('connect', () => {
21
+ const requestLine = `${req.method || 'GET'} ${req.url} HTTP/1.1\r\n`
22
+ const headers = Object.entries(req.headers).map(([name, value]) => `${name}: ${Array.isArray(value) ? value.join(', ') : value}\r\n`).join('')
23
+ peer.write(requestLine + headers + '\r\n')
24
+ if (head.length) peer.write(head)
25
+ peer.pipe(socket).pipe(peer)
26
+ })
27
+ peer.on('error', () => socket.destroy())
28
+ socket.on('error', () => peer.destroy())
29
+ })
30
+ proxy.listen(edgePort, '127.0.0.1', () => console.log(`Local edge http://127.0.0.1:${edgePort} -> /api (3030), /socket.io (3031), SSR (9100)`))
@@ -0,0 +1,14 @@
1
+ // Small pure router, separately unit-tested; no client-controlled proxy target.
2
+ export function selectTarget(pathname) {
3
+ if (pathname === '/api' || pathname.startsWith('/api/')) return 'api'
4
+ if (pathname === '/socket.io' || pathname.startsWith('/socket.io/')) return 'socket'
5
+ return 'ssr'
6
+ }
7
+ export function targetConfig(kind, env = process.env) {
8
+ if (!['api', 'socket', 'ssr'].includes(kind)) throw new TypeError('unknown target')
9
+ const port = Number(kind === 'api' ? (env.FEATHERS_API_PORT || 3030)
10
+ : kind === 'socket' ? (env.FEATHERS_SOCKET_PORT || 3031)
11
+ : (env.QUASAR_SSR_PORT || 9100))
12
+ if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('invalid target port')
13
+ return { hostname: '127.0.0.1', port }
14
+ }
@@ -0,0 +1 @@
1
+ <template><router-view /></template>
@@ -0,0 +1,20 @@
1
+ :root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#18233a;background:#f5f7fc;font-synthesis:none}
2
+ *{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;min-width:320px}a{color:inherit;text-decoration:none}button,input{font:inherit}
3
+ :focus-visible{outline:3px solid #8179f5;outline-offset:3px}
4
+ .console-shell{min-height:100vh;display:grid;grid-template-columns:250px minmax(0,1fr)}
5
+ .console-sidebar{background:#111c31;color:#e9eefc;display:flex;flex-direction:column;padding:27px 16px;min-width:0}
6
+ .console-brand{display:flex;gap:11px;align-items:center;padding:3px 12px 30px}.brand-mark{height:38px;width:38px;display:grid;place-items:center;border-radius:12px;background:#7469e8;color:#fff;font-size:21px;font-weight:800;box-shadow:0 7px 22px #7469e855}.console-brand strong,.console-brand small{display:block}.console-brand strong{font-size:14px;letter-spacing:-.02em}.console-brand small{font-size:11px;color:#96a5c1;margin-top:3px}
7
+ .sidebar-label{text-transform:uppercase;letter-spacing:.13em;font-size:10px;font-weight:800;color:#8090af;margin:9px 13px 14px}.sidebar-nav{display:grid;gap:4px}.sidebar-link{display:flex;align-items:center;gap:11px;border-radius:10px;padding:11px 13px;color:#b5c2d9;font-size:13px;font-weight:600;transition:background .15s,color .15s}.sidebar-link>span{font-size:18px;line-height:1;width:22px;text-align:center}.sidebar-link:hover,.sidebar-link:focus-visible{background:#233149;color:#fff}.sidebar-link.active{background:#303b66;color:#fff}.sidebar-foot{display:flex;gap:10px;align-items:flex-start;border-top:1px solid #2a354a;margin-top:auto;padding:24px 10px 4px}.sidebar-foot-icon{font-size:11px;color:#4bd9bd;padding-top:2px}.sidebar-foot strong,.sidebar-foot small{display:block}.sidebar-foot strong{font-size:11px;font-weight:700}.sidebar-foot small{font-size:10px;color:#9caac2;line-height:1.5;margin-top:4px}
8
+ .console-workspace{min-width:0}.console-topbar{background:#fff;border-bottom:1px solid #e8ecf3;height:74px;display:flex;align-items:center;justify-content:space-between;padding:0 clamp(22px,4vw,54px);gap:14px}.breadcrumbs{color:#8b94a7;font-size:12px}.breadcrumbs span{padding:0 10px;color:#cbd1dc}.breadcrumbs strong{color:#26344e}.topbar-end{display:flex;align-items:center;gap:16px}.topbar-technology{color:#8793a9;font-size:12px}.environment-tag{background:#eaf9f2;border:1px solid #d5f2e5;color:#187b5c;font-size:11px;font-weight:800;padding:7px 11px;border-radius:99px}.environment-tag span{font-size:9px;margin-right:4px}
9
+ .console-main{max-width:1440px;margin:auto;padding:43px clamp(22px,4vw,54px) 22px}.console-hero{display:flex;align-items:flex-end;justify-content:space-between;gap:25px;margin-bottom:32px}.eyebrow{text-transform:uppercase;letter-spacing:.11em;color:#776dd1;font-weight:800;font-size:10px;margin:0 0 12px}.console-hero h1{font-size:clamp(26px,2.5vw,39px);max-width:830px;letter-spacing:-.045em;line-height:1.18;margin:0;color:#18233a;font-weight:790}.hero-intro{font-size:13px;line-height:1.75;max-width:740px;color:#718098;margin:13px 0 0}.hero-link{flex:none;background:#6259d9;color:#fff;border-radius:10px;padding:12px 17px;font-size:12px;font-weight:750;box-shadow:0 9px 22px #6259d921}.hero-link:hover{background:#5148c7}.hero-link span{margin-left:8px}
10
+ .metric-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;margin-bottom:22px}.metric-card,.console-panel{border:1px solid #e4e9f3;box-shadow:0 3px 16px #18233a06;background:#fff;border-radius:16px}.metric-card{padding:21px 23px;min-height:156px}.metric-top{display:flex;align-items:center;justify-content:space-between;color:#748197;font-size:12px;font-weight:650}.metric-symbol{color:#736bcd;background:#f0eefc;border-radius:9px;width:31px;height:31px;display:grid;place-items:center;font-size:18px}.metric-value{display:block;font-size:32px;letter-spacing:-.04em;line-height:1.2;margin-top:15px;color:#162239}.metric-state{font-size:22px;margin-top:21px}.state-ok{color:#19805b}.state-error{color:#be3744}.state-checking{color:#756fb0}.metric-card p{font-size:11px;color:#8793a4;margin:7px 0 0}
11
+ .console-content-grid{display:grid;grid-template-columns:minmax(0,1.65fr) minmax(285px,.85fr);gap:20px;align-items:start}.console-panel{padding:25px;min-width:0}.panel-head{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:23px}.panel-head .eyebrow{margin-bottom:8px}.panel-head h2{font-size:20px;letter-spacing:-.027em;margin:0;color:#1d2b43}.panel-head p:not(.eyebrow){color:#8190a6;font-size:11px;line-height:1.6;margin:7px 0 0}.panel-count{background:#eeecff!important;color:#5c52cd!important;padding:6px 9px;border-radius:7px;white-space:nowrap;font-weight:700}
12
+ .note-form{display:flex;align-items:center;gap:12px;margin:18px 0 20px}.note-input-shell{flex:1;min-width:0}.note-input{width:100%;min-width:0}.note-input-placeholder{min-height:40px;display:flex;align-items:center;padding:0 12px;border:1px solid #d6dcea;border-radius:9px;color:#7d8aa0;background:#fff;font-size:12px}.note-input .q-field__control{border-radius:9px}.console-create{background:#6259d9!important;color:#fff!important;border-radius:9px!important;min-height:40px;padding:0 17px}.console-create:hover{background:#5148c7!important}.notes-list{border:1px solid #e7ebf3;border-radius:12px;overflow:hidden}.notes-item{min-height:66px}.notes-item:hover{background:#f8f9fc}.note-avatar{width:33px;height:33px;display:grid;place-items:center;background:#f2f0ff;border-radius:9px;color:#655dd1;font-size:21px;font-weight:800}.notes-feedback{padding:29px 16px;text-align:center;border:1px dashed #d4dbea;border-radius:12px;color:#78849a;font-size:12px}.console-error{margin-bottom:14px;background:#fff0f1;color:#9d2335;border:1px solid #ffd4da;border-radius:10px}
13
+ .architecture-panel{padding-bottom:22px}.architecture-steps{list-style:none;padding:0;margin:0;display:grid;gap:0}.architecture-steps li{position:relative;display:flex;gap:15px;padding:14px 0;align-items:flex-start}.architecture-steps li+li{border-top:1px solid #eef1f6}.architecture-steps li>span{font-size:10px;font-weight:850;color:#675ed6;background:#f0eefe;border-radius:8px;padding:8px}.architecture-steps strong,.architecture-steps small{display:block}.architecture-steps strong{font-size:12px;color:#293751}.architecture-steps small{font-size:11px;color:#8793a5;margin-top:4px;line-height:1.5}.architecture-note{background:#f6f7fd;border:1px solid #eaedf6;border-radius:11px;display:flex;gap:10px;padding:12px 13px;margin-top:13px;color:#726ac9}.architecture-note p{color:#6c7991;font-size:10px;line-height:1.65;margin:0}.console-bottom{font-size:11px;color:#a1aabc;padding:24px 0;text-align:center}
14
+ @media(max-width:1050px){.console-shell{grid-template-columns:205px minmax(0,1fr)}.console-content-grid{grid-template-columns:1fr}.console-sidebar{padding-left:10px;padding-right:10px}.console-main{padding-top:29px}}@media(max-width:700px){.console-shell{display:block}.console-sidebar{padding:10px 14px}.console-brand{padding:3px 4px 9px}.sidebar-label,.sidebar-foot{display:none}.sidebar-nav{display:flex;gap:6px;overflow:auto;padding-bottom:3px}.sidebar-link{font-size:11px;padding:8px 10px;white-space:nowrap}.sidebar-link>span{width:auto;font-size:14px}.console-topbar{height:58px;padding:0 20px}.topbar-technology{display:none}.console-main{padding:25px 18px}.console-hero{display:block}.hero-link{display:inline-flex;margin-top:19px}.metric-grid{grid-template-columns:1fr 1fr}.metric-card{padding:17px;min-height:137px}.metric-card:last-child{grid-column:1/-1}.note-form{flex-direction:column;align-items:stretch}.note-input-shell{width:100%}.console-create{width:100%}.console-panel{padding:19px}.panel-head{align-items:start;flex-wrap:wrap}}
15
+ @media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}*,*::before,*::after{transition-duration:.01ms!important;animation-duration:.01ms!important}}
16
+ .not-found-page{min-height:100vh;display:grid;place-items:center;padding:25px}.not-found-card{background:#fff;border:1px solid #e5e9f3;border-radius:20px;box-shadow:0 20px 80px #24315012;padding:clamp(30px,5vw,65px);max-width:570px}.not-found-code{font-size:94px;font-weight:900;letter-spacing:-.09em;color:#e3dfff}.not-found-card h1{font-size:30px;letter-spacing:-.04em;margin:12px 0}.not-found-card p:not(.eyebrow){font-size:13px;color:#78869b;line-height:1.7;margin-bottom:29px}
17
+
18
+ /* Certification feedback uses the underlying Socket.IO connection state. */
19
+ .notes-panel-badges { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: .5rem; }
20
+ .realtime-indicator { display: inline-flex; font-size: .75rem; line-height: 1.35; color: var(--q-secondary, #516070); }
@@ -0,0 +1,146 @@
1
+ <script setup lang="ts">
2
+ import { onMounted, onServerPrefetch, onUnmounted, ref } from 'vue'
3
+ import { useNotesStore } from '../stores/notes'
4
+
5
+ const store = useNotesStore()
6
+ const newTitle = ref('')
7
+ const health = ref<'checking' | 'ok' | 'error'>('checking')
8
+ const realtimeEnabled = import.meta.env.VITE_FEATHERS_REALTIME === '1'
9
+
10
+ async function submit(): Promise<void> {
11
+ if (!newTitle.value.trim() || store.loading) return
12
+ try {
13
+ await store.create(newTitle.value)
14
+ newTitle.value = ''
15
+ }
16
+ catch { /* The store exposes the service error in its reactive state. */ }
17
+ }
18
+
19
+ async function checkHealth(): Promise<void> {
20
+ try {
21
+ const response = await fetch('/api/health', { headers: { Accept: 'application/json' } })
22
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
23
+ const body: unknown = await response.json()
24
+ health.value = typeof body === 'object' && body !== null && 'status' in body && body.status === 'ok' ? 'ok' : 'error'
25
+ }
26
+ catch { health.value = 'error' }
27
+ }
28
+
29
+ // Prefetch only into this request's Pinia instance. Never open a socket during SSR.
30
+ onServerPrefetch(async () => { await store.load().catch(() => {}) })
31
+ onMounted(() => {
32
+ if (!store.notes.length) void store.load().catch(() => {})
33
+ void checkHealth()
34
+ store.connectRealtime()
35
+ })
36
+ onUnmounted(() => store.disconnectRealtime())
37
+ </script>
38
+
39
+ <template>
40
+ <div class="console-shell">
41
+ <aside class="console-sidebar" aria-label="Navigation de la console">
42
+ <a class="console-brand" href="/" aria-label="Feathers Console — accueil">
43
+ <span class="brand-mark" aria-hidden="true">F</span>
44
+ <span><strong>Feathers Console</strong><small>Quasar SSR workspace</small></span>
45
+ </a>
46
+ <p class="sidebar-label">Espace de travail</p>
47
+ <nav class="sidebar-nav" aria-label="Navigation principale">
48
+ <a class="sidebar-link active" href="/" aria-current="page"><span aria-hidden="true">▦</span> Vue d'ensemble</a>
49
+ <a class="sidebar-link" href="#notes"><span aria-hidden="true">≡</span> Service notes</a>
50
+ <a class="sidebar-link" href="#architecture"><span aria-hidden="true">◇</span> Architecture</a>
51
+ </nav>
52
+ <div class="sidebar-foot">
53
+ <span class="sidebar-foot-icon" aria-hidden="true">●</span>
54
+ <div><strong>Environnement de certification</strong><small>API locale · données de démonstration</small></div>
55
+ </div>
56
+ </aside>
57
+
58
+ <div class="console-workspace">
59
+ <header class="console-topbar">
60
+ <div class="breadcrumbs">Laboratoire <span aria-hidden="true">/</span> <strong>Vue d'ensemble</strong></div>
61
+ <div class="topbar-end">
62
+ <span class="topbar-technology">Vite · Vue 3 · Pinia</span>
63
+ <span class="environment-tag"><span aria-hidden="true">●</span> SSR</span>
64
+ </div>
65
+ </header>
66
+
67
+ <main class="console-main">
68
+ <section class="console-hero" aria-labelledby="page-heading">
69
+ <div>
70
+ <p class="eyebrow">Vue d'ensemble / Intégration full stack</p>
71
+ <h1 id="page-heading">Quasar 2 SSR + FeathersJS v5 + Pinia</h1>
72
+ <p id="ssr-proof" class="hero-intro">Vue SSR : liste issue du service Feathers notes. Gérez des données via les services FeathersJS, avec une interface rendue côté serveur et hydratée côté navigateur.</p>
73
+ </div>
74
+ <a class="hero-link" href="#notes">Explorer le service <span aria-hidden="true">↗</span></a>
75
+ </section>
76
+
77
+ <section class="metric-grid" aria-label="État des services">
78
+ <article class="metric-card">
79
+ <div class="metric-top"><span>Enregistrements</span><span class="metric-symbol" aria-hidden="true">≡</span></div>
80
+ <strong class="metric-value" data-testid="notes-count">{{ store.notes.length }}</strong>
81
+ <p>Notes du service Feathers</p>
82
+ </article>
83
+ <article class="metric-card">
84
+ <div class="metric-top"><span>API REST</span><span class="metric-symbol" aria-hidden="true">⌁</span></div>
85
+ <strong class="metric-value metric-state" :class="`state-${health}`" data-testid="health-status">{{ health === 'ok' ? 'Disponible' : health === 'error' ? 'Indisponible' : 'Vérification…' }}</strong>
86
+ <p>Contrôle réel de /api/health</p>
87
+ </article>
88
+ <article class="metric-card">
89
+ <div class="metric-top"><span>Rendu</span><span class="metric-symbol" aria-hidden="true">◇</span></div>
90
+ <strong class="metric-value metric-state">SSR</strong>
91
+ <p>Quasar CLI avec Vite</p>
92
+ </article>
93
+ </section>
94
+
95
+ <div class="console-content-grid">
96
+ <section id="notes" class="console-panel" aria-labelledby="notes-heading">
97
+ <div class="panel-head">
98
+ <div><p class="eyebrow">Service Feathers / notes</p><h2 id="notes-heading">Registre des notes</h2><p>CRUD depuis le store Pinia, sans accès direct au transport dans cette page.</p></div>
99
+ <div class="notes-panel-badges">
100
+ <q-badge class="panel-count" :label="`${store.notes.length} élément(s)`" />
101
+ <span class="realtime-indicator" data-testid="realtime-status" role="status">
102
+ {{ !realtimeEnabled ? 'Temps réel désactivé' : store.realtimeConnected ? 'Temps réel connecté' : 'Temps réel en attente' }}
103
+ </span>
104
+ </div>
105
+ </div>
106
+ <q-banner v-if="store.error" class="console-error" role="alert">{{ store.error }}</q-banner>
107
+ <form class="note-form" aria-label="Créer une note" @submit.prevent="submit">
108
+ <div class="note-input-shell">
109
+ <!-- Quasar QInput can generate a different inline caret style on the SSR server.
110
+ Render only this interactive field after hydration; keep the notes and
111
+ surrounding form server-rendered. Never silence hydration diagnostics. -->
112
+ <q-no-ssr>
113
+ <q-input v-model="newTitle" data-testid="note-title-input" class="note-input" label="Titre de la nouvelle note" outlined dense maxlength="120" :disable="store.loading" />
114
+ <template #placeholder>
115
+ <div class="note-input-placeholder" aria-hidden="true">Titre de la nouvelle note</div>
116
+ </template>
117
+ </q-no-ssr>
118
+ </div>
119
+ <q-btn class="console-create" label="Créer une note" type="submit" unelevated no-caps :loading="store.loading" :disable="!newTitle.trim() || store.loading" />
120
+ </form>
121
+ <div v-if="store.loading && store.notes.length === 0" class="notes-feedback" role="status">Chargement des notes…</div>
122
+ <div v-else-if="store.notes.length === 0" class="notes-feedback" role="status">Aucune note pour l'instant. Créez-en une pour vérifier le service.</div>
123
+ <q-list v-else class="notes-list" separator data-testid="notes">
124
+ <q-item v-for="note in store.notes" :key="note.id" class="notes-item">
125
+ <q-item-section avatar><span class="note-avatar" aria-hidden="true">≡</span></q-item-section>
126
+ <q-item-section><q-item-label>{{ note.title }}</q-item-label><q-item-label caption>Note #{{ note.id }}</q-item-label></q-item-section>
127
+ <q-item-section side><q-badge outline color="grey-7" label="Feathers" /></q-item-section>
128
+ </q-item>
129
+ </q-list>
130
+ </section>
131
+
132
+ <aside id="architecture" class="console-panel architecture-panel" aria-labelledby="architecture-heading">
133
+ <div class="panel-head"><div><p class="eyebrow">Architecture</p><h2 id="architecture-heading">Chaîne de données</h2><p>Responsabilités séparées et testables.</p></div></div>
134
+ <ol class="architecture-steps">
135
+ <li><span>01</span><div><strong>Vue 3 + Quasar</strong><small>Rendu HTML SSR / hydratation</small></div></li>
136
+ <li><span>02</span><div><strong>Store Pinia</strong><small>État réactif par requête SSR</small></div></li>
137
+ <li><span>03</span><div><strong>FeathersJS v5</strong><small>REST, services et Socket.IO optionnel</small></div></li>
138
+ </ol>
139
+ <div class="architecture-note"><span aria-hidden="true">ⓘ</span><p>Les données affichées proviennent du service actif. Les connexions temps réel ne démarrent qu'après hydratation.</p></div>
140
+ </aside>
141
+ </div>
142
+ <footer class="console-bottom">Feathers Console <span aria-hidden="true">·</span> Exemple de certification Quasar SSR</footer>
143
+ </main>
144
+ </div>
145
+ </div>
146
+ </template>
@@ -0,0 +1,11 @@
1
+ <template>
2
+ <main class="not-found-page">
3
+ <div class="not-found-card">
4
+ <span class="not-found-code">404</span>
5
+ <p class="eyebrow">Navigation</p>
6
+ <h1>Cette page n'existe pas.</h1>
7
+ <p>Le laboratoire Quasar SSR reste accessible depuis son tableau de bord.</p>
8
+ <router-link class="hero-link" to="/">Retour à la console <span aria-hidden="true">↗</span></router-link>
9
+ </div>
10
+ </main>
11
+ </template>
@@ -0,0 +1,8 @@
1
+ import { route } from 'quasar/wrappers'
2
+ import { createMemoryHistory, createRouter, createWebHistory } from 'vue-router'
3
+ import routes from './routes'
4
+ export default route(() => createRouter({
5
+ history: process.env.SERVER ? createMemoryHistory() : createWebHistory(),
6
+ routes,
7
+ scrollBehavior: () => ({ top: 0, left: 0 }),
8
+ }))
@@ -0,0 +1,6 @@
1
+ import type { RouteRecordRaw } from 'vue-router'
2
+ const routes: RouteRecordRaw[] = [
3
+ { path: '/', component: () => import('../pages/IndexPage.vue') },
4
+ { path: '/:catchAll(.*)*', component: () => import('../pages/NotFoundPage.vue') },
5
+ ]
6
+ export default routes
@@ -0,0 +1,4 @@
1
+ import { store } from 'quasar/wrappers'
2
+ import { createPinia } from 'pinia'
3
+ // A new Pinia instance for each SSR render (no authenticated state global).
4
+ export default store(() => createPinia())
@@ -0,0 +1,73 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref } from 'vue'
3
+ import { feathers } from '@feathersjs/feathers'
4
+ import rest from '@feathersjs/rest-client'
5
+ import socketioClient from '@feathersjs/socketio-client'
6
+ import { io, type Socket } from 'socket.io-client'
7
+
8
+ export interface Note { id: number; title: string }
9
+ function baseUrl() {
10
+ return typeof window === 'undefined'
11
+ ? (process.env.FEATHERS_INTERNAL_URL || 'http://127.0.0.1:3030/api')
12
+ : `${window.location.origin}/api`
13
+ }
14
+ function createClient() {
15
+ const client = feathers()
16
+ client.configure(rest(baseUrl()).fetch(globalThis.fetch.bind(globalThis)))
17
+ return client
18
+ }
19
+ export const useNotesStore = defineStore('quasar-ssr-notes', () => {
20
+ const notes = ref<Note[]>([])
21
+ const loading = ref(false)
22
+ const error = ref<string | null>(null)
23
+ const realtimeConnected = ref(false)
24
+ // Store instance is created in each Quasar SSR request.
25
+ // Socket connection is always browser-only and never persisted in Pinia state.
26
+ let socket: Socket | undefined
27
+ let removeListeners: (() => void) | undefined
28
+ async function load() {
29
+ loading.value = true
30
+ error.value = null
31
+ try {
32
+ const service = createClient().service('notes')
33
+ const result = await service.find() as Note[]
34
+ notes.value = result
35
+ } catch (cause) {
36
+ error.value = cause instanceof Error ? cause.message : 'API indisponible'
37
+ throw cause
38
+ } finally { loading.value = false }
39
+ }
40
+ async function create(title: string) {
41
+ if (!title.trim()) throw new Error('Le titre est obligatoire')
42
+ await createClient().service('notes').create({ title: title.trim() })
43
+ await load()
44
+ }
45
+ function connectRealtime() {
46
+ if (typeof window === 'undefined' || socket) return
47
+ if (import.meta.env.VITE_FEATHERS_REALTIME !== '1') return
48
+ const ws = io(window.location.origin, { path: '/socket.io', transports: ['websocket', 'polling'], autoConnect: false })
49
+ // Browser-only, per-store connection state. Neither socket nor listeners are serialized by SSR.
50
+ ws.on('connect', () => { realtimeConnected.value = true })
51
+ ws.on('disconnect', () => { realtimeConnected.value = false })
52
+ ws.on('connect_error', () => { realtimeConnected.value = false })
53
+ const realtimeClient = feathers()
54
+ realtimeClient.configure(socketioClient(ws))
55
+ const service = realtimeClient.service('notes')
56
+ const refresh = () => { void load().catch(() => {}) }
57
+ for (const event of ['created', 'patched', 'removed', 'updated']) service.on(event, refresh)
58
+ removeListeners = () => { for (const event of ['created', 'patched', 'removed', 'updated']) service.off(event, refresh) }
59
+ socket = ws
60
+ ws.connect()
61
+ }
62
+ function disconnectRealtime() {
63
+ removeListeners?.()
64
+ removeListeners = undefined
65
+ socket?.removeAllListeners('connect')
66
+ socket?.removeAllListeners('disconnect')
67
+ socket?.removeAllListeners('connect_error')
68
+ socket?.disconnect()
69
+ socket = undefined
70
+ realtimeConnected.value = false
71
+ }
72
+ return { notes, loading, error, realtimeConnected, load, create, connectRealtime, disconnectRealtime }
73
+ })
@@ -0,0 +1,15 @@
1
+ import { ssrMiddleware } from 'quasar/wrappers'
2
+ // Must remain the LAST middleware: all /api and Socket.IO are served by the edge proxy.
3
+ export default ssrMiddleware(({ app, resolve, render, serve }) => {
4
+ app.get(resolve.urlPath('*'), (req, res) => {
5
+ res.setHeader('Content-Type', 'text/html; charset=utf-8')
6
+ void render({ req, res }).then(html => res.send(html)).catch((error: unknown) => {
7
+ const err = error as { code?: number; url?: string }
8
+ if (err.url) { res.redirect(err.code || 302, err.url); return }
9
+ if (err.code === 404) { res.status(404).send('Not found'); return }
10
+ // Avoid Quasar v2's verbose development error page (it can expose secrets).
11
+ // The CLI is pinned to v2 for Vite 7; never forward process.env or cookies.
12
+ res.status(500).send('Internal server error')
13
+ })
14
+ })
15
+ })