@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.
- package/LICENSE +21 -0
- package/README.md +42 -2
- package/bin/create-feathers-app.mjs +33 -0
- package/lib/planner/options.mjs +74 -0
- package/lib/planner/plan.mjs +107 -0
- package/lib/scaffold.mjs +136 -0
- package/package.json +32 -4
- package/templates/common/dev-all.mjs +46 -0
- package/templates/daisyui-ssg/.env.example +6 -0
- package/templates/daisyui-ssg/README.md +21 -0
- package/templates/daisyui-ssg/api/server.mjs +45 -0
- package/templates/daisyui-ssg/api/services/health/health.class.mjs +3 -0
- package/templates/daisyui-ssg/api/services/health/health.service.mjs +4 -0
- package/templates/daisyui-ssg/api/services/notes/notes.class.mjs +25 -0
- package/templates/daisyui-ssg/api/services/notes/notes.service.mjs +10 -0
- package/templates/daisyui-ssg/gitignore.template +16 -0
- package/templates/daisyui-ssg/index.html +2 -0
- package/templates/daisyui-ssg/package.json +39 -0
- package/templates/daisyui-ssg/scripts/prerender.mjs +14 -0
- package/templates/daisyui-ssg/scripts/serve-static.mjs +32 -0
- package/templates/daisyui-ssg/src/App.vue +14 -0
- package/templates/daisyui-ssg/src/components/NotesPanel.vue +22 -0
- package/templates/daisyui-ssg/src/entry-client.ts +9 -0
- package/templates/daisyui-ssg/src/entry-server.ts +9 -0
- package/templates/daisyui-ssg/src/stores/notes.ts +46 -0
- package/templates/daisyui-ssg/src/style.css +3 -0
- package/templates/daisyui-ssg/src/vite-env.d.ts +1 -0
- package/templates/daisyui-ssg/tsconfig.json +4 -0
- package/templates/daisyui-ssg/vite.config.ts +6 -0
- package/templates/daisyui-ssr/.env.example +6 -0
- package/templates/daisyui-ssr/README.md +21 -0
- package/templates/daisyui-ssr/api/server.mjs +45 -0
- package/templates/daisyui-ssr/api/services/health/health.class.mjs +3 -0
- package/templates/daisyui-ssr/api/services/health/health.service.mjs +4 -0
- package/templates/daisyui-ssr/api/services/notes/notes.class.mjs +25 -0
- package/templates/daisyui-ssr/api/services/notes/notes.service.mjs +10 -0
- package/templates/daisyui-ssr/gitignore.template +16 -0
- package/templates/daisyui-ssr/index.html +2 -0
- package/templates/daisyui-ssr/package.json +39 -0
- package/templates/daisyui-ssr/scripts/serve-ssr.mjs +47 -0
- package/templates/daisyui-ssr/src/App.vue +14 -0
- package/templates/daisyui-ssr/src/components/NotesPanel.vue +22 -0
- package/templates/daisyui-ssr/src/entry-client.ts +9 -0
- package/templates/daisyui-ssr/src/entry-server.ts +9 -0
- package/templates/daisyui-ssr/src/stores/notes.ts +46 -0
- package/templates/daisyui-ssr/src/style.css +3 -0
- package/templates/daisyui-ssr/src/vite-env.d.ts +1 -0
- package/templates/daisyui-ssr/tsconfig.json +4 -0
- package/templates/daisyui-ssr/vite.config.ts +7 -0
- package/templates/spa/common/.env.example +7 -0
- package/templates/spa/common/api/server.mjs +45 -0
- package/templates/spa/common/api/services/health/health.class.mjs +3 -0
- package/templates/spa/common/api/services/health/health.service.mjs +4 -0
- package/templates/spa/common/api/services/notes/notes.class.mjs +25 -0
- package/templates/spa/common/api/services/notes/notes.service.mjs +10 -0
- package/templates/spa/common/gitignore.template +19 -0
- package/templates/spa/common/index.html +3 -0
- package/templates/spa/common/src/components/NotesPanel.vue +25 -0
- package/templates/spa/common/src/stores/notes.ts +46 -0
- package/templates/spa/common/src/vite-env.d.ts +1 -0
- package/templates/spa/common/tsconfig.json +4 -0
- package/templates/spa/daisyui/src/App.vue +2 -0
- package/templates/spa/daisyui/src/components/NotesPanel.vue +22 -0
- package/templates/spa/daisyui/src/main.ts +5 -0
- package/templates/spa/daisyui/src/style.css +3 -0
- package/templates/spa/daisyui/vite.config.ts +8 -0
- package/templates/spa/quasar2/src/App.vue +3 -0
- package/templates/spa/quasar2/src/components/NotesPanel.vue +23 -0
- package/templates/spa/quasar2/src/main.ts +7 -0
- package/templates/spa/quasar2/src/style.css +1 -0
- package/templates/spa/quasar2/vite.config.ts +8 -0
|
@@ -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 @@
|
|
|
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,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,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,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 @@
|
|
|
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
|
+
})
|