diff --git a/app/stores/products.ts b/app/stores/products.ts index 0c159d9a6..d77e02133 100644 --- a/app/stores/products.ts +++ b/app/stores/products.ts @@ -1,4 +1,9 @@ export const products = { + 'deno': { + id: 'deno', + price: 'price_1QIkM0BF7AptWZlctgYjiOTL', + amount: 20, + }, 'linux': { id: 'linux', price: 'price_1PY9DmBF7AptWZlcpkAbgZlP', diff --git a/content/courses/deno/_index.md b/content/courses/deno/_index.md new file mode 100644 index 000000000..62c603c08 --- /dev/null +++ b/content/courses/deno/_index.md @@ -0,0 +1,62 @@ +--- +lastmod: 2024-11-07T11:11:30-09:00 +title: Deno Full Course +description: Master TypeScript and Backend WebDev with Deno +weight: 0 +type: courses +vimeo: 1027549123 +author: Jeff Delaney +tags: + - typescript + - deno + - pro + +stack: + - deno + - ts + - js +--- + +**Deno - The Full Course** is a hands-on tutorial where you will build a complete web app with [Deno](https://kit.svelte.dev/), using zero 3rd-party denpendices to master TypeScript and Web Platform APIs. + +## What will I learn? + +- πŸ¦• Everything you need to be productive with Deno +- πŸ’ͺ TypeScript fundamentals in fast to-the-point videos +- πŸ•ΈοΈ Web Platform APIs that work everywhere +- πŸ’₯ Build a full-stack web app with zero-dependencies +- πŸ§ͺ Test-Driven Development & Benchmarking +- πŸͺ Roll your own cookie-based user authentication +- 🎹 Manage data with Deno KV +- ⚑ Stream database changes in realtime +- βš›οΈ Server-rendered HTML with JSX +- πŸš€ Deno Deployment + + +## πŸ¦„ What will I build? + +You will build a **Realtime Link Shortener** inspired by [🌴 Bit.ly](https://bit.ly/) where users can create sharable links and. The goal of this project is to help you master web development fundamentals and learn advanced TypeScript patterns. + +### πŸš€ Try it out! + +Visit the demo app and give it a test drive before you enroll. + +
+Link Shortener Live Demo +
+ +## πŸ€” Is this Course Right for Me? + +
+This course is intermediate level 🟦 and expects some familiarity with JavaScript and web development. The content is fast-paced and similar to my style on YouTube, but far more in-depth and should be followed in a linear format. +
+ + +## When was the course last updated? + +Updated November 7th, 2024 Deno 2 + +## How do I enroll? + +The first few videos are *free*, so just give it try. When you reach a paid module, you will be asked to pay for a single course or upgrade to PRO. + diff --git a/content/courses/deno/app-atomic.md b/content/courses/deno/app-atomic.md new file mode 100644 index 000000000..9c8065595 --- /dev/null +++ b/content/courses/deno/app-atomic.md @@ -0,0 +1,97 @@ +--- +title: Atomic Writes +description: ACID compliant transactions +weight: 49 +lastmod: 2024-11-05T11:11:30-09:00 +draft: false +vimeo: 1027526766 +emoji: πŸ’₯ +video_length: 3:25 +--- + + + +## Query Records by Username + + +{{< file "ts" "main.ts" >}} +```typescript +export async function storeShortLink( + longUrl: string, + shortCode: string, + userId: string, +) { + const shortLinkKey = ["shortlinks", shortCode]; + const data: ShortLink = { + shortCode, + longUrl, + userId, + createdAt: Date.now(), + clickCount: 0, + }; + + const userKey = [userId, shortCode]; + + const res = await kv.atomic() + .set(shortLinkKey, data) + .set(userKey, shortCode) + .commit() + + + return res; +} + +export async function getUserLinks(userId: string) { + + const list = kv.list({ prefix: [userId]}); + const res = await Array.fromAsync(list); + const userShortLinkKeys = res.map((v) => ['shortlinks', v.value]); + + const userRes = await kv.getMany(userShortLinkKeys) + const userShortLinks = await Array.fromAsync(userRes) + + return userShortLinks.map(v => v.value); +} +``` + +## Increment a Count + +{{< file "ts" "main.ts" >}} +```typescript +export async function incrementClickCount( + shortCode: string, + data?: Partial, +) { + const shortLinkKey = ["shortlinks", shortCode]; + const shortLink = await kv.get(shortLinkKey); + const shortLinkData = shortLink.value as ShortLink; + + const newClickCount = shortLinkData?.clickCount + 1; + + const analyicsKey = ["analytics", shortCode, newClickCount]; + const analyticsData = { + shortCode, + createdAt: Date.now(), + ...data, + // ipAddress: "192.168.1.1", + // userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + // country: "United States" + }; + + const res = await kv.atomic() + .check(shortLink) + .set(shortLinkKey, { + ...shortLinkData, + clickCount: shortLinkData?.clickCount + 1, + }) + .set(analyicsKey, analyticsData) + .commit(); + if (res.ok) { + console.log("Logged click"); + } else { + console.error("Not logged"); + } + + return res; +} +``` \ No newline at end of file diff --git a/content/courses/deno/app-auth.md b/content/courses/deno/app-auth.md new file mode 100644 index 000000000..be7b17774 --- /dev/null +++ b/content/courses/deno/app-auth.md @@ -0,0 +1,175 @@ +--- +title: User Auth +description: Roll your own user authentication flow +weight: 47 +lastmod: 2024-11-05T11:11:30-09:00 +draft: false +vimeo: 1027526785 +emoji: πŸ«‚ +video_length: 5:35 +--- + + + +## Setup Environment Variables + +{{< file "cog" ".env" >}} +```bash +GITHUB_CLIENT_ID=abc +GITHUB_CLIENT_SECRET=xyz +REDIRECT_URI=http://localhost:8000/oauth/callback +``` + +Update the dev task to include the `--env` flag to load the env vars: + +{{< file "deno" "deno.json" >}} +```json +{ + "tasks": { + "dev": "deno serve --watch --unstable-kv --env -A src/main.ts", + }, +} +``` + +## Authentication with User Profile + + +Add database logic to store and get the user profile from the Deno KV database. + +{{< file "ts" "db.ts" >}} +```typescript +export type GitHubUser = { + login: string; // username + avatar_url: string; + html_url: string; +}; + + +export async function storeUser(sessionId: string, userData: GitHubUser) { + const key = ["sessions", sessionId]; + const res = await kv.set(key, userData); + return res; +} + +export async function getUser(sessionId: string) { + const key = ["sessions", sessionId]; + const res = await kv.get(key); + return res.value; +} +``` + +Create a new file to handle authentication logic + +{{< file "ts" "auth.ts" >}} +```typescript +import { createGitHubOAuthConfig, createHelpers } from "jsr:@deno/kv-oauth"; +import { pick } from "jsr:@std/collections/pick"; +import { type GitHubUser, getUser, storeUser } from "./db.ts"; + +const oauthConfig = createGitHubOAuthConfig(); +const { + handleCallback, + getSessionId, +} = createHelpers(oauthConfig); + + +export async function getCurrentUser(req: Request) { + const sessionId = await getSessionId(req); + console.log(sessionId) + return sessionId ? await getUser(sessionId) : null; +} + +export async function getGitHubProfile(accessToken: string) { + const response = await fetch("https://api.github.com/user", { + headers: { authorization: `Bearer ${accessToken}` }, + }); + + if (!response.ok) { + response.body?.cancel(); + throw new Error("Failed to fetch GitHub user"); + } + + return response.json() as Promise; +} + +export async function handleGithubCallback(req: Request) { + const { response, tokens, sessionId } = await handleCallback(req); + const userData = await getGitHubProfile(tokens?.accessToken); + const filteredData = pick(userData, ["avatar_url", "html_url", "login"]); + await storeUser(sessionId, filteredData); + return response; +} + +``` + +Add the current user as a property on the router + +{{< file "ts" "router.ts" >}} +```typescript +import type { GitHubUser } from "./db.ts"; +import { getCurrentUser } from "./auth.ts"; + +export class Router { + #routes: Route[] = []; + + currentUser?: GitHubUser | null; // <-- HERE + + #addRoute(method: string, path: string, handler: Handler) { + const pattern = new URLPattern({ pathname: path }); + + this.#routes.push({ + pattern, + method, + handler: async (req, info, params) => { + try { + this.currentUser = await getCurrentUser(req); // <-- HERE + return await handler(req, info!, params!); + } catch (error) { + console.error("Error handling request:", error); + return new Response("Internal Server Error", { status: 500 }); + } + }, + }); + } + + get handler() { + return route(this.#routes, () => new Response("Not Found", { status: 404 })) + } + +} +``` + +Configure the OAuth 2.0 routes + +{{< file "ts" "main.ts" >}} +```typescript +import { createGitHubOAuthConfig, createHelpers } from "jsr:@deno/kv-oauth"; +import { handleGithubCallback } from "./auth.ts"; + +const app = new Router(); + +const oauthConfig = createGitHubOAuthConfig({ + redirectUri: Deno.env.get('REDIRECT_URI') +}); +const { + signIn, + signOut, +} = createHelpers(oauthConfig); + + +app.get("/oauth/signin", (req: Request) => signIn(req)); +app.get("/oauth/signout", signOut); +app.get("/oauth/callback", handleGithubCallback); + + +app.get("/", () => { + return new Response( + render(HomePage({ user: app.currentUser })), + { + status: 200, + headers: { + "content-type": "text/html", + }, + }); +}); +``` \ No newline at end of file diff --git a/content/courses/deno/app-custom-router.md b/content/courses/deno/app-custom-router.md new file mode 100644 index 000000000..dd7d07fea --- /dev/null +++ b/content/courses/deno/app-custom-router.md @@ -0,0 +1,78 @@ +--- +title: Custom Router +description: Build a custom express-like router +weight: 43 +lastmod: 2024-11-05T11:11:30-09:00 +draft: false +vimeo: 1027117748 +emoji: πŸš… +video_length: 4:50 +--- + + +## Custom Deno Express-like Router + +{{< file "ts" "router.ts" >}} +```typescript +import { type Route, route, Handler } from "jsr:@std/http" + +export class Router { + #routes: Route[] = []; + + get(path: string, handler: Handler) { + this.#addRoute("GET", path, handler); + } + + post(path: string, handler: Handler) { + this.#addRoute("POST", path, handler); + } + + put(path: string, handler: Handler) { + this.#addRoute("PUT", path, handler); + } + + delete(path: string, handler: Handler) { + this.#addRoute("DELETE", path, handler); + } + + #addRoute(method: string, path: string, handler: Handler) { + const pattern = new URLPattern({ pathname: path }); + this.#routes.push({ + pattern, + method, + handler: async (req, info, params) => { + try { + return await handler(req, info!, params!); + } catch (error) { + console.error("Error handling request:", error); + return new Response("Internal Server Error", { status: 500 }); + } + }, + }); + } + + get handler() { + return route(this.#routes, () => new Response("Not Found", { status: 404 })) + } + +} +``` + + +## Using the Custom Router + +{{< file "ts" "main.ts" >}} +```typescript +import { Router } from "./router.ts"; +const app = new Router(); + +app.get('/', () => new Response('Hi Mom!')) + +app.post('/health-check', () => new Response("It's ALIVE!")) + +export default { + fetch(req) { + return app.handler(req); + }, +} satisfies Deno.ServeDefaultExport; +``` diff --git a/content/courses/deno/app-deno-kv.md b/content/courses/deno/app-deno-kv.md new file mode 100644 index 000000000..f38aae8fd --- /dev/null +++ b/content/courses/deno/app-deno-kv.md @@ -0,0 +1,116 @@ +--- +title: Deno KV +description: Using Deno's built-in database +weight: 45 +lastmod: 2024-11-05T11:11:30-09:00 +draft: false +vimeo: 1027305799 +emoji: πŸ—„οΈ +video_length: 3:38 +--- + +## Generate a Shortcode + +{{< file "ts" "db.ts" >}} +```typescript +const kv = await Deno.openKv(); + +export type ShortLink = { + shortCode: string; + longUrl: string; + createdAt: number; + userId: string; + clickCount: number; + lastClickEvent?: string; +} + +export async function storeShortLink( + longUrl: string, + shortCode: string, + userId: string, +) { + const shortLinkKey = ["shortlinks", shortCode]; + const data: ShortLink = { + shortCode, + longUrl, + userId, + createdAt: Date.now(), + clickCount: 0, + }; + + const res = await kv.set(shortLinkKey, data); + + if (!res.ok) { + // handle errors + } + + return res; + + +} + +export async function getShortLink(shortCode: string) { + const link = await kv.get(["shortlinks", shortCode]); + return link.value; +} + + +// Temporary example to try it out +// deno run -A --unstable-kv src/db.ts +const longUrl = 'https://fireship.io'; +const shortCode = await generateShortCode(longUrl) +const userId = 'test'; + +console.log(shortCode) + + +await storeShortLink(longUrl, shortCode, userId); + +const linkData = await getShortLink(shortCode) +console.log(linkData) +``` + + +## Updated Deno Task + +{{< file "deno" "deno.json" >}} +```json +{ + "tasks": { + "dev": "deno serve --watch -A --unstable-kv src/main.ts", + } +} +``` + +## Example usage in JSON API + +```typescript +app.post("/links", async (req) => { + + const { longUrl } = await req.json() + + const shortCode = await generateShortCode(longUrl); + await storeShortLink(longUrl, shortCode, 'testUser'); + + return new Response("success!", { + status: 201, + }); +}); + + +app.get("/links/:id", async (_req, _info, params) => { + + const shortCode = params?.pathname.groups.id; + + const data = await getShortLink(shortCode!) + + return new Response(JSON.stringify(data), { + status: 201, + headers: { + "content-type": "application/json", + }, + + }); + +}) +``` diff --git a/content/courses/deno/app-deploy.md b/content/courses/deno/app-deploy.md new file mode 100644 index 000000000..39f1078f7 --- /dev/null +++ b/content/courses/deno/app-deploy.md @@ -0,0 +1,34 @@ +--- +title: Ship it +description: How to deploy a Deno app +weight: 52 +lastmod: 2024-11-05T11:11:30-09:00 +draft: false +vimeo: +emoji: πŸš€ +video_length: 3:38 +--- + + +{{< file "terminal" "command line" >}} +```bash +deno install -A jsr:@deno/deployctl --global + +deployctl deploy +``` + +{{< file "deno" "deno.json" >}} +```json +{ + "tasks": { + "dev": "deno serve --watch --unstable-kv --env -A src/main.ts", + "test": "deno test tests/", + "deploy": "deployctl deploy --project YOUR_PROJECT" + }, + + "deploy": { + "entrypoint": "src/main.ts" + } + +} +``` \ No newline at end of file diff --git a/content/courses/deno/app-form.md b/content/courses/deno/app-form.md new file mode 100644 index 000000000..03b92bb66 --- /dev/null +++ b/content/courses/deno/app-form.md @@ -0,0 +1,91 @@ +--- +title: Form Submission +description: Submit HTML forms to the backend +weight: 48 +lastmod: 2024-11-05T11:11:30-09:00 +draft: false +vimeo: 1027526776 +emoji: πŸ“ƒ +video_length: 2:36 +--- + + +{{< file "ts" "main.ts" >}} +```typescript +app.get("/links/new", (_req) => { + if (!app.currentUser) return unauthorizedResponse(); + + return new Response(render(CreateShortlinkPage()), { + status: 200, + headers: { + "content-type": "text/html", + }, + }); +}); + + +app.post("/links", async (req) => { + if (!app.currentUser) return unauthorizedResponse(); + + // Parse form data + const formData = await req.formData(); + const longUrl = formData.get("longUrl") as string; + + if (!longUrl) { + return new Response("Missing longUrl", { status: 400 }); + } + + const shortCode = await generateShortCode(longUrl); + await storeShortLink(longUrl, shortCode, app.currentUser.login); + + // Redirect to the links list page after successful creation + return new Response(null, { + status: 303, + headers: { + "Location": "/links", + }, + }); + + app.get("/links", async () => { + if (!app.currentUser) return unauthorizedResponse(); + + const shortLinks = await getUserLinks(app.currentUser.login); + + return new Response(render(LinksPage({ shortLinkList: shortLinks })), { + status: 200, + headers: { + "content-type": "text/html", + }, + }); +}); +``` + +## Form UI + +{{< file "react" "ui.tsx" >}} +```tsx +export function CreateShortlinkPage() { + return ( + +

Create a New Shortlink

+
+
+ + +
+ +
+
+ ); +} +``` + diff --git a/content/courses/deno/app-hash.md b/content/courses/deno/app-hash.md new file mode 100644 index 000000000..591967389 --- /dev/null +++ b/content/courses/deno/app-hash.md @@ -0,0 +1,84 @@ +--- +title: Deno Crypto +description: Create a cryptographic shortcode +weight: 44 +lastmod: 2024-11-05T11:11:30-09:00 +draft: false +vimeo: 1027305830 +emoji: πŸ“ +video_length: 2:57 +--- + + +## Generate a Shortcode + +{{< file "ts" "db.ts" >}} +```typescript + +import { encodeBase64Url, encodeHex } from "jsr:@std/encoding"; +import { crypto } from "jsr:@std/crypto/crypto"; + +export async function generateShortCode(longUrl: string) { + + try { + new URL(longUrl); + } catch (error) { + console.log(error); + throw new Error("Invalid URL provided"); + } + + // Generate a unique identifier for the URL + const urlData = new TextEncoder().encode(longUrl + Date.now()); + const hash = await crypto.subtle.digest("SHA-256", urlData); + const hashArray = new Uint8Array(hash); + const hashHex = encodeHex(hashArray); + + // Take the first 8 characters of the hash for the short URL + const shortCode = encodeBase64Url(hashHex.slice(0, 8)); + + return shortCode; +} +``` + + +## Optional: Testing the ShortCode Function + +{{< file "ts" "db.ts" >}} +```typescript +import { assertEquals, assertNotEquals, assertRejects } from "@std/assert"; +import { delay } from "jsr:@std/async/delay"; +import { generateShortCode } from "../src/db.ts"; + +Deno.test("URL Shortener ", async (t) => { + await t.step("should generate a short code for a valid URL", async () => { + const longUrl = "https://www.example.com/some/long/path"; + const shortCode = await generateShortCode(longUrl); + + assertEquals(typeof shortCode, "string"); + assertEquals(shortCode.length, 11); + }); + + await t.step("should be unique for each timestamp", async () => { + const longUrl = "https://www.example.com"; + const a = await generateShortCode(longUrl); + await delay(5) + const b = await generateShortCode(longUrl); + + assertNotEquals(a, b) + }); + + await t.step("throw error on bad URL", () => { + const longUrl = "this aint no url"; + + assertRejects(async () => { + await generateShortCode(longUrl); + }) + }); +}); +``` + +## Bonus Video + +
+{{< youtube NuyzuNBFWxQ >}} +
\ No newline at end of file diff --git a/content/courses/deno/app-init-structure.md b/content/courses/deno/app-init-structure.md new file mode 100644 index 000000000..daeb7652a --- /dev/null +++ b/content/courses/deno/app-init-structure.md @@ -0,0 +1,22 @@ +--- +title: Project Structure +description: How to structure a deno project +weight: 41 +lastmod: 2024-11-05T11:11:30-09:00 +draft: false +vimeo: 1027117775 +emoji: 🧱 +video_length: 2:59 +--- + +## Updated Deno Config + +{{< file "deno" "deno.json" >}} +```json +{ + "tasks": { + "dev": "deno serve --watch -A src/main.ts", + "test": "deno test tests/", + } +} +``` \ No newline at end of file diff --git a/content/courses/deno/app-jsx.md b/content/courses/deno/app-jsx.md new file mode 100644 index 000000000..c4cc46bf1 --- /dev/null +++ b/content/courses/deno/app-jsx.md @@ -0,0 +1,114 @@ +--- +title: Built-in JSX +description: Write and render HTML on the server +weight: 46 +lastmod: 2024-11-05T11:11:30-09:00 +draft: false +vimeo: 1027305746 +emoji: πŸ’½ +video_length: 4:57 +--- + + +## Deno Config Update + +{{< file "deno" "deno.json" >}} +```json +{ + //... + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "npm:preact" + } +} + +``` + +## Home Page Route + +{{< file "ts" "main.ts" >}} +```typescript +import { HomePage } from "./ui.tsx"; +import { render } from "npm:preact-render-to-string"; + +app.get("/", () => { + return new Response( + render(HomePage({user: null })), + { + status: 200, + headers: { + "content-type": "text/html", + }, + }); +}); +``` + +## Home Page UI with JSX + +Find all the Tailwind CSS styles in the [full source code]() + +{{< file "react" "ui.tsx" >}} +```tsx + +/** @jsxImportSource https://esm.sh/preact */ + +import type { ComponentChildren } from "npm:preact"; + +export function Layout({ children }) { + return ( + + + + + + + + +
+ +
+ +
+ {children} +
+ + + + ); +} + +export function HomePage({ user }) { + return ( + +
+
+
+

Welcome to link.fireship.app

+ {user ? ( +
+
Welcome back, {user.login}!
+ +
+ ) : ( + Sign In with GitHub + )} +
+
+
+
+ ); +} +``` \ No newline at end of file diff --git a/content/courses/deno/app-link-analytics.md b/content/courses/deno/app-link-analytics.md new file mode 100644 index 000000000..cf09c9528 --- /dev/null +++ b/content/courses/deno/app-link-analytics.md @@ -0,0 +1,77 @@ +--- +title: Link Analytics +description: Keep track of shortlink clicks +weight: 50 +lastmod: 2024-11-05T11:11:30-09:00 +draft: false +vimeo: 1027526752 +emoji: πŸ“ˆ +video_length: 3:19 +--- + + +## Handle Shortlink Redirects + +{{< file "ts" "main.ts" >}} +```typescript +app.get("/:id", async (req, _info, params) => { + const shortCode = params.pathname.groups["id"]; + const shortLink = await getShortLink(shortCode); + + if (shortLink) { + // Capture analytics data + const ipAddress = req.headers.get("x-forwarded-for") || + req.headers.get("cf-connecting-ip") || "Unknown"; + const userAgent = req.headers.get("user-agent") || "Unknown"; + const country = req.headers.get("cf-ipcountry") || "Unknown"; + + // Increment click count and store analytics data + await incrementClickCount(shortCode, { + ipAddress, + userAgent, + country, + }); + + // Redirect to the long URL + return new Response(null, { + status: 303, + headers: { + "Location": shortLink.longUrl, + }, + }); + } else { + // Render 404 page + return new Response(render(NotFoundPage({ shortCode })), { + status: 404, + headers: { + "Content-Type": "text/html", + }, + }); + } +}); +``` + +## Write Analytics Data to Database + +{{< file "ts" "main.ts" >}} +```typescript +``` + + +## Display ShortLink Details Page + +{{< file "ts" "main.ts" >}} +```typescript +app.get("/links/:id", async (_req, _info, params) => { + const shortCode = params?.pathname.groups["id"]; + const shortLink = await getShortLink(shortCode!); + + return new Response(render(ShortlinkViewPage({ shortLink })), { + status: 200, + headers: { + "content-type": "text/html", + }, + }); +}); + +``` \ No newline at end of file diff --git a/content/courses/deno/app-realtime.md b/content/courses/deno/app-realtime.md new file mode 100644 index 000000000..c4eee3781 --- /dev/null +++ b/content/courses/deno/app-realtime.md @@ -0,0 +1,91 @@ +--- +title: Realtime Streams +description: Listen to the database in realtime +weight: 51 +lastmod: 2024-11-05T11:11:30-09:00 +draft: false +vimeo: 1027526736 +emoji: ⚑ +video_length: 4:01 +--- + + +## Backend Realtime Stream + +{{< file "ts" "main.ts" >}} +```typescript +app.get("/realtime/:id", (_req, _info, params) => { + const shortCode = params?.pathname.groups["id"]; + + // Setup KV watch reader + const shortLinkKey = ["shortlinks", shortCode]; + const shortLinkStream = kv.watch([shortLinkKey]).getReader(); + + // Create stream response body + const body = new ReadableStream({ + async start(controller) { + // Fetch initial data if needed + // const initialData = await getShortLink(shortCode); + // controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify({ clickCount: initialData.clickCount })}\n\n`)); + + while (true) { + const { done } = await stream.read(); + if (done) { + return; + } + const shortLink = await getShortLink(shortCode); + const clickAnalytics = shortLink.clickCount > 0 && + await getClickEvent(shortCode, shortLink.clickCount); + + controller.enqueue( + new TextEncoder().encode( + `data: ${ + JSON.stringify({ + clickCount: shortLink.clickCount, + clickAnalytics, + }) + }\n\n`, + ), + ); + console.log("Stream updated"); + } + }, + cancel() { + stream.cancel(); + }, + }); + + return new Response(body, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }, + }); +}); +``` + +## Frontend Realtime Listener + +Example of how to listen to a realtime stream from frontend code. This code would typically be placed in a `