Construisez un assistant IA dans Angular 17+ : service standalone, streaming SSE, Signals, tool use et prompt caching avec l'API Claude d'Anthropic.
Pourquoi Claude dans Angular 19+ ?
Angular 19 et 20 marquent un tournant : standalone par défaut (le flag standalone: true disparaît), signal-based APIs stables (input(), output(), model(), viewChild()), nouveau mode zoneless (provideZonelessChangeDetection), linkedSignal() pour les états dérivés mutables, afterRenderEffect() pour le DOM, et la Resource API (resource(), httpResource()) qui transforme la gestion des requêtes async.
Cette base réactive est parfaitement taillée pour intégrer Claude d'Anthropic : chaque token reçu en streaming SSE déclenche un re-render fin sans subscription RxJS, sans async pipe, sans Zone.js. On construit ici un assistant IA prêt pour la production : service injectable + streaming SSE via fetch, composant chat zoneless piloté 100% par Signals, proxy backend pour ne jamais exposer la clé API, plus tool use, prompt caching, retry, abort et hydration incrémentale en SSR.
Stack moderne Angular 19+ × Claude
signal(),computed(),linkedSignal()— sources, dérivés, dérivés mutablesinput.required(),output(),model()— signal inputs/outputs (plus de décorateurs)viewChild(),contentChild()— références DOM en SignalsafterRenderEffect(),afterNextRender()— effets DOM safe SSRresource(),httpResource()— chargement async réactif (Angular 20)provideZonelessChangeDetection()— bye Zone.js@if,@for,@let— control flow et bindings réactifsDestroyRef+takeUntilDestroyed()— cleanup automatique
Modèle Claude utilisé et coûts
| Modèle | Latence (1er token) | Prix entrée / 1M tokens | Prix sortie / 1M tokens | Cas d'usage Angular |
|---|---|---|---|---|
claude-haiku-4-5 |
~400 ms | $1 | $5 | Chatbot temps réel, autocomplétion, classification |
claude-sonnet-4-6 |
~700 ms | $3 | $15 | Assistant produit, génération de contenu structuré |
claude-opus-4-7 |
~1.2 s | $15 | $75 | Analyse de code, raisonnement complexe, agents |
claude-haiku-4-5 pour un chatbot Angular. Sa latence rend l'effet typing ultra fluide via SSE, et le coût reste maîtrisé. Bascule sur claude-sonnet-4-6 uniquement quand la qualité l'exige (tool use complexes, raisonnement multi-étapes).
Bootstrap zoneless de l'application
// src/main.ts — Angular 19+ : zoneless + standalone par défaut
import { bootstrapApplication } from '@angular/platform-browser';
import { provideZonelessChangeDetection } from '@angular/core';
import { provideHttpClient, withFetch } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';
bootstrapApplication(AppComponent, {
providers: [
// Mode zoneless : Angular ne dépend plus de Zone.js
// Le change detection se déclenche uniquement sur changement de Signal
provideZonelessChangeDetection(),
// HttpClient en mode fetch (compatible httpResource, SSR, edge runtimes)
provideHttpClient(withFetch()),
provideRouter(routes),
],
});
zone.js de la section polyfills de angular.json. Le bundle initial perd 30 à 50 KB et l'app est compatible avec les runtimes Edge (Cloudflare Workers, Vercel Edge) sans shim.
Architecture sécurisée : proxy backend
Règle absolue : ne jamais embarquer une clé Anthropic dans une app Angular. Tout bundle JavaScript livré au navigateur est public — DevTools, mobile, n'importe quel curl. Une clé fuitée se traduit en factures à 5 chiffres en quelques heures.
Le pattern correct : un proxy backend léger qui détient la clé via une variable d'environnement et expose un endpoint relayant la requête vers Anthropic. L'app Angular ne connaît que l'URL de ce proxy.
Proxy Node.js (Express) — minimal
// server/proxy.js — proxy minimal Express
import express from 'express';
import Anthropic from '@anthropic-ai/sdk';
const app = express();
app.use(express.json());
// La clé reste côté serveur (env var) — JAMAIS dans Angular
const client = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
});
// Endpoint que l'app Angular appellera
app.post('/api/chat', async (req, res) => {
// On force les paramètres autorisés (defense-in-depth)
const { messages, system } = req.body;
// Validation : pas plus de 50 messages, pas de payload géant
if (!Array.isArray(messages) || messages.length > 50) {
return res.status(400).json({ error: 'Bad messages' });
}
// On configure les headers pour du streaming SSE vers Angular
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
try {
// Appel streaming chez Anthropic
const stream = await client.messages.stream({
model: 'claude-haiku-4-5',
max_tokens: 1024,
system: system ?? 'Tu es un assistant pour AngularForAll.',
messages,
});
// Forward chaque event SSE vers le client
for await (const event of stream) {
res.write(`data: ${JSON.stringify(event)}\n\n`);
}
res.end();
} catch (err) {
res.write(`data: ${JSON.stringify({ type: 'error', error: err.message })}\n\n`);
res.end();
}
});
app.listen(3001, () => console.log('Proxy on :3001'));
cors({ origin: 'https://votreapp.com' })), et logs structurés pour pouvoir auditer l'usage de la clé.
Service ClaudeService avec Signals modernes
Côté Angular 19+, on encapsule la communication dans un service injectable racine. On utilise fetch natif pour le streaming SSE (qui requiert un ReadableStream brut), et httpResource() — nouveauté Angular 20 — pour les requêtes non streamées (chargement de l'historique persisté, liste des conversations). On combine signal(), computed() et linkedSignal() pour un état réactif complet.
Squelette du service avec linkedSignal et DestroyRef
// src/app/core/claude.service.ts — Angular 19+
import {
Injectable,
inject,
signal,
computed,
linkedSignal,
DestroyRef,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
// Type minimal pour un message dans l'historique
export interface ChatMessage {
role: 'user' | 'assistant';
content: string;
// Métadonnées Anthropic (tokens, modèle) pour audit
meta?: { inputTokens?: number; outputTokens?: number; model?: string };
}
@Injectable({ providedIn: 'root' })
export class ClaudeService {
// DestroyRef : cleanup automatique des subscriptions/listeners
private readonly destroyRef = inject(DestroyRef);
// URL de notre proxy — JAMAIS l'URL d'Anthropic directement
private readonly endpoint = '/api/chat';
// Signal-source : historique complet du chat
readonly messages = signal<ChatMessage[]>([]);
// Signal-source : état de streaming en cours
readonly streaming = signal<boolean>(false);
// Signal-source : draft assistant en cours d'écriture (token par token)
readonly draft = signal<string>('');
// linkedSignal() : dérivé MUTABLE qui se réinitialise quand la source change
// Ici : compteur de tokens local qui se reset à chaque nouvelle conversation
readonly tokenBudget = linkedSignal({
source: this.messages,
computation: (msgs, prev) => {
// Si on repart de zéro (clear), on remet à 0
if (msgs.length === 0) return 0;
// Sinon on garde la valeur courante (mutable via .set/.update)
return prev?.value ?? 0;
},
});
// computed() : longueur de la conversation, recalcul auto
readonly count = computed(() => this.messages().length);
// computed() : dernier message utilisateur (analytics)
readonly lastUser = computed(() =>
[...this.messages()].reverse().find(m => m.role === 'user')?.content ?? null
);
// computed() : booléen "peut envoyer" pour binding direct dans le template
readonly canSend = computed(() => !this.streaming());
// Méthode principale : envoyer un message et streamer la réponse
async send(userInput: string): Promise<void> {
if (this.streaming()) return;
// 1. Ajout du message user (Signal réactif — le template re-render seul)
this.messages.update(prev => [...prev, { role: 'user', content: userInput }]);
// 2. Bascule en mode streaming
this.streaming.set(true);
this.draft.set('');
try {
await this.streamFromProxy();
} catch (err) {
console.error('Claude error:', err);
this.draft.set('[Erreur de communication avec l\'assistant]');
} finally {
// 3. Commit du draft dans l'historique
this.messages.update(prev => [
...prev,
{ role: 'assistant', content: this.draft() },
]);
this.draft.set('');
this.streaming.set(false);
}
}
// Reset complet (nouveau chat)
reset(): void {
this.messages.set([]);
this.draft.set('');
this.streaming.set(false);
// tokenBudget se reset automatiquement via linkedSignal
}
// Voir section suivante pour l'implémentation streaming
private async streamFromProxy(): Promise<void> { /* ... */ }
}
Bonus : précharger l'historique avec httpResource() (Angular 20)
// src/app/core/conversation.resource.ts — Resource API
import { Injectable, inject, signal } from '@angular/core';
import { httpResource } from '@angular/common/http';
@Injectable({ providedIn: 'root' })
export class ConversationStore {
// Signal qui drive la requête : changer l'ID = nouveau fetch automatique
readonly conversationId = signal<string | null>(null);
// httpResource() : ressource async réactive
// Re-fetch automatique quand conversationId() change
// Expose .value(), .status(), .error(), .isLoading() en Signals
readonly history = httpResource<ChatMessage[]>(() => {
const id = this.conversationId();
return id
? { url: `/api/conversations/${id}`, method: 'GET' }
: undefined; // undefined = ne pas déclencher de requête
});
// Le composant lit directement : history.value(), history.isLoading()
// Plus besoin d'AsyncPipe, d'observable, de subscribe/unsubscribe
}
BehaviorSubject : avec un Signal, le template lit {{ draft() }} et Angular détecte le changement sans async pipe ni OnPush manuel. En mode zoneless, le re-render se limite aux nœuds qui dépendent du Signal — exactement ce qu'on veut pour un effet typing à 60 fps.
linkedSignal() ? Quand un état dérivé doit rester modifiable mais se réinitialiser sur changement de la source. Exemple : un index de message sélectionné qui revient à 0 quand l'historique est vidé, mais que l'utilisateur peut changer librement.
Streaming SSE token par token
Le streaming Server-Sent Events permet d'afficher la réponse de Claude au fur et à mesure qu'elle est générée, plutôt que d'attendre 3-8 secondes la réponse complète. C'est la différence entre une UX "je tape" et un spinner figé.
Lire le ReadableStream de fetch
// src/app/core/claude.service.ts (suite)
private async streamFromProxy(): Promise<void> {
// Préparer le payload pour le proxy
const payload = {
messages: this.messages().map(m => ({ role: m.role, content: m.content })),
};
// fetch() retourne un ReadableStream sur response.body
const response = await fetch(this.endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok || !response.body) {
throw new Error(`HTTP ${response.status}`);
}
// Reader sur le flux d'octets
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
// Boucle de lecture jusqu'à fin de stream
while (true) {
const { done, value } = await reader.read();
if (done) break;
// Accumulation : un event SSE peut être coupé en plusieurs chunks
buffer += decoder.decode(value, { stream: true });
// Découpe par double-newline (séparateur SSE)
const events = buffer.split('\n\n');
buffer = events.pop() ?? ''; // dernier morceau peut être incomplet
for (const raw of events) {
// Format SSE : chaque ligne commence par "data: "
const line = raw.replace(/^data: /, '').trim();
if (!line) continue;
const event = JSON.parse(line);
// Anthropic émet plusieurs types d'events
// On ne s'intéresse qu'aux deltas de texte
if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
// Append au draft Signal — déclenche re-render du template
this.draft.update(prev => prev + event.delta.text);
}
if (event.type === 'message_stop') {
return; // fin propre
}
}
}
}
Format des events Anthropic
// Exemples d'events reçus en streaming SSE
{ "type": "message_start", "message": { "id": "msg_01...", "model": "claude-haiku-4-5" } }
{ "type": "content_block_start", "index": 0, "content_block": { "type": "text", "text": "" } }
{ "type": "content_block_delta", "index": 0, "delta": { "type": "text_delta", "text": "Bonjour" } }
{ "type": "content_block_delta", "index": 0, "delta": { "type": "text_delta", "text": " ! Je" } }
{ "type": "content_block_delta", "index": 0, "delta": { "type": "text_delta", "text": " suis" } }
{ "type": "message_delta", "delta": { "stop_reason": "end_turn" }, "usage": { "output_tokens": 42 } }
{ "type": "message_stop" }
fetch peuvent contenir plusieurs events ou un event coupé en deux. Le buffer + split sur \n\n est crucial. Sans ça, vous verrez des erreurs JSON.parse aléatoires en charge.
Composant chat zoneless (signal inputs, viewChild, afterRenderEffect)
On exploite ici tout l'arsenal moderne d'Angular 19+ : standalone par défaut (plus de standalone: true), signal inputs/outputs/model pour le contrat composant, viewChild() en Signal pour la référence DOM, et afterRenderEffect() pour l'auto-scroll — safe en SSR car il ne s'exécute que côté navigateur après chaque render.
Composant ChatComponent — Angular 19+
// src/app/chat/chat.component.ts
import {
Component,
inject,
signal,
computed,
input,
output,
model,
viewChild,
afterRenderEffect,
ElementRef,
ChangeDetectionStrategy,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ClaudeService } from '../core/claude.service';
@Component({
selector: 'app-chat',
// PAS de "standalone: true" : c'est le défaut depuis Angular 19
imports: [FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="chat-shell">
<!-- @let : binding réactif local au template (Angular 18+) -->
@let history = claude.messages();
@let isStreaming = claude.streaming();
<header class="chat-header">
<h2>{{ title() }}</h2>
<button type="button" (click)="onClear()" [disabled]="isStreaming">
Nouveau chat
</button>
</header>
<!-- viewChild() en Signal : on récupère l'élément DOM réactivement -->
<ul #log class="chat-log">
@for (msg of history; track $index) {
<li [class]="msg.role">
<strong>{{ msg.role === 'user' ? 'Vous' : 'Claude' }} :</strong>
{{ msg.content }}
</li>
} @empty {
<li class="empty">Démarrez la conversation avec Claude.</li>
}
<!-- Draft en cours d'écriture (token par token) -->
@if (isStreaming) {
<li class="assistant streaming">
<strong>Claude :</strong> {{ claude.draft() }}<span class="cursor">▋</span>
</li>
}
</ul>
<p class="chat-meta">{{ statusLabel() }}</p>
<form (ngSubmit)="submit()">
<!-- model() : two-way binding signal-based -->
<input
type="text"
[(ngModel)]="draftInput"
name="msg"
[disabled]="isStreaming"
[placeholder]="placeholder()"
/>
<button type="submit" [disabled]="!canSubmit()">
Envoyer
</button>
</form>
</div>
`,
})
export class ChatComponent {
// Injection moderne (sans constructor)
protected readonly claude = inject(ClaudeService);
// === SIGNAL INPUTS (remplacent @Input) ===
// input.required() : input obligatoire, garanti non-undefined
readonly title = input.required<string>();
// input() : input optionnel avec valeur par défaut
readonly placeholder = input<string>('Posez votre question à Claude…');
// === SIGNAL OUTPUT (remplace @Output + EventEmitter) ===
readonly cleared = output<void>();
// === MODEL : two-way binding signal-based ===
// Permet [(draftInput)]="..." côté parent
readonly draftInput = model<string>('');
// === viewChild() Signal : référence DOM réactive ===
// Remplace @ViewChild + ngAfterViewInit
private readonly logRef = viewChild<ElementRef<HTMLUListElement>>('log');
// === COMPUTED : dérivés synchrones ===
protected readonly statusLabel = computed(() => {
if (this.claude.streaming()) return 'Claude réfléchit…';
const n = this.claude.count();
return n === 0 ? 'Démarrez la conversation' : `${n} messages échangés`;
});
protected readonly canSubmit = computed(() =>
this.draftInput().trim().length > 0 && !this.claude.streaming()
);
constructor() {
// afterRenderEffect() : effet de rendu DOM, ne s'exécute QUE côté navigateur
// Safe en SSR (skip côté serveur), s'exécute après chaque render touchant le DOM
afterRenderEffect(() => {
// Lecture des Signals : Angular trace les dépendances automatiquement
this.claude.count(); // re-fire à chaque nouveau message
this.claude.draft(); // re-fire à chaque token streamé
const el = this.logRef()?.nativeElement;
if (el) el.scrollTop = el.scrollHeight;
});
}
submit(): void {
const text = this.draftInput().trim();
if (!text) return;
this.draftInput.set('');
this.claude.send(text);
}
onClear(): void {
this.claude.reset();
this.cleared.emit(); // signal output
}
}
Utilisation parent — self-closing tags + binding propre
<!-- src/app/app.component.html — tags auto-fermants Angular 17+ -->
<app-chat
title="Assistant AngularForAll"
placeholder="Une question sur Angular ?"
[(draftInput)]="userDraft"
(cleared)="onChatCleared()"
/>
provideZonelessChangeDetection() + Signals partout, Angular re-render uniquement les nœuds dont une dépendance Signal a changé. Le coût d'un chat avec 1000 messages est constant — pas de re-render de toute la liste à chaque token, et zéro overhead Zone.js.
afterRenderEffect() et pas effect() ? effect() peut s'exécuter avant que le DOM soit à jour (et plante en SSR si on touche au document). afterRenderEffect() garantit que le DOM est synchronisé et ne s'exécute jamais côté serveur — le bon outil pour scroll, focus, mesures de layout.
@Input → input() : exécutez ng generate @angular/core:signals pour migrer automatiquement vos décorateurs vers les signal-based APIs. Le schematic gère @Input, @Output, @ViewChild, @ContentChild dans toute la codebase.
Tool use : connecter Claude à vos APIs
Tool use permet à Claude d'appeler des fonctions que vous définissez. Côté Angular, vous ne voyez rien de cette mécanique — elle se passe entièrement côté backend. Le composant chat reçoit simplement la réponse finale, déjà enrichie des données récupérées.
Déclarer un tool côté proxy
// server/proxy.js — extension avec tool use
const tools = [
{
name: 'get_user_orders',
description: 'Retourne les commandes récentes d\'un utilisateur connecté.',
input_schema: {
type: 'object',
properties: {
userId: { type: 'string', description: 'Identifiant interne utilisateur' },
limit: { type: 'number', description: 'Nombre max de commandes (défaut 5)' },
},
required: ['userId'],
},
},
];
// Implémentation locale — appel à votre vraie base de données
async function executeTool(name, args, currentUser) {
if (name === 'get_user_orders') {
// Sécurité : on ignore args.userId fourni par Claude, on prend celui authentifié
const userId = currentUser.id;
const limit = Math.min(args.limit ?? 5, 20);
return await db.query('SELECT * FROM orders WHERE user_id=$1 LIMIT $2', [userId, limit]);
}
throw new Error(`Tool inconnu : ${name}`);
}
Boucle agentique côté backend
// server/proxy.js — gestion du cycle tool_use → tool_result
async function runChat(messages, currentUser) {
let response = await client.messages.create({
model: 'claude-sonnet-4-6', // tool use marche mieux avec Sonnet
max_tokens: 1024,
tools,
messages,
});
// Tant que Claude demande un tool, on l'exécute et on relance
while (response.stop_reason === 'tool_use') {
const toolBlock = response.content.find(c => c.type === 'tool_use');
// Exécution locale de la fonction
const result = await executeTool(toolBlock.name, toolBlock.input, currentUser);
// On enrichit l'historique avec la réponse de l'assistant + le résultat
messages.push({ role: 'assistant', content: response.content });
messages.push({
role: 'user',
content: [{
type: 'tool_result',
tool_use_id: toolBlock.id,
content: JSON.stringify(result),
}],
});
// Nouvelle passe — Claude peut soit appeler un autre tool, soit conclure
response = await client.messages.create({
model: 'claude-sonnet-4-6',
max_tokens: 1024,
tools,
messages,
});
}
// stop_reason === "end_turn" → on a la réponse finale en texte
return response.content.find(c => c.type === 'text').text;
}
input envoyés par Claude — il peut "halluciner" des paramètres. Toujours valider, autoriser, et substituer les valeurs sensibles (userId authentifié, scope) avec celles de la session utilisateur réelle.
Prompt caching pour réduire les coûts
Une app Angular avec un assistant qui répète à chaque requête le même system prompt (instructions produit, FAQ, exemples) gaspille des tokens. Le prompt caching d'Anthropic permet de mettre en cache les portions stables du prompt — les coûts de lecture sur cache tombent à 10% du tarif normal.
Activer le cache sur le system prompt
// server/proxy.js — system prompt mis en cache
const SYSTEM_PROMPT = `Tu es l'assistant de support d'AngularForAll.
Tu réponds toujours en français, de façon technique et concise.
Voici la documentation produit complète :
${productDocsMarkdown} // 50 000 tokens de doc statique
`;
const response = await client.messages.create({
model: 'claude-sonnet-4-6',
max_tokens: 1024,
system: [
{
type: 'text',
text: SYSTEM_PROMPT,
cache_control: { type: 'ephemeral' }, // ← active le cache (TTL 5 min)
},
],
messages,
});
// La 1re requête écrit le cache (coût normal + 25%)
// Toutes les suivantes dans les 5 min : lecture cache à 10% du prix
console.log('Cache hit:', response.usage.cache_read_input_tokens);
console.log('Cache write:', response.usage.cache_creation_input_tokens);
Économies réalistes pour un chatbot Angular
| Scénario | Sans cache | Avec cache (5 min TTL) | Économie |
|---|---|---|---|
| 50k tokens system, 100 requêtes / 5 min | $15.00 | $1.95 | 87 % |
| 10k tokens system, 1000 req / jour | $30.00 | $4.20 | 86 % |
cache_control est silencieusement ignoré — vérifiez usage.cache_read_input_tokens dans la réponse.
Gestion d'erreurs, retry, coûts, SSR
En production, trois types d'erreurs reviennent : rate limits (429), timeouts réseau, et coupures de connexion utilisateur (mobile). Chacune mérite un traitement distinct.
Retry exponentiel sur 429 / 529
// src/app/core/claude.service.ts — retry helper
private async withRetry<T>(
fn: () => Promise<T>,
maxAttempts = 3,
): Promise<T> {
let lastErr: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (err: any) {
lastErr = err;
const status = err?.status ?? err?.response?.status;
// 429 (rate limit) ou 529 (overloaded) : on retente
// 4xx (autre) : erreur cliente, pas de retry
if (status !== 429 && status !== 529 && status !== 503) {
throw err;
}
// Backoff exponentiel : 500ms, 1s, 2s
const delay = 500 * Math.pow(2, attempt - 1);
await new Promise(r => setTimeout(r, delay));
}
}
throw lastErr;
}
Annuler un streaming (utilisateur ferme l'onglet)
// src/app/core/claude.service.ts — AbortController
private currentController: AbortController | null = null;
async send(userInput: string): Promise<void> {
// Annuler un streaming précédent si présent
this.currentController?.abort();
this.currentController = new AbortController();
const response = await fetch(this.endpoint, {
method: 'POST',
body: JSON.stringify({ messages: this.messages() }),
signal: this.currentController.signal, // ← lien avec l'AbortController
});
// ... suite du streaming
}
cancel(): void {
this.currentController?.abort();
this.streaming.set(false);
this.draft.set('');
}
Tracker les coûts en temps réel
// On expose un Signal compteur de tokens
readonly tokensUsed = signal<{ input: number; output: number }>({ input: 0, output: 0 });
// computed() : coût estimé en USD
readonly estimatedCost = computed(() => {
const { input, output } = this.tokensUsed();
// Tarif Haiku 4.5 : $1 / 1M input, $5 / 1M output
const cost = (input / 1_000_000) * 1 + (output / 1_000_000) * 5;
return cost.toFixed(4);
});
// Mise à jour à la fin de chaque streaming (event message_delta)
private updateUsage(usage: { input_tokens: number; output_tokens: number }): void {
this.tokensUsed.update(prev => ({
input: prev.input + usage.input_tokens,
output: prev.output + usage.output_tokens,
}));
}
SSR + hydration incrémentale (Angular 19+)
Pour un assistant IA inséré dans une page SSR (page produit, blog), l'hydration incrémentale d'Angular 19 permet de différer l'hydratation du composant chat jusqu'à ce que l'utilisateur interagisse — économie majeure de JS au premier paint.
// src/main.server.ts — activation SSR + hydration incrémentale
import { bootstrapApplication } from '@angular/platform-browser';
import {
provideClientHydration,
withIncrementalHydration, // ← Angular 19+
} from '@angular/platform-browser';
bootstrapApplication(AppComponent, {
providers: [
provideClientHydration(
withIncrementalHydration(), // hydratation différée par triggers
),
],
});
<!-- Le composant chat ne s'hydrate qu'au survol ou au clic -->
<!-- Le SSR rend du HTML statique, le JS du chat ne charge qu'à l'interaction -->
@defer (hydrate on interaction) {
<app-chat title="Assistant" />
} @placeholder {
<button class="chat-cta">Discuter avec l'IA</button>
}
- Clé API jamais dans Angular — proxy backend obligatoire
- Authentification utilisateur sur le proxy (JWT / session)
- Rate limiting par IP et par utilisateur (10 req/min)
- Validation stricte du payload (taille messages, count)
- Retry exponentiel sur 429 / 529 / 503
AbortControllerpour annuler un streaming- Prompt caching activé sur le system prompt > 1k tokens
- Logs structurés des tokens consommés par utilisateur
- Alertes coût quotidien dans la console Anthropic
- Tests E2E (Playwright) sur le chat avec mock du proxy
provideZonelessChangeDetection()activé (perf + bundle)- Migration
@Input/@Output/@ViewChild→ signal APIs (ng g signals) - Hydration incrémentale (
@defer hydrate on interaction) si SSR - Bundle vérifié sans
zone.js(économie 30-50 KB)
Conclusion
Intégrer Claude dans Angular 19+ tire pleinement parti des nouveautés du framework : standalone par défaut, signal inputs/outputs/model, viewChild() en Signal, afterRenderEffect() safe SSR, linkedSignal() pour les états dérivés mutables, et httpResource() (Angular 20) pour les requêtes async réactives. Le streaming SSE devient un branchement naturel sur le graphe réactif — sans subscription manuelle, sans async pipe, sans Zone.js si vous activez le mode zoneless.
Quatre piliers pour la production : proxy backend obligatoire pour la clé API, tool use côté serveur pour les opérations sensibles, prompt caching pour la facture, et hydration incrémentale (@defer hydrate on interaction) pour économiser 80 à 150 KB de JS au premier paint. Le pattern tient pour des assistants de support, des copilotes intégrés à votre app, ou des agents qui dialoguent avec votre backend métier.
Pour aller plus loin : explorez rxResource() pour bridger des Observables existants vers le monde Signals, persistez l'historique via IndexedDB ou un endpoint exposé en httpResource(), expérimentez le mode extended thinking de Claude Opus 4 pour les cas de raisonnement long, ou structurez les workflows multi-tools en architecture agentique. La base Angular 19+ posée ici reste la même — c'est tout son intérêt.