Skip to content

feat(share): public playlist route /p/$token (phase 1.g.2)#20

Merged
InstaZDLL merged 6 commits into
mainfrom
feat/1-g-2-public-playlist-route
Jun 3, 2026
Merged

feat(share): public playlist route /p/$token (phase 1.g.2)#20
InstaZDLL merged 6 commits into
mainfrom
feat/1-g-2-public-playlist-route

Conversation

@InstaZDLL
Copy link
Copy Markdown
Owner

@InstaZDLL InstaZDLL commented Jun 3, 2026

Summary

Phase 1.g.2 — the web side of playlist sharing. Renders an SSR public preview at /p/$token, consuming the unauthenticated GET endpoint that ships in waveflow-server PR #25.

The page shows playlist metadata + cover preview + an empty tracklist for now (server-side playlist_track materialisation is a separate sub-phase). What matters for the social-share use case is already there: real <title>, <meta name=\"description\">, Open Graph (og:type=music.playlist, og:title, og:description, og:site_name) and Twitter Card meta tags emitted from the route's head() against the loader data.

What lands

  • src/server-fns/share.tsgetPublicPlaylist server-fn with strict token-shape validator (32 alphanumerics, rejects path-injection chars). Returns a discriminated union so the loader distinguishes 404 from transient errors.
  • src/lib/server/waveflow-server.tswaveflowFetchPublic anonymous fetch helper. Same WaveflowServerError shape as the authed variant.
  • src/routes/p.$token.tsx — route with loader, head() for OG/Twitter, distinct ok/not_found/error panels.
  • src/server-fns/share.test.ts — 15 vitest cases pinning the predicate's accept/reject matrix.

Notes

  • Twitter card stays summary (not summary_large_image) until the server-side artwork pipeline exposes the cover_hash as a public URL.
  • _authed parent layout NOT applied — the token is the auth.

Depends on

Summary by CodeRabbit

  • New Features

    • Aperçu public de playlists accessible via token : page sans authentification, couverture calculée, palette de couleurs, description (ou résumé avec nombre de titres), liste numérotée des titres (ou message si vide) et métadonnées Open Graph/Twitter pour partage.
  • Server

    • Support des appels vers les endpoints publics sans en-tête d’authentification et gestion claire des réponses (succès / introuvable / erreur) avec retours adaptés.
  • Tests

    • Suite de tests renforçant la validation stricte des tokens de partage.

SSR route consuming /api/v1/share/playlists/{token} (no auth).

Renders metadata, cover, empty tracklist, plus OG + Twitter tags.

Validator rejects path-injection chars (slash, dot, percent).

Signed-off-by: InstaZDLL <github.105mh@8shield.net>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 3, 2026

Review Change Stack

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

Ce PR ajoute le partage public de playlists via URL tokenisée : un utilitaire de fetch public, contrats et validation de token, une server‑fn getPublicPlaylist, et une route /p/$token rendant la playlist ou des panneaux d’erreur/not‑found avec meta tags SSR.

Changes

Public Playlist Sharing

Layer / File(s) Summary
Public API fetch utility
src/lib/server/waveflow-server.ts
waveflowFetchPublic<T> effectue des requêtes publiques sans header Authorization, gère un body JSON optionnel, lève WaveflowServerError sur réponses non‑2xx, et convertit 204/corps vides en undefined.
Public API contracts, token validator and tests
src/server-fns/share.ts, src/server-fns/share.test.ts
PublicTrack et PublicPlaylist définissent la forme publique; PublicPlaylistResult couvre ok/error; isWellShapedToken valide tokens alphanumériques de 32 caractères; tests Vitest couvrent cas valides et invalides.
Public playlist server function
src/server-fns/share.ts
getPublicPlaylist normalise l’entrée, valide le token, appelle waveflowFetchPublic vers /api/v1/share/playlists/{token}, et mappe 404not_found (throw notFound) ou autres erreurs→error (avec log).
Public route and UI components
src/routes/p.$token.tsx
Route '/p/$token' avec loader appelant getPublicPlaylist, head() émettant meta tags Open Graph/Twitter selon résultat, et composants PlaylistPanel, NotFoundPanel, ErrorPanel (incl. mapping couleurs et rendu des pistes).

Sequence Diagram

sequenceDiagram
  participant User as Navigateur
  participant Route as /p/$token route
  participant ServerFn as getPublicPlaylist
  participant Validator as isWellShapedToken
  participant PublicFetch as waveflowFetchPublic
  participant WaveflowAPI as Waveflow Public API

  User->>Route: visite /p/⟨token⟩
  Route->>ServerFn: loader(token)
  ServerFn->>Validator: isWellShapedToken(token)
  alt token invalide
    ServerFn-->>Route: throw notFound()
  else token valide
    ServerFn->>PublicFetch: GET /api/v1/share/playlists/encodeURIComponent(token)
    PublicFetch->>WaveflowAPI: GET (sans Authorization)
    alt 200 OK
      WaveflowAPI-->>PublicFetch: playlist JSON
      PublicFetch-->>ServerFn: PublicPlaylist
      ServerFn-->>Route: {kind: 'ok', playlist}
    else 404
      WaveflowAPI-->>PublicFetch: 404
      PublicFetch-->>ServerFn: WaveflowServerError(404)
      ServerFn-->>Route: throw notFound()
    else autre erreur
      WaveflowAPI-->>PublicFetch: erreur
      PublicFetch-->>ServerFn: WaveflowServerError / throw
      ServerFn-->>Route: {kind: 'error', message}
    end
  end
  Route->>User: render PlaylistPanel / NotFoundPanel / ErrorPanel
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • InstaZDLL/waveflow-web#16: Introduit le fetcher serveur waveflowFetch et WaveflowServerError que ce PR étend via waveflowFetchPublic.

Suggested labels

scope: components

"Un token ouvre la porte, la playlist va paraître,
Sans auth dans l'en‑tête, l'URL suffit pour partager,
Meta et cover colorés, trois panneaux pour guider,
Un fetch public discret, la preview peut briller,
Voilà la route /p/:token — simple à consulter."

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive La description couvre les sections clés (Summary, What lands, Notes) et explique les dépendances. Manquent toutefois les sections 'Changes' et 'Test plan' du template. Ajouter une liste 'Changes' explicite et les étapes de 'Test plan' (typecheck, lint, format, build, test, et chemin manuel ex. /p/token-valide).
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed Le titre décrit précisément la fonctionnalité principale : une nouvelle route publique pour les playlists partagées via token.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/1-g-2-public-playlist-route

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/routes/p`.$token.tsx:
- Around line 30-35: The head helper in p.$token.tsx currently treats any non-ok
loaderData as “Playlist not found”, which mislabels transient server errors;
update the head({ loaderData }) logic to distinguish loaderData.kind === 'error'
from the 'not found' case used by ErrorPanel and return an appropriate SSR title
(e.g., "Server error · WaveFlow" or a generic "Error · WaveFlow") when kind ===
'error', leaving "Playlist not found · WaveFlow" only for the not-found branch
so preview/social titles match ErrorPanel.
- Around line 77-113: PlaylistPanel is loading cover_hash, color_id and icon_id
but never rendering the cover preview; update the JSX in PlaylistPanel to render
a cover preview component/div above the playlist name that uses
playlist.cover_hash (for image URL or data), playlist.color_id (for background
color/fallback), and playlist.icon_id (for overlay/icon) and provide sensible
fallbacks when values are missing so the preview shows on the public page;
locate the PlaylistPanel function and add the preview markup/component before
the <h1> that references those playlist properties and ensure accessible
alt/text and responsive sizing.

In `@src/server-fns/share.ts`:
- Around line 65-75: The input validation currently done in inputValidator
causes malformed tokens to throw before the handler and break the intended
public-route rendering; move the token check into the getPublicPlaylist handler
so the route can return { kind: 'not_found' } for any bad token, remove the
throw from inputValidator, and tighten isWellShapedToken's regex from
/^[A-Za-z0-9]{8,128}$/ to /^[A-Za-z0-9]{32}$/ so only 32-char alphanumeric
tokens are accepted.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 34c4d726-ce4c-42f9-9257-001fea71a249

📥 Commits

Reviewing files that changed from the base of the PR and between 1218a9a and cc36890.

📒 Files selected for processing (4)
  • src/lib/server/waveflow-server.ts
  • src/routes/p.$token.tsx
  • src/server-fns/share.test.ts
  • src/server-fns/share.ts

Comment thread src/routes/p.$token.tsx
Comment thread src/routes/p.$token.tsx
Comment thread src/server-fns/share.ts
@InstaZDLL InstaZDLL self-assigned this Jun 3, 2026
…lidator

Three findings from CodeRabbit on the share preview surface.

head() now distinguishes 'not_found' from 'error' so the SSR title
and social preview match what ErrorPanel actually renders instead
of falsely claiming the playlist is gone.

PlaylistPanel renders a brand-colored cover tile derived from
color_id with the playlist name initial as overlay. cover_hash
stays unrendered until the server-side artwork pipeline exposes
it as a public URL.

getPublicPlaylist's input validator no longer throws on malformed
tokens. Shape check (strict 32-char regex, matching the server's
mint format) lives in the handler and returns not_found, so bad
tokens render the friendly empty state instead of the framework
error boundary.

Signed-off-by: InstaZDLL <github.105mh@8shield.net>
@InstaZDLL
Copy link
Copy Markdown
Owner Author

@coderabbitai thanks for the careful review — all three findings addressed in 5de25d7.

1. head() error vs not-found — added a dedicated kind === 'error' branch returning 'Server error · WaveFlow', so the SSR title and social preview now match what ErrorPanel actually renders. 'Playlist not found · WaveFlow' is reserved for the 404 branch.

2. PlaylistPanel cover preview — added a brand-colored tile (h-32 / sm:h-40, rounded-2xl) above the <h1>, with the palette mirrored from the desktop's PLAYLIST_COLORS via a static colorTileClass switch so Tailwind's scanner sees every concrete utility. The playlist name's initial fills the tile as an overlay; unknown color_id falls back to violet (matching desktop). cover_hash stays unrendered for now — the comment in share.ts already notes the artwork pipeline doesn't yet expose it as a public URL, so honouring it would mean a 404 image at best. We can wire it in the same release that flips twitter:card to summary_large_image.

3. Token validatorisWellShapedToken regex tightened from {8,128} to {32} (exact server mint length). The .inputValidator() no longer throws — it just narrows to string. Shape check moved into the handler, which returns { kind: 'not_found' } so malformed tokens render NotFoundPanel instead of the framework's error boundary. Test suite updated: kept the two 32-char accepts and added 31 / 33-char rejects so the off-by-one boundary stays pinned.

Local checks green: typecheck, lint, 16/16 vitest cases on share.test.ts.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 3, 2026

Tip

For best results, initiate chat on the files or code changes.

@InstaZDLL Toutes les corrections sont solides — merci pour les détails de chaque point.

  • head() : les trois branches (ok / not_found / error) sont maintenant cohérentes avec ce que le composant rend réellement. ✔
  • Cover tile : le switch statique colorTileClass est la bonne approche pour que le scanner Tailwind voie les utilitaires concrets ; et reporter cover_hash jusqu'au flip summary_large_image est logique. ✔
  • Validateur : {32} exact + retour not_found plutôt qu'une exception, avec les cas-limites 31/33 épinglés dans les tests — c'est propre. ✔

🐇 ᵔᴥᵔ

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/routes/p`.$token.tsx:
- Line 127: The code uses playlist.name.trim().charAt(0) which breaks
multi-codepoint graphemes (e.g., emojis); change the extraction to use an
iteration-by-codepoint approach (e.g., Array.from(...) or the spread
[...playlist.name.trim()]) to get the true first grapheme, then fall back to '♪'
and apply toUpperCase() as needed; update the expression that sets the initial
(variable initial) to use this safe-first-character logic and guard for empty
names.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: f6870d60-6c0a-4c5c-ae75-b17f7dafa852

📥 Commits

Reviewing files that changed from the base of the PR and between cc36890 and 5de25d7.

📒 Files selected for processing (3)
  • src/routes/p.$token.tsx
  • src/server-fns/share.test.ts
  • src/server-fns/share.ts

Comment thread src/routes/p.$token.tsx Outdated
CodeRabbit caught that `charAt(0)` on a name starting with an
emoji (or any supplementary-plane code point) returns a lone
surrogate, which renders as the replacement character.

Swap to `Array.from(str)[0]` so the spread iterates by code
point — a name like "🎵 Mix" now overlays "🎵" instead of a
broken half-surrogate.

Signed-off-by: InstaZDLL <github.105mh@8shield.net>
@InstaZDLL
Copy link
Copy Markdown
Owner Author

@coderabbitai good catch, fixed in 3ee20f9. Switched to Array.from(playlist.name.trim())[0]?.toUpperCase() ?? '♪' so the spread iterates by code point — a name starting with "🎵 Mix" now overlays "🎵" instead of a lone surrogate. Comment updated from "first grapheme" to "first code point" to match the actual guarantee (true grapheme clusters like 🇫🇷 flags or ZWJ family emojis would need Intl.Segmenter, which is overkill for this overlay).

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 3, 2026

Tip

For best results, initiate chat on the files or code changes.

@InstaZDLL Correction solide — Array.from itère bien sur les code points et couvre les cas courants comme "🎵 Mix". Le fallback '♪' pour un nom vide est un détail appréciable. Et oui, Intl.Segmenter serait la bonne approche pour les graphème-clusters composites (drapeaux, ZWJ), mais le rapport complexité/bénéfice ne se justifie pas pour une initiale de tuile. Le commentaire mis à jour reflète fidèlement la garantie réelle. ✔

🐇 ᵔᴥᵔ

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/routes/p.$token.tsx (1)

27-35: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fixer un vrai 404 côté SSR quand kind: 'not_found'

Le loader rend NotFoundPanel via une union { kind: 'not_found' }, mais ne déclenche ni notFound() ni setResponseStatus(404). Du coup, le SSR peut servir un statut HTTP non-404 (soft-404) malgré le contenu “playlist introuvable”.

🔧 Piste de correction
+import { setResponseStatus } from '`@tanstack/react-start/server`'
+
 export const Route = createFileRoute('/p/$token')({
   loader: async ({ params }): Promise<PublicPlaylistResult> => {
-    return getPublicPlaylist({ data: params.token })
+    const result = await getPublicPlaylist({ data: params.token })
+    if (result.kind === 'not_found') setResponseStatus(404)
+    return result
   },
   head: ({ loaderData }) => {
     if (!loaderData || loaderData.kind === 'not_found') {
       return {
         meta: [{ title: 'Playlist not found · WaveFlow' }],
       }
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/p`.$token.tsx around lines 27 - 35, The loader currently returns
getPublicPlaylist(...) directly and when the result is { kind: 'not_found' } the
route renders a NotFound UI but does not set an HTTP 404; update the loader (the
async loader function that calls getPublicPlaylist) to inspect the result, and
if result.kind === 'not_found' call the framework's server 404 helper (e.g.,
notFound() or setResponseStatus(404)) before returning so SSR emits a real 404
status; keep normal return for other kinds so head and UI logic (head,
NotFoundPanel) work unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/routes/p`.$token.tsx:
- Around line 27-35: The loader currently returns getPublicPlaylist(...)
directly and when the result is { kind: 'not_found' } the route renders a
NotFound UI but does not set an HTTP 404; update the loader (the async loader
function that calls getPublicPlaylist) to inspect the result, and if result.kind
=== 'not_found' call the framework's server 404 helper (e.g., notFound() or
setResponseStatus(404)) before returning so SSR emits a real 404 status; keep
normal return for other kinds so head and UI logic (head, NotFoundPanel) work
unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 550a6ff8-d6e8-4dae-813b-ba967da61925

📥 Commits

Reviewing files that changed from the base of the PR and between 5de25d7 and 3ee20f9.

📒 Files selected for processing (1)
  • src/routes/p.$token.tsx

CodeRabbit caught that the route was rendering NotFoundPanel
with HTTP 200 — a soft-404 that confuses crawlers and link
unfurlers.

The loader now calls setResponseStatus(404) from
@tanstack/react-start/server when the result is { kind:
'not_found' }, guarded on typeof window === 'undefined' since
the h3 event lives in AsyncLocalStorage on the server only and
client-side navigation has nothing to set.

UI rendering is unchanged — NotFoundPanel still renders, just
with the right status code on the wire.

Signed-off-by: InstaZDLL <github.105mh@8shield.net>
@InstaZDLL
Copy link
Copy Markdown
Owner Author

@coderabbitai right call, fixed in fe5a6ea. The loader now calls setResponseStatus(404) from @tanstack/react-start/server when result.kind === 'not_found', guarded on typeof window === 'undefined' — the h3 event lives in AsyncLocalStorage server-side only, so the call would throw on client-side navigation. UI rendering is unchanged (NotFoundPanel still renders), just with a real 404 on the wire for crawlers and link unfurlers.

Picked setResponseStatus over throwing notFound() from the router because the latter would redirect rendering to the router's notFoundComponent boundary, replacing our friendly NotFoundPanel. The status-only fix is more surgical.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 3, 2026

Tip

For best results, initiate chat on the files or code changes.

@InstaZDLL Approche impeccable sur tous les points.

  • setResponseStatus(404) : retourner un vrai 404 sur le fil sans perturber le rendu est exactement ce qu'il faut pour les crawlers et les link unfurlers.
  • Guard typeof window === 'undefined' : c'est le bon pattern pour un appel qui n'existe qu'en contexte h3 côté serveur — pas de risque de throw lors d'une navigation client.
  • Choix vs notFound() : la distinction est juste — notFound() délègue au notFoundComponent du routeur, ce qui écraserait NotFoundPanel. L'approche chirurgicale "status only" est préférable ici.

🐇 ᵔᴥᵔ

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/routes/p`.$token.tsx:
- Line 2: Le build échoue car setResponseStatus est importé statiquement dans la
route; déplace la logique de 404 dans une Server Function créée avec
createServerFn (par ex. serverCheckToken or handleTokenNotFound) qui importe et
utilise setResponseStatus côté serveur, puis supprime l'import de
setResponseStatus du fichier de route et remplace la logique dans loader par un
simple appel/await à cette server-fn (p. ex. await
serverCheckToken(params.token)); en résumé: remove the top-level import of
setResponseStatus, implement createServerFn(...) that performs the 404 via
setResponseStatus, export it, and have loader only call that exported server
function.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 085741de-17a8-4e9f-b328-74ee65baa13a

📥 Commits

Reviewing files that changed from the base of the PR and between 3ee20f9 and fe5a6ea.

📒 Files selected for processing (1)
  • src/routes/p.$token.tsx

Comment thread src/routes/p.$token.tsx Outdated
The previous attempt put setResponseStatus(404) in the route
loader, which broke the CI build: TanStack Start's import
protection denies @tanstack/react-start/server imports from
route files (routes are bundled for the client too).

Move the status call into getPublicPlaylist's handler instead.
Server-fn handler bodies are server-only by construction —
createServerFn splits the file so the client only sees the RPC
stub — so the import is legitimate there and the build succeeds.

UI behaviour and tests are unchanged. The route loader stays
isomorphic; the 404 emission rides along with the result the
server-fn returns.

Signed-off-by: InstaZDLL <github.105mh@8shield.net>
@InstaZDLL
Copy link
Copy Markdown
Owner Author

CI build was failing on fe5a6ea — TanStack Start's import-protection denies @tanstack/react-start/server imports from route files (routes ship to the client too, so a server-only import there is a build-time error, not a runtime concern my typeof window guard could solve).

Fixed in 3ec4ad4 by moving setResponseStatus(404) into the getPublicPlaylist server-fn handler. Server-fn handler bodies are server-only by construction — createServerFn splits the file so the client only sees the RPC stub — so the import is legitimate there. Behaviour is unchanged: route loader stays isomorphic, the 404 emission rides along with the result the server-fn returns. bun run build now passes locally.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/server-fns/share.ts (1)

82-109: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Risque de navigation client cassée avec setResponseStatus(404) dans getPublicPlaylist (RPC)

src/server-fns/share.ts met setResponseStatus(404) puis retourne { kind: 'not_found' }, mais src/routes/p.$token.tsx appelle ce server-fn dans un loader sans try/catch et affiche NotFoundPanel uniquement si loaderData.kind === 'not_found'. Or le client-RPC TanStack traite généralement les réponses HTTP non-OK comme des erreurs → le loader pourrait ne jamais recevoir { kind: 'not_found' } et tomber sur l’error boundary.

Utiliser le signal TanStack idiomatique notFound() (et gérer côté client avec isNotFound(err)/ou via notFoundComponent) au lieu de piloter le contrôle de flux via setResponseStatus(404) dans un server-fn RPC.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server-fns/share.ts` around lines 82 - 109, The handler currently calls
setResponseStatus(404) and returns { kind: 'not_found' }, which causes the
TanStack RPC client to treat the response as an HTTP error; instead, replace
those control-flow returns with the TanStack "notFound" signal: when
isWellShapedToken(token) is false or when waveflowFetchPublic throws a
WaveflowServerError with status===404, call/throw notFound() (import it from
your TanStack server helpers) rather than using setResponseStatus or returning {
kind: 'not_found' }; keep the existing catch logic for other errors and leave
waveflowFetchPublic, isWellShapedToken and WaveflowServerError checks intact,
and ensure the route loader handles notFound via isNotFound(err) /
notFoundComponent.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/server-fns/share.ts`:
- Around line 82-109: The handler currently calls setResponseStatus(404) and
returns { kind: 'not_found' }, which causes the TanStack RPC client to treat the
response as an HTTP error; instead, replace those control-flow returns with the
TanStack "notFound" signal: when isWellShapedToken(token) is false or when
waveflowFetchPublic throws a WaveflowServerError with status===404, call/throw
notFound() (import it from your TanStack server helpers) rather than using
setResponseStatus or returning { kind: 'not_found' }; keep the existing catch
logic for other errors and leave waveflowFetchPublic, isWellShapedToken and
WaveflowServerError checks intact, and ensure the route loader handles notFound
via isNotFound(err) / notFoundComponent.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: b2f2a803-e960-4c42-b2ee-c39d25cc9e86

📥 Commits

Reviewing files that changed from the base of the PR and between fe5a6ea and 3ec4ad4.

📒 Files selected for processing (2)
  • src/routes/p.$token.tsx
  • src/server-fns/share.ts

… hack

CodeRabbit caught that emitting a 404 status from the server-fn
handler breaks client-side navigation: TanStack's RPC client
treats any non-OK HTTP response as a thrown Error
(serverFnFetcher.ts:188), so the loader would never see the
discriminated-union not_found variant and the route would fall
into the framework's error boundary.

Switch to the idiomatic signal: throw notFound() in the handler
for both malformed-token and upstream-404 cases. TanStack
serialises notFound through the RPC layer as a recognised
payload, the client re-throws it as a NotFoundError, and the
router routes to the route's notFoundComponent with a proper
SSR 404 status.

PublicPlaylistResult drops the 'not_found' variant since the
404 case now propagates out-of-band. The route gains a
notFoundComponent and its head() defaults to the not-found
title when loaderData is absent.

Signed-off-by: InstaZDLL <github.105mh@8shield.net>
@InstaZDLL
Copy link
Copy Markdown
Owner Author

@coderabbitai sharp catch, fully refactored in 07fdc7c.

Verified the concern: serverFnFetcher.ts:188 in start-client-core does if (!response.ok) throw new Error(await response.text()) — so my previous setResponseStatus(404) would have broken client-side navigation by surfacing a generic Error instead of the discriminated-union not_found variant. SSR happened to work because the handler runs in-process, but every subsequent client-side route transition would have crashed into the error boundary.

Refactor:

  • Server-fn handler now throw notFound() for both the malformed-token branch and the upstream 404 branch. PublicPlaylistResult loses the 'not_found' variant — it's now { kind: 'ok' } | { kind: 'error' }, with 404s riding the router's out-of-band channel.
  • Route gains notFoundComponent: NotFoundPanel. The router catches the serialised notFound payload (handled at serverFnFetcher.ts:185, if (isNotFound(jsonPayload)) throw jsonPayload), re-throws as a NotFoundError, and routes to the component — with a proper SSR 404 status because TanStack handles that internally for thrown notFounds.
  • head() simplified: !loaderData now means "loader threw notFound" and emits the "Playlist not found · WaveFlow" title; kind === 'error' keeps its "Server error · WaveFlow" title; ok branch unchanged.

bun run build + typecheck + lint + 66/66 vitest all green.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/routes/p.$token.tsx (1)

33-73: 🧹 Nitpick | 🔵 Trivial

Pour l'unfurling social, pense à og:url (et og:image plus tard).

Les branches head() sont cohérentes avec les panneaux rendus, rien à redire côté logique. Note opérationnelle uniquement : sans og:url canonique, certains scrapers (Slack/Discord/Facebook) génèrent un aperçu moins fiable. og:image reste légitimement différé jusqu'au pipeline d'artwork public, comme indiqué dans l'objectif du PR.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/p`.$token.tsx around lines 33 - 73, Add an Open Graph canonical
URL to the social metadata in head(): when loaderData is present and
loaderData.kind === 'playlist' (i.e., the branch that destructures const {
playlist } = loaderData), include an { property: 'og:url', content: <canonical
playlist URL> } meta entry (construct the canonical URL from the playlist
token/ID or the same route URL used elsewhere), so scrapers like
Slack/Discord/Facebook get a reliable unfurl; keep og:image deferred as noted.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/routes/p`.$token.tsx:
- Around line 33-73: Add an Open Graph canonical URL to the social metadata in
head(): when loaderData is present and loaderData.kind === 'playlist' (i.e., the
branch that destructures const { playlist } = loaderData), include an {
property: 'og:url', content: <canonical playlist URL> } meta entry (construct
the canonical URL from the playlist token/ID or the same route URL used
elsewhere), so scrapers like Slack/Discord/Facebook get a reliable unfurl; keep
og:image deferred as noted.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 5fa983b7-7aec-4c5e-85a6-4e99dade5502

📥 Commits

Reviewing files that changed from the base of the PR and between fe5a6ea and 3ec4ad4.

📒 Files selected for processing (2)
  • src/routes/p.$token.tsx
  • src/server-fns/share.ts

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 3, 2026

Tip

For best results, initiate chat on the files or code changes.

@InstaZDLL Analyse correcte et refactor impeccable.

Le diagnostic sur serverFnFetcher.ts:188 est le point clé : dès que le status HTTP est non-2xx, le RPC client jette un Error brut et ignore le corps — la variante not_found du discriminated union aurait été silencieusement perdue sur toute navigation CSR. SSR fonctionnait par accident (handler in-process, pas de fetch réel), d'où la fausse impression que la première correction était correcte.

La solution finale est propre sur tous les plans :

  • throw notFound() voyage hors-bande via le canal TanStack, correctement sérialisé/désérialisé des deux côtés (ligne 185 du fetcher).
  • Suppression de la variante not_found de PublicPlaylistResult — la garder aurait été du dead code puisque ce chemin ne peut plus arriver via le return.
  • notFoundComponent: NotFoundPanel au niveau de la route est l'endroit canonique pour ce panneau.
  • head() : !loaderData — correct, TanStack ne complète pas le loader normalement quand notFound() est jeté, donc loaderData est undefined dans head().

66/66 + build : ✔

🐇 ᵔᴥᵔ


✏️ Learnings added
Learnt from: InstaZDLL
URL: https://github.com/InstaZDLL/waveflow-web/pull/20

Timestamp: 2026-06-03T22:45:17.292Z
Learning: In TanStack Start (with `createServerFn`), do NOT use `setResponseStatus(404)` combined with returning a discriminated-union result to signal "not found". The RPC client in `serverFnFetcher.ts` checks `if (!response.ok) throw new Error(await response.text())` — any non-2xx HTTP status causes the response body (the discriminated-union payload) to be discarded and a generic Error to be thrown instead. SSR appears to work because the handler runs in-process without a real HTTP round-trip, masking the bug. The correct pattern is `throw notFound()` (imported from `tanstack/react-router` or TanStack Start), which rides the router's out-of-band channel, is correctly serialized/deserialized by the fetcher (guarded at `if (isNotFound(jsonPayload)) throw jsonPayload`), and gives a proper SSR 404 status internally. The route should declare `notFoundComponent` to render the appropriate UI. Repository: InstaZDLL/waveflow-web, file: src/server-fns/share.ts.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: InstaZDLL
Repo: InstaZDLL/waveflow-web PR: 16
File: src/server-fns/profiles.ts:47-66
Timestamp: 2026-05-31T01:21:12.885Z
Learning: In Better Auth v1.6.x, `auth.api.getToken(...)` does NOT return `{ token: undefined }` when there is no active session — it throws an `APIError` (from `better-auth/api`) with `status: "UNAUTHORIZED"`. A post-await null-check (`if (!result?.token)`) will never fire for the unauthenticated case. To reliably detect "no session", wrap the call in try/catch and check `err instanceof APIError && err.status === "UNAUTHORIZED"`, then convert to a typed internal error (e.g. `NotSignedInError`). Import `APIError` from `"better-auth/api"`, not from `"better-auth"`.

@InstaZDLL
Copy link
Copy Markdown
Owner Author

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)

src/routes/p.$token.tsx (1)> 33-73: 🧹 Nitpick | 🔵 Trivial

Pour l'unfurling social, pense à og:url (et og:image plus tard).
Les branches head() sont cohérentes avec les panneaux rendus, rien à redire côté logique. Note opérationnelle uniquement : sans og:url canonique, certains scrapers (Slack/Discord/Facebook) génèrent un aperçu moins fiable. og:image reste légitimement différé jusqu'au pipeline d'artwork public, comme indiqué dans l'objectif du PR.

🤖 Prompt for AI Agents

Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/p`.$token.tsx around lines 33 - 73, Add an Open Graph canonical
URL to the social metadata in head(): when loaderData is present and
loaderData.kind === 'playlist' (i.e., the branch that destructures const {
playlist } = loaderData), include an { property: 'og:url', content: <canonical
playlist URL> } meta entry (construct the canonical URL from the playlist
token/ID or the same route URL used elsewhere), so scrapers like
Slack/Discord/Facebook get a reliable unfurl; keep og:image deferred as noted.

🤖 Prompt for all review comments with AI agents
ℹ️ Review info

@coderabbitai crée une issue pour sa stp

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 3, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

gh issue create \
  --repo InstaZDLL/waveflow-web \
  --title "feat(share): ajouter og:url canonique dans head() de la route /p/\$token" \
  --assignee InstaZDLL \
  --body "## Contexte

La route \`/p/\$token\` (introduite dans la PR `#20`) génère déjà des balises Open Graph (\`og:type\`, \`og:title\`, \`og:description\`, \`og:site_name\`) et Twitter Card dans \`head()\`, mais ne définit pas \`og:url\`.

## Problème

Sans balise \`og:url\` canonique, certains scrapers de partage social (Slack, Discord, Facebook/Meta) peuvent générer un aperçu moins fiable ou utiliser l'URL de la page de référencement plutôt que l'URL canonique de la playlist.

## Action attendue

Dans \`src/routes/p.\$token.tsx\`, dans la branche \`head()\` correspondant à \`loaderData.kind === 'ok'\`, ajouter une entrée meta :

\`\`\`ts
{ property: 'og:url', content: \`https://<domaine>/p/\${token}\` }
\`\`\`

L'URL canonique peut être construite à partir du token de route (disponible via \`params.token\` dans le loader) ou d'une constante de domaine de base déjà utilisée dans le projet.

## Hors périmètre (différé)

- \`og:image\` / \`twitter:card: summary_large_image\` : différés jusqu'à ce que le pipeline d'artwork public soit disponible (voir PR `#20`).

## Références

- PR : `#20`
- Commentaire de review : https://github.com/InstaZDLL/waveflow-web/pull/20
- Demandé par `@InstaZDLL`
"

Length of output: 1467



@InstaZDLL Issue créée : #21#21

🐇 ᵔᴥᵔ


🧠 Learnings used
Learnt from: InstaZDLL
Repo: InstaZDLL/waveflow-web PR: 0
File: :0-0
Timestamp: 2026-06-03T22:45:17.292Z
Learning: In TanStack Start (with `createServerFn`), do NOT use `setResponseStatus(404)` combined with returning a discriminated-union result to signal "not found". The RPC client in `serverFnFetcher.ts` checks `if (!response.ok) throw new Error(await response.text())` — any non-2xx HTTP status causes the response body (the discriminated-union payload) to be discarded and a generic Error to be thrown instead. SSR appears to work because the handler runs in-process without a real HTTP round-trip, masking the bug. The correct pattern is `throw notFound()` (imported from `tanstack/react-router` or TanStack Start), which rides the router's out-of-band channel, is correctly serialized/deserialized by the fetcher (guarded at `if (isNotFound(jsonPayload)) throw jsonPayload`), and gives a proper SSR 404 status internally. The route should declare `notFoundComponent` to render the appropriate UI. Repository: InstaZDLL/waveflow-web, file: src/server-fns/share.ts.

@InstaZDLL InstaZDLL merged commit ce6450f into main Jun 3, 2026
9 of 10 checks passed
@InstaZDLL InstaZDLL deleted the feat/1-g-2-public-playlist-route branch June 3, 2026 22:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant