develop-updateui #1
414
AGENTS.md
414
AGENTS.md
@@ -1,82 +1,101 @@
|
||||
# Holistream - AI Agent Guide
|
||||
# AGENTS.md
|
||||
|
||||
This document provides comprehensive guidance for AI coding agents working on the Holistream project.
|
||||
This file provides guidance for AI coding agents working with the Holistream codebase.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Holistream** is a Vue 3 streaming application with Server-Side Rendering (SSR) deployed on Cloudflare Workers. It provides video upload, management, and streaming capabilities with a focus on performance and user experience.
|
||||
**Holistream** is a Vue 3 streaming application with Server-Side Rendering (SSR) deployed on Cloudflare Workers. It provides video upload, management, and streaming capabilities for content creators.
|
||||
|
||||
- **Name**: holistream
|
||||
- **Type**: ES Module JavaScript/TypeScript project
|
||||
- **Package Manager**: Bun (uses `bun.lock`)
|
||||
### Key Characteristics
|
||||
|
||||
- **Type**: Full-stack web application with SSR
|
||||
- **Primary Language**: TypeScript
|
||||
- **Package Manager**: Bun (evident from `bun.lock`)
|
||||
- **Deployment Target**: Cloudflare Workers
|
||||
|
||||
## Technology Stack
|
||||
|
||||
| Category | Technology |
|
||||
|----------|------------|
|
||||
| **Framework** | Vue 3.5+ with Composition API |
|
||||
| **Rendering** | SSR (Server-Side Rendering) with Vue's `createSSRApp` |
|
||||
| **Router** | Vue Router 5 with SSR-aware history |
|
||||
| **Server** | Hono framework on Cloudflare Workers |
|
||||
| **Build Tool** | Vite 7 with custom SSR plugin |
|
||||
| **Styling** | UnoCSS (Tailwind-like utility-first CSS) |
|
||||
| **UI Components** | PrimeVue 4 with Aura theme |
|
||||
| **State Management** | Pinia 3 + Pinia Colada for server state |
|
||||
| **HTTP Client** | Auto-generated from OpenAPI/Swagger spec |
|
||||
| **Head Management** | @unhead/vue for SEO |
|
||||
| **Icons** | Custom Vue icon components |
|
||||
| **Validation** | Zod v4 |
|
||||
| **Utils** | VueUse, clsx, tailwind-merge |
|
||||
| Category | Technology | Version |
|
||||
|----------|------------|---------|
|
||||
| Framework | Vue | 3.5.27 |
|
||||
| Router | Vue Router | 5.0.2 |
|
||||
| Server Framework | Hono | 4.11.7 |
|
||||
| Build Tool | Vite | 7.3.1 |
|
||||
| CSS Framework | UnoCSS | 66.6.0 |
|
||||
| UI Components | PrimeVue | 4.5.4 |
|
||||
| State Management | Pinia | 3.0.4 |
|
||||
| Server State | Pinia Colada | 0.21.2 |
|
||||
| Meta/SEO | @unhead/vue | 2.1.2 |
|
||||
| Utilities | VueUse | 14.2.0 |
|
||||
| Validation | Zod | 4.3.6 |
|
||||
| Deployment | Wrangler | 4.62.0 |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
.
|
||||
├── src/
|
||||
│ ├── api/ # API client and HTTP adapters
|
||||
│ │ ├── client.ts # Auto-generated API client from Swagger
|
||||
│ │ ├── httpClientAdapter.client.ts # Browser fetch adapter
|
||||
│ │ ├── httpClientAdapter.server.ts # SSR fetch adapter with cookie forwarding
|
||||
│ │ └── rpc/ # RPC utilities
|
||||
│ │ ├── client.ts # Auto-generated API client from OpenAPI spec
|
||||
│ │ ├── httpClientAdapter.client.ts # Client-side fetch adapter
|
||||
│ │ └── httpClientAdapter.server.ts # Server-side fetch adapter
|
||||
│ ├── client.ts # Client entry point (hydration)
|
||||
│ ├── components/ # Vue components (auto-imported)
|
||||
│ │ ├── icons/ # Custom SVG icon components
|
||||
│ ├── components/ # Vue components
|
||||
│ │ ├── dashboard/ # Dashboard-specific components
|
||||
│ │ └── ui/ # UI primitive components
|
||||
│ │ ├── icons/ # Custom icon components
|
||||
│ │ ├── ui/ # UI primitive components
|
||||
│ │ ├── ClientOnly.tsx # SSR-safe client-only wrapper
|
||||
│ │ ├── DashboardLayout.vue # Main dashboard layout
|
||||
│ │ ├── GlobalUploadIndicator.vue
|
||||
│ │ ├── NotificationDrawer.vue
|
||||
│ │ └── RootLayout.vue # Root application layout
|
||||
│ ├── composables/ # Vue composables
|
||||
│ │ └── useUploadQueue.ts # File upload queue management
|
||||
│ │ └── useUploadQueue.ts # Upload queue management
|
||||
│ ├── index.tsx # Server entry point (Hono app)
|
||||
│ ├── lib/ # Utility libraries
|
||||
│ │ ├── utils.ts # Helper functions (cn, formatBytes, etc.)
|
||||
│ │ ├── liteMqtt.ts # MQTT client for real-time notifications
|
||||
│ │ ├── swr/ # SWR cache implementation
|
||||
│ │ └── directives/ # Vue custom directives
|
||||
│ │ ├── constants.ts # Application constants
|
||||
│ │ ├── directives/ # Custom Vue directives
|
||||
│ │ ├── hoc/ # Higher-order components
|
||||
│ │ ├── interface.ts # TypeScript interfaces
|
||||
│ │ ├── liteMqtt.ts # MQTT client (browser)
|
||||
│ │ ├── manifest.ts # Vite manifest utilities
|
||||
│ │ ├── PiniaSharedState.ts # Pinia state hydration plugin
|
||||
│ │ ├── primePassthrough.ts # PrimeVue theme configuration
|
||||
│ │ ├── replateStreamText.ts
|
||||
│ │ └── utils.ts # Utility functions (cn, formatters, etc.)
|
||||
│ ├── main.ts # App factory function
|
||||
│ ├── routes/ # Route components
|
||||
│ │ ├── auth/ # Login, signup, forgot password
|
||||
│ │ ├── home/ # Landing, terms, privacy
|
||||
│ │ ├── notification/ # Notification center
|
||||
│ ├── mocks/ # Mock data for development
|
||||
│ ├── routes/ # Route components (page components)
|
||||
│ │ ├── auth/ # Authentication pages
|
||||
│ │ ├── home/ # Public pages (landing, terms, privacy)
|
||||
│ │ ├── notification/ # Notification page
|
||||
│ │ ├── overview/ # Dashboard overview
|
||||
│ │ ├── plans/ # Payments & plans
|
||||
│ │ ├── profile/ # User profile
|
||||
│ │ ├── upload/ # Video upload interface
|
||||
│ │ ├── video/ # Video list and detail
|
||||
│ │ └── index.ts # Router configuration
|
||||
│ ├── server/ # Server-specific modules
|
||||
│ │ ├── upload/ # Video upload
|
||||
│ │ ├── video/ # Video management
|
||||
│ │ ├── index.ts # Router configuration
|
||||
│ │ └── NotFound.vue # 404 page
|
||||
│ ├── server/ # Server-side utilities
|
||||
│ │ └── modules/
|
||||
│ │ └── merge.ts # Video chunk merge logic
|
||||
│ ├── stores/ # Pinia stores
|
||||
│ │ └── auth.ts # Authentication state
|
||||
│ │ └── auth.ts # Authentication store
|
||||
│ ├── type.d.ts # TypeScript declarations
|
||||
│ └── worker/ # Worker utilities
|
||||
├── public/ # Static assets (favicons, etc.)
|
||||
├── docs.json # OpenAPI/Swagger specification
|
||||
├── package.json # Dependencies and scripts
|
||||
├── tsconfig.json # TypeScript configuration
|
||||
├── vite.config.ts # Vite + Cloudflare plugin config
|
||||
├── wrangler.jsonc # Cloudflare Workers configuration
|
||||
│ ├── html.ts
|
||||
│ └── ssrLayout.ts
|
||||
├── bootstrap_btn.ts # Bootstrap button preset for UnoCSS
|
||||
├── ssrPlugin.ts # Custom Vite SSR plugin
|
||||
├── uno.config.ts # UnoCSS configuration
|
||||
├── ssrPlugin.ts # Custom SSR build plugin
|
||||
├── bootstrap_btn.ts # Custom UnoCSS preset for Bootstrap buttons
|
||||
├── auto-imports.d.ts # Generated auto-import declarations
|
||||
└── components.d.ts # Generated component declarations
|
||||
├── vite.config.ts # Vite configuration
|
||||
├── wrangler.jsonc # Cloudflare Workers configuration
|
||||
├── tsconfig.json # TypeScript configuration
|
||||
├── package.json # Package dependencies
|
||||
├── bun.lock # Bun lock file
|
||||
├── docs.json # OpenAPI/Swagger spec for API
|
||||
├── auto-imports.d.ts # Auto-generated type declarations
|
||||
└── components.d.ts # Auto-generated component declarations
|
||||
```
|
||||
|
||||
## Build and Development Commands
|
||||
@@ -85,10 +104,10 @@ This document provides comprehensive guidance for AI coding agents working on th
|
||||
# Install dependencies
|
||||
bun install
|
||||
|
||||
# Development server with hot reload
|
||||
# Start development server with hot reload
|
||||
bun dev
|
||||
|
||||
# Production build (client + worker)
|
||||
# Build for production (client + worker)
|
||||
bun run build
|
||||
|
||||
# Preview production build locally
|
||||
@@ -104,45 +123,49 @@ bun run cf-typegen
|
||||
bun run tail
|
||||
```
|
||||
|
||||
> **Note**: While npm commands work (`npm run dev`, etc.), the project uses Bun as its primary package manager.
|
||||
|
||||
## Architecture Details
|
||||
|
||||
### SSR Architecture
|
||||
|
||||
The application uses a custom SSR setup (`ssrPlugin.ts`):
|
||||
The application uses a custom SSR setup defined in `ssrPlugin.ts`:
|
||||
|
||||
1. **Build Order**: Client bundle is built FIRST, then Worker bundle
|
||||
2. **Manifest Injection**: Vite manifest is injected into server build for asset rendering
|
||||
3. **Environment-based Module Resolution**: Different implementations for `@httpClientAdapter` and `@liteMqtt` based on SSR context
|
||||
1. **Build Order**: Client bundle is built FIRST, then the Worker bundle
|
||||
2. **Manifest Injection**: Vite manifest is injected into the server build for asset rendering
|
||||
3. **Environment-based Resolution**: `httpClientAdapter` and `liteMqtt` resolve to different implementations based on SSR context
|
||||
|
||||
**Entry Points**:
|
||||
**Entry Points:**
|
||||
- **Server**: `src/index.tsx` - Hono app that renders Vue SSR stream
|
||||
- **Client**: `src/client.ts` - Hydrates the SSR-rendered app
|
||||
- **Factory**: `src/main.ts` - Creates app instance (used by both)
|
||||
- **Client**: `src/client.ts` - Hydrates the SSR-rendered application
|
||||
- **App Factory**: `src/main.ts` - Creates the Vue app instance (used by both)
|
||||
|
||||
### Module Aliases
|
||||
|
||||
| Alias | Resolution |
|
||||
|-------|------------|
|
||||
| `@/` | `./src/` |
|
||||
| `@httpClientAdapter` | `src/api/httpClientAdapter.server.ts` (SSR) or `.client.ts` (browser) |
|
||||
| `@liteMqtt` | `src/lib/liteMqtt.server.ts` (SSR) or `.ts` (browser) |
|
||||
|
||||
### State Management Pattern
|
||||
### State Management with SSR
|
||||
|
||||
Uses **Pinia Colada** for server state with SSR hydration:
|
||||
|
||||
- Queries are fetched server-side and serialized to `window.__APP_DATA__`
|
||||
- Client hydrates the query cache on startup via `hydrateQueryCache()`
|
||||
- Pinia state is similarly serialized and restored via `PiniaSharedState` plugin
|
||||
- Server-side queries are fetched and serialized to `window.__APP_DATA__`
|
||||
- Client hydrates the query cache via `hydrateQueryCache()`
|
||||
- Pinia state is serialized and restored via `PiniaSharedState` plugin
|
||||
|
||||
### Module Aliases
|
||||
|
||||
Configured in `tsconfig.json` and `vite.config.ts`:
|
||||
|
||||
| Alias | Resolution |
|
||||
|-------|------------|
|
||||
| `@/` | `src/` |
|
||||
| `@httpClientAdapter` | `src/api/httpClientAdapter.server.ts` (SSR) or `.client.ts` (browser) |
|
||||
| `@liteMqtt` | `src/lib/liteMqtt.server.ts` (SSR) or `.ts` (browser) |
|
||||
|
||||
### API Client Architecture
|
||||
|
||||
The API client (`src/api/client.ts`) is auto-generated from OpenAPI/Swagger spec (`docs.json`):
|
||||
The API client (`src/api/client.ts`) is **auto-generated** from the OpenAPI spec (`docs.json`):
|
||||
|
||||
- **Base URL**: `r` (proxied to `https://api.pipic.fun`)
|
||||
- **Server Adapter** (`httpClientAdapter.server.ts`): Forwards cookies, merges headers, calls backend API
|
||||
- **Client Adapter** (`httpClientAdapter.client.ts`): Standard fetch with credentials
|
||||
- **Proxy Route**: `/r/*` paths proxy to `https://api.pipic.fun`
|
||||
- Uses `customFetch` adapter that differs between client/server
|
||||
- **Server adapter** (`httpClientAdapter.server.ts`): Forwards cookies, merges headers, proxies to `api.pipic.fun`
|
||||
- **Client adapter** (`httpClientAdapter.client.ts`): Standard fetch with credentials
|
||||
- API proxy route: `/r/*` paths proxy to `https://api.pipic.fun`
|
||||
|
||||
### Routing Structure
|
||||
|
||||
@@ -150,17 +173,16 @@ Routes are defined in `src/routes/index.ts` with three main layout groups:
|
||||
|
||||
1. **Public** (`/`): Landing page, terms, privacy
|
||||
2. **Auth** (`/login`, `/sign-up`, `/forgot`): Authentication pages (redirects if logged in)
|
||||
3. **Dashboard**: Protected routes requiring auth
|
||||
3. **Dashboard**: Protected routes requiring authentication
|
||||
- `/overview` - Main dashboard
|
||||
- `/upload` - Video upload
|
||||
- `/upload` - Video upload with queue management
|
||||
- `/video` - Video list
|
||||
- `/video/:id` - Video detail/edit
|
||||
- `/payments-and-plans` - Billing
|
||||
- `/notification` - Notifications
|
||||
- `/profile` - User settings
|
||||
- `/payments-and-plans` - Billing management
|
||||
- `/notification`, `/profile` - User settings
|
||||
|
||||
Route meta supports `@unhead/vue` for SEO:
|
||||
```typescript
|
||||
```ts
|
||||
meta: {
|
||||
head: {
|
||||
title: "Page Title",
|
||||
@@ -173,137 +195,166 @@ meta: {
|
||||
|
||||
Configuration in `uno.config.ts`:
|
||||
|
||||
- **Presets**: Wind4 (Tailwind), Typography, Attributify, Bootstrap buttons
|
||||
- **Presets**: Wind4 (Tailwind-like), Typography, Attributify, Bootstrap buttons
|
||||
- **Custom Colors**:
|
||||
- `primary`: #14a74b (green)
|
||||
- `accent`: #14a74b
|
||||
- `secondary`: #fd7906 (orange)
|
||||
- `success`, `info`, `warning`, `danger`
|
||||
- `primary` (#14a74b)
|
||||
- `secondary` (#fd7906)
|
||||
- `accent`, `success`, `info`, `warning`, `danger`
|
||||
- **Shortcuts**: `press-animated` for button press effects
|
||||
- **Transformers**: `transformerCompileClass` (prefix: `_`), `transformerVariantGroup`
|
||||
- **Transformers**:
|
||||
- `transformerCompileClass` (prefix: `_` for compiled classes)
|
||||
- `transformerVariantGroup`
|
||||
|
||||
Use `cn()` from `src/lib/utils.ts` for conditional class merging (clsx + tailwind-merge):
|
||||
```typescript
|
||||
import { cn } from '@/lib/utils';
|
||||
const className = cn('base-class', condition && 'conditional-class');
|
||||
```
|
||||
Use `cn()` from `src/lib/utils.ts` for conditional class merging (combines `clsx` + `tailwind-merge`).
|
||||
|
||||
### Component Auto-Import
|
||||
|
||||
Components in `src/components/` are auto-imported via `unplugin-vue-components`:
|
||||
Components are auto-imported via `unplugin-vue-components`:
|
||||
|
||||
- PrimeVue components resolved via `PrimeVueResolver`
|
||||
- Custom icons in `src/components/icons/` are auto-imported
|
||||
- Vue/Pinia/Vue Router APIs auto-imported via `unplugin-auto-import`
|
||||
- Type declarations auto-generated to `components.d.ts` and `auto-imports.d.ts`
|
||||
|
||||
No need to manually import `ref`, `computed`, `onMounted`, etc.
|
||||
## Development Guidelines
|
||||
|
||||
### Code Style
|
||||
|
||||
- **TypeScript**: Strict mode enabled
|
||||
- **JSX/TSX**: Supported for components (import source: `vue`)
|
||||
- **CSS**: Use UnoCSS utility classes; custom CSS in component `<style>` blocks when needed
|
||||
|
||||
### File Organization
|
||||
|
||||
- Page components go in `src/routes/` following the route structure
|
||||
- Reusable components go in `src/components/`
|
||||
- Composables go in `src/composables/`
|
||||
- Stores go in `src/stores/`
|
||||
- Server utilities go in `src/server/`
|
||||
|
||||
### HTTP Requests
|
||||
|
||||
**Always use the generated API client** instead of raw fetch:
|
||||
|
||||
```ts
|
||||
import { client } from '@/api/client';
|
||||
|
||||
// Example
|
||||
const response = await client.auth.loginCreate({ email, password });
|
||||
```
|
||||
|
||||
The client handles:
|
||||
- Base URL resolution
|
||||
- Cookie forwarding (server-side)
|
||||
- Type safety
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
- `useAuthStore` manages auth state with cookie-based sessions
|
||||
- `init()` called on every request to fetch current user via `/me` endpoint
|
||||
- `init()` is called on every request to fetch current user via `/me` endpoint
|
||||
- `beforeEach` router guard redirects unauthenticated users from protected routes
|
||||
- MQTT client connects on user login for real-time notifications
|
||||
|
||||
### File Upload Architecture
|
||||
|
||||
Upload queue (`src/composables/useUploadQueue.ts`):
|
||||
|
||||
- Supports both local files and remote URLs
|
||||
- Presigned POST URLs fetched from API
|
||||
- Parallel chunk upload for large files
|
||||
- Parallel chunk upload (90MB chunks, max 3 parallel)
|
||||
- Progress tracking with speed calculation
|
||||
|
||||
## Code Style Guidelines
|
||||
### Type Safety
|
||||
|
||||
### File Naming Conventions
|
||||
- TypeScript strict mode enabled
|
||||
- `CloudflareBindings` interface for environment variables (generated via `cf-typegen`)
|
||||
- API types auto-generated from backend OpenAPI spec (`docs.json`)
|
||||
|
||||
- **Components**: PascalCase (e.g., `VideoCard.vue`, `DashboardNav.vue`)
|
||||
- **Composables**: camelCase with `use` prefix (e.g., `useUploadQueue.ts`)
|
||||
- **Utilities**: camelCase (e.g., `utils.ts`, `liteMqtt.ts`)
|
||||
- **Routes**: PascalCase for page components (e.g., `Overview.vue`)
|
||||
- **Icons**: PascalCase with `Icon` suffix (e.g., `Bell.vue`, `VideoIcon.vue`)
|
||||
## Environment Configuration
|
||||
|
||||
### Component Patterns
|
||||
### Cloudflare Worker Bindings
|
||||
|
||||
Use Composition API with `<script setup>`:
|
||||
Configured in `wrangler.jsonc`:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// Auto-imported: ref, computed, onMounted, etc.
|
||||
const props = defineProps<{
|
||||
video: ModelVideo;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: [id: string];
|
||||
}>();
|
||||
|
||||
// Use composables
|
||||
const auth = useAuthStore();
|
||||
const route = useRoute();
|
||||
</script>
|
||||
```json
|
||||
{
|
||||
"name": "holistream",
|
||||
"compatibility_date": "2025-08-03",
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"observability": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### TypeScript
|
||||
|
||||
- Strict mode enabled
|
||||
- Use `type` for type aliases
|
||||
- Interfaces for object shapes
|
||||
- Always type function parameters and returns
|
||||
|
||||
### CSS Class Ordering
|
||||
|
||||
Use UnoCSS utility classes in this order:
|
||||
1. Layout (display, position)
|
||||
2. Spacing (margin, padding)
|
||||
3. Sizing (width, height)
|
||||
4. Typography (font, text)
|
||||
5. Visuals (bg, border, shadow)
|
||||
6. Interactivity (hover, focus)
|
||||
|
||||
Example:
|
||||
```html
|
||||
<div class="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-600">
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
**Note**: There are currently no automated test suites (like Vitest) or linting tools (like ESLint/Prettier) configured in this project.
|
||||
|
||||
Testing is currently manual:
|
||||
1. Run `bun dev` for local development testing
|
||||
2. Use `bun preview` to test production build locally
|
||||
3. Use `bun run tail` to monitor Cloudflare Worker logs in production
|
||||
|
||||
## Deployment Process
|
||||
|
||||
### Cloudflare Workers
|
||||
|
||||
- **Config**: `wrangler.jsonc`
|
||||
- **Entry**: `src/index.tsx`
|
||||
- **Compatibility Date**: 2025-08-03
|
||||
- **Compatibility Flags**: `nodejs_compat`
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Cloudflare Worker bindings (configured in `wrangler.jsonc`):
|
||||
- No explicit secrets in code - use Wrangler secrets management
|
||||
- Run `bun run cf-typegen` to update TypeScript types after changing bindings
|
||||
- Access environment variables via Hono context: `c.env.VAR_NAME`
|
||||
|
||||
### Deployment Steps
|
||||
### Local Environment
|
||||
|
||||
1. Ensure code compiles: `bun run build`
|
||||
2. Deploy: `bun run deploy`
|
||||
3. Monitor logs: `bun run tail`
|
||||
Create `.dev.vars` for local development secrets (do not commit):
|
||||
|
||||
```
|
||||
SECRET_KEY=...
|
||||
```
|
||||
|
||||
## Testing and Quality
|
||||
|
||||
**Current Status**: There are currently no automated test suites (like Vitest) or linting tools (like ESLint/Prettier) configured.
|
||||
|
||||
When adding tests or linting:
|
||||
- Add appropriate dev dependencies
|
||||
- Update this section with commands and conventions
|
||||
- Consider the SSR environment when writing tests
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Cookie-based Auth**: Session cookies are forwarded between client and API
|
||||
- **CORS**: Configured in Hono middleware
|
||||
- **CSRF**: Protected by same-origin cookie policy
|
||||
- **Secrets**: Never commit secrets to code; use Wrangler secrets management
|
||||
1. **Cookie Security**: Cookies are httpOnly, secure, and sameSite
|
||||
2. **CORS**: Configured via Hono's CORS middleware
|
||||
3. **API Proxy**: Backend API is never exposed directly to the browser; all requests go through `/r/*` proxy
|
||||
4. **Input Validation**: Use Zod for runtime validation
|
||||
5. **XSS Protection**: HTML escaping is applied to SSR data via `htmlEscape()` function
|
||||
|
||||
## Important File Locations
|
||||
## Common Patterns
|
||||
|
||||
### Creating a New Page
|
||||
|
||||
1. Create component in `src/routes/<section>/PageName.vue`
|
||||
2. Add route to `src/routes/index.ts` with appropriate meta
|
||||
3. Use `head` in route meta for SEO if needed
|
||||
|
||||
### Using the Upload Queue
|
||||
|
||||
```ts
|
||||
import { useUploadQueue } from '@/composables/useUploadQueue';
|
||||
|
||||
const { items, addFiles, addRemoteUrls, startQueue } = useUploadQueue();
|
||||
```
|
||||
|
||||
### Accessing Hono Context in Components
|
||||
|
||||
```ts
|
||||
import { inject } from 'vue';
|
||||
|
||||
const honoContext = inject('honoContext');
|
||||
```
|
||||
|
||||
### Conditional Classes
|
||||
|
||||
```ts
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const className = cn(
|
||||
'base-class',
|
||||
isActive && 'active-class',
|
||||
variant === 'primary' ? 'text-primary' : 'text-secondary'
|
||||
);
|
||||
```
|
||||
|
||||
## External Dependencies
|
||||
|
||||
- **Backend API**: `https://api.pipic.fun`
|
||||
- **MQTT Broker**: `wss://mqtt-dashboard.com:8884/mqtt`
|
||||
- **Fonts**: Google Fonts (Google Sans loaded from fonts.googleapis.com)
|
||||
|
||||
## Important Files Reference
|
||||
|
||||
| Purpose | Path |
|
||||
|---------|------|
|
||||
@@ -318,11 +369,6 @@ Cloudflare Worker bindings (configured in `wrangler.jsonc`):
|
||||
| Wrangler config | `wrangler.jsonc` |
|
||||
| Vite config | `vite.config.ts` |
|
||||
|
||||
## Development Notes
|
||||
---
|
||||
|
||||
- Always use `customFetch` from `@httpClientAdapter` for API calls, never raw fetch
|
||||
- The `honoContext` is provided to Vue app for accessing request context in components
|
||||
- MQTT client in `src/lib/liteMqtt.ts` handles real-time notifications
|
||||
- Icons are custom Vue components in `src/components/icons/`
|
||||
- Upload indicator is a global component showing queue status
|
||||
- For SEO, use route meta with `@unhead/vue` head configuration
|
||||
*This document was generated for AI coding agents. For human contributors, see README.md.*
|
||||
|
||||
171
src/index.tsx
171
src/index.tsx
@@ -1,161 +1,24 @@
|
||||
import { serializeQueryCache } from '@pinia/colada';
|
||||
import { renderSSRHead } from '@unhead/vue/server';
|
||||
import { Hono } from 'hono';
|
||||
import { contextStorage } from 'hono/context-storage';
|
||||
import { cors } from "hono/cors";
|
||||
import { streamText } from 'hono/streaming';
|
||||
import isMobile from 'is-mobile';
|
||||
import { renderToWebStream } from 'vue/server-renderer';
|
||||
import { buildBootstrapScript } from './lib/manifest';
|
||||
import { styleTags } from './lib/primePassthrough';
|
||||
import { createApp } from './main';
|
||||
import { useAuthStore } from './stores/auth';
|
||||
// @ts-ignore
|
||||
import Base from '@primevue/core/base';
|
||||
import { baseAPIURL } from './api/httpClientAdapter.server';
|
||||
import { createManifest, getListFiles, saveManifest, validateChunkUrls } from './server/modules/merge';
|
||||
const app = new Hono()
|
||||
const defaultNames = ['primitive', 'semantic', 'global', 'base', 'ripple-directive']
|
||||
// app.use(renderer)
|
||||
app.use('*', contextStorage());
|
||||
app.use(cors(), async (c, next) => {
|
||||
c.set("fetch", app.request.bind(app));
|
||||
const ua = c.req.header("User-Agent")
|
||||
if (!ua) {
|
||||
return c.json({ error: "User-Agent header is missing" }, 400);
|
||||
};
|
||||
c.set("isMobile", isMobile({ ua }));
|
||||
await next();
|
||||
}, async (c, next) => {
|
||||
const path = c.req.path
|
||||
|
||||
if (path !== '/r' && !path.startsWith('/r/')) {
|
||||
return await next()
|
||||
}
|
||||
const url = new URL(c.req.url)
|
||||
url.host = baseAPIURL.replace(/^https?:\/\//, '')
|
||||
url.protocol = 'https:'
|
||||
url.pathname = path.replace(/^\/r/, '') || '/'
|
||||
url.port = ''
|
||||
// console.log("url", url.toString())
|
||||
// console.log("c.req.raw", c.req.raw)
|
||||
const headers = new Headers(c.req.header());
|
||||
headers.delete("host");
|
||||
headers.delete("connection");
|
||||
import { setupMiddlewares } from './server/middlewares/setup';
|
||||
import { apiProxyMiddleware } from './server/middlewares/apiProxy';
|
||||
import { registerWellKnownRoutes } from './server/routes/wellKnown';
|
||||
import { registerMergeRoutes } from './server/routes/merge';
|
||||
import { registerManifestRoutes } from './server/routes/manifest';
|
||||
import { registerSSRRoutes } from './server/routes/ssr';
|
||||
|
||||
return fetch(url.toString(), {
|
||||
method: c.req.method,
|
||||
headers: headers,
|
||||
body: c.req.raw.body,
|
||||
// @ts-ignore
|
||||
duplex: 'half',
|
||||
credentials: 'include'
|
||||
});
|
||||
});
|
||||
app.get("/.well-known/*", (c) => {
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
app.post('/merge', async (c, next) => {
|
||||
const headers = new Headers(c.req.header());
|
||||
headers.delete("host");
|
||||
headers.delete("connection");
|
||||
return fetch(`${baseAPIURL}/me`, {
|
||||
method: 'GET',
|
||||
headers: headers,
|
||||
credentials: 'include'
|
||||
}).then(res => res.json()).then((r) => {
|
||||
if (r.data?.user) {
|
||||
return next();
|
||||
}
|
||||
else {
|
||||
throw new Error("Unauthorized");
|
||||
}}).catch(() => {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
});
|
||||
}, async (c) => {
|
||||
try {
|
||||
const body = await c.req.json()
|
||||
const { filename, chunks } = body
|
||||
if (!filename || !Array.isArray(chunks) || chunks.length === 0) {
|
||||
return c.json({ error: 'invalid payload' }, 400)
|
||||
}
|
||||
const hostError = validateChunkUrls(chunks)
|
||||
if (hostError) return c.json({ error: hostError }, 400)
|
||||
const app = new Hono();
|
||||
|
||||
const manifest = createManifest(filename, chunks)
|
||||
await saveManifest(manifest)
|
||||
// Global middlewares
|
||||
setupMiddlewares(app);
|
||||
|
||||
return c.json({
|
||||
status: 'ok',
|
||||
id: manifest.id,
|
||||
filename: manifest.filename,
|
||||
total_parts: manifest.total_parts,
|
||||
})
|
||||
} catch (e: any) {
|
||||
return c.json({ error: e?.message ?? String(e) }, 500)
|
||||
}
|
||||
})
|
||||
app.get('/manifest/:id', async (c) => {
|
||||
const manifest = await getListFiles()
|
||||
if (!manifest) {
|
||||
return c.json({ error: "Manifest not found" }, 404)
|
||||
}
|
||||
return c.json(manifest)
|
||||
})
|
||||
app.get("*", async (c) => {
|
||||
const nonce = crypto.randomUUID();
|
||||
const url = new URL(c.req.url);
|
||||
const { app, router, head, pinia, bodyClass, queryCache } = createApp();
|
||||
app.provide("honoContext", c);
|
||||
const auth = useAuthStore();
|
||||
auth.$reset();
|
||||
// auth.initialized = false;
|
||||
await auth.init();
|
||||
await router.push(url.pathname);
|
||||
await router.isReady();
|
||||
let usedStyles = new Set<String>();
|
||||
Base.setLoadedStyleName = async (name: string) => usedStyles.add(name)
|
||||
return streamText(c, async (stream) => {
|
||||
c.header("Content-Type", "text/html; charset=utf-8");
|
||||
c.header("Content-Encoding", "Identity");
|
||||
const ctx: Record<string, any> = {};
|
||||
const appStream = renderToWebStream(app, ctx);
|
||||
// console.log("ctx: ", );
|
||||
await stream.write("<!DOCTYPE html><html lang='en'><head>");
|
||||
await stream.write("<base href='" + url.origin + "'/>");
|
||||
// API proxy middleware (handles /r/*)
|
||||
app.use(apiProxyMiddleware);
|
||||
|
||||
await renderSSRHead(head).then((headString) => stream.write(headString.headTags.replace(/\n/g, "")));
|
||||
// await stream.write(`<link href="https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"rel="stylesheet"></link>`);
|
||||
await stream.write(`<link rel="preconnect" href="https://fonts.googleapis.com">`);
|
||||
await stream.write(`<link href="https://fonts.googleapis.com/css2?family=Google+Sans:ital,opsz,wght@0,17..18,400..700;1,17..18,400..700&display=swap" rel="stylesheet">`);
|
||||
await stream.write('<link rel="icon" href="/favicon.ico" />');
|
||||
await stream.write(buildBootstrapScript());
|
||||
if (usedStyles.size > 0) {
|
||||
defaultNames.forEach(name => usedStyles.add(name));
|
||||
}
|
||||
await Promise.all(styleTags.filter(tag => usedStyles.has(tag.name.replace(/-(variables|style)$/, ""))).map(tag => stream.write(`<style type="text/css" data-primevue-style-id="${tag.name}">${tag.value}</style>`)));
|
||||
await stream.write(`</head><body class='${bodyClass}'>`);
|
||||
// await stream.pipe(createTextTransformStreamClass(appStream, (text) => text.replace('<div id="anchor-header" class="p-4"></div>', `<div id="anchor-header" class="p-4">${ctx.teleports["#anchor-header"] || ""}</div>`).replace('<div id="anchor-top"></div>', `<div id="anchor-top">${ctx.teleports["#anchor-top"] || ""}</div>`)));
|
||||
await stream.pipe(appStream);
|
||||
delete ctx.teleports
|
||||
delete ctx.__teleportBuffers
|
||||
delete ctx.modules;
|
||||
Object.assign(ctx, { $p: pinia.state.value, $colada: serializeQueryCache(queryCache) });
|
||||
await stream.write(`<script type="application/json" data-ssr="true" id="__APP_DATA__" nonce="${nonce}">${htmlEscape((JSON.stringify(ctx)))}</script>`);
|
||||
await stream.write("</body></html>");
|
||||
});
|
||||
})
|
||||
const ESCAPE_LOOKUP: { [match: string]: string } = {
|
||||
"&": "\\u0026",
|
||||
">": "\\u003e",
|
||||
"<": "\\u003c",
|
||||
"\u2028": "\\u2028",
|
||||
"\u2029": "\\u2029",
|
||||
};
|
||||
// Routes
|
||||
registerWellKnownRoutes(app);
|
||||
registerMergeRoutes(app);
|
||||
registerManifestRoutes(app);
|
||||
registerSSRRoutes(app);
|
||||
|
||||
const ESCAPE_REGEX = /[&><\u2028\u2029]/g;
|
||||
|
||||
function htmlEscape(str: string): string {
|
||||
return str.replace(ESCAPE_REGEX, (match) => ESCAPE_LOOKUP[match]);
|
||||
}
|
||||
export default app
|
||||
export default app;
|
||||
|
||||
29
src/server/middlewares/apiProxy.ts
Normal file
29
src/server/middlewares/apiProxy.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { baseAPIURL } from '@/api/httpClientAdapter.server';
|
||||
import type { Context, Next } from 'hono';
|
||||
|
||||
export async function apiProxyMiddleware(c: Context, next: Next) {
|
||||
const path = c.req.path;
|
||||
|
||||
if (path !== '/r' && !path.startsWith('/r/')) {
|
||||
return await next();
|
||||
}
|
||||
|
||||
const url = new URL(c.req.url);
|
||||
url.host = baseAPIURL.replace(/^https?:\/\//, '');
|
||||
url.protocol = 'https:';
|
||||
url.pathname = path.replace(/^\/r/, '') || '/';
|
||||
url.port = '';
|
||||
|
||||
const headers = new Headers(c.req.header());
|
||||
headers.delete("host");
|
||||
headers.delete("connection");
|
||||
|
||||
return fetch(url.toString(), {
|
||||
method: c.req.method,
|
||||
headers: headers,
|
||||
body: c.req.raw.body,
|
||||
// @ts-ignore
|
||||
duplex: 'half',
|
||||
credentials: 'include'
|
||||
});
|
||||
}
|
||||
20
src/server/middlewares/setup.ts
Normal file
20
src/server/middlewares/setup.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { contextStorage } from 'hono/context-storage';
|
||||
import { cors } from 'hono/cors';
|
||||
import isMobile from 'is-mobile';
|
||||
import type { Hono } from 'hono';
|
||||
|
||||
export function setupMiddlewares(app: Hono) {
|
||||
app.use('*', contextStorage());
|
||||
|
||||
app.use(cors(), async (c, next) => {
|
||||
c.set("fetch", app.request.bind(app));
|
||||
|
||||
const ua = c.req.header("User-Agent");
|
||||
if (!ua) {
|
||||
return c.json({ error: "User-Agent header is missing" }, 400);
|
||||
}
|
||||
|
||||
c.set("isMobile", isMobile({ ua }));
|
||||
await next();
|
||||
});
|
||||
}
|
||||
12
src/server/routes/manifest.ts
Normal file
12
src/server/routes/manifest.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { getListFiles } from '@/server/modules/merge';
|
||||
import type { Hono } from 'hono';
|
||||
|
||||
export function registerManifestRoutes(app: Hono) {
|
||||
app.get('/manifest/:id', async (c) => {
|
||||
const manifest = await getListFiles();
|
||||
if (!manifest) {
|
||||
return c.json({ error: "Manifest not found" }, 404);
|
||||
}
|
||||
return c.json(manifest);
|
||||
});
|
||||
}
|
||||
57
src/server/routes/merge.ts
Normal file
57
src/server/routes/merge.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { baseAPIURL } from '@/api/httpClientAdapter.server';
|
||||
import {
|
||||
createManifest,
|
||||
saveManifest,
|
||||
validateChunkUrls
|
||||
} from '@/server/modules/merge';
|
||||
import type { Hono, MiddlewareHandler } from 'hono';
|
||||
|
||||
const authMiddleware: MiddlewareHandler = async (c, next) => {
|
||||
const headers = new Headers(c.req.header());
|
||||
headers.delete("host");
|
||||
headers.delete("connection");
|
||||
|
||||
try {
|
||||
const res = await fetch(`${baseAPIURL}/me`, {
|
||||
method: 'GET',
|
||||
headers: headers,
|
||||
credentials: 'include'
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.data?.user) {
|
||||
return await next();
|
||||
}
|
||||
throw new Error("Unauthorized");
|
||||
} catch {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
};
|
||||
|
||||
export function registerMergeRoutes(app: Hono) {
|
||||
app.post('/merge', authMiddleware, async (c) => {
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
const { filename, chunks } = body;
|
||||
|
||||
if (!filename || !Array.isArray(chunks) || chunks.length === 0) {
|
||||
return c.json({ error: 'invalid payload' }, 400);
|
||||
}
|
||||
|
||||
const hostError = validateChunkUrls(chunks);
|
||||
if (hostError) return c.json({ error: hostError }, 400);
|
||||
|
||||
const manifest = createManifest(filename, chunks);
|
||||
await saveManifest(manifest);
|
||||
|
||||
return c.json({
|
||||
status: 'ok',
|
||||
id: manifest.id,
|
||||
filename: manifest.filename,
|
||||
total_parts: manifest.total_parts,
|
||||
});
|
||||
} catch (e: any) {
|
||||
return c.json({ error: e?.message ?? String(e) }, 500);
|
||||
}
|
||||
});
|
||||
}
|
||||
97
src/server/routes/ssr.ts
Normal file
97
src/server/routes/ssr.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { serializeQueryCache } from '@pinia/colada';
|
||||
import { renderSSRHead } from '@unhead/vue/server';
|
||||
import { streamText } from 'hono/streaming';
|
||||
import { renderToWebStream } from 'vue/server-renderer';
|
||||
// @ts-ignore
|
||||
import Base from '@primevue/core/base';
|
||||
|
||||
import { createApp } from '@/main';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { buildBootstrapScript } from '@/lib/manifest';
|
||||
import { styleTags } from '@/lib/primePassthrough';
|
||||
import { htmlEscape } from '@/server/utils/htmlEscape';
|
||||
import type { Hono } from 'hono';
|
||||
|
||||
const DEFAULT_STYLE_NAMES = ['primitive', 'semantic', 'global', 'base', 'ripple-directive'];
|
||||
|
||||
export function registerSSRRoutes(app: Hono) {
|
||||
app.get("*", async (c) => {
|
||||
const nonce = crypto.randomUUID();
|
||||
const url = new URL(c.req.url);
|
||||
|
||||
const { app: vueApp, router, head, pinia, bodyClass, queryCache } = createApp();
|
||||
|
||||
vueApp.provide("honoContext", c);
|
||||
|
||||
const auth = useAuthStore();
|
||||
auth.$reset();
|
||||
await auth.init();
|
||||
|
||||
await router.push(url.pathname);
|
||||
await router.isReady();
|
||||
|
||||
const usedStyles = new Set<string>();
|
||||
Base.setLoadedStyleName = async (name: string) => usedStyles.add(name);
|
||||
|
||||
return streamText(c, async (stream) => {
|
||||
c.header("Content-Type", "text/html; charset=utf-8");
|
||||
c.header("Content-Encoding", "Identity");
|
||||
|
||||
const ctx: Record<string, any> = {};
|
||||
const appStream = renderToWebStream(vueApp, ctx);
|
||||
|
||||
// HTML Head
|
||||
await stream.write("<!DOCTYPE html><html lang='en'><head>");
|
||||
await stream.write("<base href='" + url.origin + "'/>");
|
||||
|
||||
// SSR Head tags
|
||||
const headResult = await renderSSRHead(head);
|
||||
await stream.write(headResult.headTags.replace(/\n/g, ""));
|
||||
|
||||
// Fonts & Favicon
|
||||
await stream.write(`<link rel="preconnect" href="https://fonts.googleapis.com">`);
|
||||
await stream.write(`<link href="https://fonts.googleapis.com/css2?family=Google+Sans:ital,opsz,wght@0,17..18,400..700;1,17..18,400..700&display=swap" rel="stylesheet">`);
|
||||
await stream.write('<link rel="icon" href="/favicon.ico" />');
|
||||
|
||||
// Bootstrap scripts
|
||||
await stream.write(buildBootstrapScript());
|
||||
|
||||
// PrimeVue styles
|
||||
if (usedStyles.size > 0) {
|
||||
DEFAULT_STYLE_NAMES.forEach(name => usedStyles.add(name));
|
||||
}
|
||||
|
||||
const activeStyles = styleTags.filter(tag =>
|
||||
usedStyles.has(tag.name.replace(/-(variables|style)$/, ""))
|
||||
);
|
||||
|
||||
for (const tag of activeStyles) {
|
||||
await stream.write(`<style type="text/css" data-primevue-style-id="${tag.name}">${tag.value}</style>`);
|
||||
}
|
||||
|
||||
// Body start
|
||||
await stream.write(`</head><body class='${bodyClass}'>`);
|
||||
|
||||
// App content
|
||||
await stream.pipe(appStream);
|
||||
|
||||
// Cleanup context
|
||||
delete ctx.teleports;
|
||||
delete ctx.__teleportBuffers;
|
||||
delete ctx.modules;
|
||||
|
||||
// Inject state
|
||||
Object.assign(ctx, {
|
||||
$p: pinia.state.value,
|
||||
$colada: serializeQueryCache(queryCache)
|
||||
});
|
||||
|
||||
// App data script
|
||||
const appDataScript = `<script type="application/json" data-ssr="true" id="__APP_DATA__" nonce="${nonce}">${htmlEscape(JSON.stringify(ctx))}</script>`;
|
||||
await stream.write(appDataScript);
|
||||
|
||||
// Close HTML
|
||||
await stream.write("</body></html>");
|
||||
});
|
||||
});
|
||||
}
|
||||
7
src/server/routes/wellKnown.ts
Normal file
7
src/server/routes/wellKnown.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { Hono } from 'hono';
|
||||
|
||||
export function registerWellKnownRoutes(app: Hono) {
|
||||
app.get("/.well-known/*", (c) => {
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
}
|
||||
13
src/server/utils/htmlEscape.ts
Normal file
13
src/server/utils/htmlEscape.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
const ESCAPE_LOOKUP: { [match: string]: string } = {
|
||||
"&": "\\u0026",
|
||||
">": "\\u003e",
|
||||
"<": "\\u003c",
|
||||
"\u2028": "\\u2028",
|
||||
"\u2029": "\\u2029",
|
||||
};
|
||||
|
||||
const ESCAPE_REGEX = /[&><\u2028\u2029]/g;
|
||||
|
||||
export function htmlEscape(str: string): string {
|
||||
return str.replace(ESCAPE_REGEX, (match) => ESCAPE_LOOKUP[match]);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
/**
|
||||
* @module
|
||||
* html Helper for Hono.
|
||||
*/
|
||||
|
||||
import { escapeToBuffer, raw, resolveCallbackSync, stringBufferToString } from 'hono/utils/html'
|
||||
import type { HtmlEscaped, HtmlEscapedString, StringBufferWithCallbacks } from 'hono/utils/html'
|
||||
|
||||
|
||||
export const html = (
|
||||
strings: TemplateStringsArray,
|
||||
...values: unknown[]
|
||||
): HtmlEscapedString | Promise<HtmlEscapedString> => {
|
||||
const buffer: StringBufferWithCallbacks = [''] as StringBufferWithCallbacks
|
||||
|
||||
for (let i = 0, len = strings.length - 1; i < len; i++) {
|
||||
buffer[0] += strings[i]
|
||||
|
||||
const children = Array.isArray(values[i])
|
||||
? (values[i] as Array<unknown>).flat(Infinity)
|
||||
: [values[i]]
|
||||
for (let i = 0, len = children.length; i < len; i++) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const child = children[i] as any
|
||||
if (typeof child === 'string') {
|
||||
escapeToBuffer(child, buffer)
|
||||
} else if (typeof child === 'number') {
|
||||
;(buffer[0] as string) += child
|
||||
} else if (typeof child === 'boolean' || child === null || child === undefined) {
|
||||
continue
|
||||
} else if (typeof child === 'object' && (child as HtmlEscaped).isEscaped) {
|
||||
if ((child as HtmlEscapedString).callbacks) {
|
||||
buffer.unshift('', child)
|
||||
} else {
|
||||
const tmp = child.toString()
|
||||
if (tmp instanceof Promise) {
|
||||
buffer.unshift('', tmp)
|
||||
} else {
|
||||
buffer[0] += tmp
|
||||
}
|
||||
}
|
||||
} else if (child instanceof Promise) {
|
||||
buffer.unshift('', child)
|
||||
} else {
|
||||
escapeToBuffer(child.toString(), buffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
buffer[0] += strings.at(-1) as string
|
||||
|
||||
return buffer.length === 1
|
||||
? 'callbacks' in buffer
|
||||
? raw(resolveCallbackSync(raw(buffer[0], buffer.callbacks)))
|
||||
: raw(buffer[0])
|
||||
: stringBufferToString(buffer, buffer.callbacks)
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { createContext, jsx, Suspense } from "hono/jsx";
|
||||
import { renderToReadableStream, StreamingContext } from "hono/jsx/streaming";
|
||||
import { HtmlEscapedCallback, HtmlEscapedString, raw } from "hono/utils/html";
|
||||
// import { jsxs } from "hono/jsx-renderer";
|
||||
import { Context } from "hono";
|
||||
import type {
|
||||
FC,
|
||||
Context as JSXContext,
|
||||
JSXNode
|
||||
} from "hono/jsx";
|
||||
import { jsxTemplate } from "hono/jsx/jsx-runtime";
|
||||
export const RequestContext: JSXContext<Context<any, any, {}> | null> =
|
||||
createContext<Context | null>(null);
|
||||
export function renderSSRLayout(c: Context, appStream: ReadableStream) {
|
||||
const body = jsxTemplate`${raw("<!DOCTYPE html>")}${_c(
|
||||
RequestContext.Provider,
|
||||
{ value: c },
|
||||
// currentLayout as any
|
||||
_c(
|
||||
"html",
|
||||
{ lang: "en" },
|
||||
_c(
|
||||
"head",
|
||||
null,
|
||||
raw('<meta charset="UTF-8"/>'),
|
||||
raw(
|
||||
'<meta name="viewport" content="width=device-width, initial-scale=1.0"/>'
|
||||
),
|
||||
raw('<link rel="icon" href="/favicon.ico" />'),
|
||||
raw(`<base href="${new URL(c.req.url).origin}/"/>`)
|
||||
),
|
||||
_c(
|
||||
"body",
|
||||
{
|
||||
class:
|
||||
"font-sans bg-[#f9fafd] text-gray-800 antialiased flex flex-col",
|
||||
},
|
||||
_c(
|
||||
StreamingContext,
|
||||
{ value: { scriptNonce: "random-nonce-value" } },
|
||||
_c(
|
||||
Suspense,
|
||||
{ fallback: _c("div", { class: "loading" }, raw("Loading...")) },
|
||||
raw(appStream.getReader())
|
||||
)
|
||||
),
|
||||
_c("script", {
|
||||
dangerouslySetInnerHTML: {
|
||||
__html: `window.__SSR_STATE__ = ${JSON.stringify(
|
||||
JSON.stringify(c.get("ssrContext") || {})
|
||||
)};`,
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
)}`;
|
||||
return renderToReadableStream(body);
|
||||
}
|
||||
function _c(
|
||||
tag: string | FC<any>,
|
||||
props: any,
|
||||
...children: (JSXNode | HtmlEscapedCallback | HtmlEscapedString | null)[]
|
||||
): JSXNode {
|
||||
return jsx(tag, props, ...(children as any));
|
||||
}
|
||||
Reference in New Issue
Block a user