@absolutejs/absolute 0.19.0-beta.170 → 0.19.0-beta.172

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ROADMAP.md CHANGED
@@ -580,3 +580,453 @@ Bun-only. Requires a long-running Bun server process. No serverless or edge adap
580
580
  - New: `src/adapters/docker/Dockerfile`
581
581
  - New: `src/adapters/lambda.ts` — AWS Lambda adapter
582
582
  - Documentation for common deployment targets
583
+
584
+ ---
585
+
586
+ ## P1 — Out-of-Order Streaming
587
+
588
+ **What SolidStart does:**
589
+ Components stream to the client as they resolve, not in DOM order. If your sidebar data query finishes before your main content query, the sidebar HTML ships first. The browser renders each chunk into the correct DOM position regardless of arrival order. This means the fastest data always appears first — no waterfall where a slow hero section blocks the entire page.
590
+
591
+ **What AbsoluteJS has today:**
592
+ Full streaming SSR via `renderToReadableStream` for all frameworks. But streaming is in-order — the HTML is sent top-to-bottom as React/Svelte/Vue render the component tree. If a component high in the tree is slow (data fetch, heavy computation), everything below it waits.
593
+
594
+ **Why this matters:**
595
+ In a typical dashboard page, you might have:
596
+ - A navbar (instant, no data)
597
+ - A stats section (slow — aggregation query)
598
+ - A recent activity feed (fast — simple query)
599
+ - A footer (instant, no data)
600
+
601
+ With in-order streaming, the activity feed waits for the stats section even though its data is ready. With out-of-order streaming, the navbar, activity feed, and footer arrive immediately while the stats section streams in when its query finishes. The user sees a useful page faster.
602
+
603
+ **What needs to be built:**
604
+
605
+ *Server side:*
606
+ - Placeholder slots in the HTML — when a component is async/suspended, send a lightweight placeholder `<div id="slot-{id}">` with a loading skeleton or empty space
607
+ - As each async component resolves, send an `<template>` or `<script>` block that contains the real HTML and swaps it into the placeholder
608
+ - This is how React 18's Suspense streaming works under the hood — `renderToReadableStream` already supports this for React via `<Suspense>` boundaries. The work is extending this pattern to Svelte, Vue, and Angular.
609
+
610
+ *Per-framework implementation:*
611
+ - **React**: Already supports this via `<Suspense>` boundaries with `renderToReadableStream`. Each `<Suspense>` boundary becomes an independent streaming slot. The main work is documenting the pattern and ensuring it works with AbsoluteJS's page handler.
612
+ - **Svelte**: Svelte 5 has `{#await}` blocks. The custom `renderToReadableStream` in `src/svelte/renderToReadableStream.ts` needs to support async resolution — when an `{#await}` block is pending, send a placeholder and stream the resolved content later.
613
+ - **Vue**: Vue's `<Suspense>` component with `renderToWebStream` can be extended similarly. Each `<Suspense>` boundary becomes a streaming slot.
614
+ - **Angular**: Angular's `@defer` blocks are the equivalent. The SSR renderer can send placeholders for `@defer` blocks and stream them in when resolved.
615
+
616
+ *Client side:*
617
+ - A small inline script (sent at the start of the stream) that listens for arriving chunks and swaps them into their placeholder slots
618
+ - Pattern: `<script>function $RC(id,html){document.getElementById('slot-'+id).outerHTML=html}</script>`
619
+ - Each resolved chunk arrives as: `<script>$RC("stats-section","<div>...real content...</div>")</script>`
620
+ - This script is ~200 bytes and makes out-of-order streaming work without any framework JS loaded yet
621
+
622
+ *Integration with islands:*
623
+ - Islands with `hydrate="idle"` or `hydrate="visible"` are natural candidates for out-of-order streaming — send the placeholder, stream the SSR'd HTML when ready, hydrate later based on the directive
624
+ - This creates a smooth pipeline: placeholder → streamed HTML (visible but not interactive) → hydrated (interactive)
625
+
626
+ **Design considerations:**
627
+ - Fallback content for each slot (loading skeleton, spinner, or empty space) should be configurable per-component
628
+ - If streaming takes too long (>5s), the placeholder should remain visible with its fallback content — don't leave empty holes
629
+ - The out-of-order script must be sent before any slot content so the browser knows how to handle arriving chunks
630
+ - CSS for streamed-in content must already be loaded (sent in the initial `<head>` or preloaded) to avoid layout shift when content swaps in
631
+
632
+ **Files likely involved:**
633
+ - `src/react/pageHandler.ts` — document and test `<Suspense>` boundary streaming (may already work)
634
+ - `src/svelte/renderToReadableStream.ts` — add out-of-order support for `{#await}` blocks
635
+ - `src/vue/pageHandler.ts` — add `<Suspense>` boundary support to the streaming pipeline
636
+ - `src/angular/pageHandler.ts` — add `@defer` block support to SSR streaming
637
+ - New: `src/utils/streamingSlots.ts` — shared utilities for generating placeholder HTML and the `$RC` swap script
638
+ - New: `src/client/streamSwap.ts` — the inline client script that handles out-of-order chunk insertion
639
+
640
+ ---
641
+
642
+ ## P1 — Form Actions with Progressive Enhancement
643
+
644
+ **What SvelteKit and Remix do:**
645
+ Forms submit to the server as plain HTML `<form action="/submit" method="POST">` — this works with zero JavaScript. The server processes the form, validates input, and returns a result (redirect, error, or updated page). When JavaScript IS available, the framework intercepts the submission, sends it via `fetch()` instead, and updates the page without a full reload. The developer writes one handler that works both ways.
646
+
647
+ SvelteKit's form actions return typed data that flows back to the page. Remix's actions return data that's available via `useActionData()`. Both support validation errors that display inline without losing form state.
648
+
649
+ **What AbsoluteJS has today:**
650
+ Elysia POST handlers work for form processing, but there's no convention for progressive enhancement. If JS fails to load, forms don't work unless the developer manually sets up a standard HTML form submission flow. HTMX pages handle this naturally, but React/Svelte/Vue/Angular pages don't.
651
+
652
+ **Why this matters:**
653
+ - Forms are the primary way users mutate data on the web. Every app has them.
654
+ - Progressive enhancement means forms work even when JS fails (slow connections, CDN outage, corporate firewalls blocking scripts). This is real resilience, not theoretical.
655
+ - The developer writes one handler and gets both behaviors. Less code, more robust.
656
+ - Inline validation errors without losing form state is a huge DX win — users hate re-filling forms after a server error.
657
+
658
+ **What needs to be built:**
659
+
660
+ *Server side — action handlers:*
661
+ - A convention for defining form actions on Elysia routes. This could be as simple as a helper that creates a POST handler with typed input validation:
662
+ ```ts
663
+ import { defineAction } from 'absolutejs'
664
+ import { t } from 'elysia'
665
+
666
+ const createUser = defineAction({
667
+ body: t.Object({
668
+ name: t.String(),
669
+ email: t.String({ format: 'email' }),
670
+ }),
671
+ handler: async ({ body }) => {
672
+ const user = await db.insert(users).values(body).returning()
673
+ return { success: true, user }
674
+ },
675
+ error: (errors) => {
676
+ return { success: false, errors }
677
+ // errors is typed: { name?: string, email?: string }
678
+ }
679
+ })
680
+
681
+ app.post('/users', createUser)
682
+ ```
683
+ - The action handler detects whether the request came from a plain form submission (no JS) or a `fetch()` call (JS available):
684
+ - Plain form: process, then redirect (POST/Redirect/GET pattern) or re-render the page with errors
685
+ - Fetch: return JSON with the result or validation errors
686
+ - Detection via `Accept` header — `application/json` means JS fetch, `text/html` means plain form
687
+
688
+ *Client side — per-framework hooks:*
689
+ - **React**: `useFormAction(actionUrl)` hook that returns `{ submit, data, errors, isSubmitting }`. Intercepts `<form onSubmit>`, sends via fetch, returns typed result. Falls back to normal form submission if hook isn't used.
690
+ ```tsx
691
+ const { submit, errors, isSubmitting } = useFormAction('/users')
692
+
693
+ <form onSubmit={submit}>
694
+ <input name="name" />
695
+ {errors?.name && <span>{errors.name}</span>}
696
+ <input name="email" />
697
+ {errors?.email && <span>{errors.email}</span>}
698
+ <button disabled={isSubmitting}>Create</button>
699
+ </form>
700
+ ```
701
+ - **Svelte**: `useFormAction` that returns a store with the same shape. Bind to `<form use:enhance>` pattern.
702
+ - **Vue**: `useFormAction` composable returning reactive refs.
703
+ - **Angular**: `FormAction` service that returns an Observable-based interface.
704
+ - All hooks preserve form state on validation errors — the form doesn't reset when the server returns errors.
705
+
706
+ *Progressive enhancement flow:*
707
+ 1. Server renders the page with a `<form action="/users" method="POST">`
708
+ 2. If JS loads: the hook intercepts submit, sends fetch, updates UI reactively
709
+ 3. If JS fails: the form submits normally, server processes it, redirects or re-renders with errors
710
+ 4. Same server handler handles both cases — the developer doesn't write two code paths
711
+
712
+ *Type safety:*
713
+ - The action's `body` schema (Elysia's `t.Object`) defines both server validation and client-side error types
714
+ - `errors` in the hook is typed to match the schema fields — `errors.name` exists only if `name` is in the schema
715
+ - The action's return type flows to the hook's `data` — full end-to-end type safety via Elysia's existing type system
716
+
717
+ **Design considerations:**
718
+ - The `<form>` must have a real `action` and `method` attribute for the no-JS path to work. The hook enhances it, doesn't replace it.
719
+ - File uploads should work in both paths — `multipart/form-data` for plain forms, `FormData` via fetch for enhanced forms.
720
+ - Optimistic UI: the hook could accept an `optimistic` callback that updates the UI immediately before the server responds, then reconciles when the real response arrives.
721
+ - CSRF protection should be built in — the action handler validates a token automatically.
722
+
723
+ **Files likely involved:**
724
+ - New: `src/utils/defineAction.ts` — action handler factory with typed validation and dual-mode response
725
+ - New: `src/react/hooks/useFormAction.ts` — React hook for form enhancement
726
+ - New: `src/svelte/useFormAction.ts` — Svelte store-based form enhancement
727
+ - New: `src/vue/useFormAction.ts` — Vue composable for form enhancement
728
+ - New: `src/angular/form-action.service.ts` — Angular service for form enhancement
729
+ - New: `types/action.ts` — types for action definitions, error shapes, and hook returns
730
+
731
+ ---
732
+
733
+ ## P2 — Partial Prerendering (requires SSG)
734
+
735
+ **What Next.js 16 does:**
736
+ A page is split into a static shell (navbar, footer, layout — cached at CDN) and dynamic "holes" that stream in at request time (user-specific content, real-time data). The static parts load instantly from cache while the dynamic parts stream in via SSR. From the user's perspective, the page appears instantly with personalized content filling in smoothly.
737
+
738
+ Next.js implements this by combining static generation with Suspense boundaries — everything outside a `<Suspense>` boundary is pre-rendered at build time, and the Suspense fallbacks are replaced with streamed server-rendered content at request time.
739
+
740
+ **What AbsoluteJS has today:**
741
+ Every page is fully server-rendered at request time. No static caching of any page content. The entire page waits for all data before any HTML is sent (unless using streaming SSR, which streams in-order but still requires the server to render everything on each request).
742
+
743
+ **Why this matters:**
744
+ Most pages are 80% static content (nav, sidebar, footer, headings, layout) and 20% dynamic (user name, notifications, personalized feed). Rendering that 80% on every request is wasted work. Partial prerendering means:
745
+ - The static shell loads from CDN in ~50ms (no server round-trip)
746
+ - The dynamic holes stream from the server in ~200-500ms
747
+ - Combined: users see a near-instant page with dynamic content appearing smoothly
748
+ - Server load drops dramatically — most of the HTML is served from cache
749
+
750
+ **What needs to be built (depends on SSG being implemented first):**
751
+
752
+ *Build-time static shell generation:*
753
+ - During the build, render each page but stop at dynamic boundaries (Suspense, `{#await}`, `<Suspense>`, `@defer`)
754
+ - Write the static HTML (everything outside dynamic boundaries) to disk with placeholder slots for the dynamic parts
755
+ - The static shell includes all CSS, the page layout, and fallback content (loading skeletons) for each dynamic slot
756
+
757
+ *Request-time dynamic streaming:*
758
+ - When a request comes in, serve the static shell immediately from disk/cache
759
+ - Simultaneously, render the dynamic parts on the server and stream them into the placeholder slots
760
+ - Reuse the out-of-order streaming infrastructure — the `$RC` swap script handles inserting dynamic content into the static shell
761
+
762
+ *Per-framework dynamic boundaries:*
763
+ - **React**: `<Suspense>` boundaries — everything inside is dynamic, everything outside is static
764
+ - **Svelte**: `{#await}` blocks or a new `<Dynamic>` component
765
+ - **Vue**: `<Suspense>` component — same pattern as React
766
+ - **Angular**: `@defer` blocks — natural fit, Angular already distinguishes static vs deferred content
767
+
768
+ *Caching strategy:*
769
+ - Static shells cached in memory and/or on disk with content-hash keys
770
+ - Cache invalidation: rebuild the shell when the page's static parts change (detected via file watcher or manual invalidation)
771
+ - Dynamic parts are never cached (they're user-specific/time-specific)
772
+ - CDN integration: set `Cache-Control` headers so the static shell is edge-cached while the dynamic stream bypasses cache
773
+
774
+ *Configuration:*
775
+ - Per-page opt-in via a config option or export:
776
+ ```ts
777
+ // In the route handler
778
+ app.get('/dashboard', () =>
779
+ handleReactPageRequest(Dashboard, manifest['DashboardIndex'], {
780
+ prerender: 'partial', // static shell + dynamic streaming
781
+ props: { userId: getCurrentUser() }
782
+ })
783
+ )
784
+ ```
785
+ - Or via the build config for pages that should always be partially prerendered
786
+
787
+ **Design considerations:**
788
+ - The static shell must be a valid HTML document on its own — if dynamic streaming fails, the user sees the shell with fallback content (loading skeletons), not a broken page.
789
+ - Dynamic boundaries should be explicit — the developer marks what's dynamic, everything else is assumed static. No guessing.
790
+ - This compounds with islands — an island with `hydrate="visible"` inside a dynamic boundary gets: static shell → streamed SSR HTML → hydrated on scroll. Three layers of progressive loading.
791
+ - Hot reloading in dev: skip the static cache and render everything server-side (same as today). Partial prerendering is a production optimization only.
792
+
793
+ **Files likely involved:**
794
+ - Builds on top of SSG implementation and out-of-order streaming
795
+ - New: `src/core/partialPrerender.ts` — orchestrates static shell serving + dynamic streaming
796
+ - New: `src/build/generateStaticShells.ts` — renders pages at build time, extracts static content, writes shells to disk
797
+ - `src/utils/streamingSlots.ts` — reused from out-of-order streaming for dynamic slot insertion
798
+ - Each framework's `pageHandler.ts` — add `prerender: 'partial'` mode that serves cached shell + streams dynamic parts
799
+ - `src/plugins/hmr.ts` — static shell cache invalidation when source files change
800
+
801
+ ---
802
+
803
+ ## P1 — AI/LLM Streaming Helpers
804
+
805
+ **What exists today in the ecosystem:**
806
+ Vercel's `ai` SDK (`npm install ai`) provides React hooks and server utilities for streaming LLM responses. It works with Next.js, SvelteKit, and Nuxt. But it uses Server-Sent Events (SSE) because Next.js has no native WebSocket support. SSE is one-directional (server → client only) — the client can't send messages mid-stream (cancel, follow-up, branch conversation) without opening a new HTTP request.
807
+
808
+ **What AbsoluteJS has today:**
809
+ Native bidirectional WebSocket via Elysia. The HMR system already proves the WebSocket infrastructure works at scale with reconnection, message typing, and broadcast. But there are no helpers for connecting an LLM API to a WebSocket channel.
810
+
811
+ **Why this is a killer feature:**
812
+ AI-powered apps are the dominant use case for new web projects in 2025-2026. Every chat interface, AI assistant, code editor, and content generator needs LLM streaming. The current state of the art (Vercel AI SDK over SSE) has real limitations:
813
+ - SSE is one-directional — canceling a generation requires a separate abort request
814
+ - SSE connections can't be reused — each message opens a new HTTP connection
815
+ - No native support for branching conversations, tool use feedback, or multi-turn streaming
816
+ - WebSocket solves all of these: bidirectional, persistent, multiplexed
817
+
818
+ AbsoluteJS has WebSocket built in. Adding thin helpers on top makes it the best framework for AI apps with zero additional infrastructure.
819
+
820
+ **What needs to be built:**
821
+
822
+ *Server side — `streamAI()` utility:*
823
+ ```ts
824
+ import { streamAI } from 'absolutejs'
825
+
826
+ app.ws('/chat', {
827
+ message: async (ws, { prompt, conversationId }) => {
828
+ // streamAI connects to the LLM provider, pipes token chunks
829
+ // over the WebSocket, and handles backpressure/cancellation
830
+ await streamAI(ws, {
831
+ provider: 'anthropic',
832
+ model: 'claude-sonnet-4-5-20250514',
833
+ messages: [{ role: 'user', content: prompt }],
834
+ // Optional: called for each token chunk before sending
835
+ onChunk: (chunk) => {
836
+ // Transform, log, filter, or store chunks
837
+ saveToConversation(conversationId, chunk)
838
+ return chunk
839
+ },
840
+ // Optional: called when the stream completes
841
+ onComplete: (fullResponse) => {
842
+ saveMessage(conversationId, fullResponse)
843
+ }
844
+ })
845
+ }
846
+ })
847
+ ```
848
+
849
+ - `streamAI()` is provider-agnostic — supports Anthropic, OpenAI, and any provider that returns a `ReadableStream` or async iterator
850
+ - Handles backpressure — if the client is slow to consume, the server buffers appropriately
851
+ - Handles cancellation — if the client disconnects or sends a cancel message, the LLM request is aborted
852
+ - Handles errors — if the LLM API errors mid-stream, sends a typed error message to the client
853
+ - The `onChunk` callback enables middleware-like processing: content filtering, token counting, database persistence, RAG augmentation
854
+
855
+ *Provider adapters:*
856
+ ```ts
857
+ // Built-in adapters for common providers
858
+ import { anthropic, openai, ollama } from 'absolutejs/ai'
859
+
860
+ // Each adapter normalizes the provider's streaming API into a common interface
861
+ await streamAI(ws, {
862
+ provider: anthropic({ apiKey: env.ANTHROPIC_API_KEY }),
863
+ // or: provider: openai({ apiKey: env.OPENAI_API_KEY }),
864
+ // or: provider: ollama({ baseUrl: 'http://localhost:11434' }),
865
+ model: 'claude-sonnet-4-5-20250514',
866
+ messages,
867
+ })
868
+ ```
869
+
870
+ - Adapters handle provider-specific auth, endpoints, and stream formats
871
+ - Common interface: `{ stream: AsyncIterable<{ type: 'text' | 'tool_use' | 'error', content: string }> }`
872
+ - Easy to add new providers — just implement the adapter interface
873
+
874
+ *Client side — per-framework hooks:*
875
+
876
+ **React:**
877
+ ```tsx
878
+ import { useAIStream } from 'absolutejs/react'
879
+
880
+ const Chat = () => {
881
+ const {
882
+ messages, // Message[] — full conversation history
883
+ send, // (content: string) => void — send a message
884
+ cancel, // () => void — cancel current generation
885
+ isStreaming, // boolean — true while LLM is generating
886
+ error, // string | null — last error
887
+ } = useAIStream('/chat')
888
+
889
+ return (
890
+ <div>
891
+ {messages.map(m => (
892
+ <div key={m.id} data-role={m.role}>
893
+ {m.content}
894
+ {m.isStreaming && <span className="cursor" />}
895
+ </div>
896
+ ))}
897
+ <form onSubmit={(e) => {
898
+ e.preventDefault()
899
+ send(e.currentTarget.input.value)
900
+ }}>
901
+ <input name="input" disabled={isStreaming} />
902
+ {isStreaming
903
+ ? <button type="button" onClick={cancel}>Stop</button>
904
+ : <button type="submit">Send</button>
905
+ }
906
+ </form>
907
+ </div>
908
+ )
909
+ }
910
+ ```
911
+
912
+ **Svelte:**
913
+ ```svelte
914
+ <script lang="ts">
915
+ import { createAIStream } from 'absolutejs/svelte'
916
+
917
+ const { messages, send, cancel, isStreaming, error } = createAIStream('/chat')
918
+ </script>
919
+
920
+ {#each $messages as message}
921
+ <div data-role={message.role}>
922
+ {message.content}
923
+ {#if message.isStreaming}<span class="cursor" />{/if}
924
+ </div>
925
+ {/each}
926
+
927
+ <form on:submit|preventDefault={(e) => send(e.currentTarget.input.value)}>
928
+ <input name="input" disabled={$isStreaming} />
929
+ {#if $isStreaming}
930
+ <button type="button" on:click={cancel}>Stop</button>
931
+ {:else}
932
+ <button type="submit">Send</button>
933
+ {/if}
934
+ </form>
935
+ ```
936
+
937
+ **Vue:**
938
+ ```vue
939
+ <script setup lang="ts">
940
+ import { useAIStream } from 'absolutejs/vue'
941
+
942
+ const { messages, send, cancel, isStreaming, error } = useAIStream('/chat')
943
+ </script>
944
+
945
+ <template>
946
+ <div v-for="m in messages" :key="m.id" :data-role="m.role">
947
+ {{ m.content }}
948
+ <span v-if="m.isStreaming" class="cursor" />
949
+ </div>
950
+ <form @submit.prevent="send($event.target.input.value)">
951
+ <input name="input" :disabled="isStreaming" />
952
+ <button v-if="isStreaming" type="button" @click="cancel">Stop</button>
953
+ <button v-else type="submit">Send</button>
954
+ </form>
955
+ </template>
956
+ ```
957
+
958
+ **Angular:**
959
+ ```typescript
960
+ import { Component, inject } from '@angular/core'
961
+ import { AIStreamService } from 'absolutejs/angular'
962
+
963
+ @Component({
964
+ selector: 'app-chat',
965
+ template: `
966
+ @for (m of ai.messages(); track m.id) {
967
+ <div [attr.data-role]="m.role">
968
+ {{ m.content }}
969
+ @if (m.isStreaming) { <span class="cursor"></span> }
970
+ </div>
971
+ }
972
+ <form (submit)="onSubmit($event)">
973
+ <input name="input" [disabled]="ai.isStreaming()" />
974
+ @if (ai.isStreaming()) {
975
+ <button type="button" (click)="ai.cancel()">Stop</button>
976
+ } @else {
977
+ <button type="submit">Send</button>
978
+ }
979
+ </form>
980
+ `
981
+ })
982
+ export class ChatComponent {
983
+ ai = inject(AIStreamService).connect('/chat')
984
+
985
+ onSubmit(e: Event) {
986
+ e.preventDefault()
987
+ const input = (e.target as HTMLFormElement).input as HTMLInputElement
988
+ this.ai.send(input.value)
989
+ }
990
+ }
991
+ ```
992
+
993
+ *WebSocket message protocol for AI streaming:*
994
+ ```ts
995
+ // Client → Server
996
+ type AIClientMessage =
997
+ | { type: 'message', content: string, conversationId?: string }
998
+ | { type: 'cancel' }
999
+
1000
+ // Server → Client
1001
+ type AIServerMessage =
1002
+ | { type: 'chunk', content: string, messageId: string }
1003
+ | { type: 'tool_use', name: string, input: unknown, messageId: string }
1004
+ | { type: 'complete', messageId: string, usage?: { inputTokens: number, outputTokens: number } }
1005
+ | { type: 'error', message: string }
1006
+ ```
1007
+
1008
+ *Advanced features:*
1009
+ - **Tool use / function calling**: When the LLM calls a tool, the server sends a `tool_use` message. The client can display a "searching..." or "running code..." UI. The server executes the tool and feeds the result back to the LLM, continuing the stream.
1010
+ - **Conversation branching**: The client sends a `conversationId` — the server maintains conversation state and supports branching (edit a previous message, regenerate from a point).
1011
+ - **Multi-model routing**: `streamAI` could accept a `router` function that picks the model based on the message (simple questions → Haiku, complex → Opus).
1012
+ - **Token counting**: The `onChunk` callback can count tokens for usage tracking / rate limiting.
1013
+ - **Reconnection**: If the WebSocket drops mid-stream, the client hook reconnects and requests the remaining content from the server (using the `messageId` as a cursor).
1014
+
1015
+ **Design considerations:**
1016
+ - The AI helpers should be optional — users who don't build AI features never import them and they're tree-shaken from the bundle.
1017
+ - Provider API keys should come from environment variables, never sent to the client.
1018
+ - The client hooks manage the WebSocket connection lifecycle — connect on mount, disconnect on unmount, reconnect on drop. Same patterns as the HMR client.
1019
+ - The message protocol should be extensible — providers may add new message types (images, audio) and the protocol should handle unknown types gracefully.
1020
+ - SSR: the chat component renders empty on the server (no messages). Conversation history loads on hydration from the server or local storage.
1021
+
1022
+ **Files likely involved:**
1023
+ - New: `src/ai/streamAI.ts` — core streaming utility that pipes LLM responses to WebSocket
1024
+ - New: `src/ai/providers/anthropic.ts` — Anthropic adapter
1025
+ - New: `src/ai/providers/openai.ts` — OpenAI adapter
1026
+ - New: `src/ai/providers/ollama.ts` — Ollama adapter (local LLMs)
1027
+ - New: `src/react/hooks/useAIStream.ts` — React hook
1028
+ - New: `src/svelte/createAIStream.ts` — Svelte store-based hook
1029
+ - New: `src/vue/useAIStream.ts` — Vue composable
1030
+ - New: `src/angular/ai-stream.service.ts` — Angular service
1031
+ - New: `types/ai.ts` — message protocol types, provider interface, hook return types
1032
+ - Package exports: `absolutejs/ai` for server utilities, per-framework exports for client hooks