1 Commits

Author SHA1 Message Date
02247f9018 feat(auth): integrate Firebase authentication and update auth flow
- Added Firebase authentication methods for login, signup, and password reset.
- Replaced mock user database with Firebase user management.
- Updated auth store to handle Firebase user state and authentication.
- Implemented middleware for Firebase authentication in RPC routes.
- Enhanced error handling and user feedback with toast notifications.
- Added Toast component for user notifications in the UI.
- Updated API client to include authorization headers for authenticated requests.
- Removed unused CSRF token logic and related code.
2026-01-16 02:55:41 +07:00
91 changed files with 1533 additions and 4433 deletions

View File

@@ -1,67 +0,0 @@
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# Build outputs
dist
build
.rsbuild
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDE and editor files
.vscode
.idea
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Git
.git
.gitignore
# Docker
Dockerfile
.dockerignore
docker-compose.yml
# Documentation
README.md
*.md
# Test files
coverage
.coverage
.nyc_output
test
tests
__tests__
*.test.js
*.test.ts
*.spec.js
*.spec.ts
# Linting
.eslintrc*
.prettierrc*
.stylelintrc*
# Other
.husky

View File

@@ -1,39 +0,0 @@
# ---------- Builder stage ----------
FROM oven/bun:1.3.5-alpine AS builder
WORKDIR /app
# Copy lockfiles & package.json
COPY package*.json ./
COPY bun.lockb* ./
COPY yarn.lock* ./
COPY pnpm-lock.yaml* ./
# Install dependencies (cached)
RUN --mount=type=cache,target=/root/.bun bun install
# Copy source
COPY . .
# Build app (RSBuild output -> dist)
RUN bun run build
# ---------- Production stage ----------
FROM oven/bun:1.3.5-alpine AS production
WORKDIR /app
# Copy built files
COPY --from=builder /app/dist ./dist
ENV NODE_ENV=production
# Expose port
EXPOSE 3000
# Optional health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:3000/ || exit 1
# Run Bun with fallback install (auto resolves missing deps)
CMD [ "bun", "--bun", "dist" ]

152
build.ts
View File

@@ -1,152 +0,0 @@
#!/usr/bin/env bun
import { existsSync } from "fs";
import { rm } from "fs/promises";
import path from "path";
if (process.argv.includes("--help") || process.argv.includes("-h")) {
console.log(`
🏗️ Bun Build Script
Usage: bun run build.ts [options]
Common Options:
--outdir <path> Output directory (default: "dist")
--minify Enable minification (or --minify.whitespace, --minify.syntax, etc)
--sourcemap <type> Sourcemap type: none|linked|inline|external
--target <target> Build target: browser|bun|node
--format <format> Output format: esm|cjs|iife
--splitting Enable code splitting
--packages <type> Package handling: bundle|external
--public-path <path> Public path for assets
--env <mode> Environment handling: inline|disable|prefix*
--conditions <list> Package.json export conditions (comma separated)
--external <list> External packages (comma separated)
--banner <text> Add banner text to output
--footer <text> Add footer text to output
--define <obj> Define global constants (e.g. --define.VERSION=1.0.0)
--help, -h Show this help message
Example:
bun run build.ts --outdir=dist --minify --sourcemap=linked --external=react,react-dom
`);
process.exit(0);
}
const toCamelCase = (str: string): string => str.replace(/-([a-z])/g, g => g[1].toUpperCase());
const parseValue = (value: string): any => {
if (value === "true") return true;
if (value === "false") return false;
if (/^\d+$/.test(value)) return parseInt(value, 10);
if (/^\d*\.\d+$/.test(value)) return parseFloat(value);
if (value.includes(",")) return value.split(",").map(v => v.trim());
return value;
};
function parseArgs(): Partial<Bun.BuildConfig> & { [key: string]: any } {
const config: Partial<Bun.BuildConfig> & { [key: string]: any } = {};
const args = process.argv.slice(2);
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === undefined) continue;
if (!arg.startsWith("--")) continue;
if (arg.startsWith("--no-")) {
const key = toCamelCase(arg.slice(5));
config[key] = false;
continue;
}
if (!arg.includes("=") && (i === args.length - 1 || args[i + 1]?.startsWith("--"))) {
const key = toCamelCase(arg.slice(2));
config[key] = true;
continue;
}
let key: string;
let value: string;
if (arg.includes("=")) {
[key, value] = arg.slice(2).split("=", 2) as [string, string];
} else {
key = arg.slice(2);
value = args[++i] ?? "";
}
key = toCamelCase(key);
if (key.includes(".")) {
const [parentKey, childKey] = key.split(".");
config[parentKey] = config[parentKey] || {};
config[parentKey][childKey] = parseValue(value);
} else {
config[key] = parseValue(value);
}
}
return config;
}
const formatFileSize = (bytes: number): string => {
const units = ["B", "KB", "MB", "GB"];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(2)} ${units[unitIndex]}`;
};
console.log("\n🚀 Starting build process...\n");
const cliConfig = parseArgs();
const outdir = cliConfig.outdir || path.join(process.cwd(), "dist");
// if (existsSync(outdir)) {
// console.log(`🗑️ Cleaning previous build at ${outdir}`);
// await rm(outdir, { recursive: true, force: true });
// }
const start = performance.now();
// const entrypoints = [...new Bun.Glob("**.html").scanSync("src")]
// .map(a => path.resolve("src", a))
// .filter(dir => !dir.includes("node_modules"));
const entrypoints = [path.resolve("dist/server/index.js")];
console.log(`📄 Found ${entrypoints.length} HTML ${entrypoints.length === 1 ? "file" : "files"} to process\n`);
const result = await Bun.build({
entrypoints,
outdir,
plugins: [],
minify: false,
// target: "browser",
target: "bun",
// sourcemap: "linked",
sourcemap: false,
define: {
"process.env.NODE_ENV": JSON.stringify("production"),
},
external: ["@nestjs/microservices",'@nestjs/platform-express', "@nestjs/websockets", "class-transformer", "class-validator"],
...cliConfig,
});
const end = performance.now();
const outputTable = result.outputs.map(output => ({
File: path.relative(process.cwd(), output.path),
Type: output.kind,
Size: formatFileSize(output.size),
}));
console.table(outputTable);
const buildTime = (end - start).toFixed(2);
console.log(`\n✅ Build completed in ${buildTime}ms\n`);

872
bun.lock

File diff suppressed because it is too large Load Diff

62
components.d.ts vendored
View File

@@ -12,47 +12,61 @@ export {}
/* prettier-ignore */ /* prettier-ignore */
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
Add: typeof import('./src/client/components/icons/Add.vue')['default'] Add: typeof import('./src/components/icons/Add.vue')['default']
Bell: typeof import('./src/client/components/icons/Bell.vue')['default'] AddFilled: typeof import('./src/components/icons/AddFilled.vue')['default']
Bell: typeof import('./src/components/icons/Bell.vue')['default']
BellFilled: typeof import('./src/components/icons/BellFilled.vue')['default']
Button: typeof import('primevue/button')['default'] Button: typeof import('primevue/button')['default']
Checkbox: typeof import('primevue/checkbox')['default'] Checkbox: typeof import('primevue/checkbox')['default']
CheckIcon: typeof import('./src/client/components/icons/CheckIcon.vue')['default'] CheckIcon: typeof import('./src/components/icons/CheckIcon.vue')['default']
Credit: typeof import('./src/client/components/icons/Credit.vue')['default'] Credit: typeof import('./src/components/icons/Credit.vue')['default']
DashboardLayout: typeof import('./src/client/components/DashboardLayout.vue')['default'] DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default']
Home: typeof import('./src/client/components/icons/Home.vue')['default'] Home: typeof import('./src/components/icons/Home.vue')['default']
HomeFilled: typeof import('./src/components/icons/HomeFilled.vue')['default']
InputText: typeof import('primevue/inputtext')['default'] InputText: typeof import('primevue/inputtext')['default']
Layout: typeof import('./src/client/components/icons/Layout.vue')['default'] Layout: typeof import('./src/components/icons/Layout.vue')['default']
LayoutFilled: typeof import('./src/components/icons/LayoutFilled.vue')['default']
Message: typeof import('primevue/message')['default'] Message: typeof import('primevue/message')['default']
Password: typeof import('primevue/password')['default'] Password: typeof import('primevue/password')['default']
RootLayout: typeof import('./src/client/components/RootLayout.vue')['default'] RootLayout: typeof import('./src/components/RootLayout.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
TestIcon: typeof import('./src/client/components/icons/TestIcon.vue')['default'] TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
Upload: typeof import('./src/client/components/icons/Upload.vue')['default'] Toast: typeof import('primevue/toast')['default']
Video: typeof import('./src/client/components/icons/Video.vue')['default'] Upload: typeof import('./src/components/icons/Upload.vue')['default']
VueHead: typeof import('./src/client/components/VueHead.tsx')['default'] UploadFilled: typeof import('./src/components/icons/UploadFilled.vue')['default']
Video: typeof import('./src/components/icons/Video.vue')['default']
VideoFilled: typeof import('./src/components/icons/VideoFilled.vue')['default']
VueHead: typeof import('./src/components/VueHead.tsx')['default']
} }
} }
// For TSX support // For TSX support
declare global { declare global {
const Add: typeof import('./src/client/components/icons/Add.vue')['default'] const Add: typeof import('./src/components/icons/Add.vue')['default']
const Bell: typeof import('./src/client/components/icons/Bell.vue')['default'] const AddFilled: typeof import('./src/components/icons/AddFilled.vue')['default']
const Bell: typeof import('./src/components/icons/Bell.vue')['default']
const BellFilled: typeof import('./src/components/icons/BellFilled.vue')['default']
const Button: typeof import('primevue/button')['default'] const Button: typeof import('primevue/button')['default']
const Checkbox: typeof import('primevue/checkbox')['default'] const Checkbox: typeof import('primevue/checkbox')['default']
const CheckIcon: typeof import('./src/client/components/icons/CheckIcon.vue')['default'] const CheckIcon: typeof import('./src/components/icons/CheckIcon.vue')['default']
const Credit: typeof import('./src/client/components/icons/Credit.vue')['default'] const Credit: typeof import('./src/components/icons/Credit.vue')['default']
const DashboardLayout: typeof import('./src/client/components/DashboardLayout.vue')['default'] const DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default']
const Home: typeof import('./src/client/components/icons/Home.vue')['default'] const Home: typeof import('./src/components/icons/Home.vue')['default']
const HomeFilled: typeof import('./src/components/icons/HomeFilled.vue')['default']
const InputText: typeof import('primevue/inputtext')['default'] const InputText: typeof import('primevue/inputtext')['default']
const Layout: typeof import('./src/client/components/icons/Layout.vue')['default'] const Layout: typeof import('./src/components/icons/Layout.vue')['default']
const LayoutFilled: typeof import('./src/components/icons/LayoutFilled.vue')['default']
const Message: typeof import('primevue/message')['default'] const Message: typeof import('primevue/message')['default']
const Password: typeof import('primevue/password')['default'] const Password: typeof import('primevue/password')['default']
const RootLayout: typeof import('./src/client/components/RootLayout.vue')['default'] const RootLayout: typeof import('./src/components/RootLayout.vue')['default']
const RouterLink: typeof import('vue-router')['RouterLink'] const RouterLink: typeof import('vue-router')['RouterLink']
const RouterView: typeof import('vue-router')['RouterView'] const RouterView: typeof import('vue-router')['RouterView']
const TestIcon: typeof import('./src/client/components/icons/TestIcon.vue')['default'] const TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
const Upload: typeof import('./src/client/components/icons/Upload.vue')['default'] const Toast: typeof import('primevue/toast')['default']
const Video: typeof import('./src/client/components/icons/Video.vue')['default'] const Upload: typeof import('./src/components/icons/Upload.vue')['default']
const VueHead: typeof import('./src/client/components/VueHead.tsx')['default'] const UploadFilled: typeof import('./src/components/icons/UploadFilled.vue')['default']
const Video: typeof import('./src/components/icons/Video.vue')['default']
const VideoFilled: typeof import('./src/components/icons/VideoFilled.vue')['default']
const VueHead: typeof import('./src/components/VueHead.tsx')['default']
} }

View File

@@ -2,55 +2,45 @@
"name": "holistream", "name": "holistream",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "bunx --bun vite", "dev": "vite",
"build": "bunx --bun vite build && bun run build.ts", "build": "vite build",
"preview": "bunx --bun vite preview" "preview": "vite preview",
"deploy": "wrangler deploy",
"cf-typegen": "wrangler types --env-interface CloudflareBindings"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.966.0", "@aws-sdk/client-s3": "^3.946.0",
"@aws-sdk/s3-presigned-post": "^3.966.0", "@aws-sdk/s3-presigned-post": "^3.946.0",
"@aws-sdk/s3-request-presigner": "^3.966.0", "@aws-sdk/s3-request-presigner": "^3.946.0",
"@hiogawa/tiny-rpc": "^0.2.3-pre.18", "@hiogawa/tiny-rpc": "^0.2.3-pre.18",
"@hiogawa/utils": "^1.7.0", "@hiogawa/utils": "^1.7.0",
"@nestjs/common": "^11.1.11",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.11",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@primeuix/themes": "^2.0.2", "@primeuix/themes": "^2.0.2",
"@primevue/forms": "^4.5.4", "@primevue/forms": "^4.5.4",
"@unhead/vue": "^2.1.1", "@unhead/vue": "^2.1.1",
"@vueuse/core": "^14.1.0", "@vueuse/core": "^14.1.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"firebase": "^12.8.0", "firebase": "^12.8.0",
"firebase-admin": "^13.6.0",
"hono": "^4.11.3", "hono": "^4.11.3",
"is-mobile": "^5.0.0", "is-mobile": "^5.0.0",
"nestjs-zod": "^5.1.1",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"primevue": "^4.5.4", "primevue": "^4.5.4",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"vue": "^3.5.26", "vue": "^3.5.26",
"vue-router": "^4.6.4", "vue-router": "^4.6.4",
"zod": "^4.3.5" "zod": "^4.3.2"
}, },
"devDependencies": { "devDependencies": {
"@hattip/adapter-node": "^0.0.49", "@cloudflare/vite-plugin": "^1.17.1",
"@hono/node-server": "^1.19.8",
"@primevue/auto-import-resolver": "^4.5.4", "@primevue/auto-import-resolver": "^4.5.4",
"@types/bun": "^1.3.5", "@types/node": "^25.0.3",
"@types/node": "^25.0.5",
"@types/passport-google-oauth20": "^2.0.17",
"@types/passport-jwt": "^4.0.1",
"@vitejs/plugin-vue": "^6.0.3", "@vitejs/plugin-vue": "^6.0.3",
"@vitejs/plugin-vue-jsx": "^5.1.3", "@vitejs/plugin-vue-jsx": "^5.1.3",
"unocss": "^66.5.12", "unocss": "^66.5.12",
"unplugin-auto-import": "^20.3.0", "unplugin-auto-import": "^20.3.0",
"unplugin-vue-components": "^30.0.0", "unplugin-vue-components": "^30.0.0",
"vite": "^7.3.1", "vite": "^7.3.0",
"vite-ssr-components": "^0.5.2" "vite-ssr-components": "^0.5.2",
"wrangler": "^4.54.0"
} }
} }

View File

@@ -1,107 +0,0 @@
// import type { SourceCodeTransformer } from '@unocss/core'
// import { escapeRegExp, expandVariantGroup } from '@unocss/core'
import { SourceCodeTransformer, escapeRegExp, expandVariantGroup } from 'unocss'
export const defaultChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
export function charCombinations(chars: string = defaultChars) {
const combination = [-1]
const charsLastIdx = chars.length - 1
const resetFromIndex = (idx: number) => {
for (let i = idx; i < combination.length; i++)
combination[i] = 0
}
return () => {
for (let i = combination.length - 1; i >= 0; i--) {
if (combination[i] !== charsLastIdx) {
combination[i] += 1
resetFromIndex(i + 1)
break
}
if (i === 0) {
resetFromIndex(0)
combination.push(0)
break
}
}
return "_"+combination.map(i => chars[i]).join('')
}
}
export interface CompileClassOptions {
/**
* Special prefix to avoid UnoCSS transforming your code.
* @default ':uno:'
*/
trigger?: string
/**
* Hash function
*/
hashFn?: () => string
/**
* The layer name of generated rules
*/
layer?: string
}
export default function transformerClassnamesMinifier(options: CompileClassOptions = {}): SourceCodeTransformer {
const {
trigger = ':uno:',
hashFn = charCombinations(),
} = options
const compiledClass = new Map()
const regexp = RegExp(`(["'\`])${escapeRegExp(trigger)}${trigger ? '\\s' : ''}(.*?)\\1`, 'g')
return {
name: 'name',
enforce: 'pre',
async transform(s, _id, { uno }) {
if(s.original.includes('p-button') || s.original.includes('p-component') || s.original.includes('p-button-secondary')) {
}
const matches = [...s.original.matchAll(regexp)]
if (!matches.length)
return
// console.log("s.original", s.original)
for (const match of matches) {
const body = match.length ? expandVariantGroup(match[2].trim()) : ''
const start = match.index!
const replacements = []
const result = await Promise.all(body.split(/\s+/).filter(Boolean).map(async i => [i, !!await uno.parseToken(i)] as const))
const known = result.filter(([, matched]) => matched).map(([i]) => i)
const unknown = result.filter(([, matched]) => !matched).map(([i]) => i)
replacements.push(...unknown)
known.forEach((i) => {
const compiled = compiledClass.get(i)
if (compiled)
return replacements.push(compiled)
const className = hashFn()
compiledClass.set(i, className)
if (options.layer)
uno.config.shortcuts.push([className, i, { layer: options.layer }])
else
uno.config.shortcuts.push([className, i])
replacements.push(className)
})
s.overwrite(start + 1, start + match[0].length - 1, replacements.join(' '))
}
},
}
}

View File

@@ -1,64 +0,0 @@
import type { Plugin, ViteDevServer } from 'vite'
import fs from 'node:fs'
import path from 'node:path'
import { bold, cyan, green } from "colorette";
export default function myDevtool(): Plugin {
let server: ViteDevServer
return {
name: 'vite-plugin-hono_di',
apply: 'serve',
configureServer(_server) {
server = _server
const baseUrl = '__hono_di'
// API cho UI
// server.middlewares.use(`/${baseUrl}/api`, (req, res) => {
// res.setHeader('Content-Type', 'application/json')
// res.end(JSON.stringify({
// time: Date.now(),
// message: 'Hello from devtool'
// }))
// })
server.middlewares.use(`/${baseUrl}/api/tree`, async (_req, res) => {
try {
if (!cached) cached = await getTree(server);
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify(cached));
} catch (e: any) {
res.statusCode = 500;
res.end(JSON.stringify({ error: String(e?.message ?? e) }));
}
});
server.middlewares.use(`/${baseUrl}/api/tree`, async (_req, res) => {
try {
if (!cached) cached = await getTree(server);
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify(cached));
} catch (e: any) {
res.statusCode = 500;
res.end(JSON.stringify({ error: String(e?.message ?? e) }));
}
});
// Serve UI
server.middlewares.use(`/${baseUrl}`, (req, res) => {
const html = fs.readFileSync(
path.resolve(__dirname, 'ui/index.html'),
'utf-8'
)
res.setHeader('Content-Type', 'text/html')
res.end(html)
})
const _printUrls = server.printUrls;
const colorUrl = (url) => cyan(url.replace(/:(\d+)\//, (_, port) => `:${bold(port)}/`));
server.printUrls = () => {
_printUrls();
for (const localUrl of server.resolvedUrls?.local ?? []) {
const appUrl = localUrl.endsWith("/") ? localUrl : `${localUrl}/`;
const inspectorUrl = `${server.config.base && appUrl.endsWith(server.config.base) ? appUrl.slice(0, -server.config.base.length) : appUrl.slice(0, -1)}/${baseUrl}/`;
console.log(` ${green("➜")} ${bold("Hono-Di devTool")}: ${colorUrl(`${inspectorUrl}`)}`);
}
};
}
}
}

View File

@@ -1,592 +0,0 @@
import type { Plugin, ViteDevServer } from "vite"
import { generate, GenerateInput, GenerateResult, GenerateType } from '@hono-di/generate';
import fs from "node:fs/promises"
import path from "node:path"
/* ------------------------ User Provided Types ------------------------ */
/* ------------------------ Utils ------------------------ */
const toPosix = (p: string) => p.split(path.sep).join("/")
const isIgnored = (p: string) =>
p.startsWith("node_modules/") ||
p.startsWith(".git/") ||
p.startsWith("dist/") ||
p.startsWith(".vite/") ||
p.includes("/.DS_Store") ||
p.includes("/.idea/") ||
p.includes("/.vscode/")
function resolveSafe(root: string, rel: string) {
const abs = path.resolve(root, rel)
const relCheck = path.relative(root, abs)
// Fix: Check if path goes outside root (starts with ..) or is absolute (different drive on win)
// This prevents partial matching vulnerabilities (e.g., /root vs /root_sibling)
if (relCheck.startsWith('..') || path.isAbsolute(relCheck)) {
throw new Error("Invalid path: Access denied")
}
return abs
}
function debounce<T extends (...args: any[]) => any>(fn: T, ms: number) {
let timer: NodeJS.Timeout
return (...args: Parameters<T>) => {
clearTimeout(timer)
timer = setTimeout(() => fn(...args), ms)
}
}
/* ------------------------ Generator Logic (Server Side) ------------------------ */
// Helper to convert "my-user" to "MyUser"
const toPascalCase = (str: string) => str.replace(/(^\w|-\w)/g, (c) => c.replace('-', '').toUpperCase());
// Helper to convert "MyUser" to "my-user"
const toKebabCase = (str: string) => str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
// Map aliases to full types for internal logic
const ALIAS_MAP: Record<string, GenerateType> = {
mo: 'module', co: 'controller', s: 'service', pr: 'provider',
cl: 'class', itf: 'interface', pi: 'pipe', gu: 'guard',
f: 'filter', itc: 'interceptor', d: 'decorator'
};
type GenerateInputBody = Omit<GenerateInput, 'type'> & { type: GenerateType[] };
/* ------------------------ Tree Builder ------------------------ */
interface TreeNode {
name: string;
path: string;
type: "file" | "dir";
children?: TreeNode[];
}
async function buildTree(root: string, dir: string): Promise<TreeNode[]> {
let entries
try { entries = await fs.readdir(dir, { withFileTypes: true }) } catch (e) { return [] }
const out: TreeNode[] = []
for (const e of entries) {
const abs = path.join(dir, e.name)
const rel = toPosix(path.relative(root, abs))
if (!rel || isIgnored(rel + (e.isDirectory() ? "/" : ""))) continue
if (e.isDirectory()) {
out.push({ name: e.name, path: rel, type: "dir", children: await buildTree(root, abs) })
} else {
out.push({ name: e.name, path: rel, type: "file" })
}
}
out.sort((a, b) => a.type !== b.type ? (a.type === "dir" ? -1 : 1) : a.name.localeCompare(b.name))
return out
}
async function getTree(server: ViteDevServer) {
const root = server.config.root
return {
rootAbs: root,
tree: { name: path.basename(root), path: "", type: "dir", children: await buildTree(root, root) } as TreeNode,
}
}
/* ------------------------ Plugin ------------------------ */
export default function fileTreeVisualizer(): Plugin {
let serverRef: ViteDevServer
let cached: any
const rebuild = debounce(async () => {
if (!serverRef) return
try {
cached = await getTree(serverRef)
serverRef.ws.send({ type: "custom", event: "filetree:update", data: cached })
} catch (e) { serverRef.config.logger.error(`[filetree] Error: ${e}`) }
}, 100)
return {
name: "vite-plugin-filetree-visualizer",
apply: "serve",
configureServer(server) {
serverRef = server
server.httpServer?.once("listening", () => {
const base = server.resolvedUrls?.local?.[0] ?? "http://localhost:5173"
setTimeout(() => server.config.logger.info(` ➜ File Tree: \x1b[36m${base}__filetree/\x1b[0m\n`), 100)
})
/* ---- Middleware ---- */
server.middlewares.use("/__filetree/", async (req, res, next) => {
if (req.originalUrl !== '/__filetree/' && !req.originalUrl?.startsWith('/__filetree/api')) return next();
if (req.originalUrl?.startsWith('/__filetree/api')) return next();
try {
const html = await server.transformIndexHtml(req.url ?? '/', UI_HTML)
res.setHeader("Content-Type", "text/html; charset=utf-8")
res.end(html)
} catch (e) { next(e) }
})
const parseBody = (req: any): Promise<any> => {
return new Promise((resolve, reject) => {
let body = ""
req.on("data", (c: any) => (body += c))
req.on("end", () => { try { resolve(JSON.parse(body)) } catch (e) { reject(e) } })
req.on("error", reject)
})
}
/* ---- API Handlers ---- */
server.middlewares.use("/__filetree/api/tree", async (_, res) => {
cached ??= await getTree(server)
res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(cached))
})
// Generate API
server.middlewares.use("/__filetree/api/generate", async (req, res) => {
try {
const input: GenerateInputBody = await parseBody(req);
server.config.logger.info(`[filetree] GENERATE ${JSON.stringify(input.type)} ${input.name}`, { timestamp: true });
const result = await Promise.all(input.type.map(async (t) => {
return new Promise<GenerateResult['operations']>(async (resolve, reject) => {
const tmpRes = generate({...input, type: t})
if (!tmpRes.success) {
return reject(tmpRes.errors?.join(", ") || "Generation failed");
}
resolve(tmpRes.operations);
});
})).then((ops) => ops.flat()).then(ops => ({ success: true, operations: ops }));
// input.type.forEach((t, i) => {
// results.operations.push(...generate({...input, type: t}).operations);
// })
// 1. Calculate Operations
// 2. Execute Operations (if not dryRun)
if (result.success && !input.dryRun) {
for (const op of result.operations) {
const absPath = resolveSafe(server.config.root, op.path);
if (op.action === 'create' || op.action === 'overwrite') {
server.config.logger.info(` - Creating: ${op.path}`, { timestamp: true });
await fs.mkdir(path.dirname(absPath), { recursive: true });
await fs.writeFile(absPath, op.content || '');
}
}
}
res.end(JSON.stringify(result));
} catch (e: any) {
server.config.logger.error(`[filetree] Generate Error: ${e.message}`, { timestamp: true });
res.statusCode = 500; res.end(JSON.stringify({ error: e.message }))
}
})
// Standard File Ops
server.middlewares.use("/__filetree/api/file/create", async (req, res) => {
try {
const { path: rel, content = "" } = await parseBody(req)
server.config.logger.info(`[filetree] CREATE FILE ${rel}`, { timestamp: true });
const abs = resolveSafe(server.config.root, rel)
await fs.mkdir(path.dirname(abs), { recursive: true })
await fs.writeFile(abs, content)
res.end(JSON.stringify({ ok: true }))
} catch (e: any) {
server.config.logger.error(`[filetree] Create File Error: ${e.message}`, { timestamp: true });
res.statusCode = 500; res.end(JSON.stringify({ error: e.message }))
}
})
server.middlewares.use("/__filetree/api/dir/create", async (req, res) => {
try {
const { path: rel } = await parseBody(req);
server.config.logger.info(`[filetree] CREATE DIR ${rel}`, { timestamp: true });
await fs.mkdir(resolveSafe(server.config.root, rel), { recursive: true });
res.end(JSON.stringify({ ok: true }))
} catch (e: any) {
server.config.logger.error(`[filetree] Create Dir Error: ${e.message}`, { timestamp: true });
res.statusCode = 500; res.end(JSON.stringify({ error: e.message }))
}
})
server.middlewares.use("/__filetree/api/delete", async (req, res) => {
try {
const { path: rel } = await parseBody(req);
server.config.logger.info(`[filetree] DELETE ${rel}`, { timestamp: true });
await fs.rm(resolveSafe(server.config.root, rel), { recursive: true, force: true });
res.end(JSON.stringify({ ok: true }))
} catch (e: any) {
server.config.logger.error(`[filetree] Delete Error: ${e.message}`, { timestamp: true });
res.statusCode = 500; res.end(JSON.stringify({ error: e.message }))
}
})
server.middlewares.use("/__filetree/api/move", async (req, res) => {
try {
const { from, to } = await parseBody(req);
server.config.logger.info(`[filetree] MOVE ${from} -> ${to}`, { timestamp: true });
const a = resolveSafe(server.config.root, from); const b = resolveSafe(server.config.root, to);
await fs.mkdir(path.dirname(b), { recursive: true }); await fs.rename(a, b);
res.end(JSON.stringify({ ok: true }))
} catch (e: any) {
server.config.logger.error(`[filetree] Move Error: ${e.message}`, { timestamp: true });
res.statusCode = 500; res.end(JSON.stringify({ error: e.message }))
}
})
server.watcher.on("all", (event, file) => { if(!isIgnored(path.relative(server.config.root, file))) rebuild() })
},
}
}
/* ------------------------ UI ------------------------ */
const UI_HTML = `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Project Explorer</title>
<!-- SweetAlert2 Dark Theme -->
<link href="https://cdn.jsdelivr.net/npm/@sweetalert2/theme-dark@4/dark.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11/dist/sweetalert2.min.js"></script>
<style>
:root { --bg: #1e1e1e; --sidebar: #252526; --text: #cccccc; --text-hover: #ffffff; --accent: #007fd4; --active: #37373d; --border: #333; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; margin: 0; background: var(--bg); color: var(--text); font-size: 13px; overflow: hidden; height: 100vh; display: flex; flex-direction: column; }
/* Icons */
svg { width: 16px; height: 16px; fill: currentColor; }
.icon-folder { color: #dcb67a; }
.icon-file { color: #519aba; }
.icon-chevron { width: 14px; height: 14px; transition: transform 0.15s; color: #888; margin-right: 2px; }
.rotate-90 { transform: rotate(90deg); }
/* Header */
header { background: var(--sidebar); padding: 10px 16px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border); height: 40px; box-sizing: border-box; }
.title { font-weight: 600; color: #fff; font-size: 14px; }
.actions { display: flex; gap: 8px; }
.btn-gen { background: #4caf50; border: none; color: white; padding: 4px 12px; border-radius: 4px; cursor: pointer; font-size: 12px; font-weight: 500; display: flex; align-items: center; gap: 4px; }
.btn-gen:hover { background: #45a049; }
.search-box { background: #3c3c3c; border: 1px solid transparent; color: white; border-radius: 4px; padding: 4px 8px; width: 200px; outline: none; font-size: 12px; }
.search-box:focus { border-color: var(--accent); }
/* Main Tree */
#app { flex: 1; overflow-y: auto; padding: 10px 0; }
ul { list-style: none; padding-left: 0; margin: 0; }
li { user-select: none; }
.row { display: flex; align-items: center; padding: 4px 16px; cursor: pointer; border-left: 2px solid transparent; white-space: nowrap; height: 22px; }
.row:hover { background: var(--active); color: var(--text-hover); }
.row.selected { background: #094771; color: #fff; border-left-color: var(--accent); }
.node-name { margin-left: 6px; }
/* Context Menu */
#context-menu { position: fixed; background: #252526; border: 1px solid #454545; box-shadow: 0 4px 12px rgba(0,0,0,0.5); border-radius: 4px; padding: 4px 0; display: none; z-index: 100; min-width: 160px; }
.menu-item { padding: 6px 16px; cursor: pointer; color: #ccc; display: flex; align-items: center; gap: 8px; }
.menu-item:hover { background: #094771; color: white; }
.separator { height: 1px; background: #454545; margin: 4px 0; }
/* Utility classes */
.hidden { display: none !important; }
/* Custom Swals */
.swal2-popup { font-size: 13px !important; border: 1px solid #454545 !important; }
.swal2-input, .swal2-select { margin: 8px auto !important; font-size: 14px !important; }
.gen-form { display: flex; flex-direction: column; gap: 10px; text-align: left; }
.gen-form label { font-weight: 600; color: #ccc; font-size: 12px; margin-bottom: 2px; }
.gen-row { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #ccc; }
/* Type Grid */
.type-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; border: 1px solid #3c3c3c; padding: 8px; border-radius: 4px; max-height: 150px; overflow-y: auto; background: #252526; }
.type-item { display: flex; align-items: center; gap: 6px; font-size: 12px; cursor: pointer; user-select: none; }
.type-item input { margin: 0; cursor: pointer; }
</style>
</head>
<body>
<header>
<div class="title">EXPLORER</div>
<div class="actions">
<button class="btn-gen" onclick="openGenerator()">⚡ Generate</button>
<input id="q" class="search-box" placeholder="Search..." autocomplete="off" />
</div>
</header>
<div id="app"></div>
<!-- Context Menu -->
<div id="context-menu">
<div class="menu-item" onclick="promptCreate('file')">📄 New File</div>
<div class="menu-item" onclick="promptCreate('dir')">📁 New Folder</div>
<div class="menu-item" onclick="openGenerator()">⚡ Generate...</div>
<div class="separator"></div>
<div class="menu-item" onclick="promptRename()">✏️ Rename</div>
<div class="menu-item" onclick="promptDelete()" style="color: #ff6b6b">🗑️ Delete</div>
</div>
<script type="module">
/* --- State --- */
let fullTree = null;
let expandedPaths = new Set(['']);
let selectedPath = null;
let contextNode = null;
const app = document.getElementById('app');
const qEl = document.getElementById('q');
const ctxMenu = document.getElementById('context-menu');
/* --- Icons --- */
const ICONS = {
chevron: '<svg viewBox="0 0 16 16"><path d="M6 4l4 4-4 4z"/></svg>',
folder: '<svg viewBox="0 0 16 16"><path d="M14.5 3H7.71l-.85-.85L6.51 2h-5C.68 2 0 2.68 0 3.5v9c0 .82.68 1.5 1.5 1.5h13c.82 0 1.5-.68 1.5-1.5v-8c0-.82-.68-1.5-1.5-1.5z"/></svg>',
file: '<svg viewBox="0 0 16 16"><path d="M13 6h-3V3H4v10h10V6zM3 2h8l3 3v9a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/></svg>'
};
/* --- API --- */
async function fetchTree(){
try {
const r = await fetch('/__filetree/api/tree');
fullTree = (await r.json()).tree;
draw();
} catch(e) { console.error(e); }
}
async function callApi(endpoint, body) {
const r = await fetch('/__filetree/api/' + endpoint, { method: 'POST', body: JSON.stringify(body) });
const res = await r.json();
if(!r.ok) throw new Error(res.error || 'Unknown error');
return res;
}
/* --- Render --- */
function filterTree(node, query) {
if (!query) return node;
const matchesSelf = node.name.toLowerCase().includes(query.toLowerCase());
if (node.type === 'file') return matchesSelf ? node : null;
const filteredChildren = (node.children || []).map(c => filterTree(c, query)).filter(Boolean);
if (matchesSelf || filteredChildren.length > 0) {
if(query) expandedPaths.add(node.path);
return { ...node, children: filteredChildren };
}
return null;
}
function renderNode(node, depth = 0) {
const isDir = node.type === 'dir';
const isExpanded = expandedPaths.has(node.path);
const isSelected = selectedPath === node.path;
const li = document.createElement('li');
const row = document.createElement('div');
row.className = \`row \${isSelected ? 'selected' : ''}\`;
row.style.paddingLeft = (depth * 12) + 'px';
row.onclick = () => {
selectedPath = node.path;
if(isDir) { isExpanded ? expandedPaths.delete(node.path) : expandedPaths.add(node.path); }
draw();
};
row.oncontextmenu = (e) => {
e.preventDefault(); e.stopPropagation();
selectedPath = node.path; contextNode = node;
showContextMenu(e.clientX, e.clientY); draw();
};
let icon = isDir ?
\`<div class="icon-chevron \${isExpanded ? 'rotate-90' : ''}">\${ICONS.chevron}</div><div class="icon-folder">\${ICONS.folder}</div>\` :
\`<div style="width:16px"></div><div class="icon-file">\${ICONS.file}</div>\`;
row.innerHTML = \`\${icon}<span class="node-name">\${node.name}</span>\`;
li.appendChild(row);
if (isDir && isExpanded && node.children) {
const ul = document.createElement('ul');
node.children.forEach(c => ul.appendChild(renderNode(c, depth + 1)));
li.appendChild(ul);
}
return li;
}
function draw() {
app.innerHTML = '';
if (!fullTree) return;
const treeToRender = filterTree(fullTree, qEl.value.trim());
if (treeToRender) { const ul = document.createElement('ul'); ul.appendChild(renderNode(treeToRender)); app.appendChild(ul); }
}
/* --- Context Menu --- */
function showContextMenu(x, y) {
ctxMenu.style.display = 'block';
const w = window.innerWidth, h = window.innerHeight;
ctxMenu.style.left = (x + ctxMenu.offsetWidth > w ? w - ctxMenu.offsetWidth : x) + 'px';
ctxMenu.style.top = (y + ctxMenu.offsetHeight > h ? h - ctxMenu.offsetHeight : y) + 'px';
}
document.addEventListener('click', () => ctxMenu.style.display = 'none');
/* --- Actions (SweetAlert2) --- */
window.promptCreate = async (type) => {
const isDir = type === 'dir';
const node = contextNode || fullTree;
const base = node.type === 'dir' ? node.path : node.path.split('/').slice(0, -1).join('/');
const { value: name } = await Swal.fire({
title: isDir ? 'New Folder' : 'New File',
input: 'text',
inputLabel: \`Inside: /\${base}\`,
inputPlaceholder: isDir ? 'folder_name' : 'file.ts',
showCancelButton: true
});
if (name) {
const fullPath = base ? \`\${base}/\${name}\` : name;
try {
await callApi(isDir ? 'dir/create' : 'file/create', { path: fullPath });
const toast = Swal.mixin({ toast: true, position: 'bottom-end', showConfirmButton: false, timer: 3000 });
toast.fire({ icon: 'success', title: 'Created successfully' });
} catch(e) { Swal.fire('Error', e.message, 'error'); }
}
};
window.promptRename = async () => {
if (!contextNode || !contextNode.path) return;
const { value: newName } = await Swal.fire({
title: 'Rename',
input: 'text',
inputValue: contextNode.name,
showCancelButton: true
});
if (newName && newName !== contextNode.name) {
const base = contextNode.path.split('/').slice(0, -1).join('/');
const to = base ? \`\${base}/\${newName}\` : newName;
try { await callApi('move', { from: contextNode.path, to }); }
catch(e) { Swal.fire('Error', e.message, 'error'); }
}
};
window.promptDelete = async () => {
if (!contextNode || !contextNode.path) return;
const result = await Swal.fire({
title: 'Are you sure?',
text: \`Delete \${contextNode.name}?\`,
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#d33',
confirmButtonText: 'Yes, delete it!'
});
if (result.isConfirmed) {
try { await callApi('delete', { path: contextNode.path }); }
catch(e) { Swal.fire('Error', e.message, 'error'); }
}
};
/* --- Generator Wizard --- */
window.openGenerator = async () => {
const node = contextNode || fullTree;
let initialPath = node ? (node.type === 'dir' ? node.path : node.path.split('/').slice(0, -1).join('/')) : '';
if (!initialPath) initialPath = 'src';
const types = [
'module', 'controller', 'service', 'provider',
'class', 'interface', 'pipe', 'guard',
'filter', 'interceptor', 'decorator'
];
const typeChecks = types.map(t => \`
<label class="type-item">
<input type="checkbox" class="type-cb" value="\${t}" \${t==='controller' || t==='service'?'checked':''}> \${t.charAt(0).toUpperCase() + t.slice(1)}
</label>
\`).join('');
const { value: formValues } = await Swal.fire({
title: 'Generate Resource',
html: \`
<div class="gen-form">
<div>
<label>Types</label>
<div class="type-grid">
\${typeChecks}
</div>
</div>
<div>
<label>Name</label>
<input id="swal-name" class="swal2-input" placeholder="e.g. user-auth" style="margin:4px 0; width:100%; box-sizing:border-box;">
</div>
<div>
<label>Path (Relative to root)</label>
<input id="swal-path" class="swal2-input" value="\${initialPath}" style="margin:4px 0; width:100%; box-sizing:border-box;">
</div>
<div class="gen-row" style="margin-top:10px">
<input type="checkbox" id="swal-flat">
<label for="swal-flat" style="margin:0;cursor:pointer">
Flat <span style="color:#888; font-weight:normal; font-size: 0.9em">(No sub-folder)</span>
</label>
</div>
<div class="gen-row">
<input type="checkbox" id="swal-spec" checked>
<label for="swal-spec" style="margin:0;cursor:pointer">
Spec <span style="color:#888; font-weight:normal; font-size: 0.9em">(Generate test file)</span>
</label>
</div>
<div class="gen-row">
<input type="checkbox" id="swal-skip-import">
<label for="swal-skip-import" style="margin:0;cursor:pointer">
Skip Import <span style="color:#888; font-weight:normal; font-size: 0.9em">(Do not import to module)</span>
</label>
</div>
<div class="gen-row">
<input type="checkbox" id="swal-force">
<label for="swal-force" style="margin:0;cursor:pointer">
Force <span style="color:#888; font-weight:normal; font-size: 0.9em">(Overwrite existing)</span>
</label>
</div>
<div class="gen-row">
<input type="checkbox" id="swal-dry">
<label for="swal-dry" style="margin:0;cursor:pointer;color:#ffab40">
Dry Run <span style="color:#ffcc80; font-weight:normal; font-size: 0.9em">(Simulate only)</span>
</label>
</div>
</div>
\`,
focusConfirm: false,
showCancelButton: true,
confirmButtonText: 'Generate',
preConfirm: () => {
const selectedTypes = Array.from(document.querySelectorAll('.type-cb:checked')).map(cb => cb.value);
return {
type: selectedTypes,
name: document.getElementById('swal-name').value,
path: document.getElementById('swal-path').value,
flat: document.getElementById('swal-flat').checked,
spec: document.getElementById('swal-spec').checked,
skipImport: document.getElementById('swal-skip-import').checked,
force: document.getElementById('swal-force').checked,
dryRun: document.getElementById('swal-dry').checked
}
}
});
if (formValues) {
if(!formValues.name) return Swal.fire('Error', 'Name is required', 'error');
if(formValues.type.length === 0) return Swal.fire('Error', 'Select at least one type', 'error');
try {
const res = await callApi('generate', formValues);
const opsHtml = res.operations.map(op => {
const color = op.action === 'create' ? '#4caf50' : '#ff9800';
return \`<div style="text-align:left; font-family:monospace; margin-top:4px">
<span style="color:\${color}; font-weight:bold">\${op.action.toUpperCase()}</span>
\${op.path.split('/').pop()}
</div>\`;
}).join('');
Swal.fire({
title: res.success ? 'Success' : 'Partial Success',
html: \`<div style="font-size:12px">\${opsHtml}</div>\`,
icon: res.success ? 'success' : 'warning'
});
} catch (e) {
Swal.fire('Error', e.message, 'error');
}
}
}
/* --- HMR --- */
if (import.meta.hot) {
import.meta.hot.on('filetree:update', d => { fullTree = d.tree; draw(); });
}
qEl.oninput = () => draw();
fetchTree();
</script>
</body>
</html>`

View File

@@ -1,148 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>File Tree Visualizer</title>
<style>
body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto; margin: 0; }
header { position: sticky; top: 0; background: #111; color: #fff; padding: 12px 14px; display:flex; gap:10px; align-items:center; }
header input { flex: 1; padding: 8px 10px; border-radius: 8px; border: 1px solid #333; background: #1b1b1b; color:#fff; }
header button { padding: 8px 10px; border-radius: 8px; border: 1px solid #333; background: #1b1b1b; color:#fff; cursor:pointer; }
main { padding: 10px 14px; }
.muted { color: #666; font-size: 12px; }
ul { list-style: none; padding-left: 16px; margin: 6px 0; }
li { margin: 2px 0; }
.row { display:flex; gap:8px; align-items:center; }
.twisty { width: 16px; text-align:center; cursor:pointer; user-select:none; }
.name { cursor: default; }
.file { color: #222; }
.dir { font-weight: 600; }
.path { color: #888; font-size: 12px; }
.hidden { display:none; }
.pill { font-size: 12px; padding: 2px 8px; border: 1px solid #333; border-radius: 999px; background:#1b1b1b; color:#ddd; }
</style>
</head>
<body>
<header>
<span class="pill">/__hono_di/</span>
<input id="q" placeholder="Filter (e.g. src/components or .ts)" />
<button id="refresh">Refresh</button>
</header>
<main>
<div class="muted" id="status">Loading…</div>
<div id="app"></div>
</main>
<script type="module">
const app = document.getElementById('app');
const statusEl = document.getElementById('status');
const qEl = document.getElementById('q');
const refreshBtn = document.getElementById('refresh');
let tree = null;
let expanded = new Set([""]); // expand root by default
function matches(node, q) {
if (!q) return true;
const hay = (node.path + "/" + node.name).toLowerCase();
return hay.includes(q);
}
function renderNode(node, q) {
const li = document.createElement('li');
const row = document.createElement('div');
row.className = 'row';
const twisty = document.createElement('div');
twisty.className = 'twisty';
const name = document.createElement('div');
name.className = 'name ' + (node.type === 'dir' ? 'dir' : 'file');
name.textContent = node.type === 'dir' ? node.name + '/' : node.name;
const pathEl = document.createElement('div');
pathEl.className = 'path';
pathEl.textContent = node.path;
row.appendChild(twisty);
row.appendChild(name);
row.appendChild(pathEl);
li.appendChild(row);
if (node.type === 'dir') {
const isOpen = expanded.has(node.path);
twisty.textContent = isOpen ? '▾' : '▸';
twisty.onclick = () => {
if (expanded.has(node.path)) expanded.delete(node.path);
else expanded.add(node.path);
draw();
};
// children
const ul = document.createElement('ul');
ul.className = isOpen ? '' : 'hidden';
const kids = node.children || [];
for (const child of kids) {
// prune theo filter: dir được giữ nếu nó hoặc con nó match
if (q) {
if (child.type === 'file') {
if (!matches(child, q)) continue;
} else {
// dir: giữ nếu match hoặc có con match
const hasMatch = matches(child, q) || (child.children || []).some(c => matches(c, q));
if (!hasMatch) continue;
expanded.add(child.path); // auto expand khi filter
}
}
ul.appendChild(renderNode(child, q));
}
li.appendChild(ul);
} else {
twisty.textContent = '·';
}
return li;
}
function draw() {
if (!tree) return;
const q = (qEl.value || '').trim().toLowerCase();
app.innerHTML = '';
const ul = document.createElement('ul');
ul.appendChild(renderNode(tree, q));
app.appendChild(ul);
statusEl.textContent = 'Updated: ' + new Date().toLocaleTimeString();
}
async function fetchTree() {
statusEl.textContent = 'Fetching…';
const res = await fetch('/__hono_di/api/tree');
tree = await res.json();
draw();
}
refreshBtn.onclick = fetchTree;
qEl.oninput = () => draw();
// Realtime via Vite HMR websocket (custom event)
try {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${location.host}`, 'vite-hmr');
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
if (msg?.type === 'custom' && msg?.event === 'filetree:update') {
tree = msg.data;
draw();
}
} catch {}
};
} catch {}
fetchTree();
</script>
</body>
</html>

View File

@@ -1,116 +0,0 @@
import type { Connect, Plugin } from "vite";
import { name as packageName } from "../package.json";
import { createMiddleware } from "@hattip/adapter-node";
import { pathToFileURL } from "url";
export function vitePluginSsrMiddleware({
entry,
preview,
mode = "ssrLoadModule",
}: {
entry: string;
preview?: string;
mode?: "ssrLoadModule" | "ModuleRunner" | "ModuleRunner-HMR";
}): Plugin {
return {
name: packageName,
apply(config, env) {
// skip client build
return Boolean(env.command === "serve" || config.build?.ssr);
},
config(config, env) {
if (env.command === "serve") {
return {
// disable builtin HTML middleware, which would rewrite `req.url` to "/index.html"
appType: "custom",
};
}
if (env.command === "build" && config.build?.ssr) {
return {
build: {
rollupOptions: {
input: {
index: entry,
},
},
},
};
}
return;
},
async configureServer(server) {
let loadModule = server.ssrLoadModule;
if (mode === "ModuleRunner" || mode === "ModuleRunner-HMR") {
const { createServerModuleRunner } = await import("vite");
const runner = createServerModuleRunner(server.environments.ssr, {
hmr: mode === "ModuleRunner-HMR" ? undefined : false,
});
loadModule = (id: string) => runner.import(id);
}
const handler: Connect.NextHandleFunction = async (req, res, next) => {
// expose ViteDevServer via request
Object.defineProperty(req, "viteDevServer", { value: server });
try {
const mod = await loadModule(entry);
await createMiddleware((ctx) => mod["default"].fetch(ctx.request))(req, res, next);
// await mod["default"](req, res, next);
} catch (e) {
next(e);
}
};
return () => server.middlewares.use(handler);
},
async configurePreviewServer(server) {
if (preview) {
const mod = await import( pathToFileURL(preview).href);
return () => server.middlewares.use(createMiddleware((ctx) => mod["default"].fetch(ctx.request)));
}
return;
},
};
}
// minimal logger inspired by
// https://github.com/koajs/logger
// https://github.com/honojs/hono/blob/25beca878f2662fedd84ed3fbf80c6a515609cea/src/middleware/logger/index.ts
export function vitePluginLogger(): Plugin {
return {
name: vitePluginLogger.name,
configureServer(server) {
return () => server.middlewares.use(loggerMiddleware());
},
configurePreviewServer(server) {
return () => server.middlewares.use(loggerMiddleware());
},
};
}
function loggerMiddleware(): Connect.NextHandleFunction {
return (req, res, next) => {
const url = new URL(req.originalUrl!, "https://test.local");
console.log(" -->", req.method, url.pathname);
const startTime = Date.now();
res.once("close", () => {
console.log(
" <--",
req.method,
url.pathname,
res.statusCode,
formatDuration(Date.now() - startTime),
);
});
next();
};
}
function formatDuration(ms: number) {
return ms < 1000 ? `${Math.floor(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
}

View File

@@ -6,6 +6,7 @@ const GET_PAYLOAD_PARAM = "payload";
export function httpClientAdapter(opts: { export function httpClientAdapter(opts: {
url: string; url: string;
pathsForGET?: string[]; pathsForGET?: string[];
headers?: () => Promise<Record<string, string>> | Record<string, string>;
}): TinyRpcClientAdapter { }): TinyRpcClientAdapter {
return { return {
send: async (data) => { send: async (data) => {
@@ -14,12 +15,18 @@ export function httpClientAdapter(opts: {
const method = opts.pathsForGET?.includes(data.path) const method = opts.pathsForGET?.includes(data.path)
? "GET" ? "GET"
: "POST"; : "POST";
const extraHeaders = opts.headers ? await opts.headers() : {};
let req: Request; let req: Request;
if (method === "GET") { if (method === "GET") {
req = new Request( req = new Request(
url + url +
"?" + "?" +
new URLSearchParams({ [GET_PAYLOAD_PARAM]: payload }) new URLSearchParams({ [GET_PAYLOAD_PARAM]: payload }),
{
headers: extraHeaders
}
); );
} else { } else {
req = new Request(url, { req = new Request(url, {
@@ -27,6 +34,7 @@ export function httpClientAdapter(opts: {
body: payload, body: payload,
headers: { headers: {
"content-type": "application/json; charset=utf-8", "content-type": "application/json; charset=utf-8",
...extraHeaders
}, },
credentials: "include", credentials: "include",
}); });

View File

@@ -7,6 +7,7 @@ const GET_PAYLOAD_PARAM = "payload";
export function httpClientAdapter(opts: { export function httpClientAdapter(opts: {
url: string; url: string;
pathsForGET?: string[]; pathsForGET?: string[];
headers?: () => Promise<Record<string, string>> | Record<string, string>;
}): TinyRpcClientAdapter { }): TinyRpcClientAdapter {
return { return {
send: async (data) => { send: async (data) => {
@@ -19,8 +20,8 @@ export function httpClientAdapter(opts: {
if (method === "GET") { if (method === "GET") {
req = new Request( req = new Request(
url + url +
"?" + "?" +
new URLSearchParams({ [GET_PAYLOAD_PARAM]: payload }) new URLSearchParams({ [GET_PAYLOAD_PARAM]: payload })
); );
} else { } else {
req = new Request(url, { req = new Request(url, {

22
src/api/rpc/auth.ts Normal file
View File

@@ -0,0 +1,22 @@
import { getContext } from "hono/context-storage";
import { HonoVarTypes } from "types";
// We can keep checkAuth to return the current user profile from the context
// which is populated by the firebaseAuthMiddleware
async function checkAuth() {
const context = getContext<HonoVarTypes>();
const user = context.get('user');
if (!user) {
return { authenticated: false, user: null };
}
return {
authenticated: true,
user: user
};
}
export const authMethods = {
checkAuth,
};

View File

@@ -6,11 +6,9 @@ import {
import { tinyassert } from "@hiogawa/utils"; import { tinyassert } from "@hiogawa/utils";
import { MiddlewareHandler, type Context, type Next } from "hono"; import { MiddlewareHandler, type Context, type Next } from "hono";
import { getContext } from "hono/context-storage"; import { getContext } from "hono/context-storage";
import { csrf } from 'hono/csrf' // import { adminAuth } from "../../lib/firebaseAdmin";
import { z } from "zod"; import { z } from "zod";
import { authMethods } from "./auth"; import { authMethods } from "./auth";
import { jwt } from "hono/jwt";
import { secret } from "./commom";
import { abortChunk, chunkedUpload, completeChunk, createPresignedUrls, imageContentTypes, nanoid, presignedPut, videoContentTypes } from "./s3_handle"; import { abortChunk, chunkedUpload, completeChunk, createPresignedUrls, imageContentTypes, nanoid, presignedPut, videoContentTypes } from "./s3_handle";
// import { createElement } from "react"; // import { createElement } from "react";
@@ -222,7 +220,7 @@ const routes = {
), ),
// access context // access context
components: async () => {}, components: async () => { },
getHomeCourses: async () => { getHomeCourses: async () => {
return listCourses.slice(0, 3); return listCourses.slice(0, 3);
}, },
@@ -298,29 +296,40 @@ const routes = {
export type RpcRoutes = typeof routes; export type RpcRoutes = typeof routes;
export const endpoint = "/rpc"; export const endpoint = "/rpc";
export const pathsForGET: (keyof typeof routes)[] = ["getCounter"]; export const pathsForGET: (keyof typeof routes)[] = ["getCounter"];
export const jwtRpc: MiddlewareHandler = async (c, next) => {
const publicPaths: (keyof typeof routes)[] = ["getHomeCourses", "getCourses", "getCourseBySlug", "getCourseContent", "login", "register"]; export const firebaseAuthMiddleware: MiddlewareHandler = async (c, next) => {
const publicPaths: (keyof typeof routes)[] = ["getHomeCourses", "getCourses", "getCourseBySlug", "getCourseContent"];
const isPublic = publicPaths.some((path) => c.req.path.split("/").includes(path)); const isPublic = publicPaths.some((path) => c.req.path.split("/").includes(path));
c.set("isPublic", isPublic); c.set("isPublic", isPublic);
// return await next();
if (c.req.path !== endpoint && !c.req.path.startsWith(endpoint + "/") || isPublic) { if (c.req.path !== endpoint && !c.req.path.startsWith(endpoint + "/") || isPublic) {
return await next(); return await next();
} }
console.log("JWT RPC Middleware:", c.req.path);
const jwtMiddleware = jwt({ const authHeader = c.req.header("Authorization");
secret, if (!authHeader || !authHeader.startsWith("Bearer ")) {
cookie: 'auth_token', // Option: return 401 or let it pass with no user?
verification: { // Old logic seemed to require it for non-public paths.
aud: "ez.lms_users", return c.json({ error: "Unauthorized" }, 401);
} }
})
return jwtMiddleware(c, next) const token = authHeader.split("Bearer ")[1];
try {
// const decodedToken = await adminAuth.verifyIdToken(token);
// c.set("user", decodedToken);
} catch (error) {
console.error("Firebase Auth Error:", error);
return c.json({ error: "Unauthorized" }, 401);
}
return await next();
} }
export const rpcServer = async (c: Context, next: Next) => { export const rpcServer = async (c: Context, next: Next) => {
if (c.req.path !== endpoint && !c.req.path.startsWith(endpoint + "/")) { if (c.req.path !== endpoint && !c.req.path.startsWith(endpoint + "/")) {
return await next(); return await next();
} }
const cert = c.req.header() const cert = c.req.header()
console.log("RPC Request Path:", c.req.raw.cf); console.log("RPC Request Path:", c.req.raw.cf);
// if (!cert) return c.text('Forbidden', 403) // if (!cert) return c.text('Forbidden', 403)
const handler = exposeTinyRpc({ const handler = exposeTinyRpc({

View File

@@ -5,15 +5,26 @@ import {
} from "@hiogawa/tiny-rpc"; } from "@hiogawa/tiny-rpc";
import type { RpcRoutes } from "./rpc"; import type { RpcRoutes } from "./rpc";
import { Result } from "@hiogawa/utils"; import { Result } from "@hiogawa/utils";
import {httpClientAdapter} from "@httpClientAdapter"; import { httpClientAdapter } from "@httpClientAdapter";
// console.log("httpClientAdapter module:", httpClientAdapter.toString()); // console.log("httpClientAdapter module:", httpClientAdapter.toString());
declare let __host__: string; declare let __host__: string;
const endpoint = "/rpc"; const endpoint = "/rpc";
const url = import.meta.env.SSR ? "http://localhost" : ""; const url = import.meta.env.SSR ? "http://localhost" : "";
const headers: Record<string, string> = {}; // inject headers to demonstrate context import { auth } from "../lib/firebase";
export const client = proxyTinyRpc<RpcRoutes>({ export const client = proxyTinyRpc<RpcRoutes>({
adapter: httpClientAdapter({ adapter: httpClientAdapter({
url: url + endpoint, url: url + endpoint,
pathsForGET: [], pathsForGET: [],
headers: async () => {
if (import.meta.env.SSR) return {}; // No client auth on server for now
const user = auth.currentUser;
if (user) {
// Force refresh if needed or just get token
const token = await user.getIdToken();
return { Authorization: `Bearer ${token}` };
}
return {};
}
}), }),
}); });

View File

@@ -1,8 +1,7 @@
import createVueApp from '@/shared/createVueApp'; import { createApp } from './main';
import 'uno.css'; import 'uno.css';
async function render() { async function render() {
const { app, router } = createVueApp(); const { app, router } = createApp();
router.isReady().then(() => { router.isReady().then(() => {
app.mount('body', true) app.mount('body', true)
}) })

View File

@@ -1,242 +0,0 @@
import { getContext } from "hono/context-storage";
import { setCookie, deleteCookie, getCookie } from 'hono/cookie';
import { HonoVarTypes } from "types";
import { sign, verify } from "hono/jwt";
interface RegisterModel {
username: string;
password: string;
email: string;
}
interface User {
id: string;
username: string;
email: string;
name: string;
}
// Mock user database (in-memory)
const mockUsers: Map<string, { password: string; user: User }> = new Map([
['admin', {
password: 'admin123',
user: {
id: '1',
username: 'admin',
email: 'admin@example.com',
name: 'Admin User'
}
}],
['user@example.com', {
password: 'password',
user: {
id: '2',
username: 'user',
email: 'user@example.com',
name: 'Test User'
}
}]
]);
// CSRF token storage (in-memory, in production use Redis or similar)
const csrfTokens = new Map<string, { token: string; expires: number }>();
// Secret for JWT signing
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
function generateCSRFToken(): string {
return crypto.randomUUID();
}
function validateCSRFToken(sessionId: string, token: string): boolean {
const stored = csrfTokens.get(sessionId);
if (!stored) return false;
if (stored.expires < Date.now()) {
csrfTokens.delete(sessionId);
return false;
}
return stored.token === token;
}
const register = async (registerModel: RegisterModel) => {
// Check if user already exists
if (mockUsers.has(registerModel.username) || mockUsers.has(registerModel.email)) {
throw new Error('User already exists');
}
const newUser: User = {
id: crypto.randomUUID(),
username: registerModel.username,
email: registerModel.email,
name: registerModel.username
};
mockUsers.set(registerModel.username, {
password: registerModel.password,
user: newUser
});
mockUsers.set(registerModel.email, {
password: registerModel.password,
user: newUser
});
const context = getContext<HonoVarTypes>();
const sessionId = crypto.randomUUID();
const csrfToken = generateCSRFToken();
// Store CSRF token (expires in 1 hour)
csrfTokens.set(sessionId, {
token: csrfToken,
expires: Date.now() + 60 * 60 * 1000
});
// Create JWT token with user info
const token = await sign({
sub: newUser.id,
username: newUser.username,
email: newUser.email,
sessionId,
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 // 24 hours
}, JWT_SECRET);
// Set HTTP-only cookie
setCookie(context, 'auth_token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'Lax',
path: '/',
maxAge: 60 * 60 * 24 // 24 hours
});
return {
success: true,
user: newUser,
csrfToken // Return CSRF token to client for subsequent requests
};
};
const login = async (username: string, password: string) => {
// Try to find user by username or email
const userRecord = mockUsers.get(username);
if (!userRecord) {
throw new Error('Invalid credentials');
}
if (userRecord.password !== password) {
throw new Error('Invalid credentials');
}
const context = getContext<HonoVarTypes>();
const sessionId = crypto.randomUUID();
const csrfToken = generateCSRFToken();
// Store CSRF token (expires in 1 hour)
csrfTokens.set(sessionId, {
token: csrfToken,
expires: Date.now() + 60 * 60 * 1000
});
// Create JWT token with user info
const token = await sign({
sub: userRecord.user.id,
username: userRecord.user.username,
email: userRecord.user.email,
sessionId,
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 // 24 hours
}, JWT_SECRET);
// Set HTTP-only cookie
setCookie(context, 'auth_token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'Lax',
path: '/',
maxAge: 60 * 60 * 24 // 24 hours
});
return {
success: true,
user: userRecord.user,
csrfToken // Return CSRF token to client for subsequent requests
};
};
async function checkAuth() {
const context = getContext<HonoVarTypes>();
const token = getCookie(context, 'auth_token');
if (!token) {
return { authenticated: false, user: null };
}
try {
const payload = await verify(token, JWT_SECRET) as any;
// Find user
const userRecord = Array.from(mockUsers.values()).find(
record => record.user.id === payload.sub
);
if (!userRecord) {
return { authenticated: false, user: null };
}
return {
authenticated: true,
user: userRecord.user
};
} catch (error) {
return { authenticated: false, user: null };
}
}
async function logout() {
const context = getContext<HonoVarTypes>();
const token = getCookie(context, 'auth_token');
if (token) {
try {
const payload = await verify(token, JWT_SECRET) as any;
// Remove CSRF token
if (payload.sessionId) {
csrfTokens.delete(payload.sessionId);
}
} catch (error) {
// Token invalid, just delete cookie
}
}
deleteCookie(context, 'auth_token', { path: '/' });
return { success: true };
}
async function getCSRFToken() {
const context = getContext<HonoVarTypes>();
const token = getCookie(context, 'auth_token');
if (!token) {
throw new Error('Not authenticated');
}
const payload = await verify(token, JWT_SECRET) as any;
const stored = csrfTokens.get(payload.sessionId);
// if (!stored) {
// throw new Error('CSRF token not found');
// }
return { csrfToken: stored?.token || null };
}
export const authMethods = {
register,
login,
checkAuth,
logout,
getCSRFToken,
};
export { validateCSRFToken };

View File

@@ -1,179 +0,0 @@
import { type ReactiveHead, type ResolvableValue } from "@unhead/vue";
import { headSymbol } from '@unhead/vue'
import {
createMemoryHistory,
createRouter,
createWebHistory,
type RouteRecordRaw,
} from "vue-router";
import { useAuthStore } from "@/client/stores/auth";
type RouteData = RouteRecordRaw & {
meta?: ResolvableValue<ReactiveHead> & { requiresAuth?: boolean };
children?: RouteData[];
};
const routes: RouteData[] = [
{
path: "/",
component: () => import("@/client/components/RootLayout.vue"),
children: [
{
path: "",
component: () => import("./public-routes/Layout.vue"),
children: [
{
path: "",
component: () => import("./public-routes/Home.vue"),
beforeEnter: (to, from, next) => {
const auth = useAuthStore();
if (auth.user) {
next({ name: "overview" });
} else {
next();
}
},
},
{
path: "/terms",
name: "terms",
component: () => import("./public-routes/Terms.vue"),
},
{
path: "/privacy",
name: "privacy",
component: () => import("./public-routes/Privacy.vue"),
},
]
},
{
path: "",
component: () => import("./auth/layout.vue"),
beforeEnter: (to, from, next) => {
const auth = useAuthStore();
if (auth.user) {
next({ name: "overview" });
} else {
next();
}
},
children: [
{
path: "login",
name: "login",
component: () => import("./auth/login.vue"),
},
{
path: "sign-up",
name: "signup",
component: () => import("./auth/signup.vue"),
},
{
path: "forgot",
name: "forgot",
component: () => import("./auth/forgot.vue"),
},
],
},
{
path: "",
component: () => import("@/client/components/DashboardLayout.vue"),
meta: { requiresAuth: true },
children: [
{
path: "",
name: "overview",
component: () => import("./add/Add.vue"),
meta: {
head: {
title: 'Overview - Holistream',
},
}
},
{
path: "upload",
name: "upload",
component: () => import("./add/Add.vue"),
meta: {
head: {
title: 'Upload - Holistream',
},
}
},
{
path: "video",
name: "video",
component: () => import("./add/Add.vue"),
meta: {
head: {
title: 'Videos - Holistream',
meta: [
{ name: 'description', content: 'Manage your video content.' },
],
},
}
},
{
path: "plans",
name: "plans",
component: () => import("./add/Add.vue"),
meta: {
head: {
title: 'Plans & Billing',
meta: [
{ name: 'description', content: 'Manage your plans and billing information.' },
],
},
}
},
{
path: "notification",
name: "notification",
component: () => import("./add/Add.vue"),
meta: {
head: {
title: 'Notification - Holistream',
},
}
},
],
},
{
path: "/:pathMatch(.*)*",
name: "not-found",
component: () => import("./NotFound.vue"),
}
],
},
];
const createAppRouter = () => {
const router = createRouter({
history: import.meta.env.SSR
? createMemoryHistory() // server
: createWebHistory(), // client
routes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition
}
return { top: 0 }
}
});
router.beforeEach((to, from, next) => {
const auth = useAuthStore();
const head = inject(headSymbol);
(head as any).push(to.meta.head || {});
if (to.matched.some((record) => record.meta.requiresAuth)) {
if (!auth.user) {
next({ name: "login" });
} else {
next();
}
} else {
next();
}
});
return router;
}
export default createAppRouter;

View File

@@ -1,231 +0,0 @@
<template>
<section class=":m: relative pt-32 pb-20 lg:pt-48 lg:pb-32 overflow-hidden min-h-svh flex">
<!-- <div class="absolute inset-0 bg-grid-pattern opacity-[0.4] -z-10"></div> -->
<div
class=":m: absolute top-0 right-0 -translate-y-1/2 translate-x-1/2 w-[800px] h-[800px] bg-primary-light/40 rounded-full blur-3xl -z-10 mix-blend-multiply animate-pulse duration-1000">
</div>
<div
class=":m: absolute bottom-0 left-0 translate-y-1/2 -translate-x-1/2 w-[600px] h-[600px] bg-teal-100/50 rounded-full blur-3xl -z-10 mix-blend-multiply">
</div>
<div class="max-w-7xl m-auto px-4 sm:px-6 lg:px-8 text-center">
<h1
class="text-5xl md:text-7xl font-extrabold tracking-tight text-slate-900 mb-6 leading-[1.1] animate-backwards">
Video infrastructure for <br>
<span class="text-gradient">modern internet.</span>
</h1>
<p class="text-xl text-slate-500 max-w-2xl mx-auto mb-10 leading-relaxed animate-backwards delay-50">
Seamlessly host, encode, and stream video with our developer-first API.
Optimized for speed, built for scale.
</p>
<div class="flex flex-col sm:flex-row justify-center gap-4">
<RouterLink to="/get-started" class="flex btn btn-success !rounded-xl !p-4 press-animated">
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="46 -286 524 580">
<path d="M56 284v-560L560 4 56 284z" fill="#fff" />
</svg>&nbsp;
Get Started
</RouterLink>
<RouterLink to="/docs" class="flex btn btn-outline-primary !rounded-xl">
<svg xmlns="http://www.w3.org/2000/svg" width="28" viewBox="0 0 596 468">
<path
d="M10 314c0-63 41-117 98-136-1-8-2-16-2-24 0-79 65-144 144-144 55 0 104 31 128 77 14-8 30-13 48-13 53 0 96 43 96 96 0 16-4 31-10 44 44 20 74 64 74 116 0 71-57 128-128 128H154c-79 0-144-64-144-144zm199-73c-9 9-9 25 0 34s25 9 34 0l31-31v102c0 13 11 24 24 24s24-11 24-24V244l31 31c9 9 25 9 34 0s9-25 0-34l-72-72c-10-9-25-9-34 0l-72 72z"
fill="#14a74b" />
<path
d="M281 169c9-9 25-9 34 0l72 72c9 9 9 25 0 34s-25 9-34 0l-31-31v102c0 13-11 24-24 24s-24-11-24-24V244l-31 31c-9 9-25 9-34 0s-9-25 0-34l72-72z"
fill="#fff" />
</svg>&nbsp;
Upload video
</RouterLink>
</div>
</div>
</section>
<section id="features" class="py-24 bg-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="mb-16 md:text-center max-w-3xl mx-auto">
<h2 class="text-3xl font-bold text-slate-900 mb-4">Everything you need to ship video</h2>
<p class="text-lg text-slate-500">Focus on building your product. We'll handle the complex video
infrastructure.</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div
class=":m: md:col-span-2 bg-slate-50 rounded-2xl p-8 border border-slate-100 hover:border-primary/60 transition-all group overflow-hidden relative">
<div class="relative z-10">
<div
class="w-12 h-12 bg-white rounded-xl flex items-center justify-center mb-6 border border-slate-100">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="532" viewBox="-8 -258 529 532">
<path
d="M342 32c-2 69-16 129-35 172-10 23-22 40-32 49-10 10-16 11-19 11h-1c-3 0-9-1-19-11-10-9-22-26-32-49-19-43-33-103-35-172h173zm169 0c-9 103-80 188-174 219 30-51 50-129 53-219h121zm-390 0c3 89 23 167 53 218C80 219 11 134 2 32h119zm53-266c-30 51-50 129-53 218H2c9-102 78-186 172-218zm82-14c3 0 9 1 19 11 10 9 22 26 32 50 19 42 33 102 35 171H169c3-69 16-129 35-171 10-24 22-41 32-50s16-11 19-11h1zm81 13c94 31 165 116 174 219H390c-3-90-23-168-53-219z"
fill="#059669" />
</svg>
</div>
<h3 class="text-xl font-bold text-slate-900 mb-2">Global Edge Network</h3>
<p class="text-slate-500 max-w-md">Content delivered from 200+ PoPs worldwide. Automatic region
selection ensures the lowest latency for every viewer.</p>
</div>
<div class="absolute right-0 bottom-0 opacity-10 translate-x-1/4 translate-y-1/4">
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="-10 -258 532 532">
<path
d="M464 8c0-19-3-38-8-56l-27-5c-8-2-15 2-19 9-6 11-19 17-31 13l-14-5c-8-2-17 0-22 5-4 4-4 10 0 14l33 33c5 5 8 12 8 19 0 12-8 23-20 26l-6 1c-3 1-6 5-6 9v12c0 13-4 27-13 38l-25 34c-6 8-16 13-26 13-18 0-32-14-32-32V88c0-9-7-16-16-16h-32c-26 0-48-22-48-48V-4c0-13 6-24 16-32l39-30c6-4 13-6 20-6 3 0 7 1 10 2l32 10c7 3 15 3 22 1l36-9c10-2 17-11 17-22 0-8-5-16-13-20l-29-15c-3-2-8-1-11 2l-4 4c-4 4-11 7-17 7-4 0-8-1-11-3l-15-7c-7-4-15-2-20 4l-13 17c-6 7-16 8-22 1-3-2-5-6-5-10v-41c0-6-1-11-4-16l-10-18C102-154 48-79 48 8c0 115 93 208 208 208S464 123 464 8zM0 8c0-141 115-256 256-256S512-133 512 8 397 264 256 264 0 149 0 8z"
fill="#1e3050" />
</svg>
</div>
</div>
<div class=":m: md:row-span-2 bg-slate-900 rounded-2xl p-8 text-white relative overflow-hidden group">
<div class=":m: absolute inset-0 bg-gradient-to-b from-slate-800/50 to-transparent"></div>
<div class="relative z-10">
<div
class=":m: w-12 h-12 bg-white/10 rounded-xl flex items-center justify-center mb-6 backdrop-blur-sm border border-white/10">
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="-10 -146 468 384">
<path
d="M392-136c-31 0-56 25-56 56v280c0 16 13 28 28 28h28c31 0 56-25 56-56V-80c0-31-25-56-56-56zM168 4c0-31 25-56 56-56h28c16 0 28 13 28 28v224c0 16-12 28-28 28h-56c-15 0-28-12-28-28V4zM0 88c0-31 25-56 56-56h28c16 0 28 13 28 28v140c0 16-12 28-28 28H56c-31 0-56-25-56-56V88z"
fill="#fff" />
</svg>
</div>
<h3 class="text-xl font-bold mb-2">Live Streaming API</h3>
<p class="text-slate-400 text-sm leading-relaxed mb-8">Scale to millions of concurrent viewers
with ultra-low latency. RTMP ingest and HLS playback supported natively.</p>
<!-- Visual -->
<div
class="bg-slate-800/50 rounded-lg p-4 border border-white/5 font-mono text-xs text-brand-300">
<div class="flex justify-between items-center mb-3 border-b border-white/5 pb-2">
<span class="text-slate-500">Live Status</span>
<span
class=":m: flex items-center gap-1.5 text-red-500 text-[10px] uppercase font-bold tracking-wider animate-pulse"><span
class="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse"></span> On Air</span>
</div>
<div class="space-y-1">
<div class="flex justify-between"><span class="text-slate-400">Bitrate:</span> <span
class="text-white">6000 kbps</span></div>
<div class="flex justify-between"><span class="text-slate-400">FPS:</span> <span
class="text-white">60</span></div>
<div class="flex justify-between"><span class="text-slate-400">Latency:</span> <span
class="text-brand-400">~2s</span></div>
</div>
</div>
</div>
</div>
<!-- Standard Feature -->
<div
class=":m: bg-slate-50 rounded-2xl p-8 border border-slate-100 transition-all group hover:(border-brand-200 shadow-lg shadow-brand-500/5)">
<div
class=":m: w-12 h-12 bg-white rounded-xl shadow-sm flex items-center justify-center mb-6 text-purple-600 border border-slate-100">
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="0 0 570 570">
<path
d="M50 428c-5 5-5 14 0 19s14 5 19 0l237-237c5-5 5-14 0-19s-14-5-19 0L50 428zm16-224c-5 5-5 13 0 19 5 5 14 5 19 0l12-12c5-5 5-14 0-19-6-5-14-5-20 0l-11 12zM174 60c-5 5-5 13 0 19 5 5 14 5 19 0l12-12c5-5 5-14 0-19-6-5-14-5-20 0l-11 12zm215 29c-5 5-5 14 0 19s14 5 19 0l39-39c5-5 5-14 0-19s-14-5-19 0l-39 39zm21 357c-5 5-5 14 0 19s14 5 19 0l18-18c5-5 5-14 0-19s-14-5-19 0l-18 18z"
fill="#a6acb9" />
<path
d="M170 26c14-15 36-15 50 0l18 18c15 14 15 36 0 50l-18 18c-14 15-36 15-50 0l-18-18c-15-14-15-36 0-50l18-18zm35 41c5-5 5-14 0-19-6-5-14-5-20 0l-11 12c-5 5-5 13 0 19 5 5 14 5 19 0l12-12zm204 342c21-21 55-21 76 0l18 18c21 21 21 55 0 76l-18 18c-21 21-55 21-76 0l-18-18c-21-21-21-55 0-76l18-18zm38 38c5-5 5-14 0-19s-14-5-19 0l-18 18c-5 5-5 14 0 19s14 5 19 0l18-18zM113 170c-15-15-37-15-51 0l-18 18c-14 14-14 36 0 50l18 18c14 15 37 15 51 0l18-18c14-14 14-36 0-50l-18-18zm-16 41-12 12c-5 5-14 5-19 0-5-6-5-14 0-20l11-11c6-5 14-5 20 0 5 5 5 14 0 19zM485 31c-21-21-55-21-76 0l-39 39c-21 21-21 55 0 76l54 54c21 21 55 21 76 0l39-39c21-21 21-55 0-76l-54-54zm-38 38-39 39c-5 5-14 5-19 0s-5-14 0-19l39-39c5-5 14-5 19 0s5 14 0 19zm-49 233c21-21 21-55 0-76l-54-54c-21-21-55-21-76 0L31 409c-21 21-21 55 0 76l54 54c21 21 55 21 76 0l237-237zm-92-92L69 447c-5 5-14 5-19 0s-5-14 0-19l237-237c5-5 14-5 19 0s5 14 0 19z"
fill="#1e3050" />
</svg>
</div>
<h3 class="text-xl font-bold text-slate-900 mb-2">Instant Encoding</h3>
<p class="text-slate-500 text-sm">Upload raw files and get optimized HLS/DASH streams in seconds.
</p>
</div>
<!-- Standard Feature -->
<div
class=":m: bg-slate-50 rounded-2xl p-8 border border-slate-100 transition-all group hover:(border-brand-200 shadow-lg shadow-brand-500/5)">
<div
class=":m: w-12 h-12 bg-white rounded-xl shadow-sm flex items-center justify-center mb-6 text-orange-600 border border-slate-100">
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="-10 -226 532 468">
<path
d="M32-216c18 0 32 14 32 32v336c0 9 7 16 16 16h400c18 0 32 14 32 32s-14 32-32 32H80c-44 0-80-36-80-80v-336c0-18 14-32 32-32zM144-24c18 0 32 14 32 32v64c0 18-14 32-32 32s-32-14-32-32V8c0-18 14-32 32-32zm144-64V72c0 18-14 32-32 32s-32-14-32-32V-88c0-18 14-32 32-32s32 14 32 32zm80 32c18 0 32 14 32 32v96c0 18-14 32-32 32s-32-14-32-32v-96c0-18 14-32 32-32zm144-96V72c0 18-14 32-32 32s-32-14-32-32v-224c0-18 14-32 32-32s32 14 32 32z"
fill="#1e3050" />
</svg>
</div>
<h3 class="text-xl font-bold text-slate-900 mb-2">Deep Analytics</h3>
<p class="text-slate-500 text-sm">Session-level insights, quality of experience (QoE) metrics, and
more.</p>
</div>
</div>
</div>
</section>
<!-- Pricing -->
<section id="pricing" class="py-24 border-t border-slate-100 bg-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-3xl font-bold text-slate-900 mb-4">{{ pricing.title }}</h2>
<p class="text-slate-500">{{ pricing.subtitle }}</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 w-full">
<div v-for="pack in pricing.packs" :key="pack.name"
:class="cn(':uno: p-8 rounded-2xl relative overflow-hidden hover:border-primary transition-colors flex flex-col justify-between', pack.tag == 'POPULAR' ? 'border-primary/80 border-2' : 'border-slate-200 border')"
:style="{ background: pack.bg }">
<div v-if="pack.tag"
class=":m: absolute top-0 right-0 bg-primary/80 text-white text-xs font-bold px-3 py-1 rounded-bl-lg uppercase">
{{ pack.tag }}</div>
<div>
<h3 class="font-semibold text-slate-900 text-xl mb-2">{{ pack.name }}</h3>
<div class="flex items-baseline gap-1 mb-6">
<span class="text-4xl font-bold text-slate-900">{{ pack.price }}</span>
<span class="text-slate-500">/mo</span>
</div>
</div>
<ul class="space-y-3 mb-8 text-sm text-slate-600">
<li v-for="value in pack.features" :key="value" class="flex items-center gap-3"><Check-Icon
class="fas fa-check text-brand-500" /> {{ value }}</li>
</ul>
<router-link to="/sign-up"
:class="cn('btn flex justify-center w-full !py-2.5', pack.tag == 'POPULAR' ? 'btn-primary' : 'btn-outline-primary')">{{
pack.buttonText }}</router-link>
</div>
</div>
</div>
</section>
</template>
<script lang="ts" setup>
import { Head } from '@unhead/vue/components'
import { cn } from '@/client/lib/utils';
const pricing = {
title: "Simple, transparent pricing",
subtitle: "Choose the plan that fits your needs. No hidden fees.",
packs: [
{
name: "Hobby",
price: "$0",
features: [
"Unlimited upload",
"1 Hour of Storage",
"Standard Support",
],
buttonText: "Start Free",
tag: "",
bg: "#f9fafb",
},
{
name: "Pro",
price: "$29",
features: [
"Ads free player",
"Support M3U8",
"Unlimited upload",
"Custom ads"
],
buttonText: "Get Started",
tag: "POPULAR",
bg: "#eff6ff",
},
{
name: "Scale",
price: "$99",
features: [
"5 TB Bandwidth",
"500 Hours Storage",
"Priority Support"
],
buttonText: "Contact Sales",
tag: "Best Value",
bg: "#eef4f7",
}
]
}
</script>

View File

@@ -1,84 +0,0 @@
<template>
<header>
<nav class="fixed w-full z-50 glass-nav transition-all duration-300" id="navbar">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<router-link to="/" class="flex items-center gap-2 cursor-pointer">
<img class="h-8 w-8" src="/apple-touch-icon.png" alt="Logo" />
<span class="font-bold text-xl tracking-tight text-slate-900">EcoStream</span>
</router-link>
<div class="hidden md:flex items-center space-x-8">
<a href="#features"
class="text-sm font-medium text-slate-600 hover:text-brand-600 transition-colors">Features</a>
<a href="#pricing"
class="text-sm font-medium text-slate-600 hover:text-brand-600 transition-colors">Pricing</a>
</div>
<div class="hidden md:flex items-center gap-4">
<RouterLink to="/login"
class="text-sm font-semibold text-slate-600 hover:text-slate-900 cursor-pointer">Log in
</RouterLink>
<RouterLink to="/sign-up"
class="bg-slate-900 hover:bg-black text-white px-5 py-2.5 rounded-lg text-sm font-semibold cursor-pointer">
Start for free
</RouterLink>
</div>
</div>
</div>
</nav>
</header>
<main class="animate-fade-in delay-50 grow">
<router-view />
</main>
<!-- Footer -->
<footer class="bg-white border-t border-slate-100 pt-16 pb-8">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid grid-cols-2 md:grid-cols-5 gap-8 mb-12">
<div class="col-span-2">
<div class="flex items-center gap-2 mb-4">
<div class="w-6 h-6 bg-brand-600 rounded flex items-center justify-center text-white">
<img class="h-6 w-6" src="/apple-touch-icon.png" alt="Logo" />
</div>
<span class="font-bold text-lg text-slate-900">EcoStream</span>
</div>
<p class="text-slate-500 text-sm max-w-xs">Building the video layer of the internet. Designed for
developers.</p>
</div>
<div>
<h4 class="font-semibold text-slate-900 mb-4 text-sm">Product</h4>
<ul class="space-y-2 text-sm text-slate-500">
<li><a href="#" class="hover:text-brand-600">Features</a></li>
<li><a href="#" class="hover:text-brand-600">Pricing</a></li>
<li><a href="#" class="hover:text-brand-600">Showcase</a></li>
</ul>
</div>
<div>
<h4 class="font-semibold text-slate-900 mb-4 text-sm">Company</h4>
<ul class="space-y-2 text-sm text-slate-500">
<li><a href="#" class="hover:text-brand-600">About</a></li>
<li><a href="#" class="hover:text-brand-600">Blog</a></li>
<li><a href="#" class="hover:text-brand-600">Careers</a></li>
</ul>
</div>
<div>
<h4 class="font-semibold text-slate-900 mb-4 text-sm">Legal</h4>
<ul class="space-y-2 text-sm text-slate-500">
<li><router-link to="/privacy" class="hover:text-brand-600">Privacy</router-link></li>
<li><router-link to="/terms" class="hover:text-brand-600">Terms</router-link></li>
</ul>
</div>
</div>
<div class="pt-8 border-t border-slate-100 text-center text-sm text-slate-400">
&copy; 2026 EcoStream Inc. All rights reserved.
</div>
</div>
</footer>
<Head>
<title>EcoStream - Video infrastructure for modern internet</title>
<meta name="description"
content="Seamlessly host, encode, and stream video with our developer-first API. Optimized for speed, built for scale." />
</Head>
</template>
<script lang="ts" setup>
import { Head } from '@unhead/vue/components'
</script>

View File

@@ -1,61 +0,0 @@
<template>
<div class="max-w-4xl mx-auto space-y-10" style="opacity: 1; transform: none;">
<div class="grow pt-32 pb-12 px-4">
<div class="max-w-4xl mx-auto space-y-10">
<div class="space-y-3">
<p
class="inline-block px-4 py-1.5 rounded-full bg-info/20 font-bold text-sm uppercase">
{{ pageContent.data.pageSubheading }}</p>
<h1 class="text-4xl md:text-5xl font-heading font-extrabold">{{ pageContent.data.pageHeading }}</h1>
<p class="text-slate-600 text-lg font-medium">{{ pageContent.data.description }}</p>
</div>
<div class="bg-white p-8 rounded-xl border border-gray-200 shadow-hard space-y-6">
<section v-for="(item, index) in pageContent.data.list" :key="index">
<h2 class="text-2xl font-bold mb-4">{{ item.heading }}</h2>
<p class="leading-relaxed">{{ item.text }}</p>
</section>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {useHead} from "@unhead/vue";
const title = "Privacy Policy - Ecostream";
const description = "Read about Ecostream's commitment to protecting your privacy and data security.";
const pageContent = {
head: {
title,
meta: [
{ name: "description", content: description },
{ property: "og:title", content: title },
{ property: "og:description", content: description },
{ property: "twitter:title", content: title },
{ property: "twitter:description", content: description },
{ property: "twitter:image", content: "https://Ecostream.com/thumb.png" }
]
},
data: {
pageHeading: "Legal & Privacy Policy",
pageSubheading: "Legal & Privacy Policy",
description: "Our legal and privacy policy.",
list: [{
heading: "1. Privacy Policy",
text: "At Ecostream, we take your privacy seriously. This policy describes how we collect, use, and protect your personal information. We only collect information that is necessary for the operation of our service, including email addresses for account creation and payment information for subscription processing."
},
{
heading: "2. Data Collection",
text: "We collect data such as IP addresses, browser types, and access times to analyze trends and improve our service. Uploaded content is stored securely and is only accessed as required for the delivery of our hosting services."
},
{
heading: "3. Cookie Policy",
text: "We use cookies to maintain user sessions and preferences. By using our website, you consent to the use of cookies in accordance with this policy."
},
{
heading: "4. DMCA & Copyright",
text: "Ecostream respects the intellectual property rights of others. We respond to notices of alleged copyright infringement in accordance with the Digital Millennium Copyright Act (DMCA). Please report any copyright violations to our support team."
}]
}
}
useHead(pageContent.head);
</script>

View File

@@ -1,67 +0,0 @@
<template>
<div class="max-w-4xl mx-auto space-y-10" style="opacity: 1; transform: none;">
<div class="grow pt-32 pb-12 px-4">
<div class="max-w-4xl mx-auto space-y-10">
<div class="space-y-3">
<p
class="inline-block px-4 py-1.5 rounded-full bg-info/20 font-bold text-sm uppercase">
{{ pageContent.data.pageSubheading }}</p>
<h1 class="text-4xl md:text-5xl font-heading font-extrabold">{{ pageContent.data.pageHeading }}</h1>
<p class="text-slate-600 text-lg font-medium">{{ pageContent.data.description }}</p>
</div>
<div class="bg-white p-8 rounded-xl border border-gray-200 shadow-hard space-y-6">
<section v-for="(item, index) in pageContent.data.list" :key="index">
<h2 class="text-2xl font-bold mb-4">{{ item.heading }}</h2>
<p class="leading-relaxed">{{ item.text }}</p>
</section>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {useHead} from "@unhead/vue";
const title = "Terms and Conditions - Ecostream";
const description = "Read Ecostream's terms and conditions for using our video hosting and streaming services.";
const pageContent = {
head: {
title,
meta: [
{ name: "description", content: description },
{ property: "og:title", content: title },
{ property: "og:description", content: description },
{ property: "twitter:title", content: title },
{ property: "twitter:description", content: description },
{ property: "twitter:image", content: "https://Ecostream.com/thumb.png" }
]
},
data: {
pageHeading: "Terms and Conditions Details",
pageSubheading: "Terms and Conditions",
description: "Our terms and conditions set forth important guidelines and rules for using Ecostream's services.",
list: [
{
heading: "1. Acceptance of Terms",
text: "By accessing and using Ecostream, you accept and agree to be bound by the terms and provision of this agreement."
},
{
heading: "2. Service Usage",
text: "You agree to use our service only for lawful purposes. You are prohibited from posting or transmitting any unlawful, threatening, libelous, defamatory, obscene, or profane material. We reserve the right to terminate accounts that violate these terms."
},
{
heading: "3. Content Ownership",
text: "You retain all rights and ownership of the content you upload to Ecostream. However, by uploading content, you grant us a license to host, store, and display the content as necessary to provide our services."
},
{
heading: "4. Limitation of Liability",
text: "Ecostream shall not be liable for any direct, indirect, incidental, special, or consequential damages resulting from the use or inability to use our service."
},
{
heading: "5. Changes to Terms",
text: "We reserve the right to modify these terms at any time. Your continued use of the service after any such changes constitutes your acceptance of the new terms."
}
]
}
}
useHead(pageContent.head);
</script>

View File

@@ -1,86 +0,0 @@
import { client } from '@/client/api/rpcclient';
import { User } from 'firebase/auth';
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { emailAuth, signUp } from '../lib/firebase';
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null);
const router = useRouter();
const loading = ref(false);
const error = ref<string | null>(null);
const csrfToken = ref<string | null>(null);
const initialized = ref(false);
// Check auth status on init (reads from cookie)
async function init() {
if (initialized.value) return;
// try {
// const response = await client.checkAuth();
// if (response.authenticated && response.user) {
// user.value = response.user;
// // Get CSRF token if authenticated
// try {
// const csrfResponse = await client.getCSRFToken();
// csrfToken.value = csrfResponse.csrfToken;
// } catch (e) {
// // CSRF token might not be available yet
// }
// }
// } catch (e) {
// // Not authenticated, that's fine
// } finally {
// initialized.value = true;
// }
}
async function login(username: string, password: string) {
loading.value = true;
error.value = null;
return emailAuth(username, password).then((userCredential) => {
user.value = userCredential.user;
// csrfToken.value = userCredential.csrfToken;
router.push('/');
}).catch((e: any) => {
// error.value = e.message || 'Login failed';
error.value = 'Login failed';
throw e;
}).finally(() => {
loading.value = false;
});
}
async function register(username: string, email: string, password: string) {
loading.value = true;
error.value = null;
return signUp(email, password).then((response) => {
user.value = response.user;
csrfToken.value = response.csrfToken;
router.push('/');
}).catch((e: any) => {
// error.value = e.message || 'Registration failed';
error.value = 'Registration failed';
throw e;
}).finally(() => {
loading.value = false;
});
}
async function logout() {
return client.logout().then(() => {
user.value = null;
csrfToken.value = null;
router.push('/');
})
}
return { user, loading, error, csrfToken, initialized, init, login, register, logout, $reset: () => {
user.value = null;
loading.value = false;
error.value = null;
csrfToken.value = null;
initialized.value = false;
} };
});

View File

@@ -1,10 +1,12 @@
<script lang="ts" setup> <script lang="ts" setup>
import Home from "@/client/components/icons/Home.vue"; import Add from "@/components/icons/Add.vue";
import Video from "@/client/components/icons/Video.vue"; import Bell from "@/components/icons/Bell.vue";
import Credit from "@/client/components/icons/Credit.vue"; import Home from "@/components/icons/Home.vue";
import Video from "@/components/icons/Video.vue";
import Credit from "@/components/icons/Credit.vue";
import Upload from "./icons/Upload.vue"; import Upload from "./icons/Upload.vue";
import { cn } from "@/client/lib/utils"; import { cn } from "@/lib/utils";
import { useAuthStore } from "@/client/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { createStaticVNode } from "vue"; import { createStaticVNode } from "vue";
const auth = useAuthStore(); const auth = useAuthStore();
@@ -17,25 +19,26 @@ const links = [
{ href: "/upload", label: "Upload", icon: Upload, type: "a" }, { href: "/upload", label: "Upload", icon: Upload, type: "a" },
{ href: "/video", label: "Video", icon: Video, type: "a" }, { href: "/video", label: "Video", icon: Video, type: "a" },
{ href: "/plans", label: "Plans", icon: Credit, type: "a" }, { href: "/plans", label: "Plans", icon: Credit, type: "a" },
// { href: "/notification", label: "Notification", icon: Bell, type: "a" }, { href: "/notification", label: "Notification", icon: Bell, type: "a" },
]; ];
</script> </script>
<template> <template>
<header class=":uno: fixed left-0 w-18 flex flex-col items-center pt-4 gap-6 z-41 max-h-screen h-screen border-r border-gray-200 bg-white"> <header
class=":uno: fixed left-0 w-18 flex flex-col items-center pt-4 gap-6 z-41 max-h-screen h-screen border-r border-gray-200 bg-white">
<component :is="i.type === 'a' ? 'router-link' : 'div'" v-for="i in links" :key="i.label" <component :is="i.type === 'a' ? 'router-link' : 'div'" v-for="i in links" :key="i.label"
v-bind="i.type === 'a' ? { to: i.href } : {}" v-tooltip="i.label" v-bind="i.type === 'a' ? { to: i.href } : {}" v-tooltip="i.label"
:class="cn(className, $route.path === i.href && 'bg-primary/15')"> :class="cn(className, $route.path === i.href && 'bg-primary/15')">
<component :is="i.icon" :filled="$route.path === i.href" /> <component :is="i.icon" :filled="$route.path === i.href" />
</component> </component>
<div class=":m: w-12 h-12 rounded-2xl hover:bg-primary/15 flex"> <div class="w-12 h-12 rounded-2xl hover:bg-primary/15 flex">
<button class=":m: h-[38px] w-[38px] rounded-full m-a ring-2 ring flex press-animated" @click="auth.logout()"> <button class="h-[38px] w-[38px] rounded-full m-a ring-2 ring flex press-animated" @click="auth.logout()">
<img class=":m: h-8 w-8 rounded-full m-a ring-1 ring-white" <img class="h-8 w-8 rounded-full m-a ring-1 ring-white"
src="https://picsum.photos/seed/user123/40/40.jpg" alt="User avatar" /> src="https://picsum.photos/seed/user123/40/40.jpg" alt="User avatar" />
</button> </button>
</div> </div>
</header> </header>
<main class="flex flex-1 overflow-hidden md:ps-18"> <main class="flex flex-1 overflow-hidden md:ps-18">
<div class=":m: flex-1 overflow-auto p-4 bg-white rounded-lg md:(mr-2 mb-2) min-h-[calc(100vh-8rem)]"> <div class="flex-1 overflow-auto p-4 bg-white rounded-lg md:(mr-2 mb-2) min-h-[calc(100vh-8rem)]">
<router-view v-slot="{ Component }"> <router-view v-slot="{ Component }">
<Transition enter-active-class="transition-all duration-300 ease-in-out" <Transition enter-active-class="transition-all duration-300 ease-in-out"
enter-from-class="opacity-0 transform translate-y-4" enter-from-class="opacity-0 transform translate-y-4"

81
src/index.tsx Normal file
View File

@@ -0,0 +1,81 @@
import { Hono } from 'hono'
import { createApp } from './main';
import { renderToWebStream } from 'vue/server-renderer';
import { streamText } from 'hono/streaming';
import { renderSSRHead } from '@unhead/vue/server';
import { buildBootstrapScript, getHrefFromManifest, loadCssByModules } from './lib/manifest';
import { contextStorage } from 'hono/context-storage';
import { cors } from "hono/cors";
import { firebaseAuthMiddleware, rpcServer } from './api/rpc';
import isMobile from 'is-mobile';
import { useAuthStore } from './stores/auth';
import { cssContent } from './lib/primeCssContent';
import { styleTags } from './lib/primePassthrough';
// @ts-ignore
import Base from '@primevue/core/base';
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();
}, firebaseAuthMiddleware, rpcServer);
app.get("/.well-known/*", (c) => {
return c.json({ ok: true });
});
app.get("*", async (c) => {
const nonce = crypto.randomUUID();
const url = new URL(c.req.url);
const { app, router, head, pinia, bodyClass } = 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, "")));
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="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(appStream);
Object.assign(ctx, { $p: pinia.state.value });
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;
function htmlEscape(str: string): string {
return str.replace(ESCAPE_REGEX, (match) => ESCAPE_LOOKUP[match]);
}
export default app

View File

@@ -5,20 +5,21 @@ import { createUserWithEmailAndPassword, getAuth, GoogleAuthProvider, sendPasswo
// Your web app's Firebase configuration // Your web app's Firebase configuration
const firebaseConfig = { const firebaseConfig = {
apiKey: "AIzaSyBTr0L5qxdrVEtWuP2oAicJXQvVyeXkMts", apiKey: "AIzaSyBTr0L5qxdrVEtWuP2oAicJXQvVyeXkMts",
authDomain: "trello-7ea39.firebaseapp.com", authDomain: "trello-7ea39.firebaseapp.com",
projectId: "trello-7ea39", projectId: "trello-7ea39",
storageBucket: "trello-7ea39.firebasestorage.app", storageBucket: "trello-7ea39.firebasestorage.app",
messagingSenderId: "321067890572", messagingSenderId: "321067890572",
appId: "1:321067890572:web:e34e1e657125d37be688a9" appId: "1:321067890572:web:e34e1e657125d37be688a9"
}; };
// Initialize Firebase // Initialize Firebase
const appFirebase = initializeApp(firebaseConfig); const appFirebase = initializeApp(firebaseConfig);
const provider = new GoogleAuthProvider(); const provider = new GoogleAuthProvider();
const auth = getAuth(appFirebase); export const auth = getAuth(appFirebase);
export const googleAuth = signInWithPopup(auth, provider).then((result) => { export const googleAuth = () => signInWithPopup(auth, provider).then((result) => {
console.log('User signed in:', result.user); console.log('User signed in:', result.user);
return result;
}) })
export const emailAuth = (username: string, password: string) => { export const emailAuth = (username: string, password: string) => {
return signInWithEmailAndPassword(auth, username, password) return signInWithEmailAndPassword(auth, username, password)

12
src/lib/firebaseAdmin.ts Normal file
View File

@@ -0,0 +1,12 @@
// import { initializeApp, getApps, cert } from 'firebase-admin/app';
// import { getAuth } from 'firebase-admin/auth';
// import certJson from './cert.json';
// const firebaseAdminConfig = {
// credential: cert(certJson as any)
// };
// if (getApps().length === 0) {
// initializeApp(firebaseAdminConfig);
// }
// export const adminAuth = getAuth();

View File

@@ -84,7 +84,7 @@ export function buildBootstrapScript() {
assets: [], assets: [],
}, },
"1": { "1": {
file: "src/client/index.ts", file: "src/client.ts",
isEntry: true, isEntry: true,
css: [], css: [],
}, },

View File

@@ -1,80 +1,56 @@
import { NestFactory } from "@nestjs/core"; import { createHead as CSRHead } from "@unhead/vue/client";
import Bun from "bun"; import { createHead as SSRHead } from "@unhead/vue/server";
import { Hono } from "hono"; import { createSSRApp } from 'vue';
import { contextStorage } from "hono/context-storage"; import { RouterView } from 'vue-router';
import isMobile from "is-mobile"; import { withErrorBoundary } from './lib/hoc/withErrorBoundary';
import { AppModule } from "./server/app.module"; import { vueSWR } from './lib/swr/use-swrv';
import { HonoAdapter, NestHonoApplication } from "./server/common/adapter/hono"; import createAppRouter from './routes';
import { ssrRender } from "./server/HonoAdapter/ssrRender"; import PrimeVue from 'primevue/config';
import { TransformInterceptor } from "./server/common/interceptor/transform.interceptor"; import Aura from '@primeuix/themes/aura';
import { ZodValidationPipe } from "nestjs-zod"; import { createPinia } from "pinia";
declare global { import { useAuthStore } from './stores/auth';
var __APP__: { import ToastService from 'primevue/toastservice';
app?: NestHonoApplication; import Tooltip from 'primevue/tooltip';
hono?: Hono; const bodyClass = ":uno: font-sans text-gray-800 antialiased flex flex-col min-h-screen"
server?: Bun.Server<any>; export function createApp() {
} | undefined; const pinia = createPinia();
} const app = createSSRApp(withErrorBoundary(RouterView));
if (!globalThis.__APP__) { const head = import.meta.env.SSR ? SSRHead() : CSRHead();
globalThis.__APP__ = {};
} app.use(head);
if (globalThis.__APP__.app) { app.use(PrimeVue, {
await globalThis.__APP__.app.close(); // unstyled: true,
} theme: {
let serve: Bun.Server<undefined> | any = { preset: Aura,
stop: async () => {}, options: {
} darkModeSelector: '.my-app-dark',
const hono = new Hono(); cssLayer: false,
globalThis.__APP__.hono = hono; // cssLayer: {
// name: 'primevue',
const app = await NestFactory.create<NestHonoApplication>( // order: 'theme, base, primevue'
AppModule, // }
new HonoAdapter({ }
hono, }
close: () => { });
console.log("Closing server"); app.use(ToastService);
return serve!.stop(); app.directive('nh', {
}, created(el) {
address: () => String(serve!.hostname), el.__v_skip = true;
listen({ port, hostname, hono, httpsOptions = {}, forceCloseConnections }) { }
return new Promise<void>((resolve) => { });
serve = Bun.serve({ app.directive("tooltip", Tooltip)
port, if (!import.meta.env.SSR) {
hostname, Object.entries(JSON.parse(document.getElementById("__APP_DATA__")?.innerText || "{}")).forEach(([key, value]) => {
fetch: hono.fetch.bind(hono), (window as any)[key] = value;
}); });
console.log(`Server listening on http://${serve.hostname}:${serve.port}`); if ((window as any).$p ) {
resolve(); pinia.state.value = (window as any).$p;
}); }
}, }
}) app.use(pinia);
); app.use(vueSWR({revalidateOnFocus: false}));
globalThis.__APP__.app = app; const router = createAppRouter();
app.setGlobalPrefix("api"); app.use(router);
app.enableShutdownHooks();
// Validation Pipe (Zod) and Transform Interceptor return { app, router, head, pinia, bodyClass };
app.useGlobalInterceptors(new TransformInterceptor()); }
app.useGlobalPipes(new ZodValidationPipe());
app.useStaticAssets("/*", { root: "./dist/client" });
await app.init();
// Hono Zone Middleware
hono.use(async (c, next) => {
c.set("fetch", hono.request.bind(hono));
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();
}, contextStorage());
hono.get("/.well-known/*", (c) => {
return c.json({ ok: true });
});
hono.use(ssrRender);
if (import.meta.env.PROD) {
await app.listen(3500);
}
const honoDev = import.meta.env.DEV ? hono : null;
export default honoDev;
// };

View File

@@ -1,6 +1,6 @@
<template> <template>
<vue-head :input="{title: '404 - Page Not Found'}"/> <vue-head :input="{title: '404 - Page Not Found'}"/>
<div class=":m: mx-auto text-center mt-20 flex flex-col items-center gap-4"> <div class="mx-auto text-center mt-20 flex flex-col items-center gap-4">
<h1>404 - Page Not Found</h1> <h1>404 - Page Not Found</h1>
<p>The page you are looking for does not exist.</p> <p>The page you are looking for does not exist.</p>
<router-link class="btn btn-primary" to="/">Go back to Home</router-link> <router-link class="btn btn-primary" to="/">Go back to Home</router-link>
@@ -8,5 +8,5 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { VueHead } from "@/client/components/VueHead"; import { VueHead } from "@/components/VueHead";
</script> </script>

View File

@@ -1,5 +1,6 @@
<template> <template>
<div class="w-full"> <div class="w-full">
<Toast />
<Form v-slot="$form" :resolver="resolver" :initialValues="initialValues" @submit="onFormSubmit" <Form v-slot="$form" :resolver="resolver" :initialValues="initialValues" @submit="onFormSubmit"
class="flex flex-col gap-4 w-full"> class="flex flex-col gap-4 w-full">
<div class="text-sm text-gray-600 mb-2"> <div class="text-sm text-gray-600 mb-2">
@@ -36,6 +37,13 @@ import { zodResolver } from '@primevue/forms/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { useAuthStore } from '@/stores/auth';
import { useToast } from "primevue/usetoast";
import { forgotPassword } from '@/lib/firebase';
const auth = useAuthStore();
const toast = useToast();
const initialValues = reactive({ const initialValues = reactive({
email: '' email: ''
}); });
@@ -48,9 +56,11 @@ const resolver = zodResolver(
const onFormSubmit = ({ valid, values }: FormSubmitEvent) => { const onFormSubmit = ({ valid, values }: FormSubmitEvent) => {
if (valid) { if (valid) {
console.log('Form submitted:', values); forgotPassword(values.email).then(() => {
// toast.add({ severity: 'success', summary: 'Success', detail: 'Reset link sent', life: 3000 }); toast.add({ severity: 'success', summary: 'Success', detail: 'Reset link sent', life: 3000 });
// Handle actual forgot password logic here }).catch(() => {
toast.add({ severity: 'error', summary: 'Error', detail: auth.error, life: 3000 });
});
} }
}; };
</script> </script>

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class=":m: w-full max-w-md bg-white p-8 rounded-xl border border-gray-200 m-auto overflow-hidden"> <div class="w-full max-w-md bg-white p-8 rounded-xl border border-primary m-auto overflow-hidden">
<div class="text-center mb-8"> <div class="text-center mb-8">
<router-link to="/" class=":m: inline-flex items-center justify-center w-12 h-12 mb-4"> <router-link to="/" class="inline-flex items-center justify-center w-12 h-12 mb-4">
<img class="w-12 h-12" src="/apple-touch-icon.png" alt="Logo" /> <img class="w-12 h-12" src="/apple-touch-icon.png" alt="Logo" />
</router-link> </router-link>
<h2 class="text-2xl font-bold text-gray-900"> <h2 class="text-2xl font-bold text-gray-900">

View File

@@ -1,11 +1,11 @@
<template> <template>
<div class="w-full"> <div class="w-full">
<Toast />
<Form v-slot="$form" :resolver="resolver" :initialValues="initialValues" @submit="onFormSubmit" <Form v-slot="$form" :resolver="resolver" :initialValues="initialValues" @submit="onFormSubmit"
class="flex flex-col gap-4 w-full"> class="flex flex-col gap-4 w-full">
<Message v-if="auth.error" severity="error">Failed to sign in. Please check your credentials or try again later.</Message>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<label for="email" class="text-sm font-medium text-gray-700">Email or Username</label> <label for="email" class="text-sm font-medium text-gray-700">Email</label>
<InputText name="email" type="text" placeholder="admin or user@example.com" fluid <InputText name="email" type="text" placeholder="user@example.com" fluid
:disabled="auth.loading" /> :disabled="auth.loading" />
<Message v-if="$form.email?.invalid" severity="error" size="small" variant="simple">{{ <Message v-if="$form.email?.invalid" severity="error" size="small" variant="simple">{{
$form.email.error?.message }}</Message> $form.email.error?.message }}</Message>
@@ -19,10 +19,10 @@
$form.password.error?.message }}</Message> $form.password.error?.message }}</Message>
</div> </div>
<div class=":m: flex items-center justify-between"> <div class="flex items-center justify-between">
<div class=":m: flex items-center gap-2"> <div class="flex items-center gap-2">
<Checkbox inputId="remember-me" name="rememberMe" binary :disabled="auth.loading" /> <Checkbox inputId="remember-me" name="rememberMe" binary :disabled="auth.loading" />
<label for="remember-me" class=":m: text-sm text-gray-900">Remember me</label> <label for="remember-me" class="text-sm text-gray-900">Remember me</label>
</div> </div>
<div class="text-sm"> <div class="text-sm">
<router-link to="/forgot" <router-link to="/forgot"
@@ -56,15 +56,6 @@
<router-link to="/sign-up" class="font-medium text-blue-600 hover:text-blue-500 hover:underline">Sign up <router-link to="/sign-up" class="font-medium text-blue-600 hover:text-blue-500 hover:underline">Sign up
for free</router-link> for free</router-link>
</p> </p>
<!-- Hint for demo credentials -->
<div class="mt-2 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<p class="text-xs text-blue-800 font-medium mb-1">Demo Credentials:</p>
<p class="text-xs text-blue-600">Username: <code class="bg-blue-100 px-1 rounded">admin</code> |
Password: <code class="bg-blue-100 px-1 rounded">admin123</code></p>
<p class="text-xs text-blue-600">Email: <code class="bg-blue-100 px-1 rounded">user@example.com</code> |
Password: <code class="bg-blue-100 px-1 rounded">password</code></p>
</div>
</Form> </Form>
</div> </div>
</template> </template>
@@ -74,19 +65,24 @@ import { reactive } from 'vue';
import { Form, type FormSubmitEvent } from '@primevue/forms'; import { Form, type FormSubmitEvent } from '@primevue/forms';
import { zodResolver } from '@primevue/forms/resolvers/zod'; import { zodResolver } from '@primevue/forms/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { useAuthStore } from '@/client/stores/auth'; import { useAuthStore } from '@/stores/auth';
import Toast from 'primevue/toast';
import { useToast } from "primevue/usetoast";
const t = useToast();
const auth = useAuthStore(); const auth = useAuthStore();
// const $form = Form.useFormContext(); // const $form = Form.useFormContext();
watch(() => auth.error, (newError) => {
if (newError) {
t.add({ severity: 'error', summary: String(auth.error), detail: newError, life: 5000 });
}
});
const initialValues = reactive({ const initialValues = reactive({
email: '', email: '',
password: '', password: '',
rememberMe: false rememberMe: false
}); });
watch(() => initialValues, (newValues) => {
auth.error = null;
// console.log('Form values changed:', newValues);
});
const resolver = zodResolver( const resolver = zodResolver(
z.object({ z.object({
email: z.string().min(1, { message: 'Email or username is required.' }), email: z.string().min(1, { message: 'Email or username is required.' }),
@@ -99,7 +95,6 @@ const onFormSubmit = async ({ valid, values }: FormSubmitEvent) => {
}; };
const loginWithGoogle = () => { const loginWithGoogle = () => {
console.log('Login with Google'); auth.loginWithGoogle();
// Handle Google login logic here
}; };
</script> </script>

View File

@@ -43,6 +43,12 @@ import { zodResolver } from '@primevue/forms/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { useAuthStore } from '@/stores/auth';
import { useToast } from "primevue/usetoast";
const auth = useAuthStore();
const toast = useToast();
const initialValues = reactive({ const initialValues = reactive({
name: '', name: '',
email: '', email: '',
@@ -59,9 +65,9 @@ const resolver = zodResolver(
const onFormSubmit = ({ valid, values }: FormSubmitEvent) => { const onFormSubmit = ({ valid, values }: FormSubmitEvent) => {
if (valid) { if (valid) {
console.log('Form submitted:', values); auth.register(values.name, values.email, values.password).catch(() => {
// toast.add({ severity: 'success', summary: 'Success', detail: 'Account created successfully', life: 3000 }); toast.add({ severity: 'error', summary: 'Error', detail: auth.error, life: 3000 });
// Handle actual signup logic here });
} }
}; };
</script> </script>

234
src/routes/home/Home.vue Normal file
View File

@@ -0,0 +1,234 @@
<template>
<nav class="fixed w-full z-50 glass-nav transition-all duration-300" id="navbar">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<div class="flex items-center gap-2 cursor-pointer" onclick="window.scrollTo(0,0)"><img class="h-8 w-8" src="/apple-touch-icon.png" alt="Logo" />
<span class="font-bold text-xl tracking-tight text-slate-900">EcoStream</span>
</div>
<div class="hidden md:flex items-center space-x-8">
<a href="#features" class="text-sm font-medium text-slate-600 hover:text-brand-600 transition-colors">Features</a>
<a href="#pricing" class="text-sm font-medium text-slate-600 hover:text-brand-600 transition-colors">Pricing</a>
</div>
<div class="hidden md:flex items-center gap-4">
<RouterLink to="/login" class="text-sm font-semibold text-slate-600 hover:text-slate-900 cursor-pointer">Log in</RouterLink>
<RouterLink to="/sign-up" class="bg-slate-900 hover:bg-black text-white px-5 py-2.5 rounded-lg text-sm font-semibold cursor-pointer">
Start for free
</RouterLink>
</div>
</div>
</div>
</nav>
<section class="relative pt-32 pb-20 lg:pt-48 lg:pb-32 overflow-hidden">
<div class="absolute inset-0 opacity-[0.4] -z-10"></div>
<div class="absolute top-0 right-0 -translate-y-1/2 translate-x-1/2 w-[800px] h-[800px] bg-brand-100/50 rounded-full blur-3xl -z-10 mix-blend-multiply"></div>
<div class="absolute bottom-0 left-0 translate-y-1/2 -translate-x-1/2 w-[600px] h-[600px] bg-teal-100/50 rounded-full blur-3xl -z-10 mix-blend-multiply"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h1 class="text-5xl md:text-7xl font-extrabold tracking-tight text-slate-900 mb-6 leading-[1.1]">
Video infrastructure for <br>
<span class="text-gradient">modern internet.</span>
</h1>
<p class="text-xl text-slate-500 max-w-2xl mx-auto mb-10 leading-relaxed">
Seamlessly host, encode, and stream video with our developer-first API.
Optimized for speed, built for scale.
</p>
<div class="flex flex-col sm:flex-row justify-center gap-4">
<RouterLink to="/get-started" class="flex btn btn-secondary !rounded-xl !p-4 press-animated">
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="46 -286 524 580"><path d="M56 284v-560L560 4 56 284z" fill="#fff"/></svg>&nbsp;
Get Started
</RouterLink>
<RouterLink to="/docs" class="flex btn btn-outline-primary !rounded-xl">
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="-10 -261 468 503"><path d="M256-139V72c0 18-14 32-32 32s-32-14-32-32v-211l-41 42c-13 12-33 12-46 0-12-13-12-33 0-46l96-96c13-12 33-12 46 0l96 96c12 13 12 33 0 46-13 12-33 12-46 0l-41-42zm-32 291c44 0 80-36 80-80h80c35 0 64 29 64 64v32c0 35-29 64-64 64H64c-35 0-64-29-64-64v-32c0-35 29-64 64-64h80c0 44 36 80 80 80zm144 24c13 0 24-11 24-24s-11-24-24-24-24 11-24 24 11 24 24 24z" fill="#14a74b"/></svg>&nbsp;
Upload video
</RouterLink>
</div>
</div>
</section>
<section id="features" class="py-24 bg-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="mb-16 md:text-center max-w-3xl mx-auto">
<h2 class="text-3xl font-bold text-slate-900 mb-4">Everything you need to ship video</h2>
<p class="text-lg text-slate-500">Focus on building your product. We'll handle the complex video infrastructure.</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="md:col-span-2 bg-slate-50 rounded-2xl p-8 border border-slate-100 hover:border-primary/60 transition-all group overflow-hidden relative">
<div class="relative z-10">
<div class="w-12 h-12 bg-white rounded-xl flex items-center justify-center mb-6 border border-slate-100">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="532" viewBox="-8 -258 529 532"><path d="M342 32c-2 69-16 129-35 172-10 23-22 40-32 49-10 10-16 11-19 11h-1c-3 0-9-1-19-11-10-9-22-26-32-49-19-43-33-103-35-172h173zm169 0c-9 103-80 188-174 219 30-51 50-129 53-219h121zm-390 0c3 89 23 167 53 218C80 219 11 134 2 32h119zm53-266c-30 51-50 129-53 218H2c9-102 78-186 172-218zm82-14c3 0 9 1 19 11 10 9 22 26 32 50 19 42 33 102 35 171H169c3-69 16-129 35-171 10-24 22-41 32-50s16-11 19-11h1zm81 13c94 31 165 116 174 219H390c-3-90-23-168-53-219z" fill="#059669"/></svg>
</div>
<h3 class="text-xl font-bold text-slate-900 mb-2">Global Edge Network</h3>
<p class="text-slate-500 max-w-md">Content delivered from 200+ PoPs worldwide. Automatic region selection ensures the lowest latency for every viewer.</p>
</div>
<div class="absolute right-0 bottom-0 opacity-10 translate-x-1/4 translate-y-1/4">
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="-10 -258 532 532"><path d="M464 8c0-19-3-38-8-56l-27-5c-8-2-15 2-19 9-6 11-19 17-31 13l-14-5c-8-2-17 0-22 5-4 4-4 10 0 14l33 33c5 5 8 12 8 19 0 12-8 23-20 26l-6 1c-3 1-6 5-6 9v12c0 13-4 27-13 38l-25 34c-6 8-16 13-26 13-18 0-32-14-32-32V88c0-9-7-16-16-16h-32c-26 0-48-22-48-48V-4c0-13 6-24 16-32l39-30c6-4 13-6 20-6 3 0 7 1 10 2l32 10c7 3 15 3 22 1l36-9c10-2 17-11 17-22 0-8-5-16-13-20l-29-15c-3-2-8-1-11 2l-4 4c-4 4-11 7-17 7-4 0-8-1-11-3l-15-7c-7-4-15-2-20 4l-13 17c-6 7-16 8-22 1-3-2-5-6-5-10v-41c0-6-1-11-4-16l-10-18C102-154 48-79 48 8c0 115 93 208 208 208S464 123 464 8zM0 8c0-141 115-256 256-256S512-133 512 8 397 264 256 264 0 149 0 8z" fill="#1e3050"/></svg>
</div>
</div>
<div class="md:row-span-2 bg-slate-900 rounded-2xl p-8 text-white relative overflow-hidden group">
<div class="absolute inset-0 bg-gradient-to-b from-slate-800/50 to-transparent"></div>
<div class="relative z-10">
<div class="w-12 h-12 bg-white/10 rounded-xl flex items-center justify-center mb-6 backdrop-blur-sm border border-white/10">
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="-10 -146 468 384"><path d="M392-136c-31 0-56 25-56 56v280c0 16 13 28 28 28h28c31 0 56-25 56-56V-80c0-31-25-56-56-56zM168 4c0-31 25-56 56-56h28c16 0 28 13 28 28v224c0 16-12 28-28 28h-56c-15 0-28-12-28-28V4zM0 88c0-31 25-56 56-56h28c16 0 28 13 28 28v140c0 16-12 28-28 28H56c-31 0-56-25-56-56V88z" fill="#fff"/></svg>
</div>
<h3 class="text-xl font-bold mb-2">Live Streaming API</h3>
<p class="text-slate-400 text-sm leading-relaxed mb-8">Scale to millions of concurrent viewers with ultra-low latency. RTMP ingest and HLS playback supported natively.</p>
<!-- Visual -->
<div class="bg-slate-800/50 rounded-lg p-4 border border-white/5 font-mono text-xs text-brand-300">
<div class="flex justify-between items-center mb-3 border-b border-white/5 pb-2">
<span class="text-slate-500">Live Status</span>
<span class="flex items-center gap-1.5 text-red-500 text-[10px] uppercase font-bold tracking-wider animate-pulse"><span class="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse"></span> On Air</span>
</div>
<div class="space-y-1">
<div class="flex justify-between"><span class="text-slate-400">Bitrate:</span> <span class="text-white">6000 kbps</span></div>
<div class="flex justify-between"><span class="text-slate-400">FPS:</span> <span class="text-white">60</span></div>
<div class="flex justify-between"><span class="text-slate-400">Latency:</span> <span class="text-brand-400">~2s</span></div>
</div>
</div>
</div>
</div>
<!-- Standard Feature -->
<div class="bg-slate-50 rounded-2xl p-8 border border-slate-100 hover:border-brand-200 transition-all group hover:shadow-lg hover:shadow-brand-500/5">
<div class="w-12 h-12 bg-white rounded-xl shadow-sm flex items-center justify-center mb-6 text-purple-600 border border-slate-100">
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="0 0 570 570"><path d="M50 428c-5 5-5 14 0 19s14 5 19 0l237-237c5-5 5-14 0-19s-14-5-19 0L50 428zm16-224c-5 5-5 13 0 19 5 5 14 5 19 0l12-12c5-5 5-14 0-19-6-5-14-5-20 0l-11 12zM174 60c-5 5-5 13 0 19 5 5 14 5 19 0l12-12c5-5 5-14 0-19-6-5-14-5-20 0l-11 12zm215 29c-5 5-5 14 0 19s14 5 19 0l39-39c5-5 5-14 0-19s-14-5-19 0l-39 39zm21 357c-5 5-5 14 0 19s14 5 19 0l18-18c5-5 5-14 0-19s-14-5-19 0l-18 18z" fill="#a6acb9"/><path d="M170 26c14-15 36-15 50 0l18 18c15 14 15 36 0 50l-18 18c-14 15-36 15-50 0l-18-18c-15-14-15-36 0-50l18-18zm35 41c5-5 5-14 0-19-6-5-14-5-20 0l-11 12c-5 5-5 13 0 19 5 5 14 5 19 0l12-12zm204 342c21-21 55-21 76 0l18 18c21 21 21 55 0 76l-18 18c-21 21-55 21-76 0l-18-18c-21-21-21-55 0-76l18-18zm38 38c5-5 5-14 0-19s-14-5-19 0l-18 18c-5 5-5 14 0 19s14 5 19 0l18-18zM113 170c-15-15-37-15-51 0l-18 18c-14 14-14 36 0 50l18 18c14 15 37 15 51 0l18-18c14-14 14-36 0-50l-18-18zm-16 41-12 12c-5 5-14 5-19 0-5-6-5-14 0-20l11-11c6-5 14-5 20 0 5 5 5 14 0 19zM485 31c-21-21-55-21-76 0l-39 39c-21 21-21 55 0 76l54 54c21 21 55 21 76 0l39-39c21-21 21-55 0-76l-54-54zm-38 38-39 39c-5 5-14 5-19 0s-5-14 0-19l39-39c5-5 14-5 19 0s5 14 0 19zm-49 233c21-21 21-55 0-76l-54-54c-21-21-55-21-76 0L31 409c-21 21-21 55 0 76l54 54c21 21 55 21 76 0l237-237zm-92-92L69 447c-5 5-14 5-19 0s-5-14 0-19l237-237c5-5 14-5 19 0s5 14 0 19z" fill="#1e3050"/></svg>
</div>
<h3 class="text-xl font-bold text-slate-900 mb-2">Instant Encoding</h3>
<p class="text-slate-500 text-sm">Upload raw files and get optimized HLS/DASH streams in seconds.</p>
</div>
<!-- Standard Feature -->
<div class="bg-slate-50 rounded-2xl p-8 border border-slate-100 hover:border-brand-200 transition-all group hover:shadow-lg hover:shadow-brand-500/5">
<div class="w-12 h-12 bg-white rounded-xl shadow-sm flex items-center justify-center mb-6 text-orange-600 border border-slate-100">
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="-10 -226 532 468"><path d="M32-216c18 0 32 14 32 32v336c0 9 7 16 16 16h400c18 0 32 14 32 32s-14 32-32 32H80c-44 0-80-36-80-80v-336c0-18 14-32 32-32zM144-24c18 0 32 14 32 32v64c0 18-14 32-32 32s-32-14-32-32V8c0-18 14-32 32-32zm144-64V72c0 18-14 32-32 32s-32-14-32-32V-88c0-18 14-32 32-32s32 14 32 32zm80 32c18 0 32 14 32 32v96c0 18-14 32-32 32s-32-14-32-32v-96c0-18 14-32 32-32zm144-96V72c0 18-14 32-32 32s-32-14-32-32v-224c0-18 14-32 32-32s32 14 32 32z" fill="#1e3050"/></svg>
</div>
<h3 class="text-xl font-bold text-slate-900 mb-2">Deep Analytics</h3>
<p class="text-slate-500 text-sm">Session-level insights, quality of experience (QoE) metrics, and more.</p>
</div>
</div>
</div>
</section>
<!-- Pricing -->
<section id="pricing" class="py-24 border-t border-slate-100 bg-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-3xl font-bold text-slate-900 mb-4">{{ pricing.title }}</h2>
<p class="text-slate-500">{{ pricing.subtitle }}</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 w-full">
<div v-for="pack in pricing.packs" :key="pack.name" :class="cn(':uno: p-8 rounded-2xl relative overflow-hidden hover:border-primary transition-colors flex flex-col justify-between', pack.tag == 'POPULAR' ? 'border-primary/80 border-2' : 'border-slate-200 border')" :style="{background: pack.bg}">
<div v-if="pack.tag" class="absolute top-0 right-0 bg-primary/80 text-white text-xs font-bold px-3 py-1 rounded-bl-lg uppercase">{{ pack.tag }}</div>
<div>
<h3 class="font-semibold text-slate-900 text-xl mb-2">{{ pack.name }}</h3>
<div class="flex items-baseline gap-1 mb-6">
<span class="text-4xl font-bold text-slate-900">{{ pack.price }}</span>
<span class="text-slate-500">/mo</span>
</div>
</div>
<ul class="space-y-3 mb-8 text-sm text-slate-600">
<li v-for="value in pack.features" :key="value" class="flex items-center gap-3"><Check-Icon class="fas fa-check text-brand-500"/> {{ value }}</li>
</ul>
<router-link to="/sign-up" :class="cn('btn flex justify-center w-full !py-2.5', pack.tag == 'POPULAR' ? 'btn-primary' : 'btn-outline-primary')">{{ pack.buttonText }}</router-link>
</div>
</div>
</div>
</section>
<!-- Footer -->
<footer class="bg-white border-t border-slate-100 pt-16 pb-8">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid grid-cols-2 md:grid-cols-5 gap-8 mb-12">
<div class="col-span-2">
<div class="flex items-center gap-2 mb-4">
<div class="w-6 h-6 bg-brand-600 rounded flex items-center justify-center text-white">
<img class="h-6 w-6" src="/apple-touch-icon.png" alt="Logo" />
</div>
<span class="font-bold text-lg text-slate-900">EcoStream</span>
</div>
<p class="text-slate-500 text-sm max-w-xs">Building the video layer of the internet. Designed for developers.</p>
</div>
<div>
<h4 class="font-semibold text-slate-900 mb-4 text-sm">Product</h4>
<ul class="space-y-2 text-sm text-slate-500">
<li><a href="#" class="hover:text-brand-600">Features</a></li>
<li><a href="#" class="hover:text-brand-600">Pricing</a></li>
<li><a href="#" class="hover:text-brand-600">Showcase</a></li>
</ul>
</div>
<div>
<h4 class="font-semibold text-slate-900 mb-4 text-sm">Company</h4>
<ul class="space-y-2 text-sm text-slate-500">
<li><a href="#" class="hover:text-brand-600">About</a></li>
<li><a href="#" class="hover:text-brand-600">Blog</a></li>
<li><a href="#" class="hover:text-brand-600">Careers</a></li>
</ul>
</div>
<div>
<h4 class="font-semibold text-slate-900 mb-4 text-sm">Legal</h4>
<ul class="space-y-2 text-sm text-slate-500">
<li><a href="#" class="hover:text-brand-600">Privacy</a></li>
<li><a href="#" class="hover:text-brand-600">Terms</a></li>
</ul>
</div>
</div>
<div class="pt-8 border-t border-slate-100 text-center text-sm text-slate-400">
&copy; 2026 EcoStream Inc. All rights reserved.
</div>
</div>
</footer>
<Head>
<title>EcoStream - Video infrastructure for modern internet</title>
<meta name="description" content="Seamlessly host, encode, and stream video with our developer-first API. Optimized for speed, built for scale." />
</Head>
</template>
<script lang="ts" setup>
import { Head } from '@unhead/vue/components'
import { cn } from '@/lib/utils';
const pricing = {
title: "Simple, transparent pricing",
subtitle: "Choose the plan that fits your needs. No hidden fees.",
packs: [
{
name: "Hobby",
price: "$0",
features: [
"Unlimited upload",
"1 Hour of Storage",
"Standard Support",
],
buttonText: "Start Free",
tag: "",
bg: "#f9fafb",
},
{
name: "Pro",
price: "$29",
features: [
"Ads free player",
"Support M3U8",
"Unlimited upload",
"Custom ads"
],
buttonText: "Get Started",
tag: "POPULAR",
bg: "#eff6ff",
},
{
name: "Scale",
price: "$99",
features: [
"5 TB Bandwidth",
"500 Hours Storage",
"Priority Support"
],
buttonText: "Contact Sales",
tag: "Best Value",
bg: "#eef4f7",
}
]
}
</script>

157
src/routes/index.ts Normal file
View File

@@ -0,0 +1,157 @@
import { type ReactiveHead, type ResolvableValue } from "@unhead/vue";
import { headSymbol } from '@unhead/vue'
import {
createMemoryHistory,
createRouter,
createWebHistory,
type RouteRecordRaw,
} from "vue-router";
import { useAuthStore } from "@/stores/auth";
type RouteData = RouteRecordRaw & {
meta?: ResolvableValue<ReactiveHead> & { requiresAuth?: boolean };
children?: RouteData[];
};
const routes: RouteData[] = [
{
path: "/",
component: () => import("@/components/RootLayout.vue"),
children: [
{
path: "",
component: () => import("./home/Home.vue"),
beforeEnter: (to, from, next) => {
const auth = useAuthStore();
if (auth.user) {
next({ name: "overview" });
} else {
next();
}
},
},
{
path: "",
component: () => import("./auth/layout.vue"),
beforeEnter: (to, from, next) => {
const auth = useAuthStore();
if (auth.user) {
next({ name: "overview" });
} else {
next();
}
},
children: [
{
path: "login",
name: "login",
component: () => import("./auth/login.vue"),
},
{
path: "sign-up",
name: "signup",
component: () => import("./auth/signup.vue"),
},
{
path: "forgot",
name: "forgot",
component: () => import("./auth/forgot.vue"),
},
],
},
{
path: "",
component: () => import("@/components/DashboardLayout.vue"),
meta: { requiresAuth: true },
children: [
{
path: "",
name: "overview",
component: () => import("./add/Add.vue"),
meta: {
head: {
title: 'Overview - Holistream',
},
}
},
{
path: "upload",
name: "upload",
component: () => import("./add/Add.vue"),
meta: {
head: {
title: 'Upload - Holistream',
},
}
},
{
path: "video",
name: "video",
component: () => import("./add/Add.vue"),
meta: {
head: {
title: 'Videos - Holistream',
meta: [
{ name: 'description', content: 'Manage your video content.' },
],
},
}
},
{
path: "plans",
name: "plans",
component: () => import("./add/Add.vue"),
meta: {
head: {
title: 'Plans & Billing',
meta: [
{ name: 'description', content: 'Manage your plans and billing information.' },
],
},
}
},
{
path: "notification",
name: "notification",
component: () => import("./add/Add.vue"),
meta: {
head: {
title: 'Notification - Holistream',
},
}
},
],
},
{
path: "/:pathMatch(.*)*",
name: "not-found",
component: () => import("./NotFound.vue"),
}
],
},
];
const createAppRouter = () => {
const router = createRouter({
history: import.meta.env.SSR
? createMemoryHistory() // server
: createWebHistory(), // client
routes,
});
router.beforeEach((to, from, next) => {
const auth = useAuthStore();
const head = inject(headSymbol);
(head as any).push(to.meta.head || {});
if (to.matched.some((record) => record.meta.requiresAuth)) {
if (!auth.user) {
next({ name: "login" });
} else {
next();
}
} else {
next();
}
});
return router;
}
export default createAppRouter;

View File

@@ -1,437 +0,0 @@
import { RequestMethod } from '@nestjs/common';
import { HttpStatus, Logger } from '@nestjs/common';
import { ErrorHandler, NestApplicationOptions, RequestHandler } from '@nestjs/common/interfaces';
import { isObject } from '@nestjs/common/utils/shared.utils';
import { AbstractHttpAdapter } from '@nestjs/core/adapters/http-adapter';
import { Context, Next, Hono } from 'hono';
import { bodyLimit } from 'hono/body-limit';
import { cors } from 'hono/cors';
// import { Data } from 'hono/dist/types/context';
import { RedirectStatusCode, StatusCode } from 'hono/utils/http-status';
import { HonoRequest, TypeBodyParser } from './interfaces';
import { Data } from 'node_modules/hono/dist/types/context';
// Define HttpBindings as an empty interface if you don't need specific bindings
interface HttpBindings {}
type HonoHandler = RequestHandler<HonoRequest, Context>;
type Ctx = Context | (() => Promise<Context>);
type Method =
| 'all'
| 'get'
| 'post'
| 'put'
| 'delete'
| 'use'
| 'patch'
| 'options';
/**
* Adapter for using Hono with NestJS.
*/
export class HonoAdapter extends AbstractHttpAdapter<
any,
HonoRequest,
Context
> {
private _isParserRegistered: boolean = false;
protected readonly instance: Hono<{ Bindings: HttpBindings }>;
constructor() {
const honoInstance = new Hono<{ Bindings: HttpBindings }>();
super(honoInstance);
this.instance = honoInstance;
}
get isParserRegistered(): boolean {
return !!this._isParserRegistered;
}
private getRouteAndHandler(
pathOrHandler: string | HonoHandler,
handler?: HonoHandler,
): [string, HonoHandler] {
const path = typeof pathOrHandler === 'function' ? '' : pathOrHandler;
handler = typeof pathOrHandler === 'function' ? pathOrHandler : handler;
return [path, handler!];
}
private createRouteHandler(routeHandler: HonoHandler) {
return async (ctx: Context, next: Next) => {
// ctx.req['params'] = ctx.req.param();
// ctx.req['params'] = ctx.req.param();
Object.assign(ctx.req, { params: ctx.req.param() });
await routeHandler(ctx.req, ctx, next);
return this.getBody(ctx);
};
}
private async normalizeContext(ctx: Ctx): Promise<Context> {
if (typeof ctx === 'function') {
return await ctx();
}
return ctx;
}
private async getBody(ctx: Ctx, body?: Data) {
ctx = await this.normalizeContext(ctx);
if (body === undefined && ctx.res && ctx.res.body !== null) {
return ctx.res;
}
let responseContentType = await this.getHeader(ctx, 'Content-Type');
if (!responseContentType || responseContentType.startsWith('text/plain')) {
if (
body instanceof Buffer ||
body instanceof Uint8Array ||
body instanceof ArrayBuffer ||
body instanceof ReadableStream
) {
responseContentType = 'application/octet-stream';
} else if (isObject(body)) {
responseContentType = 'application/json';
}
await this.setHeader(ctx, 'Content-Type', String(responseContentType));
}
if (responseContentType === 'application/json' && isObject(body)) {
return ctx.json(body);
} else if (body === undefined) {
return ctx.newResponse(null);
}
return ctx.body(body);
}
private registerRoute(
method: Method,
pathOrHandler: string | HonoHandler,
handler?: HonoHandler,
) {
const [routePath, routeHandler] = this.getRouteAndHandler(
pathOrHandler,
handler,
);
const routeHandler2 = this.createRouteHandler(routeHandler);
switch (method) {
case 'all':
this.instance.all(routePath, routeHandler2);
break;
case 'get':
this.instance.get(routePath, routeHandler2);
break;
case 'post':
this.instance.post(routePath, routeHandler2);
break;
case 'put':
this.instance.put(routePath, routeHandler2);
break;
case 'delete':
this.instance.delete(routePath, routeHandler2);
break;
case 'use':
this.instance.use(routePath, routeHandler2);
break;
case 'patch':
this.instance.patch(routePath, routeHandler2);
break;
case 'options':
this.instance.options(routePath, routeHandler2);
break;
}
}
public all(pathOrHandler: string | HonoHandler, handler?: HonoHandler) {
this.registerRoute('all', pathOrHandler, handler);
}
public get(pathOrHandler: string | HonoHandler, handler?: HonoHandler) {
this.registerRoute('get', pathOrHandler, handler);
}
public post(pathOrHandler: string | HonoHandler, handler?: HonoHandler) {
this.registerRoute('post', pathOrHandler, handler);
}
public put(pathOrHandler: string | HonoHandler, handler?: HonoHandler) {
this.registerRoute('put', pathOrHandler, handler);
}
public delete(pathOrHandler: string | HonoHandler, handler?: HonoHandler) {
this.registerRoute('delete', pathOrHandler, handler);
}
public use(pathOrHandler: string | HonoHandler, handler?: HonoHandler) {
this.registerRoute('use', pathOrHandler, handler);
}
public patch(pathOrHandler: string | HonoHandler, handler?: HonoHandler) {
this.registerRoute('patch', pathOrHandler, handler);
}
public options(pathOrHandler: string | HonoHandler, handler?: HonoHandler) {
this.registerRoute('options', pathOrHandler, handler);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public async reply(ctx: Ctx, body: any, statusCode?: StatusCode) {
ctx = await this.normalizeContext(ctx);
if (statusCode) {
ctx.status(statusCode);
}
ctx.res = await this.getBody(ctx, body);
}
public async status(ctx: Ctx, statusCode: StatusCode) {
ctx = await this.normalizeContext(ctx);
return ctx.status(statusCode);
}
public async end() {
// return RESPONSE_ALREADY_SENT;
new Response(null, {
headers: { ['x-hono-already-sent']: "true" }
})
}
public render() {
throw new Error('Method not implemented.');
}
public async redirect(ctx: Ctx, statusCode: RedirectStatusCode, url: string) {
ctx = await this.normalizeContext(ctx);
ctx.res = ctx.redirect(url, statusCode);
}
public setErrorHandler(handler: ErrorHandler) {
this.instance.onError(async (err: Error, ctx: Context) => {
await handler(err, ctx.req, ctx);
return this.getBody(ctx);
});
}
public setNotFoundHandler(handler: RequestHandler) {
this.instance.notFound(async (ctx: Context) => {
await handler(ctx.req, ctx);
await this.status(ctx, HttpStatus.NOT_FOUND);
return this.getBody(ctx, 'Not Found');
});
}
public async useStaticAssets(path: string, options: any) {
Logger.log('Registering static assets middleware');
if ((process as any).versions?.bun && import.meta.env.PROD) {
const { serveStatic } = await import("hono/bun");
// app.use(serveStatic({ root: "./dist/client" }))
// this.instance.use(serveStatic({ root: "./dist/client" }))
this.instance.use(path, serveStatic(options));
}
}
public setViewEngine() {
throw new Error('Method not implemented.');
}
public async isHeadersSent(ctx: Ctx): Promise<boolean> {
ctx = await this.normalizeContext(ctx);
return ctx.finalized;
}
public async getHeader(ctx: Ctx, name: string) {
ctx = await this.normalizeContext(ctx);
return ctx.res.headers.get(name);
}
public async setHeader(ctx: Ctx, name: string, value: string) {
ctx = await this.normalizeContext(ctx);
ctx.res.headers.set(name, value);
}
public async appendHeader(ctx: Ctx, name: string, value: string) {
ctx = await this.normalizeContext(ctx);
ctx.res.headers.append(name, value);
}
public async getRequestHostname(ctx: Ctx): Promise<string> {
ctx = await this.normalizeContext(ctx);
return ctx.req.header().host;
}
public getRequestMethod(request: HonoRequest): string {
return request.method;
}
public getRequestUrl(request: HonoRequest): string {
return request.url;
}
public enableCors(options: {
origin:
| string
| string[]
| ((origin: string, c: Context) => string | undefined | null);
allowMethods?: string[];
allowHeaders?: string[];
maxAge?: number;
credentials?: boolean;
exposeHeaders?: string[];
}) {
this.instance.use(cors(options));
}
public useBodyParser(
type: TypeBodyParser,
rawBody?: boolean,
bodyLimit?: number,
) {
Logger.log(
`Registering body parser middleware for type: ${type} ${bodyLimit ? 'with limit ' + bodyLimit + ' bytes' : ''}`,
);
if(bodyLimit !== undefined) {
this.instance.use(this.bodyLimit(bodyLimit));
}
// To avoid the Nest application init to override our custom
// body parser, we mark the parsers as registered.
this._isParserRegistered = true;
}
public close(): Promise<void> {
return new Promise((resolve) => this.httpServer.close(() => resolve()));
}
private extractClientIp(ctx: Context): string {
return (
ctx.req.header('cf-connecting-ip') ??
ctx.req.header('x-forwarded-for') ??
ctx.req.header('x-real-ip') ??
ctx.req.header('forwarded') ??
ctx.req.header('true-client-ip') ??
ctx.req.header('x-client-ip') ??
ctx.req.header('x-cluster-client-ip') ??
ctx.req.header('x-forwarded') ??
ctx.req.header('forwarded-for') ??
ctx.req.header('via')??
"unknown"
);
}
private async parseRequestBody(
ctx: Context,
contentType: string,
rawBody: boolean,
): Promise<void> {
if (
contentType?.startsWith('multipart/form-data') ||
contentType?.startsWith('application/x-www-form-urlencoded')
) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(ctx.req as any).body = await ctx.req
.parseBody({
all: true,
})
.catch(() => {});
} else if (
contentType?.startsWith('application/json') ||
contentType?.startsWith('text/plain')
) {
if (rawBody) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(ctx.req as any).rawBody = Buffer.from(await ctx.req.text());
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(ctx.req as any).body = await ctx.req.json().catch(() => {});
}
}
public initHttpServer(options: NestApplicationOptions) {
Logger.log('Initializing Hono HTTP server adapter');
this.instance.use(async (ctx, next) => {
// ctx.req['ip'] = this.extractClientIp(ctx);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// ctx.req['query'] = ctx.req.query() as any;
// ctx.req['headers'] = Object.fromEntries(ctx.req.raw.headers);
Object.assign(ctx.req, {
ip: this.extractClientIp(ctx),
query: ctx.req.query() as Record<string, string>,
headers: Object.fromEntries(ctx.req.raw.headers as any),
})
const contentType = ctx.req.header('content-type');
if (contentType) {
await this.parseRequestBody(ctx, contentType, options.rawBody!);
}
await next();
});
return this.httpServer;
}
public getType(): string { return 'hono'; }
public registerParserMiddleware(_prefix?: string, rawBody?: boolean) {
if (this._isParserRegistered) {
return;
}
Logger.log('Registering parser middleware');
this.useBodyParser('application/x-www-form-urlencoded', rawBody);
this.useBodyParser('application/json', rawBody);
this.useBodyParser('text/plain', rawBody);
this._isParserRegistered = true;
}
public async createMiddlewareFactory(requestMethod: RequestMethod) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
return (path: string, callback: Function) => {
const routeMethodsMap: Record<string, Function> = {
[RequestMethod.ALL]: this.instance.all,
[RequestMethod.DELETE]: this.instance.delete,
[RequestMethod.GET]: this.instance.get,
[RequestMethod.OPTIONS]: this.instance.options,
[RequestMethod.PATCH]: this.instance.patch,
[RequestMethod.POST]: this.instance.post,
[RequestMethod.PUT]: this.instance.put,
};
const routeMethod = (
routeMethodsMap[requestMethod] || this.instance.get
).bind(this.instance);
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
routeMethod(path, async (ctx: Context, next: Function) => {
await callback(ctx.req, ctx, next);
});
};
}
public applyVersionFilter(): () => () => unknown {
throw new Error('Versioning not yet supported in Hono');
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public listen(...args: any[]) {}
public getHonoInstance(): Hono<{ Bindings: HttpBindings }> {
return this.instance;
}
public bodyLimit(maxSize: number) {
return bodyLimit({
maxSize,
onError: (ctx) => {
const errorMessage = `Body size exceeded: ${maxSize} bytes. Size: ${ctx.req.header('Content-Length')} bytes. Method: ${ctx.req.method}. Path: ${ctx.req.path}`;
throw new Error(errorMessage);
},
});
}
}

View File

@@ -1,80 +0,0 @@
import { HonoRequest as Request } from 'hono';
// import { ServeStaticOptions } from '@hono/node-server/serve-static';
import { HttpServer, INestApplication } from '@nestjs/common';
import { Context, Hono, MiddlewareHandler } from 'hono';
import { ServeStaticOptions } from 'hono/serve-static';
export type TypeBodyParser =
| 'application/json'
| 'text/plain'
| 'application/x-www-form-urlencoded';
interface HonoViewOptions {
engine: string;
templates: string;
}
/**
* @publicApi
*/
export interface NestHonoApplication<
TServer extends Hono = Hono,
> extends INestApplication<TServer> {
/**
* Returns the underlying HTTP adapter bounded to a Hono app.
*
* @returns {HttpServer}
*/
getHttpAdapter(): HttpServer<Context, MiddlewareHandler, Hono>;
/**
* Register Hono body parsers on the fly.
*
* @example
* // enable the json parser with a parser limit of 50mb
* app.useBodyParser('application/json', 50 * 1024 * 1024);
*
* @returns {this}
*/
useBodyParser(type: TypeBodyParser, bodyLimit?: number): this;
/**
* Sets a base directory for public assets.
* Example `app.useStaticAssets('public', { root: '/' })`
* @returns {this}
*/
useStaticAssets(path: string, options: ServeStaticOptions): this;
/**
* Sets a view engine for templates (views), for example: `pug`, `handlebars`, or `ejs`.
*
* Don't pass in a string. The string type in the argument is for compatibility reason and will cause an exception.
* @returns {this}
*/
setViewEngine(options: HonoViewOptions | string): this;
/**
* Starts the application.
* @returns A Promise that, when resolved, is a reference to the underlying HttpServer.
*/
listen(
port: number | string,
callback?: (err: Error, address: string) => void,
): Promise<TServer>;
listen(
port: number | string,
address: string,
callback?: (err: Error, address: string) => void,
): Promise<TServer>;
listen(
port: number | string,
address: string,
backlog: number,
callback?: (err: Error, address: string) => void,
): Promise<TServer>;
fetch(input: RequestInfo, init?: RequestInit): Promise<Response>;
getHonoInstance(): Hono;
}
export type HonoRequest = Request & {
headers?: Record<string, string>;
};

View File

@@ -1,95 +0,0 @@
import createVueApp from "@/shared/createVueApp";
import { renderSSRHead } from "@unhead/vue/server";
import { Context } from "hono";
import { streamText } from "hono/streaming";
import { renderToWebStream } from "vue/server-renderer";
import { buildBootstrapScript } from "@/client/lib/manifest";
import { styleTags } from "@/client/lib/primePassthrough";
import { useAuthStore } from "@/client/stores/auth";
// @ts-ignore
import Base from "@primevue/core/base";
import { BlankEnv, BlankInput } from "hono/types";
const defaultNames = [
"primitive",
"semantic",
"global",
"base",
"ripple-directive",
];
export async function ssrRender(
c: Context<BlankEnv, "*", BlankInput>
): Promise<Response> {
if (c.req.method !== "GET") {
return c.json({ error: "Method not allowed" }, 405);
}
const nonce = crypto.randomUUID();
const url = new URL(c.req.url);
const { app, router, head, pinia, bodyClass } = createVueApp();
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, ""))
);
// 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="stylesheet" href="https://rsms.me/inter/inter.css">');
await stream.write('<link rel="icon" href="/favicon.ico" />');
await stream.write('<link rel="shortcut icon" href="/favicon-32x32.png" sizes="32x32" type="image/x-icon">');
await stream.write('<link rel="icon" href="/android-chrome-192x192.png" sizes="192x192" type="image/x-icon">');
await stream.write('<link rel="icon" href="/android-chrome-512x512.png" sizes="512x512" type="image/x-icon">');
await stream.write('<link rel="apple-touch-icon" href="/apple-touch-icon.png">');
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(appStream);
Object.assign(ctx, { $p: pinia.state.value });
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;
function htmlEscape(str: string): string {
return str.replace(ESCAPE_REGEX, (match) => ESCAPE_LOOKUP[match]);
}

View File

@@ -1,18 +0,0 @@
import { MiddlewareConsumer, Module, NestModule } from "@nestjs/common";
import { APP_FILTER } from "@nestjs/core";
import { HttpExceptionFilter } from "./common/filter/http-exception.filter";
import { LoggerMiddleware } from "./middleware";
@Module({
providers: [
{
provide: APP_FILTER,
useClass: HttpExceptionFilter,
},
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(LoggerMiddleware).forRoutes("*");
}
}

View File

@@ -1,86 +0,0 @@
import { Hono } from "hono";
import type { NestApplicationOptions } from "@nestjs/common/interfaces";
import type { Server as NodeHttpServer } from "node:http";
import type { NestHttpServerRequired } from "./_nest";
import { createNestRequiredHttpServer, isFakeHttpServer } from "./_util";
import { HonoRouterAdapter } from "./_adapter";
export type InitHttpServerConfig = Pick<NestApplicationOptions, "httpsOptions" | "forceCloseConnections"> & {
hono: Hono;
};
export interface CreateHonoAdapterOption {
/** Called during server initialization */
initHttpServer?(config: InitHttpServerConfig): NestHttpServerRequired;
/** Obtain the address information for HTTP Server listening */
address?: () => ReturnType<NodeHttpServer["address"]>;
/** Call when shutting down the server */
close?: () => Promise<void>;
listen?: (config: InitHttpServerConfig & { port: number; hostname?: string }) => Promise<void>;
/** Customize Hono instance */
hono?: Hono;
}
/**
* Adapter for using Hono with NestJS.
*/
export class HonoAdapter extends HonoRouterAdapter {
#honoAdapterConfig: CreateHonoAdapterOption;
constructor(config: CreateHonoAdapterOption = {}) {
super(config.hono ?? new Hono());
this.#honoAdapterConfig = config;
}
override listen(port: string | number, callback?: () => void): void;
override listen(port: string | number, hostname: string, callback?: () => void): void;
override async listen(port: string | number, ...args: any[]): Promise<void> {
port = +port;
let callback = args[args.length - 1];
if (typeof callback !== "function") callback = undefined;
const config = this.#honoAdapterConfig;
if (config.listen) {
try {
const hostname = typeof args[0] === "string" ? args[0] : undefined;
await config.listen({
...this.#initHttServerOption!,
port: +port,
hostname,
hono: this.instance,
});
} catch (error) {
callback(error);
return;
}
}
if (isFakeHttpServer(this.httpServer)) {
this.httpServer.address = config.address ?? (() => "127.0.0.1");
callback?.();
} else {
try {
port = +port;
return this.httpServer.listen!(port, ...args);
} catch (error) {
callback?.(error);
}
}
}
//implement
async close(): Promise<void> {
if (!isFakeHttpServer(this.httpServer) && this.httpServer.close) {
await new Promise<void>((resolve, reject) => {
return this.httpServer.close!((err) => (err ? reject(err) : resolve()));
});
}
return this.#honoAdapterConfig.close?.();
}
#initHttServerOption?: Omit<InitHttpServerConfig, "hono">;
//implement
initHttpServer(options: NestApplicationOptions = {}) {
const { forceCloseConnections, httpsOptions } = options;
this.#initHttServerOption = { forceCloseConnections, httpsOptions };
const httpServer = this.#honoAdapterConfig.initHttpServer?.({ ...this.#initHttServerOption!, hono: this.instance });
this.httpServer = httpServer ?? createNestRequiredHttpServer();
}
}

View File

@@ -1,36 +0,0 @@
import type { INestApplication } from "@nestjs/common";
import type { HonoApplicationExtra, HonoBodyParser } from "./hono.impl.ts";
import type { CORSOptions } from "./_adapter.ts";
import type { NestHttpServerRequired } from "./_nest.ts";
import type { MiddlewareHandler } from "hono";
export interface NestHonoApplication<TServer extends NestHttpServerRequired = NestHttpServerRequired>
extends INestApplication<TServer>, HonoApplicationExtra {
use(...handlers: MiddlewareHandler[]): this;
use(path: string, ...handlers: MiddlewareHandler[]): this;
// getHttpAdapter(): HonoAdapter;
/**
* By default, `application/json`, `application/x-www-form-urlencoded`, `multipart/form-data` and `text/plain` are automatically resolved
* You can customize the parser, for example to parse the `application/custom` request body unmapped
* ```ts
* const app = await NestFactory.create<NestHonoApplication>(AppModule, adapter);
* app.useBodyParser("application/custom", async (honoRequest) => {
* const json = await honoRequest.json();
* return new Map(json);
* });
*
* @Controller()
* class Test {
* @Get("data")
* method(@Body() body: Map) {
* return data;
* }
* }
*/
useBodyParser(contentType: string, parser: HonoBodyParser): void;
enableCors(options?: CORSOptions): void;
enableCors(options: any): void;
}
declare module "@nestjs/common" {
interface INestApplication<TServer = any> {}
}

View File

@@ -1,298 +0,0 @@
import type { Context, Env, Hono, Next, Schema } from "hono";
import type { ServeStaticOptions } from "hono/serve-static";
import { bodyLimit as bodyLimitMid } from "hono/body-limit";
import { cors } from "hono/cors";
import type { RedirectStatusCode, StatusCode } from "hono/utils/http-status";
import { RequestMethod } from "@nestjs/common";
import type { ErrorHandler, RequestHandler } from "@nestjs/common/interfaces";
import { AbstractHttpAdapter } from "@nestjs/core/adapters/http-adapter";
import type { BlankEnv, BlankSchema, MiddlewareHandler } from "hono/types";
import type { NestHandler, NestHttpServerRequired } from "./_nest";
import { createHonoReq, createHonoRes, InternalHonoReq, InternalHonoRes, sendResult } from "./_util";
import type { HonoApplicationExtra, HonoBodyParser } from "./hono.impl";
import type { CorsOptions, CorsOptionsDelegate } from "@nestjs/common/interfaces/external/cors-options.interface";
export type CORSOptions = Partial<NonNullable<Parameters<typeof cors>[0]>>;
const NEST_HEADERS = Symbol("nest_headers");
type NestHonoHandler = NestHandler<InternalHonoReq, InternalHonoRes>;
export interface HonoRouterAdapter<
E extends Env = BlankEnv,
S extends Schema = BlankSchema,
BasePath extends string = "/",
> extends AbstractHttpAdapter<NestHttpServerRequired, InternalHonoReq, InternalHonoRes> {
getInstance(): Hono<E, S, BasePath>;
getInstance<T = any>(): T;
getHeader(response: any, name: string): any;
appendHeader(response: any, name: string, value: string): any;
}
export abstract class HonoRouterAdapter
extends AbstractHttpAdapter<NestHttpServerRequired, InternalHonoReq, InternalHonoRes>
implements HonoApplicationExtra {
declare protected readonly instance: Hono;
declare protected httpServer: NestHttpServerRequired;
private createRouteHandler(routeHandler: NestHonoHandler) {
return async (ctx: Context, next?: Next): Promise<Response> => {
const nestHeaders: Record<string, string> = {};
Reflect.set(ctx, NEST_HEADERS, nestHeaders);
let body: any;
const contentType = ctx.req.header("Content-Type");
if (contentType) {
const parser = this.#bodyParsers.get(contentType);
if (parser) body = await parser(ctx.req);
}
await routeHandler(
createHonoReq(ctx, { body, params: ctx.req.param(), rawBody: undefined }),
createHonoRes(ctx),
next,
);
return sendResult(ctx, nestHeaders);
};
}
/**
* Router
*/
override all(handler: NestHonoHandler): void;
override all(path: string, handler: NestHonoHandler): void;
override all(pathOrHandler: string | NestHonoHandler, handler?: NestHonoHandler) {
const [routePath, routeHandler] = getRouteAndHandler(pathOrHandler, handler);
this.instance.all(routePath, this.createRouteHandler(routeHandler));
}
override get(pathOrHandler: string | NestHonoHandler, handler?: NestHonoHandler) {
const [routePath, routeHandler] = getRouteAndHandler(pathOrHandler, handler);
this.instance.get(routePath, this.createRouteHandler(routeHandler));
}
override post(pathOrHandler: string | NestHonoHandler, handler?: NestHonoHandler) {
const [routePath, routeHandler] = getRouteAndHandler(pathOrHandler, handler);
this.instance.post(routePath, this.createRouteHandler(routeHandler));
}
override put(pathOrHandler: string | NestHonoHandler, handler?: NestHonoHandler) {
const [routePath, routeHandler] = getRouteAndHandler(pathOrHandler, handler);
this.instance.put(routePath, this.createRouteHandler(routeHandler));
}
override delete(pathOrHandler: string | NestHonoHandler, handler?: NestHonoHandler) {
const [routePath, routeHandler] = getRouteAndHandler(pathOrHandler, handler);
this.instance.delete(routePath, this.createRouteHandler(routeHandler));
}
override patch(pathOrHandler: string | NestHonoHandler, handler?: NestHonoHandler) {
const [routePath, routeHandler] = getRouteAndHandler(pathOrHandler, handler);
this.instance.patch(routePath, this.createRouteHandler(routeHandler));
}
override options(pathOrHandler: string | NestHonoHandler, handler?: NestHonoHandler) {
const [routePath, routeHandler] = getRouteAndHandler(pathOrHandler, handler);
this.instance.options(routePath, this.createRouteHandler(routeHandler));
}
override use(...handler: [string | MiddlewareHandler, ...MiddlewareHandler[]]): void {
//@ts-ignore
this.instance.use(...handler);
}
useBodyLimit(bodyLimit: number) {
this.instance.use(
bodyLimitMid({
maxSize: bodyLimit,
onError: () => {
throw new Error("Body too large");
},
}),
);
}
#bodyParsers: Map<string | undefined, HonoBodyParser> = new Map();
useBodyParser(contentType: string, parser: HonoBodyParser): void;
/**
* @param rawBody When NestApplicationOptions.bodyParser is set to true, rawBody will be true
*/
useBodyParser(contentType: string, rawBody?: boolean | HonoBodyParser, parser?: HonoBodyParser) {
if (typeof rawBody === "function") {
parser = rawBody;
} else if (typeof parser !== "function") {
return;
}
this.#bodyParsers.set(contentType, parser);
}
//implement
// useStaticAssets(path: string, options: ServeStaticOptions): never {
// throw new Error("Method useStaticAssets not implemented."); //TODO useStaticAssets
// }
async useStaticAssets(path: string, options: ServeStaticOptions) {
// this.Logger.log('Registering static assets middleware');
if ((process as any).versions?.bun && import.meta.env.PROD) {
const { serveStatic } = await import("hono/bun");
// app.use(serveStatic({ root: "./dist/client" }))
// this.instance.use(serveStatic({ root: "./dist/client" }))
this.instance.use(path, serveStatic(options));
}
return this;
}
//implement
setViewEngine(options: any | string): never {
throw new Error("Method setViewEngine not implemented."); //TODO setViewEngine
}
//implement
getRequestHostname(request: InternalHonoReq): string {
return request.req.header("Host") ?? "";
}
//implement
getRequestMethod(request: InternalHonoReq): string {
return request.req.method;
}
//implement
getRequestUrl(request: InternalHonoReq): string {
return request.req.url;
}
// implement
status(res: InternalHonoRes, statusCode: StatusCode) {
res.status(statusCode);
}
//implement
/**
* 回复数据
*/
reply(res: InternalHonoRes, body: any, statusCode?: StatusCode) {
if (statusCode) res.status(statusCode);
res.send(body);
}
/**
* implement
* 没有响应数据时用于结束http响应
*/
end(res: InternalHonoRes, message?: string) {
res.send(message ?? null);
}
//implement
render(res: InternalHonoRes, view: string | Promise<string>, options: any) {
res.send(res.render(view));
}
//implement
redirect(res: InternalHonoRes, statusCode: RedirectStatusCode, url: string) {
res.send(res.redirect(url, statusCode));
}
//implement
setErrorHandler(handler: ErrorHandler<InternalHonoReq, InternalHonoRes>) {
this.instance.onError(async (err: Error, ctx: Context) => {
await handler(err, createHonoReq(ctx, { body: {}, params: {}, rawBody: undefined }), createHonoRes(ctx));
return sendResult(ctx, {});
});
}
//implement
setNotFoundHandler(handler: RequestHandler<InternalHonoReq, InternalHonoRes>) {
this.instance.notFound(async (ctx: Context) => {
await handler(createHonoReq(ctx, { body: {}, params: {}, rawBody: undefined }), createHonoRes(ctx));
return sendResult(ctx, {});
});
}
//implement
isHeadersSent(res: InternalHonoRes): boolean {
return res.finalized;
}
//implement
setHeader(res: InternalHonoRes, name: string, value: string) {
Reflect.get(res, NEST_HEADERS)[name] = value.toLowerCase();
}
//implement
getType(): string {
return "hono";
}
#isParserRegistered: boolean = false;
// implement nest 初始化时设定的一些默认解析器
registerParserMiddleware(prefix: string = "", rawBody?: boolean) {
if (this.#isParserRegistered) return;
this.useBodyParser("application/x-www-form-urlencoded", (honoReq) => honoReq.parseBody());
this.useBodyParser("multipart/form-data", (honoReq) => honoReq.parseBody());
this.useBodyParser("application/json", (honoReq) => honoReq.json());
this.useBodyParser("text/plain", (honoReq) => honoReq.text());
this.#isParserRegistered = true;
}
//implement
override enableCors(options?: CORSOptions): void;
override enableCors(options: any): void;
override enableCors(options?: any) {
this.instance.use(cors(options));
}
//implement
createMiddlewareFactory(requestMethod: RequestMethod): (path: string, callback: Function) => any {
return (path: string, callback: Function) => {
const nestMiddleware = callback as NestMiddlewareHandler;
async function handler(ctx: Context, next: () => Promise<void>) {
// let calledNext = false;
// await nestMiddleware(ctx, ctx, () => {
// calledNext = true;
// });
// if (calledNext) return next();
await nestMiddleware(
ctx.req,
ctx,
// createHonoReq(ctx, { body: {}, params: {}, rawBody: undefined }),
// createHonoRes(ctx),
next
);
}
if (requestMethod === RequestMethod.ALL || (requestMethod as number) === -1) {
this.instance.use(path, handler);
} else {
const method = RequestMethod[requestMethod];
this.instance.on(method, path, handler);
}
};
}
//implement
applyVersionFilter(): () => () => any {
throw new Error("Versioning not yet supported in Hono"); //TODO applyVersionFilter
}
//@ts-ignore nest 10 implement
override getHeader = undefined;
//@ts-ignore nest 10 implement
override appendHeader = undefined;
}
type NestMiddlewareHandler = (req: Context['req'], res: Context, next: Next) => Promise<void>;
function getRouteAndHandler(
pathOrHandler: string | NestHonoHandler,
handler?: NestHonoHandler,
): [string, NestHonoHandler] {
let path: string;
if (typeof pathOrHandler === "function") {
handler = pathOrHandler;
path = "";
} else {
path = pathOrHandler;
handler = handler!;
}
return [path, handler];
}
function transformsNestCrosOption(options: CorsOptions | CorsOptionsDelegate<InternalHonoReq> = {}): CORSOptions {
if (typeof options === "function") throw new Error("options must be an object");
let { origin } = options;
if (typeof origin === "function") origin = undefined; //TODO
return {
//@ts-ignore 需要转换
origin, //TODO
allowHeaders: toArray(options.allowedHeaders),
allowMethods: toArray(options.methods),
exposeHeaders: toArray(options.exposedHeaders),
credentials: options.credentials,
maxAge: options.maxAge,
};
}
function toArray<T>(item?: T | T[]): T[] | undefined {
if (!item) return undefined;
return item instanceof Array ? item : [item];
}

View File

@@ -1,28 +0,0 @@
import type { RequestHandler } from "@nestjs/common/interfaces";
import type { Server } from "node:http";
//see nest project: packages\core\router\route-params-factory.ts
export interface NestReqRequired {
rawBody?: any;
session?: any;
files?: any;
body: Record<string, any>;
params: Record<string, string | undefined>;
ip: string;
hosts?: Record<string, string | undefined>;
query: Record<string, string | undefined>;
headers: Record<string, string | undefined>;
}
export type NestHandler<Req extends NestReqRequired, Res extends object> = RequestHandler<Req, Res>;
//see nest project: packages/core/nest-application.ts
export interface NestHttpServerRequired {
once(event: "error", listener: (err: any) => void): this;
removeListener(event: "error", listener: (...args: any[]) => void): this;
// 必须返回一个对象或者字符串,否则 NestApplication.listen() 返回的 Promise 将用于不会解决
address(): ReturnType<Server["address"]>;
listen?(port: number, ...args: any[]): void;
close?(callback: (err?: any) => void): void;
}

View File

@@ -1,154 +0,0 @@
import type { NestHttpServerRequired, NestReqRequired } from "./_nest.ts";
import type { Context } from "hono";
export function createHonoReq(
ctx: Context,
info: {
body: Record<string, any>;
params: Record<string, string>;
rawBody: any;
},
): InternalHonoReq {
const { params, rawBody } = info;
let body = info.body ?? {};
const honoReq = ctx as InternalHonoReq;
if (Object.hasOwn(honoReq, IS_HONO_REQUEST)) return honoReq;
const nestReq: Omit<NestReqRequired, "headers" | "query" | "body" | "ip"> = {
rawBody,
session: ctx.get("session") ?? {},
//hosts: {}, //nest sets up hosts automatically
files: {}, //TODO files
params: params,
};
Object.assign(honoReq, nestReq);
let ip: string | undefined;
let headers: Record<string, any> | undefined;
let query: Record<string, any> | undefined;
Object.defineProperties(honoReq, {
headers: {
get(this: Context) {
return headers ?? (headers = Object.fromEntries(this.req.raw.headers as any));
},
enumerable: true,
},
query: {
get(this: Context) {
return query ?? (query = this.req.query());
},
enumerable: true,
},
// Hono Context already has a body attribute, so we need to use a Proxy to override it
body: {
value: new Proxy(ctx.body, {
get(rawBody, key: string) {
return body[key];
},
ownKeys(rawBody) {
return Object.keys(body);
},
apply(rawBody, thisArg, args) {
return rawBody.apply(ctx, args as any);
},
}),
enumerable: true,
writable: false,
},
ip: {
get() {
return "";
},
enumerable: true,
},
[IS_HONO_REQUEST]: {
value: true,
},
});
return honoReq;
}
const IS_HONO_REQUEST = Symbol("Is Hono Request");
function mountResponse(ctx: Context, data: any) {
Reflect.set(ctx, NEST_BODY, data);
}
export function createHonoRes(context: Context): InternalHonoRes {
Reflect.set(context, "send", function (this: Context, response: any = this.newResponse(null)) {
mountResponse(this, response);
});
return context as any as InternalHonoRes;
}
export function sendResult(ctx: Context, headers: Record<string, string>) {
const body = Reflect.get(ctx, NEST_BODY);
let response: Response;
switch (typeof body) {
case "string": {
response = ctx.text(body);
break;
}
case "object": {
if (body === null) response = ctx.body(null);
else if (body instanceof Response) response = body;
else if (body instanceof ReadableStream) response = ctx.body(body);
else if (body instanceof Uint8Array) response = ctx.body(body as any);
else if (body instanceof Blob) response = ctx.body(body.stream());
else response = ctx.json(body);
break;
}
case "undefined": {
response = ctx.body(null);
break;
}
default:
return ctx.text("HonoAdapter cannot convert unknown types", 500);
}
Object.entries(headers).forEach(([key, value]) => response.headers.set(key, value));
return response;
}
export type InternalHonoReq = NestReqRequired & Context;
export type InternalHonoRes = Context & {
send(data?: any): void;
};
const NEST_BODY = Symbol("nest_body");
const FakeHttpServer = Symbol("Fake HTTP Server");
export function isFakeHttpServer(server: any) {
return Reflect.get(server, FakeHttpServer);
}
export function createNestRequiredHttpServer(): NestHttpServerRequired {
return new Proxy(
{
once() {
return this;
},
removeListener() {
return this;
},
address() {
return null;
},
then: undefined,
[FakeHttpServer]: true,
},
{
get(target, key) {
if (typeof key === "symbol" || Object.hasOwn(target, key)) {
//@ts-ignore
return target[key];
}
console.trace("Nest Adapter: Nest uses undefined httpServer property", key);
const value = function () {
throw new Error(`Nest call undefined httpServer property '${String(key)}'`);
};
//@ts-ignore
target[key] = value;
return value;
},
},
);
}

View File

@@ -1,18 +0,0 @@
import type { HonoRequest } from "hono/request";
import type { ServeStaticOptions } from "hono/serve-static";
export type { InternalHonoRes as HonoResponse } from "./_util.ts";
export type { HonoRequest } from "hono/request";
export interface HonoApplicationExtra {
useBodyParser(contentType: string, parser: HonoBodyParser): void;
/**
* Sets a base directory for public assets.
* Example `app.useStaticAssets('public', { root: '/' })`
* @returns {this}
*/
useStaticAssets(path: string, options: ServeStaticOptions): Promise<this>;
}
export type HonoBodyParser = (context: HonoRequest) => Promise<any> | any;

View File

@@ -1,3 +0,0 @@
export * from "./hono.impl";
export * from "./HonoApplication";
export * from "./HonoAdapter";

View File

@@ -1,71 +0,0 @@
export const MSG = {
OBJECT: {
BILL: 'Bill',
COURSE: 'Course',
COMBO: 'Combo',
UNIT: 'Unit',
RESOURCE: 'Resource',
NOTI: 'Noti',
OBJECT: '[OBJECT]',
},
FRONTEND: {
DELETE_SUCCESS: 'Deleted successfully!',
UPDATE_SUCCESS: 'Updated successfully!',
USERNAME_DUPLICATED: 'Username has been used',
USERNAME_NOT_EXIST: 'Username not exists',
OTP_NOT_EXIST: 'Otp not exists',
USERNAME_INVALID: 'Username not valid format',
EMAIL_DUPLICATED: 'Duplicate email',
EMAIL_NOT_EXIST: 'Email not found',
EMAIL_INACTIVE_DUPLICATED: 'Email duplicate',
ACCOUNT_INACTIVE: 'Email not active',
WRONG_PASSWORD: 'Wrong password',
DOUPLICATE_PASSWORD: 'The same password',
TOO_MANY_REQUEST: 'Too many request',
ID_NOTFOUND: 'Id not found',
NO_PERMISSION: 'You might have not permission to do this action',
OBJECT_NOT_FOUND: '[OBJECT] does not exist!',
AUTH: {
UN_AUTH: 'unauthorized!',
LOGIN_SUCCESS: 'Đăng nhập thành công!',
LOGIN_ERR: 'Email hoặc mật khẩu không đúng!',
// AUTH_FAILED: 'Invalid login info!',
AUTH_FAILED: 'Thông tin đăng nhập không chính xác!',
AUTH_FAILED_EMAIL_NOT_EXIST: 'Not found Email!',
AUTH_FAILED_WRONG_PASSWORD: 'Wrong password!',
},
ACTIVE_USER: {
ERROR: 'Internal error',
TOKEN_INVALID: 'Token invalid',
EMAIL_VERIFIED: 'Email have been verify',
EMAIL_VERIFY_SUCCESS: 'Success verify email',
SUCCESS: 'Account success verify',
EXPIRY: 'Expiry account',
},
},
RESPONSE: {
SUCCESS: 'Thành công!',
GET_REQUEST_OK: 'GET OK',
POST_REQUEST_OK: 'POST OK',
PUT_REQUEST_OK: 'Cập nhật thành công!',
PATCH_REQUEST_OK: 'PATCH OK',
DELETE_REQUEST_OK: 'DELETE OK',
CREATED: 'Đã tạo thành công!',
REQUEST_GET_FAIL: 'GET OK',
POST_REQUEST_FAIL: 'POST FAIL',
PUT_REQUEST_FAIL: 'PUT FAIL',
PATCH_REQUEST_FAIL: 'PATCH FAIL',
DELETE_REQUEST_FAIL: 'DELETE FAIL',
DUPLICATED: 'DUPLICATED',
PROCESSING: 'Processing request',
BAD_REQUEST: 'Bad request',
INTERNAL_SERVER_ERROR: 'Internal Server Error',
METHOD_NOT_ALLOWED: 'Method Not Allowed Error',
DELETE_BILL_ERROR: 'Không thể xoá hoá đơn đã thanh toán!',
},
UPLOAD: {
IMAGE_FILE_TYPE_ONLY: 'Forbidden image type',
DOCUMENT_FILE_TYPE_ONLY: 'Forbidden file type',
},
};

View File

@@ -1,105 +0,0 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import * as path from 'path';
import { MSG } from '../constant/messages';
import { Context } from 'hono';
import { HttpAdapterHost } from '@nestjs/core';
import { sendResult } from '../adapter/hono/_util';
import { HonoResponse } from '../adapter/hono';
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
constructor(private readonly httpAdapterHost: HttpAdapterHost) {}
private convertStack(stack: string): string {
return stack
.split('\n')
.map((row) => row.trim())[0];
}
async catch(exception: any, host: ArgumentsHost) {
// console.log(host.getArgs());
this.logger.error(this.convertStack(exception.stack));
// this.logger.error(JSON.stringify(exception, null, 2));
const ctx = host.switchToHttp().getResponse<HonoResponse>();
// const request = ctx.getRequest<Request>();
// const response = ctx.getResponse();
let statusCode: any = HttpStatus.INTERNAL_SERVER_ERROR;
let message: any = MSG.RESPONSE.INTERNAL_SERVER_ERROR;
if (exception instanceof HttpException) {
[statusCode, message] = this.handleHttpException(exception);
}
if (
String(exception.name) === 'QueryFailedError' &&
exception.errno === 1062
) {
[statusCode, message] = this.handleConflict();
}
if (String(exception.name) === 'ValidationError') {
[statusCode, message] = this.handleValidatorError(exception);
}
if (String(exception.name) === 'BadRequestException') {
[statusCode, message] = this.handleValidatorError(exception);
}
if (String(exception.name) === 'UnauthorizedException') {
[statusCode, message] = this.handleAuthError(exception);
}
// const user = request.user as User;
const time = new Date().toLocaleString();
const routePath = `${ctx.req.method} ${ctx.req.url}`;
const errorObject = {
error: {
time,
message,
path: routePath,
detail: {
stack: this.convertStack(exception.stack),
},
},
exception: exception.code || exception.name,
statusCode,
};
// sendResult(ctx, {});
ctx.status(statusCode);
ctx.send(errorObject);
// mountResponse
// httpAdapter.reply(ctx, errorObject, statusCode);
// return ctx.json(errorObject);
// ctx.set("__nest_response", ctx.json(errorObject, statusCode));
// return response.status(500).json({
// statusCode: 500,
// message: "Internal server error",
// });
}
handleHttpException(exception: any) {
const statusCode = Number(exception.getStatus());
const message = exception.response.error;
return [statusCode, message];
}
handleAuthError(exception: any) {
const statusCode = Number(exception.getStatus());
const message = exception.response.message;
return [statusCode, message];
}
handleValidatorError(exception: any) {
const statusCode = HttpStatus.BAD_REQUEST;
const message = exception.response.message[0].constraints;
return [statusCode, message];
}
handleConflict() {
const statusCode = HttpStatus.CONFLICT;
const message = `${MSG.FRONTEND.USERNAME_DUPLICATED} OR ${MSG.FRONTEND.EMAIL_DUPLICATED}`;
return [statusCode, message];
}
}

View File

@@ -1,18 +0,0 @@
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { Response } from '../interfaces/response.interface';
import { InternalHonoRes } from '../adapter/hono/_util';
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> {
intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> {
return next.handle().pipe(
map(data => ({
statusCode: context.switchToHttp().getResponse<InternalHonoRes>().res.status,
message: 'Success',
data,
})),
);
}
}

View File

@@ -1,12 +0,0 @@
// import { ApiProperty } from '@nestjs/swagger';
export class Response<T> {
// @ApiProperty()
statusCode: number = 200;
// @ApiProperty()
message: string = 'Success';
// @ApiProperty()
data: T = {} as T;
}

View File

@@ -1,14 +0,0 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { ZodValidationPipe } from 'nestjs-zod';
import { ZodError } from 'zod';
import { MSG } from '../constant/messages';
@Injectable()
export class CustomZodValidationPipe extends ZodValidationPipe {
protected exceptionFactory(error: ZodError) {
return new BadRequestException(
error,
MSG.RESPONSE.BAD_REQUEST,
);
}
}

View File

@@ -1,34 +0,0 @@
// Source - https://stackoverflow.com/a
// Posted by Stark Jeon
// Retrieved 2026-01-10, License - CC BY-SA 4.0
import { Injectable, NestMiddleware, Logger } from "@nestjs/common";
import { Context } from "hono";
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
private logger = new Logger("HTTP");
async use(request: Request, response: Response, next: any) {
const start = Date.now()
// console.log("Request:", request.method, request.url, arguments[2].toString());
// const { ip, method, originalUrl } = request;
// const userAgent = request.get("user-agent") || "";
const ctx = arguments[2] as Context;
// ctx
// response.on("finish", () => {
// const { statusCode } = response;
// const contentLength = response.get("content-length");
// this.logger.log(
// `${method} ${originalUrl} ${statusCode} ${contentLength} - ${userAgent} ${ip}`,
// );
// });
await next().finally(() => {
const ms = Date.now() - start
this.logger.log(
`${request.method} ${request.url} - ${ms}ms`,
) ;
});
}
}

View File

@@ -1,56 +0,0 @@
import Aura from '@primeuix/themes/aura';
import { createHead as CSRHead } from "@unhead/vue/client";
import { createHead as SSRHead } from "@unhead/vue/server";
import { createPinia } from "pinia";
import PrimeVue from 'primevue/config';
import ToastService from 'primevue/toastservice';
import Tooltip from 'primevue/tooltip';
import { createSSRApp } from 'vue';
import { RouterView } from 'vue-router';
import { withErrorBoundary } from '@/client/lib/hoc/withErrorBoundary';
import { vueSWR } from '@/client/lib/swr/use-swrv';
import createAppRouter from '@/client/routes';
const bodyClass = ":uno: font-sans text-gray-800 antialiased flex flex-col min-h-screen bg-gray-50";
function createApp() {
const pinia = createPinia();
const app = createSSRApp(withErrorBoundary(RouterView));
const head = import.meta.env.SSR ? SSRHead() : CSRHead();
app.use(head);
app.use(PrimeVue, {
// unstyled: true,
theme: {
preset: Aura,
options: {
darkModeSelector: '.my-app-dark',
cssLayer: false,
// cssLayer: {
// name: 'primevue',
// order: 'theme, base, primevue'
// }
}
}
});
app.use(ToastService);
app.directive('nh', {
created(el) {
el.__v_skip = true;
}
});
app.directive("tooltip", Tooltip)
if (!import.meta.env.SSR) {
Object.entries(JSON.parse(document.getElementById("__APP_DATA__")?.innerText || "{}")).forEach(([key, value]) => {
(window as any)[key] = value;
});
if ((window as any).$p ) {
pinia.state.value = (window as any).$p;
}
}
app.use(pinia);
app.use(vueSWR({revalidateOnFocus: false}));
const router = createAppRouter();
app.use(router);
return { app, router, head, pinia, bodyClass };
}
export default createApp;

119
src/stores/auth.ts Normal file
View File

@@ -0,0 +1,119 @@
import { defineStore } from 'pinia';
import { useRouter } from 'vue-router';
// import { client } from '@/api/rpcclient'; // client no longer used for auth actions
import { ref, onMounted } from 'vue';
import { emailAuth, signUp, auth, googleAuth } from '@/lib/firebase';
import { onAuthStateChanged, signOut, User as FirebaseUser } from 'firebase/auth';
interface User {
id: string;
username: string;
email: string;
name: string;
}
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null);
const router = useRouter();
const loading = ref(false);
const error = ref<string | null>(null);
const initialized = ref(false);
// Check auth status on init using Firebase observer
async function init() {
if (initialized.value) return;
return new Promise<void>((resolve) => {
const unsubscribe = onAuthStateChanged(auth, (currentUser) => {
if (currentUser) {
user.value = mapFirebaseUser(currentUser);
} else {
user.value = null;
}
initialized.value = true;
resolve();
// We could unsubscribe here if we only want initial load,
// but keeping it listens for changes (token refresh etc)
// However, 'init' usually implies just ONCE waiter.
// For reactivity, user.value is updated.
});
// Note: onAuthStateChanged returns an unsubscribe function.
// If we want to keep listening, we shouldn't unsubscribe immediately,
// but for 'await auth.init()' we just want to wait for the first known state.
});
}
function mapFirebaseUser(fwUser: FirebaseUser): User {
return {
id: fwUser.uid,
username: fwUser.email?.split('@')[0] || 'user', // fallback
email: fwUser.email || '',
name: fwUser.displayName || fwUser.email?.split('@')[0] || 'User'
};
}
async function login(username: string, password: string) {
loading.value = true;
error.value = null;
// Assuming username is email for Firebase, or we need to look it up?
// Firebase works with Email. If input is username, this might fail.
// For now assume email.
return emailAuth(username, password).then((userCredential) => {
user.value = mapFirebaseUser(userCredential.user);
router.push('/');
}).catch((e: any) => {
console.error(e);
error.value = 'Login failed: ' + (e.message || 'Unknown error');
throw e;
}).finally(() => {
loading.value = false;
});
}
async function loginWithGoogle() {
loading.value = true;
error.value = null;
return googleAuth().then((result) => {
user.value = mapFirebaseUser(result.user);
router.push('/');
}).catch((e: any) => {
console.error(e);
error.value = 'Google Login failed';
throw e;
}).finally(() => {
loading.value = false;
});
}
async function register(username: string, email: string, password: string) {
loading.value = true;
error.value = null;
return signUp(email, password).then((fwUser) => {
// update profile with username?
// updateProfile(fwUser, { displayName: username });
user.value = mapFirebaseUser(fwUser);
router.push('/');
}).catch((e: any) => {
console.error(e);
error.value = 'Registration failed: ' + (e.message || 'Unknown error');
throw e;
}).finally(() => {
loading.value = false;
});
}
async function logout() {
return signOut(auth).then(() => {
user.value = null;
router.push('/');
})
}
return {
user, loading, error, initialized, init, login, loginWithGoogle, register, logout, $reset: () => {
user.value = null;
loading.value = false;
error.value = null;
initialized.value = false;
}
};
});

10
src/type.d.ts vendored
View File

@@ -6,12 +6,6 @@ declare module "@httpClientAdapter" {
export function httpClientAdapter(opts: { export function httpClientAdapter(opts: {
url: string; url: string;
pathsForGET?: string[]; pathsForGET?: string[];
headers?: () => Promise<{ Authorization?: undefined; } | { Authorization: string; }>
}): TinyRpcClientAdapter; }): TinyRpcClientAdapter;
} }
declare global {
var __APP__: {
app?: NestHonoApplication;
hono?: Hono;
server?: Bun.Server;
} | undefined;
}

56
src/worker/html.ts Normal file
View File

@@ -0,0 +1,56 @@
/**
* @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

@@ -23,7 +23,9 @@ export function renderSSRLayout(c: Context, appStream: ReadableStream) {
"head", "head",
null, null,
raw('<meta charset="UTF-8"/>'), raw('<meta charset="UTF-8"/>'),
raw('<meta name="viewport" content="width=device-width, initial-scale=1.0"/>'), raw(
'<meta name="viewport" content="width=device-width, initial-scale=1.0"/>'
),
raw('<link rel="icon" href="/favicon.ico" />'), raw('<link rel="icon" href="/favicon.ico" />'),
raw(`<base href="${new URL(c.req.url).origin}/"/>`) raw(`<base href="${new URL(c.req.url).origin}/"/>`)
), ),

View File

@@ -32,11 +32,10 @@ export function clientFirstBuild(): Plugin {
// Client build first // Client build first
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (clientEnvironment) { if (clientEnvironment) {
// clientEnvironment.config.build.outDir = "dist/client"; // console.log("Client First Build Plugin: Building client...", clientEnvironment.resolve);
// console.log("Client First Build Plugin: Building client...", Object.keys());
await builder.build(clientEnvironment); await builder.build(clientEnvironment);
} }
// console.log("Client First Build Plugin: Client build complete.", workerEnvironments);
// Then worker builds // Then worker builds
for (const workerEnv of workerEnvironments) { for (const workerEnv of workerEnvironments) {
await builder.build(workerEnv); await builder.build(workerEnv);
@@ -111,12 +110,12 @@ export default function ssrPlugin(): Plugin[] {
}, },
resolveId(id, importer, options) { resolveId(id, importer, options) {
if (!id.startsWith('@httpClientAdapter')) return if (!id.startsWith('@httpClientAdapter')) return
const pwd = process.cwd()
return path.resolve( return path.resolve(
__dirname, __dirname,
options?.ssr options?.ssr
? pwd+"/src/client/api/httpClientAdapter.server.ts" ? "./src/api/httpClientAdapter.server.ts"
: pwd+"/src/client/api/httpClientAdapter.client.ts" : "./src/api/httpClientAdapter.client.ts"
); );
}, },
async configResolved(config) { async configResolved(config) {
@@ -135,8 +134,7 @@ export default function ssrPlugin(): Plugin[] {
const clientBuild = viteConfig.environments.client.build; const clientBuild = viteConfig.environments.client.build;
clientBuild.manifest = true; clientBuild.manifest = true;
clientBuild.rollupOptions = clientBuild.rollupOptions || {}; clientBuild.rollupOptions = clientBuild.rollupOptions || {};
// clientBuild.rollupOptions.input = "src/client.ts"; clientBuild.rollupOptions.input = "src/client.ts";
// clientBuild.outDir = "dist/client";
if (!viteConfig.environments.ssr) { if (!viteConfig.environments.ssr) {
const manifestPath = path.join(clientBuild.outDir as string, '.vite/manifest.json') const manifestPath = path.join(clientBuild.outDir as string, '.vite/manifest.json')
try { try {

View File

@@ -13,10 +13,8 @@
"jsxImportSource": "vue", "jsxImportSource": "vue",
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"], "@/*": ["./src/*"]
}, }
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
}, },
"include": [ "include": [
"src/**/*.ts", "src/**/*.ts",

View File

@@ -1,6 +1,6 @@
import { defineConfig, presetAttributify, presetTypography, presetWind4, transformerCompileClass, transformerVariantGroup } from 'unocss' import { defineConfig, presetAttributify, presetTypography, presetWind4, transformerCompileClass, transformerVariantGroup } from 'unocss'
import { presetBootstrapBtn } from "./bootstrap_btn"; import { presetBootstrapBtn } from "./bootstrap_btn";
// import transformerClassnamesMinifier from './plugins/encodeClassTransformer'
export default defineConfig({ export default defineConfig({
presets: [ presets: [
presetWind4() as any, presetWind4() as any,
@@ -69,11 +69,6 @@ export default defineConfig({
light: "#e2e6ea", light: "#e2e6ea",
dark: "#e2e6ea", dark: "#e2e6ea",
}, },
foreground: {
DEFAULT: "#212529",
light: "#495057",
dark: "#121212",
}
}, },
boxShadow: { boxShadow: {
"primary-box": "2px 2px 10px #aff6b8", "primary-box": "2px 2px 10px #aff6b8",
@@ -96,29 +91,18 @@ export default defineConfig({
"press-animated", "press-animated",
"transition-all duration-200 ease-[cubic-bezier(.22,1,.36,1)] active:translate-y-0 active:scale-90 active:shadow-md", "transition-all duration-200 ease-[cubic-bezier(.22,1,.36,1)] active:translate-y-0 active:scale-90 active:shadow-md",
], ],
{ ["animate-loadingBar", ["animation", "loadingBar 1.5s linear infinite"]],
"animate-backwards": "animate-fade-in-up delay-200 duration-500",
"animate-loading-bar": "relative overflow-hidden before:absolute before:inset-0 before:bg-gradient-to-r before:from-transparent before:via-white/50 before:to-transparent before:animate-loadingBar before:content-['']",
// "bg-grid-pattern": "bg-[url(\"data:image/svg+xml,%3Csvg width='40' height='40' viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%239C92AC' fill-opacity='0.05' fill-rule='evenodd'%3E%3Cpath d='M0 40L40 0H20L0 20M40 40V20L20 40'/%3E%3C/g%3E%3C/svg%3E\")]",
},
// ["animate-loadingBar", ["animation", "loadingBar 1.5s linear infinite"]],
// ["bg-grid-pattern"]
], ],
transformers: [transformerVariantGroup(), transformerCompileClass({ transformers: [transformerVariantGroup(), transformerCompileClass({
classPrefix: "_", classPrefix: "_",
}), })],
// transformerClassnamesMinifier({
// trigger: ':m:',
// })
],
preflights: [ preflights: [
{ {
getCSS: (context) => { getCSS: (context) => {
return ` return `
:root { :root {
--font-sans: Inter var, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica, Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol; --font-sans: 'Be Vietnam Pro', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-geist-sans: "Inter", "system-ui", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; --font-serif: 'Playfair Display', serif, 'Times New Roman', Times, serif;
--font-geist-mono: "Roboto Mono", "SFMono-Regular", "Menlo", monospace;
} }
:focus { :focus {
outline-color: ${context.theme.colors?.primary?.active}; outline-color: ${context.theme.colors?.primary?.active};
@@ -135,7 +119,7 @@ export default defineConfig({
border-bottom: 1px solid rgba(0,0,0,0.05); border-bottom: 1px solid rgba(0,0,0,0.05);
} }
.text-gradient { .text-gradient {
background: linear-gradient(135deg, #064e3b 0%, #2dc76b 100%); background: linear-gradient(135deg, #064e3b 0%, #10b981 100%);
-webkit-background-clip: text; -webkit-background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
} }
@@ -148,9 +132,7 @@ export default defineConfig({
.fade-leave-to { .fade-leave-to {
opacity: 0; opacity: 0;
} }
.bg-grid-pattern {
background-image: url(\"data:image/svg+xml,%3Csvg width='40' height='40' viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%239C92AC' fill-opacity='0.05' fill-rule='evenodd'%3E%3Cpath d='M0 40L40 0H20L0 20M40 40V20L20 40'/%3E%3C/g%3E%3C/svg%3E\");
}
`; `;
}, },
}, },

View File

@@ -1,3 +1,4 @@
import { cloudflare } from "@cloudflare/vite-plugin";
import { PrimeVueResolver } from "@primevue/auto-import-resolver"; import { PrimeVueResolver } from "@primevue/auto-import-resolver";
import vue from "@vitejs/plugin-vue"; import vue from "@vitejs/plugin-vue";
import vueJsx from "@vitejs/plugin-vue-jsx"; import vueJsx from "@vitejs/plugin-vue-jsx";
@@ -6,71 +7,41 @@ import unocss from "unocss/vite";
import Components from "unplugin-vue-components/vite"; import Components from "unplugin-vue-components/vite";
import AutoImport from "unplugin-auto-import/vite"; import AutoImport from "unplugin-auto-import/vite";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import ssrPlugin from "./plugins/ssrPlugin"; import ssrPlugin from "./ssrPlugin";
import { vitePluginSsrMiddleware } from "./plugins/vite-plugin-ssr-middleware";
export default defineConfig((env) => { export default defineConfig((env) => {
// console.log("env:", env, import.meta.env); // console.log("env:", env, import.meta.env);
return { return {
plugins: [ plugins: [
unocss(), unocss(),
vue(), vue(),
vueJsx(), vueJsx(),
AutoImport({ AutoImport({
imports: ["vue", "vue-router", "pinia"], // Common presets imports: ["vue", "vue-router", "pinia"], // Common presets
dts: true, // Generate TypeScript declaration file dts: true, // Generate TypeScript declaration file
}), }),
Components({ Components({
dirs: ["src/client/components"], dirs: ["src/components"],
extensions: ["vue", "tsx"], extensions: ["vue", "tsx"],
dts: true, dts: true,
dtsTsx: true, dtsTsx: true,
directives: false, directives: false,
resolvers: [PrimeVueResolver()], resolvers: [PrimeVueResolver()],
}), }),
ssrPlugin(), ssrPlugin(),
vitePluginSsrMiddleware({ cloudflare(),
entry: "src/main.ts", ],
preview: path.resolve("dist/server/index.js"), resolve: {
}), alias: {
// devServer({ "@": path.resolve(__dirname, "./src"),
// entry: 'src/index.tsx', // "httpClientAdapter": path.resolve(__dirname, "./src/api/httpClientAdapter.server.ts")
// }), },
// cloudflare(), },
], optimizeDeps: {
environments: { exclude: ["vue"],
client: { },
build: {
outDir: "dist/client", ssr: {
rollupOptions: { noExternal: ["vue"],
input: { index: "/src/client/index.ts" }, },
}, };
},
},
server: {
build: {
outDir: "dist/server",
copyPublicDir: false,
rollupOptions: {
input: { index: "/src/main.ts" },
},
},
},
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
// "httpClientAdapter": path.resolve(__dirname, "./src/api/httpClientAdapter.server.ts")
},
},
optimizeDeps: {
exclude: ["vue"],
},
server: {
port: 3000,
},
ssr: {
// external: ["vue"]
// noExternal: ["vue"],
},
};
}); });

9
wrangler.jsonc Normal file
View File

@@ -0,0 +1,9 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "holistream",
"compatibility_date": "2025-08-03",
"main": "./src/index.tsx",
"compatibility_flags": [
"nodejs_compat"
]
}