Commit 851767ff by PLN (Algolia)

feat(clients): @nech/api published — retire interim nech.ts (#44)

Verdaccio is live on npm.nech.pl and @nech/api@0.1.0 is published + install-verified
(AudioApi/Configuration import clean as a consumer). So the hand-written zero-dep
nech.ts drop-in is retired (git rm); @nech/api is the one true client. README +
onboarding.html now show the @nech scope install with the read token, and the docs
row reflects that /docs + /openapi.json are bearer-gated (api:docs) not public.
VERDACCIO.md marked DEPLOYED with the three gotchas folded in (listen 0.0.0.0,
chown 10001, TLSv1.2-only) + the auth-gated-reads note.
parent ae65658e
...@@ -8,9 +8,16 @@ the nginx TLS edge. ...@@ -8,9 +8,16 @@ the nginx TLS edge.
DNS `npm.nech.pl` → erable is set. erable = `pln@nech.pl`, Docker, nginx in DNS `npm.nech.pl` → erable is set. erable = `pln@nech.pl`, Docker, nginx in
`/etc/nginx/sites-enabled/`, passwordless sudo. Run as `pln`. `/etc/nginx/sites-enabled/`, passwordless sudo. Run as `pln`.
> **Status:** runbook ready, NOT yet deployed — needs an ssh session to erable > **Status: DEPLOYED 2026-06-29.** `npm.nech.pl` is live (LE TLS, loopback
> (`ssh erable` failed with `Permission denied (publickey)` from the build host; > Verdaccio 6.7.4 on `:4873`), publisher `pln` seeded + signups locked, and
> load the key first). Everything below is copy-paste once you're on erable. > `@nech/api@0.1.0` is published. Creds stashed on erable `~/.config/nechapi/`
> (`verdaccio.pln.{pw,token}`, 0600). Three gotchas the bare runbook below missed,
> now folded in: (1) set `listen: 0.0.0.0:4873` in config or the container binds
> localhost-INSIDE and the `-p` map is dead; (2) `chown -R 10001:65533
> ~/srv/verdaccio` for the bind-mount (verdaccio uid); (3) erable's old OpenSSL
> rejects `TLSv1.3` — `ssl_protocols TLSv1.2;` only. **Reads are auth-gated too**
> (`@nech/*` access=$authenticated, consistent with the bearer-gated
> `/openapi.json`), so consumers need an npm read token, not just the registry line.
## 1. Verdaccio container (loopback :4873) ## 1. Verdaccio container (loopback :4873)
......
...@@ -16,10 +16,15 @@ the API. One package, per-domain subpath imports (you only bundle what you use). ...@@ -16,10 +16,15 @@ the API. One package, per-domain subpath imports (you only bundle what you use).
Source + build: [`nech-api/`](./nech-api/). Source + build: [`nech-api/`](./nech-api/).
```bash ```bash
echo '@nech:registry=https://npm.nech.pl' >> .npmrc # the @nech scope is private # the @nech scope is private — point it at the registry + add your read token
printf '@nech:registry=https://npm.nech.pl/\n//npm.nech.pl/:_authToken=%s\n' "$NECH_NPM_TOKEN" >> .npmrc
npm i @nech/api npm i @nech/api
``` ```
(`@nech/*` is auth-gated for reads too — consistent with the bearer-gated
`/openapi.json` — so installing needs an npm read token; ask for one alongside
your API bearer.)
```ts ```ts
import { AudioApi, Configuration } from "@nech/api/audio"; import { AudioApi, Configuration } from "@nech/api/audio";
...@@ -50,10 +55,9 @@ export async function POST(req: Request) { ...@@ -50,10 +55,9 @@ export async function POST(req: Request) {
} }
``` ```
> **Interim:** until Verdaccio (`npm.nech.pl`) is deployed (see > `@nech/api` is **live** on `npm.nech.pl` (Verdaccio — see
> [`../VERDACCIO.md`](../VERDACCIO.md)), the zero-dep drop-in > [`../VERDACCIO.md`](../VERDACCIO.md)) as of 2026-06-29. The old zero-dep drop-in
> [`nech.ts`](./nech.ts) — `new NechAPI({token}).audio.emotion(clip)` — is the > `nech.ts` is retired; this generated package is the one true client.
> working client. It's superseded by `@nech/api` once the registry is live.
## Scopes ## Scopes
......
/**
* NechAPI — typed client for the Nech.PL APIs platform (api.nech.pl).
*
* Zero dependencies: uses the global `fetch` / `FormData` / `Blob` available in
* Node 18+, Vercel Functions, and the Edge runtime.
*
* One client, namespaced per sub-API:
*
* import { NechAPI } from "./nech";
* const nech = new NechAPI({ token: process.env.NECHPL_TOKEN! });
* const mood = await nech.audio.emotion(clip); // { emotion: {…} }
* const spec = await nech.audio.spectrum(clip, { bands: 64, frames: 200 });
* // future sub-APIs add namespaces (nech.geo.…) — your import never changes.
*
* Prefer a smaller bundle? Import just the sub-client you need:
*
* import { NechAudio } from "./nech";
* const audio = new NechAudio({ token });
* const mood = await audio.emotion(clip);
*
* Every audio call takes a clip (a `Blob` — e.g. `await fetch(url).then(r =>
* r.blob())`, or a `File` from an upload) and returns typed JSON. Identical audio
* is cached server-side: check `result._cache === "hit"`.
*/
export interface NechOptions {
/** Platform bearer token (provisioned for your tenant). */
token: string;
/** Platform base; defaults to the public gateway. */
baseUrl?: string;
/** Inject a custom fetch (tests / non-standard runtimes). */
fetchImpl?: typeof fetch;
}
/** Options for a standalone sub-client; baseUrl is that API's versioned root. */
export interface SubClientOptions {
token: string;
baseUrl?: string;
fetchImpl?: typeof fetch;
}
export type Cache = "hit" | "miss";
type WithMeta<T> = T & { filename: string; content_id: string; _cache: Cache };
export interface Emotion {
valence: number; arousal: number;
top: [string, number][]; dist: Record<string, number>;
confidence: number; engine: string;
}
export interface Features { features: Record<string, number | string>; tiers: Record<string, string[]>; sr: number; engine: string; }
export interface SampleProfile {
profile: { rms_db: number; peak_db: number; centroid: number; bands: Record<string, number> };
role: "percs" | "bass" | "melodic" | "tops" | "atmos";
centroid: number | null; broadband: boolean; time_activity: number;
name_hint: string | null; sr: number; engine: string;
}
export interface Grade {
grade: { path: string; kind: "loop" | "one_shot"; grade: number; tier: "S" | "A" | "B" | "C" | "D";
sub: Record<string, number>; metrics: Record<string, number>; flags: string[] };
}
export interface Onsets { tempo_bpm: number; onsets: number[]; n_onsets: number; onset_rate: number; duration_s: number; sr: number; engine: string; }
export interface Waveform { bins: number; peaks: [number, number][]; rms: number[]; duration_s: number; sr: number; engine: string; }
export interface Analyze { emotion: Emotion; features: Record<string, number | string>; sample: SampleProfile; }
export interface Loudness {
lufs_integrated: number | null; peak_dbfs: number; true_peak_dbfs: number; rms_dbfs: number;
crest_db: number; gain_to_target_db: { streaming: number; club: number } | null;
channels: number; sr: number; engine: string;
}
export interface Spectrum { bands: number; frames: number; mel: boolean; band_hz: number[]; spec: number[][]; duration_s: number; sr: number; engine: string; }
export interface Naming { suggested_name: string; role: string; character: string; stem: string; lint: string[]; }
export class NechError extends Error {
constructor(public status: number, public detail: unknown) {
super(`NechAPI ${status}: ${typeof detail === "string" ? detail : JSON.stringify(detail)}`);
}
}
/**
* The `audio` sub-API client — usable standalone or via `NechAPI#audio`.
* Public path: https://api.nech.pl/audio/v1
*/
export class NechAudio {
private base: string;
private token: string;
private f: typeof fetch;
constructor(opts: SubClientOptions) {
this.token = opts.token;
this.base = (opts.baseUrl ?? "https://api.nech.pl/audio/v1").replace(/\/$/, "");
this.f = opts.fetchImpl ?? fetch;
}
private async post<T>(path: string, clip: Blob, query: Record<string, unknown> = {},
filename = "clip.wav"): Promise<WithMeta<T>> {
const qs = Object.entries(query)
.filter(([, v]) => v !== undefined && v !== null && v !== "")
.map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`).join("&");
const form = new FormData();
form.append("file", clip, filename);
const res = await this.f(`${this.base}${path}${qs ? `?${qs}` : ""}`, {
method: "POST",
headers: { Authorization: `Bearer ${this.token}` },
body: form,
});
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new NechError(res.status, (body as any)?.detail ?? body);
(body as any)._cache = (res.headers.get("X-Nech-Cache") as Cache) ?? "miss";
return body as WithMeta<T>;
}
/** Service health + per-engine availability (unauthenticated). */
async health(): Promise<any> {
const res = await this.f(`${this.base}/healthz`);
return res.json();
}
emotion(clip: Blob, filename?: string) { return this.post<Emotion & { emotion: Emotion }>("/analyze/emotion", clip, {}, filename); }
features(clip: Blob, o: { rhythm?: boolean } = {}) { return this.post<Features>("/features", clip, o); }
samples(clip: Blob, o: { name?: string } = {}) { return this.post<SampleProfile>("/analyze/samples", clip, o); }
grade(clip: Blob, o: { role?: string } = {}) { return this.post<Grade>("/grade", clip, o); }
onsets(clip: Blob) { return this.post<Onsets>("/onsets", clip); }
waveform(clip: Blob, o: { bins?: number } = {}) { return this.post<Waveform>("/waveform", clip, o); }
analyze(clip: Blob) { return this.post<Analyze>("/analyze", clip); }
loudness(clip: Blob) { return this.post<Loudness>("/loudness", clip); }
spectrum(clip: Blob, o: { bands?: number; frames?: number; mel?: boolean } = {}) { return this.post<Spectrum>("/spectrum", clip, o); }
naming(clip: Blob, o: { index?: number } = {}) { return this.post<Naming>("/naming", clip, o); }
}
/**
* The umbrella platform client. Sub-APIs hang off it as namespaces; today that's
* `nech.audio`, tomorrow `nech.geo`, … — with no change to your import.
*/
export class NechAPI {
/** The Audio Intelligence API (api.nech.pl/audio/v1). */
readonly audio: NechAudio;
constructor(opts: NechOptions) {
const base = (opts.baseUrl ?? "https://api.nech.pl").replace(/\/$/, "");
this.audio = new NechAudio({ token: opts.token, baseUrl: `${base}/audio/v1`, fetchImpl: opts.fetchImpl });
}
}
export default NechAPI;
...@@ -111,23 +111,31 @@ curl -H <span class="s">"Authorization: Bearer $KEY"</span> \ ...@@ -111,23 +111,31 @@ curl -H <span class="s">"Authorization: Bearer $KEY"</span> \
<tr><td><span class="mono">POST /loudness</span></td><td>BS.1770 LUFS + sample/true peak + gain-to-target (−14/−9)</td></tr> <tr><td><span class="mono">POST /loudness</span></td><td>BS.1770 LUFS + sample/true peak + gain-to-target (−14/−9)</td></tr>
<tr><td><span class="mono">POST /naming</span></td><td>convention-compliant sample name from the measured role</td></tr> <tr><td><span class="mono">POST /naming</span></td><td>convention-compliant sample name from the measured role</td></tr>
<tr><td><span class="mono">POST /separate</span></td><td><span class="chip">503</span> stem separation — GPU runner pending; code against it now</td></tr> <tr><td><span class="mono">POST /separate</span></td><td><span class="chip">503</span> stem separation — GPU runner pending; code against it now</td></tr>
<tr><td><span class="mono">GET&nbsp; /healthz · /openapi.json · /docs</span></td><td>public: liveness · spec · interactive docs</td></tr> <tr><td><span class="mono">GET&nbsp; /healthz</span></td><td>public: liveness probe (no token)</td></tr>
<tr><td><span class="mono">GET&nbsp; /docs · /openapi.json</span></td><td>interactive docs · spec — need an <span class="mono">api:docs</span> scope (your <span class="mono">api:*</span> token covers it)</td></tr>
</table> </table>
<p class="sub">Interactive docs &amp; full schema: <span class="mono">https://api.nech.pl/audio/v1/docs</span> <p class="sub">Interactive docs &amp; full schema: <span class="mono">https://api.nech.pl/audio/v1/docs</span>
· <span class="mono">/openapi.json</span> — generate a client in any language from the spec.</p> · <span class="mono">/openapi.json</span> (bearer-gated) — generate a client in any language from the spec.</p>
<h2>3 · TypeScript / Vercel (zero deps)</h2> <h2>3 · TypeScript / Vercel — <span class="mono">@nech/api</span> (generated)</h2>
<p>Drop <span class="mono">nech.ts</span> (ships with this kit) into your project — it uses only <p>The official client is generated from the OpenAPI spec, so it never drifts. Install from the
global <span class="mono">fetch</span>/<span class="mono">FormData</span>, so it runs on Node 18+, Vercel Functions and the Edge. private registry (needs your npm read token), then import the per-API subpath — runs on Node 18+,
One client, namespaced per API (<span class="mono">nech.audio.…</span>).</p> Vercel Functions and the Edge.</p>
<pre><span class="k">import</span> { NechAPI } <span class="k">from</span> <span class="s">"./nech"</span>; <pre><span class="c"># .npmrc</span>
@nech:registry=https://npm.nech.pl/
//npm.nech.pl/:_authToken=${NECH_NPM_TOKEN}
<span class="k">const</span> nech = <span class="k">new</span> NechAPI({ token: process.env.<span class="k">NECHPL_TOKEN</span>! }); <span class="c">$ npm i @nech/api</span></pre>
<pre><span class="k">import</span> { AudioApi, Configuration } <span class="k">from</span> <span class="s">"@nech/api/audio"</span>;
<span class="k">const</span> audio = <span class="k">new</span> AudioApi(<span class="k">new</span> Configuration({
basePath: <span class="s">"https://api.nech.pl/audio/v1"</span>,
accessToken: process.env.<span class="k">NECH_TOKEN</span>!,
}));
<span class="k">const</span> clip = <span class="k">await</span> fetch(trackUrl).then(r =&gt; r.blob()); <span class="k">const</span> clip = <span class="k">await</span> fetch(trackUrl).then(r =&gt; r.blob());
<span class="k">const</span> mood = <span class="k">await</span> nech.audio.emotion(clip); <span class="c">// { emotion: { valence, arousal, … } }</span> <span class="k">const</span> mood = <span class="k">await</span> audio.analyzeEmotion({ file: clip }); <span class="c">// { emotion: { valence, arousal, … } }</span>
<span class="k">const</span> spec = <span class="k">await</span> nech.audio.spectrum(clip, { bands: 64, frames: 200 }); <span class="k">const</span> spec = <span class="k">await</span> audio.spectrum({ file: clip, bands: 64, frames: 200 });</pre>
<span class="k">if</span> (mood._cache === <span class="s">"hit"</span>) { <span class="c">/* came free from the cache */</span> }</pre>
<div class="two"> <div class="two">
<div> <div>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment