Browse Source

build expansion

imwald
Silberengel 2 weeks ago
parent
commit
d20bf4cf05
  1. 38
      PROXY_SETUP.md
  2. 2
      docker-compose.prod.yml
  3. 1
      package.json
  4. 11
      services/piper-tts-proxy/Dockerfile
  5. 61
      services/piper-tts-proxy/http.ts
  6. 1047
      services/piper-tts-proxy/server.ts
  7. 4
      src/constants.ts
  8. 31
      src/lib/event.ts

38
PROXY_SETUP.md

@ -99,16 +99,48 @@ docker-compose up -d
## Read-aloud / Piper TTS (same-origin `/api/piper-tts`) ## Read-aloud / Piper TTS (same-origin `/api/piper-tts`)
The client uses **`POST /api/piper-tts`** on the **same host** as the app (default build: `VITE_READ_ALOUD_TTS_URL=/api/piper-tts`) so the browser does not need cross-origin CORS to aitherboard. The client uses **`POST /api/piper-tts`** on the **same host** as the app (default build: `VITE_READ_ALOUD_TTS_URL=/api/piper-tts`) so the browser does not need cross-origin CORS.
Add these **before** the catch-all `ProxyPass /` to the Imwald static container (same ordering as `/sites/`): **Backend:** Wyoming Piper (`silberengel/wyoming-piper`, TCP **10200**) has no HTTP API. You need a small bridge that accepts `POST /api/piper-tts` and talks Wyoming over TCP.
- **In this repo:** `services/piper-tts-proxy/` — HTTP server with the same JSON/WAV contract as the old aitherboard route. Build from repo root:
- `docker build -f services/piper-tts-proxy/Dockerfile -t imwald-piper-tts-proxy .`
- Run on **the same Docker network** as the `piper-tts` container so hostname **`piper-tts`** resolves, e.g.:
- `docker network create piper-stack` (once)
- `docker network connect piper-stack piper-tts`
- `docker run -d --name imwald-piper-tts-proxy --restart unless-stopped --network piper-stack -p 127.0.0.1:9876:9876 -e PIPER_TTS_HOST=piper-tts -e PIPER_TTS_PORT=10200 imwald-piper-tts-proxy`
- Alternatively, publish Wyoming on the host (`-p 127.0.0.1:10200:10200` on `piper-tts`) and run the proxy with `--network host` and `PIPER_TTS_HOST=127.0.0.1`.
Add these **before** the catch-all `ProxyPass /` to the Imwald static container (same ordering as `/sites/`). Example **full** fragment (matches a typical TLS vhost; adjust `ServerName` / SSL paths as you do today):
```apache ```apache
ProxyPreserveHost On
ProxyRequests Off
# WebSocket upgrade handling - CRITICAL for Nostr apps
RewriteEngine On
RewriteCond %{HTTP:Upgrade} websocket [NC]
RewriteCond %{HTTP:Connection} upgrade [NC]
RewriteRule ^/?(.*) "ws://127.0.0.1:8089/$1" [P,L]
# OG / link preview (before catch-all)
ProxyPass /sites/ http://127.0.0.1:8090/sites/
ProxyPassReverse /sites/ http://127.0.0.1:8090/sites/
# Read-aloud Piper — same-origin /api/piper-tts → imwald-piper-tts-proxy (HTTP) → Wyoming piper-tts:10200 (before catch-all)
ProxyPass /api/piper-tts http://127.0.0.1:9876/api/piper-tts ProxyPass /api/piper-tts http://127.0.0.1:9876/api/piper-tts
ProxyPassReverse /api/piper-tts http://127.0.0.1:9876/api/piper-tts ProxyPassReverse /api/piper-tts http://127.0.0.1:9876/api/piper-tts
# Static SPA (catch-all — must be last)
ProxyPass / http://127.0.0.1:8089/
ProxyPassReverse / http://127.0.0.1:8089/
ProxyAddHeaders On
Header always set X-Forwarded-Proto "https"
Header always set X-Forwarded-Port "443"
``` ```
Use the port where **aitherboard** listens (example: `9876`). Reload Apache, then test: Use the port where **`imwald-piper-tts-proxy`** listens (example **`9876`**). Reload Apache, then test:
```bash ```bash
curl -sS -o /tmp/t.wav -w "%{http_code}\n" -H "Content-Type: application/json" \ curl -sS -o /tmp/t.wav -w "%{http_code}\n" -H "Content-Type: application/json" \

2
docker-compose.prod.yml

@ -6,7 +6,7 @@
# ProxyPass /api/piper-tts http://127.0.0.1:9876/api/piper-tts # ProxyPass /api/piper-tts http://127.0.0.1:9876/api/piper-tts
# ProxyPassReverse /api/piper-tts http://127.0.0.1:9876/api/piper-tts # ProxyPassReverse /api/piper-tts http://127.0.0.1:9876/api/piper-tts
# ProxyPass / http://127.0.0.1:8089/ # ProxyPass / http://127.0.0.1:8089/
# so the browser hits same-origin /api/piper-tts → aitherboard; /sites/ → OG proxy; else static SPA on 8089. # so the browser hits same-origin /api/piper-tts → piper HTTP proxy (see services/piper-tts-proxy + PROXY_SETUP.md); /sites/ → OG proxy; else static SPA on 8089.
# VITE_PROXY_SERVER / VITE_READ_ALOUD_TTS_URL are baked at image build — see scripts/build-and-push-prod.sh # VITE_PROXY_SERVER / VITE_READ_ALOUD_TTS_URL are baked at image build — see scripts/build-and-push-prod.sh
# #
# NIP-66 monitor: set NIP66_MONITOR_NSEC (and optionally NIP66_MONITOR_NPUB) in the host env or .env. # NIP-66 monitor: set NIP66_MONITOR_NSEC (and optionally NIP66_MONITOR_NPUB) in the host env or .env.

1
package.json

@ -16,6 +16,7 @@
"dev": "vite --host", "dev": "vite --host",
"dev:refresh": "rm -rf node_modules/.vite && vite --host", "dev:refresh": "rm -rf node_modules/.vite && vite --host",
"docker:editor-tools": "docker compose -f docker-compose.dev.yml --profile editor-tools up -d languagetool libretranslate", "docker:editor-tools": "docker compose -f docker-compose.dev.yml --profile editor-tools up -d languagetool libretranslate",
"piper-tts-proxy": "cross-env NODE_ENV=development npx --yes tsx services/piper-tts-proxy/http.ts",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"knip": "npx --yes knip@5", "knip": "npx --yes knip@5",

11
services/piper-tts-proxy/Dockerfile

@ -0,0 +1,11 @@
# Build from repo root: docker build -f services/piper-tts-proxy/Dockerfile -t imwald-piper-tts-proxy .
FROM node:20-alpine
WORKDIR /app
COPY services/piper-tts-proxy/server.ts services/piper-tts-proxy/http.ts ./
RUN npm init -y >/dev/null && npm install tsx@4.19.2 --omit=dev
ENV NODE_ENV=production
ENV PORT=9876
ENV PIPER_TTS_HOST=piper-tts
ENV PIPER_TTS_PORT=10200
EXPOSE 9876
CMD ["npx", "tsx", "http.ts"]

61
services/piper-tts-proxy/http.ts

@ -0,0 +1,61 @@
/**
* Standalone HTTP server: same contract as legacy aitherboard `POST /api/piper-tts`
* (JSON `{ text, speed?, voice? }` `audio/wav`), forwarding to Wyoming Piper over TCP.
*/
import http from 'node:http'
import { handlePiperTtsPost } from './server'
const PORT = Number(process.env.PORT || 9876)
function cors(res: http.ServerResponse) {
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
}
const server = http.createServer(async (req, res) => {
cors(res)
if (req.method === 'OPTIONS') {
res.writeHead(204).end()
return
}
const rawPath = req.url?.split('?')[0] || ''
const path = rawPath.replace(/\/$/, '') || '/'
if (req.method !== 'POST' || path !== '/api/piper-tts') {
res.writeHead(404, { 'Content-Type': 'text/plain' }).end('Not found')
return
}
const chunks: Buffer[] = []
for await (const c of req) {
chunks.push(c as Buffer)
}
const bodyBuf = Buffer.concat(chunks)
const request = new Request(`http://127.0.0.1:${PORT}${rawPath}`, {
method: 'POST',
headers: { 'content-type': req.headers['content-type'] || 'application/json' },
body: bodyBuf
})
try {
const out = await handlePiperTtsPost(request)
res.statusCode = out.status
out.headers.forEach((value, key) => {
res.setHeader(key, value)
})
const ab = await out.arrayBuffer()
res.end(Buffer.from(ab))
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
console.error('[piper-tts-proxy]', msg)
res.statusCode = 500
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: msg }))
}
})
server.listen(PORT, '0.0.0.0', () => {
const host = process.env.PIPER_TTS_HOST || '(default)'
console.log(`[piper-tts-proxy] http://0.0.0.0:${PORT}/api/piper-tts → Wyoming ${host}:${process.env.PIPER_TTS_PORT || '10200'}`)
})

1047
services/piper-tts-proxy/server.ts

File diff suppressed because it is too large Load Diff

4
src/constants.ts

@ -19,8 +19,8 @@ export const GITREPUBLIC_WEB_BASE_URL = (
.replace(/\/$/, '') .replace(/\/$/, '')
/** /**
* Piper TTS (same contract as aitherboard `POST /api/piper-tts`: JSON `{ text, voice?, speed? }`, body `audio/wav`). * Piper TTS (same contract as `POST /api/piper-tts`: JSON `{ text, voice?, speed? }`, body `audio/wav`).
* Default production: `/api/piper-tts` (same origin; reverse-proxy to aitherboard see PROXY_SETUP.md). * Default production: `/api/piper-tts` (same origin; reverse-proxy to Wyoming e.g. `services/piper-tts-proxy` or any host implementing that path see PROXY_SETUP.md).
* For cross-origin aitherboard instead, set full URL and configure CORS on that host. * For cross-origin aitherboard instead, set full URL and configure CORS on that host.
* If empty, read-aloud uses the Web Speech API only. * If empty, read-aloud uses the Web Speech API only.
*/ */

31
src/lib/event.ts

@ -36,15 +36,29 @@ function listThreadLinkETags(event: Event): string[][] {
return event.tags.filter(([n]) => n === 'e' || n === 'E') return event.tags.filter(([n]) => n === 'e' || n === 'E')
} }
/** Hex note ids on `e`/`E` that are not this event's id (some clients emit a bogus self-`e` on kind 1111). */
function listThreadLinkETagsExcludingSelf(event: Event): string[][] {
const self = event.id.toLowerCase()
return listThreadLinkETags(event).filter(
([, id]) => typeof id === 'string' && /^[0-9a-f]{64}$/i.test(id) && id.toLowerCase() !== self
)
}
/** /**
* Parent `e` for kind 1111 / voice comment: prefer `reply` marker, else last `e` when multiple * Parent `e` for kind 1111 / voice comment: prefer `reply` marker, else last `e` when multiple
* (NIP-10 root-then-reply), else first. Avoids treating the thread root as the parent when clients omit uppercase `E`. * (NIP-10 root-then-reply), else first. Avoids treating the thread root as the parent when clients omit uppercase `E`.
* Ignores `e`/`E` whose id is this event (bad self-links); parent then falls through to {@link getParentATag} / naddr.
*/ */
function getParentETagCommentOrDiscussion(event: Event): string[] | undefined { function getParentETagCommentOrDiscussion(event: Event): string[] | undefined {
const isETag = (n: string) => n === 'e' || n === 'E' const isETag = (n: string) => n === 'e' || n === 'E'
const byMarker = event.tags.find(([tagName, , , marker]) => isETag(tagName) && marker === 'reply') const self = event.id.toLowerCase()
const byMarker = event.tags.find(([tagName, id, , marker]) => {
if (!isETag(tagName) || marker !== 'reply' || typeof id !== 'string') return false
if (!/^[0-9a-f]{64}$/i.test(id)) return false
return id.toLowerCase() !== self
})
if (byMarker) return byMarker if (byMarker) return byMarker
const etags = listThreadLinkETags(event) const etags = listThreadLinkETagsExcludingSelf(event)
if (etags.length >= 2) return etags[etags.length - 1] if (etags.length >= 2) return etags[etags.length - 1]
return etags[0] return etags[0]
} }
@ -55,11 +69,18 @@ function getParentETagCommentOrDiscussion(event: Event): string[] | undefined {
*/ */
function getRootETagCommentOrDiscussion(event: Event): string[] | undefined { function getRootETagCommentOrDiscussion(event: Event): string[] | undefined {
const isETag = (n: string) => n === 'e' || n === 'E' const isETag = (n: string) => n === 'e' || n === 'E'
const byMarker = event.tags.find(([tagName, , , marker]) => isETag(tagName) && marker === 'root') const self = event.id.toLowerCase()
const byMarker = event.tags.find(([tagName, id, , marker]) => {
if (!isETag(tagName) || marker !== 'root' || typeof id !== 'string') return false
if (!/^[0-9a-f]{64}$/i.test(id)) return false
return id.toLowerCase() !== self
})
if (byMarker) return byMarker if (byMarker) return byMarker
const upperE = event.tags.find(tagNameEquals('E')) const upperE = event.tags.find(
(t) => t[0] === 'E' && typeof t[1] === 'string' && /^[0-9a-f]{64}$/i.test(t[1]) && t[1].toLowerCase() !== self
)
if (upperE) return upperE if (upperE) return upperE
const etags = listThreadLinkETags(event) const etags = listThreadLinkETagsExcludingSelf(event)
if (etags.length >= 2) return etags[0] if (etags.length >= 2) return etags[0]
return etags[0] return etags[0]
} }

Loading…
Cancel
Save