develop-updateui #1

Merged
lethdat merged 78 commits from develop-updateui into master 2026-04-02 05:59:23 +00:00
11 changed files with 488 additions and 465 deletions
Showing only changes of commit ff1d4902bc - Show all commits

426
AGENTS.md
View File

@@ -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 ## 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 ### Key Characteristics
- **Type**: ES Module JavaScript/TypeScript project
- **Package Manager**: Bun (uses `bun.lock`) - **Type**: Full-stack web application with SSR
- **Primary Language**: TypeScript
- **Package Manager**: Bun (evident from `bun.lock`)
- **Deployment Target**: Cloudflare Workers
## Technology Stack ## Technology Stack
| Category | Technology | | Category | Technology | Version |
|----------|------------| |----------|------------|---------|
| **Framework** | Vue 3.5+ with Composition API | | Framework | Vue | 3.5.27 |
| **Rendering** | SSR (Server-Side Rendering) with Vue's `createSSRApp` | | Router | Vue Router | 5.0.2 |
| **Router** | Vue Router 5 with SSR-aware history | | Server Framework | Hono | 4.11.7 |
| **Server** | Hono framework on Cloudflare Workers | | Build Tool | Vite | 7.3.1 |
| **Build Tool** | Vite 7 with custom SSR plugin | | CSS Framework | UnoCSS | 66.6.0 |
| **Styling** | UnoCSS (Tailwind-like utility-first CSS) | | UI Components | PrimeVue | 4.5.4 |
| **UI Components** | PrimeVue 4 with Aura theme | | State Management | Pinia | 3.0.4 |
| **State Management** | Pinia 3 + Pinia Colada for server state | | Server State | Pinia Colada | 0.21.2 |
| **HTTP Client** | Auto-generated from OpenAPI/Swagger spec | | Meta/SEO | @unhead/vue | 2.1.2 |
| **Head Management** | @unhead/vue for SEO | | Utilities | VueUse | 14.2.0 |
| **Icons** | Custom Vue icon components | | Validation | Zod | 4.3.6 |
| **Validation** | Zod v4 | | Deployment | Wrangler | 4.62.0 |
| **Utils** | VueUse, clsx, tailwind-merge |
## Project Structure ## Project Structure
``` ```
.
├── src/ ├── src/
│ ├── api/ # API client and HTTP adapters │ ├── api/ # API client and HTTP adapters
│ │ ├── client.ts # Auto-generated API client from Swagger │ │ ├── client.ts # Auto-generated API client from OpenAPI spec
│ │ ├── httpClientAdapter.client.ts # Browser fetch adapter │ │ ├── httpClientAdapter.client.ts # Client-side fetch adapter
│ │ ── httpClientAdapter.server.ts # SSR fetch adapter with cookie forwarding │ │ ── httpClientAdapter.server.ts # Server-side fetch adapter
│ │ └── rpc/ # RPC utilities
│ ├── client.ts # Client entry point (hydration) │ ├── client.ts # Client entry point (hydration)
│ ├── components/ # Vue components (auto-imported) │ ├── components/ # Vue components
│ │ ├── icons/ # Custom SVG icon components
│ │ ├── dashboard/ # Dashboard-specific 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 │ ├── composables/ # Vue composables
│ │ └── useUploadQueue.ts # File upload queue management │ │ └── useUploadQueue.ts # Upload queue management
│ ├── index.tsx # Server entry point (Hono app) │ ├── index.tsx # Server entry point (Hono app)
│ ├── lib/ # Utility libraries │ ├── lib/ # Utility libraries
│ │ ├── utils.ts # Helper functions (cn, formatBytes, etc.) │ │ ├── constants.ts # Application constants
│ │ ├── liteMqtt.ts # MQTT client for real-time notifications │ │ ├── directives/ # Custom Vue directives
│ │ ├── swr/ # SWR cache implementation │ │ ├── hoc/ # Higher-order components
│ │ ── directives/ # Vue custom directives │ │ ── 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 │ ├── main.ts # App factory function
│ ├── routes/ # Route components │ ├── mocks/ # Mock data for development
│ ├── auth/ # Login, signup, forgot password │ ├── routes/ # Route components (page components)
│ │ ├── home/ # Landing, terms, privacy │ │ ├── auth/ # Authentication pages
│ │ ├── notification/ # Notification center │ │ ├── home/ # Public pages (landing, terms, privacy)
│ │ ├── notification/ # Notification page
│ │ ├── overview/ # Dashboard overview │ │ ├── overview/ # Dashboard overview
│ │ ├── plans/ # Payments & plans │ │ ├── plans/ # Payments & plans
│ │ ├── profile/ # User profile │ │ ├── profile/ # User profile
│ │ ├── upload/ # Video upload interface │ │ ├── upload/ # Video upload
│ │ ├── video/ # Video list and detail │ │ ├── video/ # Video management
│ │ ── index.ts # Router configuration │ │ ── index.ts # Router configuration
├── server/ # Server-specific modules │ └── NotFound.vue # 404 page
│ ├── server/ # Server-side utilities
│ │ └── modules/
│ │ └── merge.ts # Video chunk merge logic
│ ├── stores/ # Pinia stores │ ├── stores/ # Pinia stores
│ │ └── auth.ts # Authentication state │ │ └── auth.ts # Authentication store
│ ├── type.d.ts # TypeScript declarations │ ├── type.d.ts # TypeScript declarations
│ └── worker/ # Worker utilities │ └── worker/ # Worker utilities
├── public/ # Static assets (favicons, etc.) │ ├── html.ts
├── docs.json # OpenAPI/Swagger specification │ └── ssrLayout.ts
├── package.json # Dependencies and scripts ├── bootstrap_btn.ts # Bootstrap button preset for UnoCSS
├── tsconfig.json # TypeScript configuration ├── ssrPlugin.ts # Custom Vite SSR plugin
├── vite.config.ts # Vite + Cloudflare plugin config
├── wrangler.jsonc # Cloudflare Workers configuration
├── uno.config.ts # UnoCSS configuration ├── uno.config.ts # UnoCSS configuration
├── ssrPlugin.ts # Custom SSR build plugin ├── vite.config.ts # Vite configuration
├── bootstrap_btn.ts # Custom UnoCSS preset for Bootstrap buttons ├── wrangler.jsonc # Cloudflare Workers configuration
├── auto-imports.d.ts # Generated auto-import declarations ├── tsconfig.json # TypeScript configuration
── components.d.ts # Generated component declarations ── 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 ## Build and Development Commands
@@ -85,10 +104,10 @@ This document provides comprehensive guidance for AI coding agents working on th
# Install dependencies # Install dependencies
bun install bun install
# Development server with hot reload # Start development server with hot reload
bun dev bun dev
# Production build (client + worker) # Build for production (client + worker)
bun run build bun run build
# Preview production build locally # Preview production build locally
@@ -104,45 +123,49 @@ bun run cf-typegen
bun run tail bun run tail
``` ```
> **Note**: While npm commands work (`npm run dev`, etc.), the project uses Bun as its primary package manager.
## Architecture Details ## Architecture Details
### SSR Architecture ### 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 1. **Build Order**: Client bundle is built FIRST, then the Worker bundle
2. **Manifest Injection**: Vite manifest is injected into server build for asset rendering 2. **Manifest Injection**: Vite manifest is injected into the server build for asset rendering
3. **Environment-based Module Resolution**: Different implementations for `@httpClientAdapter` and `@liteMqtt` based on SSR context 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 - **Server**: `src/index.tsx` - Hono app that renders Vue SSR stream
- **Client**: `src/client.ts` - Hydrates the SSR-rendered app - **Client**: `src/client.ts` - Hydrates the SSR-rendered application
- **Factory**: `src/main.ts` - Creates app instance (used by both) - **App Factory**: `src/main.ts` - Creates the Vue app instance (used by both)
### Module Aliases ### State Management with SSR
| 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
Uses **Pinia Colada** for server state with SSR hydration: Uses **Pinia Colada** for server state with SSR hydration:
- Queries are fetched server-side and serialized to `window.__APP_DATA__` - Server-side queries are fetched and serialized to `window.__APP_DATA__`
- Client hydrates the query cache on startup via `hydrateQueryCache()` - Client hydrates the query cache via `hydrateQueryCache()`
- Pinia state is similarly serialized and restored via `PiniaSharedState` plugin - 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 ### 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`) - Uses `customFetch` adapter that differs between client/server
- **Server Adapter** (`httpClientAdapter.server.ts`): Forwards cookies, merges headers, calls backend API - **Server adapter** (`httpClientAdapter.server.ts`): Forwards cookies, merges headers, proxies to `api.pipic.fun`
- **Client Adapter** (`httpClientAdapter.client.ts`): Standard fetch with credentials - **Client adapter** (`httpClientAdapter.client.ts`): Standard fetch with credentials
- **Proxy Route**: `/r/*` paths proxy to `https://api.pipic.fun` - API proxy route: `/r/*` paths proxy to `https://api.pipic.fun`
### Routing Structure ### Routing Structure
@@ -150,22 +173,21 @@ Routes are defined in `src/routes/index.ts` with three main layout groups:
1. **Public** (`/`): Landing page, terms, privacy 1. **Public** (`/`): Landing page, terms, privacy
2. **Auth** (`/login`, `/sign-up`, `/forgot`): Authentication pages (redirects if logged in) 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 - `/overview` - Main dashboard
- `/upload` - Video upload - `/upload` - Video upload with queue management
- `/video` - Video list - `/video` - Video list
- `/video/:id` - Video detail/edit - `/video/:id` - Video detail/edit
- `/payments-and-plans` - Billing - `/payments-and-plans` - Billing management
- `/notification` - Notifications - `/notification`, `/profile` - User settings
- `/profile` - User settings
Route meta supports `@unhead/vue` for SEO: Route meta supports `@unhead/vue` for SEO:
```typescript ```ts
meta: { meta: {
head: { head: {
title: "Page Title", title: "Page Title",
meta: [{ name: "description", content: "..." }] meta: [{ name: "description", content: "..." }]
} }
} }
``` ```
@@ -173,137 +195,166 @@ meta: {
Configuration in `uno.config.ts`: Configuration in `uno.config.ts`:
- **Presets**: Wind4 (Tailwind), Typography, Attributify, Bootstrap buttons - **Presets**: Wind4 (Tailwind-like), Typography, Attributify, Bootstrap buttons
- **Custom Colors**: - **Custom Colors**:
- `primary`: #14a74b (green) - `primary` (#14a74b)
- `accent`: #14a74b - `secondary` (#fd7906)
- `secondary`: #fd7906 (orange) - `accent`, `success`, `info`, `warning`, `danger`
- `success`, `info`, `warning`, `danger`
- **Shortcuts**: `press-animated` for button press effects - **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): Use `cn()` from `src/lib/utils.ts` for conditional class merging (combines `clsx` + `tailwind-merge`).
```typescript
import { cn } from '@/lib/utils';
const className = cn('base-class', condition && 'conditional-class');
```
### Component Auto-Import ### 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` - 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` - 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 ### Authentication Flow
- `useAuthStore` manages auth state with cookie-based sessions - `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 - `beforeEach` router guard redirects unauthenticated users from protected routes
- MQTT client connects on user login for real-time notifications - MQTT client connects on user login for real-time notifications
### File Upload Architecture ### File Upload Architecture
Upload queue (`src/composables/useUploadQueue.ts`): Upload queue (`src/composables/useUploadQueue.ts`):
- Supports both local files and remote URLs - Supports both local files and remote URLs
- Presigned POST URLs fetched from API - 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 - 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`) ## Environment Configuration
- **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`)
### Component Patterns ### Cloudflare Worker Bindings
Use Composition API with `<script setup>`: Configured in `wrangler.jsonc`:
```vue ```json
<script setup lang="ts"> {
// Auto-imported: ref, computed, onMounted, etc. "name": "holistream",
const props = defineProps<{ "compatibility_date": "2025-08-03",
video: ModelVideo; "compatibility_flags": ["nodejs_compat"],
}>(); "observability": { ... }
}
const emit = defineEmits<{
delete: [id: string];
}>();
// Use composables
const auth = useAuthStore();
const route = useRoute();
</script>
``` ```
### 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 - 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` Create `.dev.vars` for local development secrets (do not commit):
2. Deploy: `bun run deploy`
3. Monitor logs: `bun run tail` ```
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 ## Security Considerations
- **Cookie-based Auth**: Session cookies are forwarded between client and API 1. **Cookie Security**: Cookies are httpOnly, secure, and sameSite
- **CORS**: Configured in Hono middleware 2. **CORS**: Configured via Hono's CORS middleware
- **CSRF**: Protected by same-origin cookie policy 3. **API Proxy**: Backend API is never exposed directly to the browser; all requests go through `/r/*` proxy
- **Secrets**: Never commit secrets to code; use Wrangler secrets management 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 | | Purpose | Path |
|---------|------| |---------|------|
@@ -318,11 +369,6 @@ Cloudflare Worker bindings (configured in `wrangler.jsonc`):
| Wrangler config | `wrangler.jsonc` | | Wrangler config | `wrangler.jsonc` |
| Vite config | `vite.config.ts` | | Vite config | `vite.config.ts` |
## Development Notes ---
- Always use `customFetch` from `@httpClientAdapter` for API calls, never raw fetch *This document was generated for AI coding agents. For human contributors, see README.md.*
- 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

View File

@@ -1,161 +1,24 @@
import { serializeQueryCache } from '@pinia/colada';
import { renderSSRHead } from '@unhead/vue/server';
import { Hono } from 'hono'; 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/')) { import { setupMiddlewares } from './server/middlewares/setup';
return await next() import { apiProxyMiddleware } from './server/middlewares/apiProxy';
} import { registerWellKnownRoutes } from './server/routes/wellKnown';
const url = new URL(c.req.url) import { registerMergeRoutes } from './server/routes/merge';
url.host = baseAPIURL.replace(/^https?:\/\//, '') import { registerManifestRoutes } from './server/routes/manifest';
url.protocol = 'https:' import { registerSSRRoutes } from './server/routes/ssr';
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");
return fetch(url.toString(), { const app = new Hono();
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 manifest = createManifest(filename, chunks) // Global middlewares
await saveManifest(manifest) setupMiddlewares(app);
return c.json({ // API proxy middleware (handles /r/*)
status: 'ok', app.use(apiProxyMiddleware);
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 + "'/>");
await renderSSRHead(head).then((headString) => stream.write(headString.headTags.replace(/\n/g, ""))); // Routes
// 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>`); registerWellKnownRoutes(app);
await stream.write(`<link rel="preconnect" href="https://fonts.googleapis.com">`); registerMergeRoutes(app);
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">`); registerManifestRoutes(app);
await stream.write('<link rel="icon" href="/favicon.ico" />'); registerSSRRoutes(app);
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",
};
const ESCAPE_REGEX = /[&><\u2028\u2029]/g; export default app;
function htmlEscape(str: string): string {
return str.replace(ESCAPE_REGEX, (match) => ESCAPE_LOOKUP[match]);
}
export default app

View 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'
});
}

View 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();
});
}

View 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);
});
}

View 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
View 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>");
});
});
}

View 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 });
});
}

View 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]);
}

View File

@@ -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)
}

View File

@@ -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));
}