diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..836f4ed --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +PORT=1234 +DEBUG=false +TELEGRAM_BOT_TOKEN=abcd +TELEGRAM_CHAT_ID=efgh diff --git a/.eslintrc.json b/.eslintrc.json index 1f5c47b..bd0edbc 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,25 +1,25 @@ { - "parser": "@babel/eslint-parser", - "parserOptions": { - "ecmaVersion": 2021, - "sourceType": "module", - "babelOptions": { - "presets": [ - "@babel/preset-env", - "@babel/preset-react" - ] - } - }, - "env": { - "browser": true, - "node": true - }, - "plugins": [ - "react" - ], - "extends": [ - "eslint:recommended", - "plugin:react/recommended" - ], - "rules": {} -} \ No newline at end of file + "parser": "@babel/eslint-parser", + "parserOptions": { + "ecmaVersion": 2021, + "sourceType": "module", + "babelOptions": { + "presets": ["@babel/preset-env", "@babel/preset-react"] + } + }, + "env": { + "browser": true, + "node": true + }, + "plugins": ["react", "@typescript-eslint"], + "extends": [ + "eslint:recommended", + "plugin:react/recommended", + "plugin:@typescript-eslint/recommended" + ], + "rules": { + "@typescript-eslint/explicit-module-boundary-types": "error", + "@typescript-eslint/explicit-function-return-type": "error" + } +} + diff --git a/.gitignore b/.gitignore index 5c005f9..5a566f8 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ yarn-debug.log* yarn-error.log* # local env files +.env .env.local .env.development.local .env.test.local @@ -42,5 +43,7 @@ package-lock.json **/*.bun server +worker db.sqlite +*.db diff --git a/README.md b/README.md index 6d5c10a..563f39a 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,40 @@ # hyperwave -hyperwave combines the benefits of traditional server-rendered applications with the flexibility of modern client-side frameworks. +hyperwave combines the strengths of traditional server-rendered applications with the flexibility of modern client-side frameworks. It is designed to deliver fast, responsive applications while providing a streamlined developer experience. -- **Performance:** Server-side rendering ensures fast, responsive applications, tailored to produce the smallest possible bundles. -- **Developer experience:** HTMX and Tailwind provide a minimalistic and declarative approach to building user interfaces -- **Deployment:** bun applications can be easily deployed on any platform as portable binaries +- Performance: Only 200 lines of JS with zero client side dependencies. Works well in 2G network conditions. +- Developer Experience: tailwind-compatible syntax and html over the wire offer a minimalistic and declarative approach to UI development. +- Deployment: Bun applications can be deployed easily on any platform as portable binaries. -## Getting started +``` +$ git clone https://github.com/tireymorris/hyperwave.git +$ cd hyperwave +$ bun install +$ bun dev +``` -Follow these steps to start developing with hyperwave: +Navigate to http://localhost:1234 in your browser and start editing server.tsx to observe your changes live. -1. Clone the repository: +remove any of the articles code from the example if you want, it's not many lines. see the server, model files, routes, and db.ts. - ```sh - git clone https://github.com/tireymorris/hyperwave.git - cd hyperwave - ``` -2. Install dependencies: +## hyperwave.js - ```sh - bun install - ``` +dynamically load content on user events, without requiring a page reload. -3. Start the development server: +attaches automatically to any element with an href attribute (besides an anchor/link tag, which is treated as normal) - ```sh - bun dev - ``` +### Usage: -4. Visit `http://localhost:1234` in your browser. +``` +
+``` -5. Start editing `server.tsx` to see your changes live. +- `trigger`: Event that triggers loading (e.g., click, scroll). +- `method`: HTTP request method (e.g., GET, POST). +- `debounce`: Delay in milliseconds to optimize performance. +- `offset, limit, data-total`: Manage pagination settings. + +``` +
+``` diff --git a/bun.lockb b/bun.lockb index ab03cfd..7df1bdb 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/ecosystem.config.js b/ecosystem.config.js new file mode 100644 index 0000000..41e08a8 --- /dev/null +++ b/ecosystem.config.js @@ -0,0 +1,18 @@ +module.exports = { + apps: [ + { + name: "hyperwave-server", + script: "./dist/server", + env: { + NODE_ENV: "production", + }, + }, + { + name: "hyperwave-worker", + script: "./dist/worker", + env: { + NODE_ENV: "production", + }, + }, + ], +}; diff --git a/package.json b/package.json index a9981ef..ff5f709 100644 --- a/package.json +++ b/package.json @@ -2,27 +2,38 @@ "name": "hyperwave", "version": "0.2.1", "scripts": { - "build": "bun build:css && bun build --compile ./src/server.tsx", + "build": "bun build:css && bun build:server && bun build:worker", "build:css": "unocss \"src/**/*.tsx\" -o public/styles/uno.css", - "css": "unocss --watch \"src/**/*.tsx\" -o public/styles/uno.css", + "build:server": "bun build --compile ./src/server.tsx --outfile ./dist/server", + "build:worker": "bun build --compile ./src/worker.ts --outfile ./dist/worker", + "css:watch": "unocss --watch \"src/**/*.tsx\" -o public/styles/uno.css", "db": "bun run src/db.ts", - "dev": "bun install && concurrently --restart-tries=3 \"bun css\" \"nodemon --watch src --ext ts,tsx --exec 'bun run --hot src/server.tsx'\"", + "dev": "bun install && DEBUG=true concurrently --restart-tries=3 \"bun css:watch\" \"bun server:watch\" \"bun worker\"", "prettier": "bunx prettier --write src/ test/ --plugin prettier-plugin-tailwindcss", + "server:watch": "nodemon --watch src --ext ts,tsx --exec 'bun run --hot src/server.tsx'", + "start": "bun run build && pm2 start; pm2 reload all --update-env", + "start:debug": "DEBUG=true bun run build && pm2 start; pm2 reload all --update-env", + "stop": "pm2 kill", "server": "bun run --hot src/server.tsx", - "test": "bun run test", - "update-deps": "bunx npm-check-updates -u && bun install" + "test": "NODE_ENV=test bun run test", + "update-deps": "bunx npm-check-updates -u && bun install", + "worker": "bun run src/worker.ts" }, "dependencies": { "@unocss/preset-web-fonts": "^0.61.3", + "cheerio": "^1.0.0-rc.12", "hono": "^4.4.12", "nodemon": "^3.1.4", "unocss": "^0.61.3", + "uuid": "^10.0.0", "zod": "^3.23.8" }, "devDependencies": { + "@types/bun": "^1.1.6", "@unocss/cli": "^0.61.3", "bun-types": "^1.1.18", "concurrently": "^8.2.2", + "pm2": "^5.4.2", "prettier": "^3.3.2", "prettier-plugin-tailwindcss": "^0.6.5" }, diff --git a/public/scripts/hyperwave.js b/public/scripts/hyperwave.js new file mode 100644 index 0000000..572bffc --- /dev/null +++ b/public/scripts/hyperwave.js @@ -0,0 +1,238 @@ +const DEBUG = true; + +/** + * Logs messages to the console if DEBUG is true. + * @param {string} level - The level of logging (e.g., 'log', 'warn', 'error'). + * @param {...any} messages - The messages or data to log. + */ +const log = (level, ...messages) => + DEBUG && console[level](`hyperwave:`, ...messages); + +/** + * Creates a debounced function that delays the execution of the provided function. + * Useful to limit the rate at which a function is invoked. + * @param {Function} func - The function to debounce. + * @param {number} delay - The number of milliseconds to delay. + * @returns {Function} - The debounced function. + */ +const createDebouncedFunction = (func, delay) => { + let timeoutId; + return (...args) => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => func.apply(this, args), delay); + }; +}; + +/** + * Fetches content from the specified URL using the provided options. + * @param {string} url - The URL to fetch content from. + * @param {RequestInit} fetchOptions - The options for the fetch request. + * @returns {Promise} - The fetched content as a string. + */ +const fetchContent = async (url, fetchOptions) => { + try { + log("log", `Fetching content from ${url}`); + const response = await fetch(url, fetchOptions); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const content = await response.text(); + log("log", `Content fetched from ${url}`, content.length); + return content; + } catch (error) { + log("error", `Error fetching from ${url}:`, error); + return null; + } +}; + +/** + * Updates the target element with the provided content. + * @param {HTMLElement} targetElement - The element to update. + * @param {string} content - The new content to append to the target element. + */ +const updateTargetElement = (targetElement, content) => { + const tempDiv = document.createElement("div"); + tempDiv.innerHTML = content; + while (tempDiv.firstChild) { + targetElement.appendChild(tempDiv.firstChild); + } + log("log", `Content appended to target element`); + attachHyperwaveHandlers(targetElement); +}; + +/** + * Builds the URL for the next page to load, used for pagination. + * @param {HTMLElement} triggerElement - The element that triggers pagination. + * @param {number} offset - The current offset. + * @param {number} limit - The number of items per page. + * @returns {string} - The URL for the next page. + */ +const buildPaginationUrl = (triggerElement, offset, limit) => { + const url = new URL( + triggerElement.getAttribute("href"), + window.location.origin, + ); + url.searchParams.set("offset", offset); + url.searchParams.set("limit", limit); + return url.toString(); +}; + +/** + * Handles pagination for loading additional content. + * @param {HTMLElement} triggerElement - The element that triggers pagination. + * @param {RequestInit} fetchOptions - The options for the fetch request. + * @returns {Function} - The function to load the next page of content. + */ +const handlePagination = (triggerElement, fetchOptions) => { + let offset = parseInt(triggerElement.getAttribute("offset") || "0", 10); + const limit = parseInt(triggerElement.getAttribute("limit") || "10", 10); + const totalItems = parseInt( + triggerElement.getAttribute("data-total") || "999999999", + 10, + ); + + return async () => { + if (offset >= totalItems) return; + + const url = buildPaginationUrl(triggerElement, offset, limit); + const content = await fetchContent(url, fetchOptions); + if (content) { + updateTargetElement( + document.querySelector(triggerElement.getAttribute("target")), + content, + ); + offset += limit; + triggerElement.setAttribute("offset", offset); + } + }; +}; + +/** + * Sets up event listeners and handlers based on element attributes. + * Handles different triggers like click and DOMContentLoaded. + * @param {HTMLElement} triggerElement - The element to handle the request for. + */ +const setupEventHandlers = (triggerElement) => { + const method = triggerElement.getAttribute("method") || "GET"; + const trigger = triggerElement.getAttribute("trigger") || "click"; + const debounceDelay = parseInt( + triggerElement.getAttribute("debounce") || "50", + 10, + ); + + if (!triggerElement.getAttribute("href")) { + log("warn", `Missing href for element:`, triggerElement); + return; + } + + const fetchOptions = { + method: method.toUpperCase(), + headers: { Accept: "text/html" }, + }; + + const loadNextPage = handlePagination(triggerElement, fetchOptions); + + if (trigger.includes("DOMContentLoaded")) { + loadNextPage(); + } else { + // Remove any existing event listener before adding a new one + if (triggerElement._hyperwaveHandler) { + triggerElement.removeEventListener( + trigger, + triggerElement._hyperwaveHandler, + ); + } + + const eventHandler = createDebouncedFunction((event) => { + event.preventDefault(); + loadNextPage(); + }, debounceDelay); + + triggerElement.addEventListener(trigger, eventHandler); + triggerElement._hyperwaveHandler = eventHandler; + } +}; + +/** + * Handles infinite scrolling to load more content as the user scrolls near the bottom of the page. + * @param {HTMLElement} triggerElement - The element that triggers infinite scroll. + * @param {Function} loadNextPage - The function to load the next page of content. + * @param {number} debounceDelay - The debounce time in milliseconds for the scroll event. + */ +const setupInfiniteScroll = (triggerElement, loadNextPage, debounceDelay) => { + let isLoading = false; + const threshold = 200; // Pixels from the bottom to trigger loading + + const onScroll = createDebouncedFunction(async () => { + const scrollTop = window.pageYOffset || document.documentElement.scrollTop; + const clientHeight = window.innerHeight; + const scrollHeight = document.documentElement.scrollHeight; + + if (scrollTop + clientHeight >= scrollHeight - threshold && !isLoading) { + isLoading = true; + await loadNextPage(); + isLoading = false; + } + }, debounceDelay); + + window.addEventListener("scroll", onScroll); + loadNextPage(); // Load initial content if needed + + // Store the event handler to remove it later if needed + triggerElement._hyperwaveScrollHandler = onScroll; +}; + +/** + * Attaches Hyperwave functionality to elements within the specified root element. + * It scans for elements with `href` attribute and sets up the necessary handlers. + * @param {HTMLElement} rootElement - The root element to search for elements to attach Hyperwave to. + */ +const attachHyperwaveHandlers = (rootElement) => { + const elements = Array.from(rootElement.querySelectorAll("[href]")).filter( + (element) => !["A", "LINK"].includes(element.tagName), + ); + elements.forEach((element) => { + setupEventHandlers(element); + + const trigger = element.getAttribute("trigger") || "click"; + if (trigger.includes("scroll")) { + const debounceDelay = parseInt( + element.getAttribute("debounce") || "50", + 10, + ); + const loadNextPage = handlePagination(element, { + method: element.getAttribute("method") || "GET", + headers: { Accept: "text/html" }, + }); + + // Remove any existing scroll event listener before adding a new one + if (element._hyperwaveScrollHandler) { + window.removeEventListener("scroll", element._hyperwaveScrollHandler); + } + setupInfiniteScroll(element, loadNextPage, debounceDelay); + } + }); +}; + +/** + * Initializes Hyperwave on DOMContentLoaded and sets up a MutationObserver to attach Hyperwave to newly added elements. + * Ensures dynamic elements loaded after initial page load are also enhanced. + */ +document.addEventListener("DOMContentLoaded", () => { + attachHyperwaveHandlers(document); + + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + mutation.addedNodes.forEach((node) => { + if (node.nodeType === Node.ELEMENT_NODE) { + attachHyperwaveHandlers(node); + } + }); + }); + }); + + observer.observe(document.body, { + childList: true, // Monitor for additions or removals of child elements + subtree: true, // Monitor the entire subtree, not just immediate children + }); +}); diff --git a/public/styles/uno.css b/public/styles/uno.css index d7f315c..458d9ec 100644 --- a/public/styles/uno.css +++ b/public/styles/uno.css @@ -1,84 +1,72 @@ /* layer: preflights */ *,::before,::after{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgb(0 0 0 / 0);--un-ring-shadow:0 0 rgb(0 0 0 / 0);--un-shadow-inset: ;--un-shadow:0 0 rgb(0 0 0 / 0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgb(147 197 253 / 0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;}::backdrop{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgb(0 0 0 / 0);--un-ring-shadow:0 0 rgb(0 0 0 / 0);--un-shadow-inset: ;--un-shadow:0 0 rgb(0 0 0 / 0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgb(147 197 253 / 0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;} -/* latin-ext */ -@font-face { - font-family: 'Lato'; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(https://fonts.gstatic.com/s/lato/v24/S6uyw4BMUTPHjxAwXjeu.woff2) format('woff2'); - unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; -} -/* latin */ -@font-face { - font-family: 'Lato'; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(https://fonts.gstatic.com/s/lato/v24/S6uyw4BMUTPHjx4wXg.woff2) format('woff2'); - unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} - /* layer: default */ -.m-0{margin:0;} -.m-auto{margin:auto;} +.m-0, +[m-0=""]{margin:0;} +.mb-1{margin-bottom:0.25rem;} +.mr-4{margin-right:1rem;} +.mt-1{margin-top:0.25rem;} +.h-12{height:3rem;} .h-8{height:2rem;} -.h-full{height:100%;} -[h1=""]{height:0.25rem;} +.min-h-screen{min-height:100vh;} +.w-12{width:3rem;} +.w-full{width:100%;} .flex{display:flex;} .flex-col{flex-direction:column;} .cursor-pointer{cursor:pointer;} +.list-none, +[list-none=""]{list-style-type:none;} .items-center{align-items:center;} .justify-center{justify-content:center;} +.gap-2{gap:0.5rem;} .gap-3{gap:0.75rem;} -.gap-8{gap:2rem;} .border{border-width:1px;} +.border-b, +[border-b=""]{border-bottom-width:1px;} .border-gray-2{--un-border-opacity:1;border-color:rgb(229 231 235 / var(--un-border-opacity));} .focus\:border-blue-200:focus{--un-border-opacity:1;border-color:rgb(191 219 254 / var(--un-border-opacity));} -.rounded{border-radius:0.25rem;} .rounded-md{border-radius:0.375rem;} +.rounded-sm{border-radius:0.125rem;} .border-none{border-style:none;} .border-solid{border-style:solid;} -.bg-blue-200{--un-bg-opacity:1;background-color:rgb(191 219 254 / var(--un-bg-opacity)) /* #bfdbfe */;} .bg-blue-300{--un-bg-opacity:1;background-color:rgb(147 197 253 / var(--un-bg-opacity)) /* #93c5fd */;} -.bg-gray-300{--un-bg-opacity:1;background-color:rgb(209 213 219 / var(--un-bg-opacity)) /* #d1d5db */;} -.bg-indigo-200{--un-bg-opacity:1;background-color:rgb(199 210 254 / var(--un-bg-opacity)) /* #c7d2fe */;} .bg-transparent{background-color:transparent /* transparent */;} .bg-white, [bg-white=""]{--un-bg-opacity:1;background-color:rgb(255 255 255 / var(--un-bg-opacity)) /* #fff */;} -.dark .dark\:bg-blue-600{--un-bg-opacity:1;background-color:rgb(37 99 235 / var(--un-bg-opacity)) /* #2563eb */;} -.dark .dark\:bg-gray-700{--un-bg-opacity:1;background-color:rgb(55 65 81 / var(--un-bg-opacity)) /* #374151 */;} .dark .dark\:bg-gray-900, .dark [dark\:bg-gray-900=""]{--un-bg-opacity:1;background-color:rgb(17 24 39 / var(--un-bg-opacity)) /* #111827 */;} -.dark .dark\:bg-indigo-600{--un-bg-opacity:1;background-color:rgb(79 70 229 / var(--un-bg-opacity)) /* #4f46e5 */;} .hover\:bg-blue-400:hover{--un-bg-opacity:1;background-color:rgb(96 165 250 / var(--un-bg-opacity)) /* #60a5fa */;} -.p-0{padding:0;} -.px-10{padding-left:2.5rem;padding-right:2.5rem;} +.from-blue-500{--un-gradient-from-position:0%;--un-gradient-from:rgb(59 130 246 / var(--un-from-opacity, 1)) var(--un-gradient-from-position);--un-gradient-to-position:100%;--un-gradient-to:rgb(59 130 246 / 0) var(--un-gradient-to-position);--un-gradient-stops:var(--un-gradient-from), var(--un-gradient-to);} +.to-purple-500{--un-gradient-to-position:100%;--un-gradient-to:rgb(168 85 247 / var(--un-to-opacity, 1)) var(--un-gradient-to-position);} +.bg-gradient-to-r{--un-gradient-shape:to right;--un-gradient:var(--un-gradient-shape), var(--un-gradient-stops);background-image:linear-gradient(var(--un-gradient));} +.p-0, +[p-0=""]{padding:0;} +.p-4{padding:1rem;} .px-2{padding-left:0.5rem;padding-right:0.5rem;} .px-4{padding-left:1rem;padding-right:1rem;} -.px-6{padding-left:1.5rem;padding-right:1.5rem;} .py-1{padding-top:0.25rem;padding-bottom:0.25rem;} .py-2{padding-top:0.5rem;padding-bottom:0.5rem;} -.py-3{padding-top:0.75rem;padding-bottom:0.75rem;} -.py-8{padding-top:2rem;padding-bottom:2rem;} .pl-3{padding-left:0.75rem;} .pr-10{padding-right:2.5rem;} -.text-center{text-align:center;} -.text-5xl{font-size:3rem;line-height:1;} .text-base{font-size:1rem;line-height:1.5rem;} .text-sm{font-size:0.875rem;line-height:1.25rem;} +.text-xl{font-size:1.25rem;line-height:1.75rem;} .dark .dark\:text-white, -.dark [dark\:text-white=""]{--un-text-opacity:1;color:rgb(255 255 255 / var(--un-text-opacity)) /* #fff */;} +.dark [dark\:text-white=""], +.text-white{--un-text-opacity:1;color:rgb(255 255 255 / var(--un-text-opacity)) /* #fff */;} .text-black{--un-text-opacity:1;color:rgb(0 0 0 / var(--un-text-opacity)) /* #000 */;} +.text-gray-500, +[text-gray-500=""]{--un-text-opacity:1;color:rgb(107 114 128 / var(--un-text-opacity)) /* #6b7280 */;} .text-neutral-500{--un-text-opacity:1;color:rgb(115 115 115 / var(--un-text-opacity)) /* #737373 */;} .text-slate-900{--un-text-opacity:1;color:rgb(15 23 42 / var(--un-text-opacity)) /* #0f172a */;} -.dark .dark\:hover\:text-yellow-300:hover{--un-text-opacity:1;color:rgb(253 224 71 / var(--un-text-opacity)) /* #fde047 */;} -.hover\:text-yellow-600:hover{--un-text-opacity:1;color:rgb(202 138 4 / var(--un-text-opacity)) /* #ca8a04 */;} +.text-teal-500, +[text-teal-500=""]{--un-text-opacity:1;color:rgb(20 184 166 / var(--un-text-opacity)) /* #14b8a6 */;} +.visited\:text-purple-600:visited{--un-text-opacity:1;color:rgb(147 51 234 / var(--un-text-opacity)) /* #9333ea */;} .font-bold{font-weight:700;} -.font-extrabold{font-weight:800;} -.font-lato{font-family:"Lato";} -.underline{text-decoration-line:underline;} +.font-serif{font-family:ui-serif,Georgia,Cambria,"Times New Roman",Times,serif;} +.italic{font-style:italic;} +.hover\:underline:hover{text-decoration-line:underline;} +[hover\:underline=""]:hover{text-decoration-line:underline;} .shadow-lg{--un-shadow:var(--un-shadow-inset) 0 10px 15px -3px var(--un-shadow-color, rgb(0 0 0 / 0.1)),var(--un-shadow-inset) 0 4px 6px -4px var(--un-shadow-color, rgb(0 0 0 / 0.1));box-shadow:var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);} .shadow-md{--un-shadow:var(--un-shadow-inset) 0 4px 6px -1px var(--un-shadow-color, rgb(0 0 0 / 0.1)),var(--un-shadow-inset) 0 2px 4px -2px var(--un-shadow-color, rgb(0 0 0 / 0.1));box-shadow:var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);} -.outline{outline-style:solid;} -.drop-shadow-lg{--un-drop-shadow:drop-shadow(0 10px 8px var(--un-drop-shadow-color, rgb(0 0 0 / 0.04))) drop-shadow(0 4px 3px var(--un-drop-shadow-color, rgb(0 0 0 / 0.1)));filter:var(--un-blur) var(--un-brightness) var(--un-contrast) var(--un-drop-shadow) var(--un-grayscale) var(--un-hue-rotate) var(--un-invert) var(--un-saturate) var(--un-sepia);} \ No newline at end of file +.outline{outline-style:solid;} \ No newline at end of file diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000..524d2c8 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,33 @@ +import { formatRelativeTime, getLastUpdatedTimestamp } from "util/time.ts"; + +export default async function Header() { + const lastUpdatedDate = getLastUpdatedTimestamp(); + const lastUpdated = lastUpdatedDate + ? formatRelativeTime(lastUpdatedDate) + : null; + + return ( +
+ + + +
+

hyperwave news

+ {lastUpdated && ( +

Last updated: {lastUpdated}

+ )} +
+
+ ); +} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 1dd8649..60a33e4 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,12 +1,14 @@ -import EnableDarkMode from "../util/EnableDarkMode"; +import EnableDarkMode from "util/EnableDarkMode"; +import Header from "components/Header"; type LayoutProps = { title: string; currentPath?: string; children: any; + lastUpdated?: string | null; }; -function Layout({ title, children }: LayoutProps) { +export default function Layout({ title, children, lastUpdated }: LayoutProps) { return ( - - - + + - - + +
{children} ); } - -export default Layout; diff --git a/src/db.ts b/src/db.ts new file mode 100644 index 0000000..3fde43f --- /dev/null +++ b/src/db.ts @@ -0,0 +1,23 @@ +import { Database } from "bun:sqlite"; + +const isTest = process.env.NODE_ENV === "test"; +const db = new Database(isTest ? "test_articles.db" : "articles.db"); + +db.run(` + CREATE TABLE IF NOT EXISTS articles ( + id TEXT PRIMARY KEY, + title TEXT UNIQUE, + link TEXT, + source TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) +`); + +db.run(` + CREATE TABLE IF NOT EXISTS source_hashes ( + source TEXT PRIMARY KEY, + hash TEXT + ) +`); + +export default db; diff --git a/src/models/article.ts b/src/models/article.ts new file mode 100644 index 0000000..62d8a5c --- /dev/null +++ b/src/models/article.ts @@ -0,0 +1,139 @@ +import db from "@/db"; +import { debug, log } from "util/log"; +import { NewsSource, newsSources } from "models/newsSources"; +import { z } from "zod"; +import { fetchArticlesFromSource } from "util/crawler"; + +export interface Article { + id: string; + title: string; + link: string; + source: NewsSource["name"]; + created_at: string; +} + +export const articleSchema = z.object({ + title: z + .string() + .refine((title) => title.split(" ").length >= 5, { + message: "Title must contain at least 4 words", + }) + .refine( + (title) => + !["Video Duration", "play", "play-inverse"].some((prefix) => + title.startsWith(prefix), + ), + { + message: "Title starts with an invalid prefix", + }, + ), + link: z.string().url(), + source: z.string(), +}); + +export const isValidArticle = (article: Article) => { + try { + articleSchema.parse(article); + return true; + } catch (e) { + debug(`INVALID: ${article.source}: ${article.title} - ${e}`); + return false; + } +}; + +export const insertArticle = (article: Article): boolean => { + const insert = db.prepare( + "INSERT INTO articles (id, title, link, source, created_at) VALUES (?, ?, ?, ?, ?)", + ); + + const checkExistence = db.prepare( + "SELECT COUNT(*) as count FROM articles WHERE link = ?", + ); + + const result = checkExistence.get(article.link) as { count: number }; + if (result.count === 0) { + try { + insert.run( + article.id, + article.title, + article.link, + article.source, + new Date().toISOString(), + ); + return true; + } catch (error) { + debug(`ERROR: ${error}`); + return false; + } + } else { + debug(`DUPLICATE: ${article.link}`); + return false; + } +}; + +export const getCachedArticles = (offset: number, limit: number): Article[] => { + debug(`Getting cached articles with offset: ${offset}, limit: ${limit}`); + + const query = ` + SELECT * FROM articles + ORDER BY created_at DESC + LIMIT ? OFFSET ?`; + + const articles = db.prepare(query).all(limit, offset) as Article[]; + debug(`*** Retrieved ${articles.length} cached articles`); + + return articles; +}; + +export const fetchAndStoreArticles = async (): Promise => { + const allArticles = await fetchAllArticles(); + + const fetchedLinks = allArticles.map((article) => article.link); + + if (fetchedLinks.length === 0) { + return []; + } + + const placeholders = fetchedLinks.map(() => "?").join(","); + const existingLinksResult = db + .prepare(`SELECT link FROM articles WHERE link IN (${placeholders})`) + .all(...fetchedLinks); + + const existingLinks = new Set( + existingLinksResult.map((row: any) => row.link), + ); + + const newArticles = allArticles.filter( + (article) => !existingLinks.has(article.link), + ); + + if (newArticles.length === 0) { + debug( + "All fetched articles already exist in the database. Skipping insertion.", + ); + return []; + } + + const insertedArticles = newArticles.filter(insertArticle); + + debug(`Inserted ${insertedArticles.length} new articles into the database.`); + return insertedArticles; +}; + +const fetchAllArticles = async (): Promise => { + const allArticles: Article[] = []; + + for (const source of newsSources) { + const fetchedArticles = await fetchArticlesFromSource(source); + allArticles.push(...fetchedArticles); + } + + log(`Total articles fetched: ${allArticles.length}`); + + return allArticles; +}; + +export const insertArticles = (articles: Article[]) => { + log(`*** Inserting ${articles.length} articles into the database`); + articles.forEach(insertArticle); +}; diff --git a/src/models/newsSources.ts b/src/models/newsSources.ts new file mode 100644 index 0000000..18871a9 --- /dev/null +++ b/src/models/newsSources.ts @@ -0,0 +1,30 @@ +export interface NewsSource { + name: string; + url: string; + listSelector: string; + titleSelector?: string; + baseUrl?: string; + limit?: number; +} + +export const newsSources: NewsSource[] = [ + { + name: "NPR", + url: "https://text.npr.org/1001", + listSelector: "ul > li > a", + baseUrl: "https://text.npr.org", + }, + { + name: "Al Jazeera", + url: `https://www.aljazeera.com/us-canada`, + listSelector: "article .gc__content a", + baseUrl: "https://www.aljazeera.com", + }, + { + name: "AP News", + url: "https://apnews.com/us-news", + listSelector: "div.PagePromo-content a.Link", + titleSelector: "span.PagePromoContentIcons-text", + baseUrl: "https://apnews.com", + }, +]; diff --git a/src/routes/articles.tsx b/src/routes/articles.tsx new file mode 100644 index 0000000..ad3c661 --- /dev/null +++ b/src/routes/articles.tsx @@ -0,0 +1,41 @@ +import { Hono } from "hono"; +import { getCachedArticles } from "models/article"; +import { debug } from "util/log"; +import { formatRelativeTime } from "util/time"; + +export default function articlesRoutes(app: Hono) { + app.get("/articles", async (c) => { + debug("GET /articles - Start"); + + const offset = parseInt(c.req.query("offset") || "0", 10); + const limit = parseInt(c.req.query("limit") || "15", 10); + + debug("Offset:", offset); + debug("Limit:", limit); + + const articles = getCachedArticles(offset, limit).map((article) => ({ + ...article, + relativeDate: formatRelativeTime(new Date(article.created_at)), + })); + + debug("Articles retrieved:", articles.length); + + return c.html( +
    + {articles.map((article) => ( +
  • + + {article.title} + +
    + {article.relativeDate} - {article.source} +
    +
  • + ))} +
, + ); + }); +} diff --git a/src/server.tsx b/src/server.tsx index b5a98d0..00cbe08 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -1,40 +1,41 @@ +import Layout from "components/Layout"; import { Hono } from "hono"; -import { serveStatic } from "hono/bun"; import { logger } from "hono/logger"; -import Layout from "./components/Layout.tsx"; +import { serveStatic } from "hono/bun"; +import articlesRoutes from "routes/articles"; +import { formatRelativeTime, getLastUpdatedTimestamp } from "util/time"; const app = new Hono(); app.use("/styles/*", serveStatic({ root: "./public/" })); +app.use("/scripts/*", serveStatic({ root: "./public/" })); app.use("*", logger()); -app.get("/", (c) => - c.html( - -
-

- 🌊 hyperwave -

-
-          ⌨️ edit 
-          
-            src/server.tsx
-          
-        
-
-          📚 read the 
-          
-            friendly manual
-          
-          !
-        
-
+app.get("/", async (c) => { + const lastUpdatedDate = getLastUpdatedTimestamp(); + const lastUpdated = lastUpdatedDate + ? formatRelativeTime(lastUpdatedDate) + : null; + + return c.html( + +
+
+
, - ), -); + ); +}); + +articlesRoutes(app); export default { port: process.env.PORT || 1234, diff --git a/src/util/crawler.ts b/src/util/crawler.ts new file mode 100644 index 0000000..00d9b1f --- /dev/null +++ b/src/util/crawler.ts @@ -0,0 +1,74 @@ +import { load } from "cheerio"; +import { Article, isValidArticle } from "models/article"; +import { log, debug } from "util/log"; +import db from "@/db"; +import { NewsSource } from "../models/newsSources"; + +const getStoredHash = (source: string): string | null => { + const result = db + .prepare("SELECT hash FROM source_hashes WHERE source = ?") + .get(source) as { hash: string } | undefined; + return result ? result.hash : null; +}; + +const updateStoredHash = (source: string, hash: string): void => { + db.prepare( + "INSERT OR REPLACE INTO source_hashes (source, hash) VALUES (?, ?)", + ).run(source, hash); +}; + +export const fetchArticlesFromSource = async ( + source: NewsSource, +): Promise => { + log(`Fetching articles from: ${source.name}`); + + const response = await fetch(source.url); + const text = await response.text(); + + const currentHash = Bun.hash(text).toString(); + const storedHash = getStoredHash(source.name); + + if (currentHash === storedHash) { + debug(`No changes detected for ${source.name}. Skipping processing.`); + return []; + } + + updateStoredHash(source.name, currentHash); + + debug(`*** Fetched ${text.length} bytes from: ${source.name}`); + const $ = load(text); + const articles: Article[] = []; + + $(source.listSelector) + .slice(0, source.limit || 100) + .each((_, element) => { + const titleElement = source.titleSelector + ? $(element).find(source.titleSelector) + : $(element); + const title = titleElement.text().trim(); + const relativeLink = $(element).attr("href"); + + if (title && relativeLink) { + const link = new URL(relativeLink, source.baseUrl).href; + const article: Article = { + id: Bun.hash(title).toString(), + title, + link, + source: source.name, + created_at: new Date().toISOString(), + }; + if (!isValidArticle(article)) { + debug(`*** INVALID: ${source.name}: ${title} ${link}`); + } else { + articles.push(article); + debug(`*** VALID: ${source.name}: ${title} ${link}`); + } + } else { + debug(`*** MISSING INFO: ${source.name}: ${title} ${relativeLink}`); + } + }); + + debug(`*** Fetched ${articles.length} articles from: ${source.name}`); + + return articles; +}; diff --git a/src/util/log.ts b/src/util/log.ts new file mode 100644 index 0000000..63ba532 --- /dev/null +++ b/src/util/log.ts @@ -0,0 +1,9 @@ +export function debug(...args: any[]): void { + if (process.env["DEBUG"] === "true") { + console.log("$", new Date().toISOString(), ...args); + } +} + +export function log(...args: any[]): void { + console.log(">>", new Date().toISOString(), ...args); +} diff --git a/src/util/sendTelegramMessage.ts b/src/util/sendTelegramMessage.ts new file mode 100644 index 0000000..bfb028c --- /dev/null +++ b/src/util/sendTelegramMessage.ts @@ -0,0 +1,32 @@ +const sendTelegramMessage = async (message: string) => { + const botToken = process.env.TELEGRAM_BOT_TOKEN; + const chatId = process.env.TELEGRAM_CHAT_ID; + const url = `https://api.telegram.org/bot${botToken}/sendMessage`; + + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + chat_id: chatId, + text: message, + }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error( + `Error sending message: ${response.statusText}, ${JSON.stringify(data)}`, + ); + } + + console.log("Message sent successfully."); + } catch (error) { + console.error("Error sending Telegram message:", error); + } +}; + +export default sendTelegramMessage; diff --git a/src/util/time.ts b/src/util/time.ts new file mode 100644 index 0000000..b2f8cc6 --- /dev/null +++ b/src/util/time.ts @@ -0,0 +1,39 @@ +import db from "@/db"; + +export const SECONDS = 1000; +export const MINUTES = 60 * SECONDS; +export const HOURS = 60 * MINUTES; +export const DAYS = 24 * HOURS; +export const WEEKS = 7 * DAYS; + +const generateRelativeTimeString = (diffInMillis: number): string => { + const diffInSeconds = Math.floor(diffInMillis / SECONDS); + const diffInMinutes = Math.floor(diffInMillis / MINUTES); + const diffInHours = Math.floor(diffInMillis / HOURS); + const diffInDays = Math.floor(diffInMillis / DAYS); + const diffInWeeks = Math.floor(diffInMillis / WEEKS); + + if (diffInSeconds < 60) return "a few seconds ago"; + if (diffInMinutes === 1) return "a minute ago"; + if (diffInMinutes < 60) return `${diffInMinutes} minutes ago`; + if (diffInHours === 1) return "an hour ago"; + if (diffInHours < 24) return `${diffInHours} hours ago`; + if (diffInDays === 1) return "yesterday"; + if (diffInDays < 7) return `${diffInDays} days ago`; + if (diffInWeeks === 1) return "a week ago"; + return `${diffInWeeks} weeks ago`; +}; + +export const formatRelativeTime = (date: Date): string => { + const now = new Date(); + const diffInMillis = now.getTime() - date.getTime(); + return generateRelativeTimeString(diffInMillis); +}; + +export const getLastUpdatedTimestamp = (): Date | null => { + const result = db + .prepare("SELECT created_at FROM articles ORDER BY created_at DESC LIMIT 1") + .get() as { created_at: string } | undefined; + + return result ? new Date(result.created_at) : null; +}; diff --git a/src/worker.ts b/src/worker.ts new file mode 100644 index 0000000..2f218d2 --- /dev/null +++ b/src/worker.ts @@ -0,0 +1,18 @@ +import { fetchAndStoreArticles } from "models/article"; + +const scheduleArticleUpdate = async () => { + await fetchAndStoreArticles(); +}; + +const runEveryMinute = () => { + const now = new Date(); + const millisecondsUntilNextMinute = + 60000 - (now.getSeconds() * 1000 + now.getMilliseconds()); + + setTimeout(() => { + scheduleArticleUpdate(); + setInterval(scheduleArticleUpdate, 60000); + }, millisecondsUntilNextMinute); +}; + +runEveryMinute(); diff --git a/stress.js b/stress.js new file mode 100644 index 0000000..d3b3422 --- /dev/null +++ b/stress.js @@ -0,0 +1,17 @@ +import http from "k6/http"; +import { check, sleep } from "k6"; + +export let options = { + stages: [ + { duration: "1m", target: 250 }, + { duration: "1m", target: 0 }, + ], +}; + +export default function () { + let res = http.get("https://hyperwave.news"); + check(res, { + "status is 200": (r) => r.status === 200, + }); + sleep(1); +} diff --git a/test/crawler.test.ts b/test/crawler.test.ts new file mode 100644 index 0000000..654df81 --- /dev/null +++ b/test/crawler.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, beforeAll, afterAll } from "bun:test"; +import { newsSources } from "models/newsSources"; +import { fetchArticlesFromSource } from "util/crawler"; +import db from "@/db"; + +beforeAll(() => { + db.run("DELETE FROM articles"); +}); + +afterAll(() => { + db.run("DROP TABLE IF EXISTS articles"); +}); + +describe("Article Fetching Functions", () => { + it("Should fetch and parse NPR articles", async () => { + const articles = await fetchArticlesFromSource(newsSources[0]); + expect(articles.length).toBeGreaterThanOrEqual(10); + articles.forEach((article) => { + expect(article).toMatchObject({ + title: expect.any(String), + link: expect.any(String), + source: "NPR", + }); + }); + }); + + it("Should fetch and parse Al Jazeera articles", async () => { + const articles = await fetchArticlesFromSource(newsSources[1]); + expect(articles.length).toBeGreaterThanOrEqual(10); + articles.forEach((article) => { + expect(article).toMatchObject({ + title: expect.any(String), + link: expect.any(String), + source: "Al Jazeera", + }); + }); + }); +}); diff --git a/test/time.test.ts b/test/time.test.ts new file mode 100644 index 0000000..7c78cf4 --- /dev/null +++ b/test/time.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "bun:test"; +import { + SECONDS, + MINUTES, + HOURS, + DAYS, + WEEKS, + formatRelativeTime, +} from "util/time"; + +describe("formatRelativeTime", () => { + it('should display "a few seconds ago" when delta is less than a minute', () => { + const date = new Date(Date.now() - 30 * SECONDS); + expect(formatRelativeTime(date)).toBe("a few seconds ago"); + }); + + it('should display "a minute ago" when delta is less than 2 minutes', () => { + const date = new Date(Date.now() - 1.5 * MINUTES); + expect(formatRelativeTime(date)).toBe("a minute ago"); + }); + + it('should display "5 minutes ago" when delta is less than 5 minutes', () => { + const date = new Date(Date.now() - 5 * MINUTES); + expect(formatRelativeTime(date)).toBe("5 minutes ago"); + }); + + it('should display "10 minutes ago" when delta is less than 10 minutes', () => { + const date = new Date(Date.now() - 10 * MINUTES); + expect(formatRelativeTime(date)).toBe("10 minutes ago"); + }); + + it('should display "15 minutes ago" when delta is less than 15 minutes', () => { + const date = new Date(Date.now() - 15 * MINUTES); + expect(formatRelativeTime(date)).toBe("15 minutes ago"); + }); + + it('should display "an hour ago" when delta is less than an hour', () => { + const date = new Date(Date.now() - 60 * MINUTES); + expect(formatRelativeTime(date)).toBe("an hour ago"); + }); + + it('should display "2 hours ago" when delta is less than 2 hours', () => { + const date = new Date(Date.now() - 2 * HOURS); + expect(formatRelativeTime(date)).toBe("2 hours ago"); + }); + + it('should display "3 hours ago" when delta is less than 3 hours', () => { + const date = new Date(Date.now() - 3 * HOURS); + expect(formatRelativeTime(date)).toBe("3 hours ago"); + }); + + it('should display "yesterday" when delta is less than 24 hours', () => { + const date = new Date(Date.now() - 1 * DAYS); + expect(formatRelativeTime(date)).toBe("yesterday"); + }); + + it('should display "2 days ago" when delta is less than 2 days', () => { + const date = new Date(Date.now() - 2 * DAYS); + expect(formatRelativeTime(date)).toBe("2 days ago"); + }); + + it('should display "several days ago" when delta is less than a week', () => { + const date = new Date(Date.now() - 6 * DAYS); + expect(formatRelativeTime(date)).toBe("6 days ago"); + }); + + it('should display "a week ago" when delta is less than 2 weeks', () => { + const date = new Date(Date.now() - 1 * WEEKS); + expect(formatRelativeTime(date)).toBe("a week ago"); + }); + + it('should display "several weeks ago" when delta is more than a week', () => { + const date = new Date(Date.now() - 3 * WEEKS); + expect(formatRelativeTime(date)).toBe("3 weeks ago"); + }); + + it('should default to "a long time ago" when delta is more than 4 weeks', () => { + const date = new Date(Date.now() - 5 * WEEKS); + expect(formatRelativeTime(date)).toBe("5 weeks ago"); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 4fb3088..dc8e4ce 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,11 +1,23 @@ { - "compilerOptions": { + "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "hono/jsx", "allowImportingTsExtensions": true, "noEmit": true, - "types": [ - "bun-types" - ] - } + "types": ["bun-types"], + "esModuleInterop": true, + "moduleResolution": "node", + "target": "ES2021", + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "paths": { + "components/*": ["./src/components/*"], + "routes/*": ["./src/routes/*"], + "models/*": ["./src/models/*"], + "util/*": ["./src/util/*"], + "@/*": ["./src/*"] + } + }, + "include": ["src", "test"] } diff --git a/uno.config.ts b/uno.config.ts index 0614cb8..5dc61fc 100644 --- a/uno.config.ts +++ b/uno.config.ts @@ -1,5 +1,4 @@ import { defineConfig, presetAttributify, presetWind } from "unocss"; -import presetWebFonts from "@unocss/preset-web-fonts"; export default defineConfig({ presets: [ @@ -7,11 +6,5 @@ export default defineConfig({ presetWind({ dark: "class", }), - presetWebFonts({ - provider: "google", - fonts: { - lato: "Lato", - }, - }), ], });