@absolutejs/absolute 0.19.0-beta.171 → 0.19.0-beta.173
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 +450 -0
- package/dist/cli/index.js +139 -85
- package/dist/index.js +40 -9
- package/dist/index.js.map +3 -3
- package/dist/src/core/prepare.d.ts +52 -0
- package/dist/src/core/prerender.d.ts +20 -0
- package/dist/types/build.d.ts +5 -0
- package/package.json +1 -1
- package/types/build.ts +7 -0
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
|