diff --git a/06-assets/src/worker.py b/06-assets/src/worker.py index 5ba8980..fd15f5a 100644 --- a/06-assets/src/worker.py +++ b/06-assets/src/worker.py @@ -27,6 +27,4 @@ async def fetch(self, request): if path in ["/", "/index.html"]: return Response(INDEX_PAGE, headers={"Content-Type": "text/html"}) - # TODO: Once https://github.com/cloudflare/workerd/pull/4926 is released, can do - # self.env.ASSETS.fetch(request) without the .js_object - return await self.env.ASSETS.fetch(request.js_object) + return await self.env.ASSETS.fetch(request) diff --git a/11-opengraph/README.md b/11-opengraph/README.md new file mode 100644 index 0000000..4d64bb4 --- /dev/null +++ b/11-opengraph/README.md @@ -0,0 +1,68 @@ +# OpenGraph Meta Tag Injection Example + +This example demonstrates how to build a Python Worker that dynamically injects OpenGraph meta tags into web pages based on the request path. This is perfect for controlling how your content appears when shared on social media platforms like Facebook, Twitter, LinkedIn, and Slack. + +## What It Does + +The Worker: +1. **Receives a request** for a specific URL path (e.g., `/blog/my-article`) +2. **Generates OpenGraph metadata** dynamically based on the path +3. **Fetches the original HTML** from your target website +4. **Uses Cloudflare's HTMLRewriter** to inject OpenGraph meta tags into the HTML `` section +5. **Returns the enhanced HTML** with proper social media preview tags + +This example showcases how to use Cloudflare's powerful HTMLRewriter API from Python Workers via the `js` module interop. + +## How to Run + +First ensure that `uv` is installed: +https://docs.astral.sh/uv/getting-started/installation/#standalone-installer + +Now, if you run `uv run pywrangler dev` within this directory, it should use the config +in `wrangler.jsonc` to run the example. + +```bash +uv run pywrangler dev +``` + +Then visit: +- `http://localhost:8787/` - Home page with default metadata +- `http://localhost:8787/blog/python-workers-intro` - Blog post example +- `http://localhost:8787/products/awesome-widget` - Product page example +- `http://localhost:8787/about` - About page example + +## Deployment + +Deploy to Cloudflare Workers: + +```bash +uv run pywrangler deploy +``` + +## Customization + +To adapt this example for your own website: + +1. **Update the target URL** in `src/entry.py`: + ```python + target_url = f"https://your-website.com{path}/" + ``` + +2. **Customize metadata patterns** in the `get_opengraph_data()` method: + ```python + if path.startswith("/your-section/"): + og_data.update({ + "title": "Your Custom Title", + "description": "Your custom description", + "image": "https://your-image-url.com/image.jpg" + }) + ``` + +3. **Add more URL patterns** to match your site structure + +## Testing Your OpenGraph Tags + +Use these tools to validate your OpenGraph tags: +- [Facebook Sharing Debugger](https://developers.facebook.com/tools/debug/) +- [X Card Validator](https://cards-dev.x.com/validator) +- [LinkedIn Post Inspector](https://www.linkedin.com/post-inspector/) diff --git a/11-opengraph/package.json b/11-opengraph/package.json new file mode 100644 index 0000000..b77afec --- /dev/null +++ b/11-opengraph/package.json @@ -0,0 +1,13 @@ +{ + "name": "python-opengraph", + "version": "0.0.0", + "private": true, + "scripts": { + "deploy": "uv run pywrangler deploy", + "dev": "uv run pywrangler dev", + "start": "uv run pywrangler dev" + }, + "devDependencies": { + "wrangler": "^4.46.0" + } +} diff --git a/11-opengraph/pyproject.toml b/11-opengraph/pyproject.toml new file mode 100644 index 0000000..252b233 --- /dev/null +++ b/11-opengraph/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "python-opengraph" +version = "0.1.0" +description = "Python opengraph example" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "webtypy>=0.1.7", +] + +[dependency-groups] +dev = [ + "workers-py", + "workers-runtime-sdk" +] diff --git a/11-opengraph/src/entry.py b/11-opengraph/src/entry.py new file mode 100644 index 0000000..4b7f3a5 --- /dev/null +++ b/11-opengraph/src/entry.py @@ -0,0 +1,170 @@ +from workers import WorkerEntrypoint, Request, fetch +from js import HTMLRewriter +from urllib.parse import urlparse +from html import escape + +from pyodide.ffi import create_proxy + + +class MetaTagInjector: + """ + Element handler for HTMLRewriter that injects OpenGraph meta tags. + Uses Python's html.escape() for proper HTML escaping. + """ + + def __init__(self, og_data: dict): + self.og_data = og_data + self.injected = False + + def element(self, element): + """Called when the element is encountered.""" + if not self.injected: + # Create and inject meta tags + self._inject_meta_tags(element) + self.injected = True + + def _inject_meta_tags(self, head_element): + """Inject OpenGraph and Twitter Card meta tags.""" + # OpenGraph tags + self._create_meta(head_element, "property", "og:title", self.og_data["title"]) + self._create_meta( + head_element, "property", "og:description", self.og_data["description"] + ) + self._create_meta(head_element, "property", "og:image", self.og_data["image"]) + self._create_meta(head_element, "property", "og:url", self.og_data["url"]) + self._create_meta(head_element, "property", "og:type", self.og_data["type"]) + self._create_meta( + head_element, "property", "og:site_name", self.og_data["site_name"] + ) + + # Twitter Card tags + self._create_meta(head_element, "name", "twitter:card", "summary_large_image") + self._create_meta(head_element, "name", "twitter:title", self.og_data["title"]) + self._create_meta( + head_element, "name", "twitter:description", self.og_data["description"] + ) + self._create_meta(head_element, "name", "twitter:image", self.og_data["image"]) + + def _create_meta(self, head_element, attr_name: str, attr_value: str, content: str): + """ + Create a meta tag and prepend it to the head element. + Uses Python's html.escape() for proper attribute escaping. + """ + # Use Python's built-in html.escape() which handles all necessary escaping + escaped_attr_value = escape(attr_value, quote=True) + escaped_content = escape(content, quote=True) + meta_html = ( + f'' + ) + head_element.prepend(meta_html, html=True) + + +class ExistingMetaRemover: + """ + Element handler that removes existing OpenGraph and Twitter meta tags. + """ + + def element(self, element): + """Remove the element by calling remove().""" + element.remove() + + +class Default(WorkerEntrypoint): + """ + OpenGraph Meta Tag Injection Example + + This Worker fetches a web page and injects OpenGraph meta tags + based on the request path using Cloudflare's HTMLRewriter API. + """ + + async def fetch(self, request: Request): + # Parse the request path to determine which page we're serving + url = urlparse(request.url) + path = url.path + + # Define OpenGraph metadata based on the path + og_data = self.get_opengraph_data(path) + + # Fetch the original HTML from a target website + # In this example, we'll use example.com, but you can replace this + # with your actual website URL + # + # Note that this isn't necessary if your worker will also be serving + # content of your website, in that case you should already have the HTML + # you're returning ready to go here. + target_url = f"https://example.com{path}/" + + # Fetch the original page + response = await fetch(target_url) + + # Use HTMLRewriter to inject OpenGraph meta tags + rewritten_response = self.inject_opengraph_tags(response, og_data) + + return rewritten_response + + def get_opengraph_data(self, path: str) -> dict: + """ + Generate OpenGraph metadata based on the request path. + Customize this function to match your site's structure. + """ + # Default metadata + og_data = { + "title": "My Awesome Website", + "description": "Welcome to my website built with Python Workers!", + "image": "https://images.unsplash.com/photo-1518770660439-4636190af475", + "url": f"https://yoursite.com{path}/", + "type": "website", + "site_name": "Python Workers Demo", + } + + # Customize based on path + if path.startswith("/blog/"): + article_slug = path.replace("/blog/", "").strip("/") + og_data.update( + { + "title": f"Blog Post: {article_slug.replace('-', ' ').title()}", + "description": f"Read our latest article about {article_slug.replace('-', ' ')}", + "image": "https://images.unsplash.com/photo-1499750310107-5fef28a66643", + "type": "article", + } + ) + elif path.startswith("/products/"): + product_slug = path.replace("/products/", "").strip("/") + og_data.update( + { + "title": f"Product: {product_slug.replace('-', ' ').title()}", + "description": f"Check out our amazing {product_slug.replace('-', ' ')} product", + "image": "https://images.unsplash.com/photo-1505740420928-5e560c06d30e", + "type": "product", + } + ) + elif path == "/about": + og_data.update( + { + "title": "About Us - Python Workers", + "description": "Learn more about our team and what we do with Python Workers", + "image": "https://images.unsplash.com/photo-1522071820081-009f0129c71c", + } + ) + + return og_data + + def inject_opengraph_tags(self, response, og_data: dict): + """ + Use HTMLRewriter to inject OpenGraph meta tags into the HTML response. + Removes existing OG tags first to avoid duplicates. + """ + # Create an HTMLRewriter instance + rewriter = HTMLRewriter.new() + + meta_remover = create_proxy(ExistingMetaRemover()) + meta_injector = create_proxy(MetaTagInjector(og_data)) + + rewriter = HTMLRewriter.new() + # Remove existing OpenGraph and Twitter meta tags to avoid duplicates + rewriter.on('meta[property^="og:"]', meta_remover) + rewriter.on('meta[name^="twitter:"]', meta_remover) + # Inject new OpenGraph meta tags into the element + rewriter.on("head", meta_injector) + + return rewriter.transform(response.js_object) diff --git a/11-opengraph/wrangler.jsonc b/11-opengraph/wrangler.jsonc new file mode 100644 index 0000000..42be06b --- /dev/null +++ b/11-opengraph/wrangler.jsonc @@ -0,0 +1,12 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "python-opengraph", + "main": "src/entry.py", + "compatibility_date": "2025-11-02", + "compatibility_flags": [ + "python_workers" + ], + "observability": { + "enabled": true + } +} diff --git a/12-image-gen/README.md b/12-image-gen/README.md new file mode 100644 index 0000000..fa555fa --- /dev/null +++ b/12-image-gen/README.md @@ -0,0 +1,44 @@ +# Image Generation with Pillow Example + +This example demonstrates how to build a Python Worker that dynamically generates images using the Pillow (PIL) library. + +## What It Does + +The Worker provides four different image generation endpoints: +1. **Gradient Generator** (`/gradient`) - Creates gradient images with customizable colors and dimensions +2. **Badge Generator** (`/badge`) - Generates badges or buttons with text +3. **Placeholder Generator** (`/placeholder`) - Creates placeholder images with dimensions displayed +4. **Chart Generator** (`/chart`) - Produces simple bar charts + +## How to Run + +First ensure that `uv` is installed: +https://docs.astral.sh/uv/getting-started/installation/#standalone-installer + +Now, if you run `uv run pywrangler dev` within this directory, it should use the config +in `wrangler.jsonc` to run the example. + +```bash +uv run pywrangler dev +``` + +Then visit: +- `http://localhost:8787/` - Interactive demo page with all examples +- `http://localhost:8787/gradient?width=600&height=300&color1=FF6B6B&color2=4ECDC4` - Gradient image +- `http://localhost:8787/badge?text=Python+Workers&bg_color=2196F3` - Custom badge +- `http://localhost:8787/placeholder?width=500&height=300` - Placeholder image +- `http://localhost:8787/chart?values=15,30,25,40,20&labels=Mon,Tue,Wed,Thu,Fri` - Bar chart + +## Deployment + +Deploy to Cloudflare Workers: + +```bash +uv run pywrangler deploy +``` + +## Learn More + +- [Pillow Documentation](https://pillow.readthedocs.io/) +- [Python Workers Documentation](https://developers.cloudflare.com/workers/languages/python/) +- [ImageDraw Reference](https://pillow.readthedocs.io/en/stable/reference/ImageDraw.html) diff --git a/12-image-gen/package.json b/12-image-gen/package.json new file mode 100644 index 0000000..26070a9 --- /dev/null +++ b/12-image-gen/package.json @@ -0,0 +1,13 @@ +{ + "name": "python-image-gen", + "version": "0.0.0", + "private": true, + "scripts": { + "deploy": "uv run pywrangler deploy", + "dev": "uv run pywrangler dev", + "start": "uv run pywrangler dev" + }, + "devDependencies": { + "wrangler": "^4.46.0" + } +} diff --git a/12-image-gen/pyproject.toml b/12-image-gen/pyproject.toml new file mode 100644 index 0000000..42aeac3 --- /dev/null +++ b/12-image-gen/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "python-image-gen" +version = "0.1.0" +description = "Python image generation example using Pillow" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "webtypy>=0.1.7", + "pillow", +] + +[dependency-groups] +dev = [ + "workers-py", + "workers-runtime-sdk" +] diff --git a/12-image-gen/src/entry.py b/12-image-gen/src/entry.py new file mode 100644 index 0000000..1bad378 --- /dev/null +++ b/12-image-gen/src/entry.py @@ -0,0 +1,339 @@ +from workers import WorkerEntrypoint, Response, Request +from PIL import Image, ImageDraw, ImageFont +from io import BytesIO +from urllib.parse import urlparse, parse_qs +import random +from pathlib import Path + +from pyodide.ffi import to_js + + +class Default(WorkerEntrypoint): + """ + Image Generation Example using Pillow (PIL) + + This Worker demonstrates how to use the Pillow library to dynamically + generate images in a Cloudflare Python Worker. It showcases various + image generation techniques including gradients, text rendering, shapes, + and more. + + Available endpoints: + - /gradient - Generate a colorful gradient image + - /badge - Generate a badge with custom text + - /placeholder - Generate a placeholder image with dimensions + - /chart - Generate a simple bar chart + - / - Show available endpoints + """ + + async def fetch(self, request: Request): + # Parse the request URL to determine which image to generate + url = urlparse(request.url) + path = url.path + + # Parse query parameters for customization + query_params = parse_qs(url.query) + + # Route to different image generators based on path + if path == "/gradient": + return self.generate_gradient(query_params) + elif path == "/badge": + return self.generate_badge(query_params) + elif path == "/placeholder": + return self.generate_placeholder(query_params) + elif path == "/chart": + return self.generate_chart(query_params) + else: + # Return a simple HTML page showing available endpoints + return self.show_endpoints() + + def generate_gradient(self, params: dict) -> Response: + """ + Generate a gradient image. + + Query parameters: + - width: Image width (default: 800) + - height: Image height (default: 400) + - color1: Start color in hex (default: random) + - color2: End color in hex (default: random) + """ + # Get dimensions from query params or use defaults + width = int(params.get("width", [800])[0]) + height = int(params.get("height", [400])[0]) + + # Get colors or generate random ones + color1 = params.get("color1", [None])[0] + color2 = params.get("color2", [None])[0] + + if not color1: + color1 = "#{:06x}".format(random.randint(0, 0xFFFFFF)) + if not color2: + color2 = "#{:06x}".format(random.randint(0, 0xFFFFFF)) + + # Convert hex colors to RGB tuples + r1, g1, b1 = self.hex_to_rgb(color1) + r2, g2, b2 = self.hex_to_rgb(color2) + + # Create a new image with RGB mode + image = Image.new("RGB", (width, height)) + draw = ImageDraw.Draw(image) + + # Draw gradient by interpolating between colors + for y in range(height): + # Calculate interpolation factor (0.0 to 1.0) + factor = y / height + + # Interpolate each color channel + r = int(r1 + (r2 - r1) * factor) + g = int(g1 + (g2 - g1) * factor) + b = int(b1 + (b2 - b1) * factor) + + # Draw a horizontal line with the interpolated color + draw.line([(0, y), (width, y)], fill=(r, g, b)) + + # Convert image to bytes and return as PNG + return self.image_to_response(image, "image/png") + + def generate_badge(self, params: dict) -> Response: + """ + Generate a badge/button with custom text. + + Query parameters: + - text: Badge text (default: "Hello World") + - bg_color: Background color in hex (default: #4CAF50) + - text_color: Text color in hex (default: #FFFFFF) + """ + # Get parameters + text = params.get("text", ["Hello World"])[0] + bg_color = params.get("bg_color", ["#4CAF50"])[0] + text_color = params.get("text_color", ["#FFFFFF"])[0] + + # Convert hex colors to RGB + bg_rgb = self.hex_to_rgb(bg_color) + text_rgb = self.hex_to_rgb(text_color) + + # Create image with padding for text + # We'll estimate size based on text length + padding = 20 + + # Create a temporary image to measure text size + temp_img = Image.new("RGB", (1, 1)) + temp_draw = ImageDraw.Draw(temp_img) + + # Use default font (Pillow's built-in font) + # Note: In a production environment, you might want to include custom fonts + font = ImageFont.load_default() + + # Get text bounding box to calculate required image size + bbox = temp_draw.textbbox((0, 0), text, font=font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + + # Create the actual image with proper dimensions + width = text_width + (padding * 2) + height = text_height + (padding * 2) + + image = Image.new("RGB", (width, height), bg_rgb) + draw = ImageDraw.Draw(image) + + # Draw rounded rectangle background (simulate with rectangle for simplicity) + # Draw the text centered + text_x = padding + text_y = padding + draw.text((text_x, text_y), text, fill=text_rgb, font=font) + + return self.image_to_response(image, "image/png") + + def generate_placeholder(self, params: dict) -> Response: + """ + Generate a placeholder image with dimensions displayed. + + Query parameters: + - width: Image width (default: 400) + - height: Image height (default: 300) + - bg_color: Background color in hex (default: #CCCCCC) + - text_color: Text color in hex (default: #666666) + """ + # Get dimensions + width = int(params.get("width", [400])[0]) + height = int(params.get("height", [300])[0]) + bg_color = params.get("bg_color", ["#CCCCCC"])[0] + text_color = params.get("text_color", ["#666666"])[0] + + # Convert colors + bg_rgb = self.hex_to_rgb(bg_color) + text_rgb = self.hex_to_rgb(text_color) + + # Create image + image = Image.new("RGB", (width, height), bg_rgb) + draw = ImageDraw.Draw(image) + + # Draw an X across the image + draw.line([(0, 0), (width, height)], fill=text_rgb, width=2) + draw.line([(width, 0), (0, height)], fill=text_rgb, width=2) + + # Draw border + draw.rectangle([(0, 0), (width - 1, height - 1)], outline=text_rgb, width=2) + + # Add dimensions text in the center + text = f"{width} ร— {height}" + + font = ImageFont.load_default() + + # Get text size and center it + bbox = draw.textbbox((0, 0), text, font=font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + + text_x = (width - text_width) // 2 + text_y = (height - text_height) // 2 + + # Draw text with a background for better visibility + padding = 10 + draw.rectangle( + [ + (text_x - padding, text_y - padding), + (text_x + text_width + padding, text_y + text_height + padding), + ], + fill=bg_rgb, + ) + draw.text((text_x, text_y), text, fill=text_rgb, font=font) + + return self.image_to_response(image, "image/png") + + def generate_chart(self, params: dict) -> Response: + """ + Generate a simple bar chart. + + Query parameters: + - values: Comma-separated values (default: 10,25,15,30,20) + - labels: Comma-separated labels (default: A,B,C,D,E) + - color: Bar color in hex (default: #2196F3) + """ + # Parse values and labels + values_str = params.get("values", ["10,25,15,30,20"])[0] + values = [int(v.strip()) for v in values_str.split(",")] + + labels_str = params.get("labels", ["A,B,C,D,E"])[0] + labels = [label.strip() for label in labels_str.split(",")] + + bar_color = params.get("color", ["#2196F3"])[0] + bar_rgb = self.hex_to_rgb(bar_color) + + # Chart dimensions + width = 600 + height = 400 + padding = 50 + chart_width = width - (padding * 2) + chart_height = height - (padding * 2) + + # Create image with white background + image = Image.new("RGB", (width, height), (255, 255, 255)) + draw = ImageDraw.Draw(image) + + # Draw axes + draw.line( + [(padding, padding), (padding, height - padding)], fill=(0, 0, 0), width=2 + ) # Y-axis + draw.line( + [(padding, height - padding), (width - padding, height - padding)], + fill=(0, 0, 0), + width=2, + ) # X-axis + + # Calculate bar dimensions + num_bars = len(values) + bar_width = chart_width // (num_bars * 2) + spacing = bar_width + max_value = max(values) if values else 1 + + # Draw bars + font = ImageFont.load_default() + + for i, (value, label) in enumerate(zip(values, labels)): + # Calculate bar position and height + bar_height = int((value / max_value) * chart_height) + x = padding + spacing + (i * (bar_width + spacing)) + y = height - padding - bar_height + + # Draw bar + draw.rectangle( + [(x, y), (x + bar_width, height - padding)], + fill=bar_rgb, + outline=(0, 0, 0), + ) + + # Draw value on top of bar + value_text = str(value) + bbox = draw.textbbox((0, 0), value_text, font=font) + text_width = bbox[2] - bbox[0] + draw.text( + (x + (bar_width - text_width) // 2, y - 20), + value_text, + fill=(0, 0, 0), + font=font, + ) + + # Draw label below bar + bbox = draw.textbbox((0, 0), label, font=font) + text_width = bbox[2] - bbox[0] + draw.text( + (x + (bar_width - text_width) // 2, height - padding + 5), + label, + fill=(0, 0, 0), + font=font, + ) + + return self.image_to_response(image, "image/png") + + def hex_to_rgb(self, hex_color: str) -> tuple: + """ + Convert a hex color string to an RGB tuple. + + Args: + hex_color: Color in format "#RRGGBB" or "RRGGBB" + + Returns: + Tuple of (R, G, B) values + """ + # Remove '#' if present + hex_color = hex_color.lstrip("#") + + # Convert to RGB + return tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4)) + + def image_to_response(self, image: Image.Image, content_type: str) -> Response: + """ + Convert a PIL Image to a Response object. + + Args: + image: PIL Image object + content_type: MIME type for the response + + Returns: + Response object with image data + """ + # Create a BytesIO buffer to hold the image data + buffer = BytesIO() + + # Save image to buffer in PNG format + image.save(buffer, format="PNG") + + image_bytes = buffer.getvalue() + + # Create and return response with appropriate headers + # + # TODO: This currently performs an unnecessary copy, need to fix. + return Response( + to_js(image_bytes).buffer, + headers={ + "Content-Type": content_type, + "Cache-Control": "public, max-age=3600", + }, + ) + + def show_endpoints(self) -> Response: + """ + Return an HTML page showing available endpoints and examples. + """ + index = Path(__file__).parent / "index.html" + return Response(index.read_text(), headers={"Content-Type": "text/html"}) diff --git a/12-image-gen/src/index.html b/12-image-gen/src/index.html new file mode 100644 index 0000000..317c105 --- /dev/null +++ b/12-image-gen/src/index.html @@ -0,0 +1,125 @@ + + + + + + Pillow Image Generation Demo + + + +

๐ŸŽจ Pillow Image Generation Demo

+

This Python Worker demonstrates dynamic image generation using the Pillow library.

+ +
+

1. Gradient Generator

+

Generate gradient images with custom colors and dimensions.

+

Endpoint: /gradient

+

Parameters:

+ +
+ Example: /gradient?width=600&height=300&color1=FF6B6B&color2=4ECDC4 +
Gradient example +
+
+ +
+

2. Badge Generator

+

Create custom badges or buttons with text.

+

Endpoint: /badge

+

Parameters:

+ +
+ Example: /badge?text=Python+Workers&bg_color=2196F3&text_color=FFFFFF +
Badge example +
+
+ +
+

3. Placeholder Generator

+

Generate placeholder images with dimensions displayed.

+

Endpoint: /placeholder

+

Parameters:

+ +
+ Example: /placeholder?width=500&height=300 +
Placeholder example +
+
+ +
+

4. Chart Generator

+

Create simple bar charts with custom data.

+

Endpoint: /chart

+

Parameters:

+ +
+ Example: /chart?values=15,30,25,40,20&labels=Mon,Tue,Wed,Thu,Fri +
Chart example +
+
+ +

๐Ÿ’ก Tips

+ + + diff --git a/12-image-gen/wrangler.jsonc b/12-image-gen/wrangler.jsonc new file mode 100644 index 0000000..39f47ab --- /dev/null +++ b/12-image-gen/wrangler.jsonc @@ -0,0 +1,12 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "python-image-gen", + "main": "src/entry.py", + "compatibility_date": "2025-11-02", + "compatibility_flags": [ + "python_workers" + ], + "observability": { + "enabled": true + } +} diff --git a/14-websocket-stream-consumer/README.md b/14-websocket-stream-consumer/README.md new file mode 100644 index 0000000..26fd8ab --- /dev/null +++ b/14-websocket-stream-consumer/README.md @@ -0,0 +1,24 @@ +# WebSocket Stream Consumer - Bluesky Firehose + +This example demonstrates a long-running Durable Object that connects to the Bluesky firehose (via Jetstream) and filters for post events, with rate limiting to print at most 1 per second. + +## How to Run + +First ensure that `uv` is installed: +https://docs.astral.sh/uv/getting-started/installation/#standalone-installer + +Now, if you run `uv run pywrangler dev` within this directory, it should use the config +in `wrangler.jsonc` to run the example. + +You can also run `uv run pywrangler deploy` to deploy the example. + +## Testing the Firehose Consumer + +1. Start the worker: `uv run pywrangler dev` +2. Make any request to initialize the DO: `curl "http://localhost:8787/status"` +3. Watch the logs to see filtered Bluesky post events in real-time (rate limited to 1/sec)! + +The Durable Object automatically connects to Jetstream when first accessed. It will maintain a persistent WebSocket connection and print out post events to the console, including the author DID, post text (truncated to 100 chars), and timestamp. Posts are rate limited to display at most 1 per second to avoid overwhelming the logs. + +**Available endpoints:** +- `/status` - Check connection status diff --git a/14-websocket-stream-consumer/package.json b/14-websocket-stream-consumer/package.json new file mode 100644 index 0000000..603d1f1 --- /dev/null +++ b/14-websocket-stream-consumer/package.json @@ -0,0 +1,13 @@ +{ + "name": "python-websocket-stream-consumer", + "version": "0.0.0", + "private": true, + "scripts": { + "deploy": "uv run pywrangler deploy", + "dev": "uv run pywrangler dev", + "start": "uv run pywrangler dev" + }, + "devDependencies": { + "wrangler": "^4.46.0" + } +} diff --git a/14-websocket-stream-consumer/pyproject.toml b/14-websocket-stream-consumer/pyproject.toml new file mode 100644 index 0000000..cee560e --- /dev/null +++ b/14-websocket-stream-consumer/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "python-websocket-stream-consumer" +version = "0.1.0" +description = "Python WebSocket stream consumer example" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "webtypy>=0.1.7", +] + +[dependency-groups] +dev = [ + "workers-py", + "workers-runtime-sdk" +] diff --git a/14-websocket-stream-consumer/src/entry.py b/14-websocket-stream-consumer/src/entry.py new file mode 100644 index 0000000..77b6427 --- /dev/null +++ b/14-websocket-stream-consumer/src/entry.py @@ -0,0 +1,159 @@ +from workers import WorkerEntrypoint, Response, DurableObject +import js +import json +import time +from pyodide.ffi import create_proxy +from urllib.parse import urlparse + + +class BlueskyFirehoseConsumer(DurableObject): + """Durable Object that maintains a persistent WebSocket connection to Bluesky Jetstream.""" + + def __init__(self, state, env): + super().__init__(state, env) + self.websocket = None + self.connected = False + self.last_print_time = 0 # Track last time we printed a post + + async def fetch(self, request): + """Handle incoming requests to the Durable Object.""" + # If we're not connected then make sure we start a connection. + if not self.connected: + await self._schedule_next_alarm() + await self._connect_to_jetstream() + + url = urlparse(request.url) + path = url.path + + if path == "/status": + status = "connected" if self.connected else "disconnected" + return Response(f"Firehose status: {status}") + else: + return Response("Available endpoints: /status") + + async def alarm(self): + """Handle alarm events - used to ensure that the DO stays alive and connected""" + print("Alarm triggered - making sure we are connected to jetstream...") + if not self.connected: + await self._connect_to_jetstream() + else: + print("Already connected, skipping reconnection") + + # Schedule the next alarm to keep the DO alive + await self._schedule_next_alarm() + + async def _schedule_next_alarm(self): + """Schedule the next alarm to run in 1 minute to keep the DO alive.""" + # Schedule alarm for 1 minute from now, overwriting any existing alarms + next_alarm_time = int(time.time() * 1000) + 60000 + return await self.ctx.storage.setAlarm(next_alarm_time) + + async def _on_open(self, event): + """Handle WebSocket open event.""" + self.connected = True + print("Connected to Bluesky Jetstream firehose!") + print("Filtering for: app.bsky.feed.post (post events, rate limited to 1/sec)") + # Ensure alarm is set when we connect + await self._schedule_next_alarm() + + def _on_message(self, event): + """Handle incoming WebSocket messages.""" + try: + # Parse the JSON message + data = json.loads(event.data) + + # Store the timestamp for resumption on reconnect + if time_us := data.get("time_us"): + # Store the timestamp asynchronously + self.ctx.storage.kv.put("last_event_timestamp", time_us) + + # Jetstream sends different event types + # We're interested in 'commit' events which contain posts + if data.get("kind") != "commit": + return + + commit = data.get("commit", {}) + collection = commit.get("collection") + + # Filter for post events + if collection != "app.bsky.feed.post": + return + + # Rate limiting: only print at most 1 per second + current_time = time.time() + if current_time - self.last_print_time >= 1.0: + record = commit.get("record", {}) + print("Post record", record) + + # Update last print time + self.last_print_time = current_time + + except Exception as e: + print(f"Error processing message: {e}") + + def _on_error(self, event): + """Handle WebSocket error event.""" + print(f"WebSocket error: {event}") + self.connected = False + self.ctx.abort("WebSocket error occurred") + + async def _on_close(self, event): + """Handle WebSocket close event.""" + print(f"WebSocket closed: code={event.code}, reason={event.reason}") + self.connected = False + self.ctx.abort("WebSocket closed") + + async def _connect_to_jetstream(self): + """Connect to the Bluesky Jetstream WebSocket and start consuming events.""" + # Get the last event timestamp from storage to resume from the right position + last_timestamp = self.ctx.storage.kv.get("last_event_timestamp") + + # Jetstream endpoint - we'll filter for posts + # Using wantedCollections parameter to only get post events + jetstream_url = "wss://jetstream2.us-east.bsky.network/subscribe?wantedCollections=app.bsky.feed.post" + + # If we have a last timestamp, add it to resume from that point + if last_timestamp: + jetstream_url += f"&cursor={last_timestamp}" + print( + f"Connecting to Bluesky Jetstream at {jetstream_url} (resuming from timestamp: {last_timestamp})" + ) + else: + print( + f"Connecting to Bluesky Jetstream at {jetstream_url} (starting fresh)" + ) + + # Create WebSocket using JS FFI + ws = js.WebSocket.new(jetstream_url) + self.websocket = ws + + # Attach event handlers + # + # Note that ordinarily proxies need to be destroyed once they are no longer used. + # However, in this Durable Object context, the WebSocket and its event listeners + # persist for the lifetime of the Durable Object, so we don't explicitly destroy + # the proxies here. When the websocket connection closes, the Durable Object + # is restarted which destroys these proxies. + # + # In the future, we plan to provide support for native Python websocket APIs which + # should eliminate the need for proxy wrappers. + ws.addEventListener("open", create_proxy(self._on_open)) + ws.addEventListener("message", create_proxy(self._on_message)) + ws.addEventListener("error", create_proxy(self._on_error)) + ws.addEventListener("close", create_proxy(self._on_close)) + + +class Default(WorkerEntrypoint): + """Main worker entry point that routes requests to the Durable Object.""" + + async def fetch(self, request): + # Get the Durable Object namespace from the environment + namespace = self.env.BLUESKY_FIREHOSE + + # Use a fixed ID so we always connect to the same Durable Object instance + # This ensures we maintain a single persistent connection + id = namespace.idFromName("bluesky-consumer") + stub = namespace.get(id) + + # Forward the request to the Durable Object + return await stub.fetch(request) diff --git a/14-websocket-stream-consumer/wrangler.jsonc b/14-websocket-stream-consumer/wrangler.jsonc new file mode 100644 index 0000000..f9c2bda --- /dev/null +++ b/14-websocket-stream-consumer/wrangler.jsonc @@ -0,0 +1,29 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "python-websocket-stream-consumer", + "main": "src/entry.py", + "compatibility_date": "2025-11-02", + "compatibility_flags": [ + "python_workers" + ], + "observability": { + "enabled": true + }, + "durable_objects": { + "bindings": [ + { + "name": "BLUESKY_FIREHOSE", + "class_name": "BlueskyFirehoseConsumer", + "script_name": "python-websocket-stream-consumer" + } + ] + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": [ + "BlueskyFirehoseConsumer" + ] + } + ] +} diff --git a/15-chatroom/README.md b/15-chatroom/README.md new file mode 100644 index 0000000..4d21ea9 --- /dev/null +++ b/15-chatroom/README.md @@ -0,0 +1,13 @@ +# WebSocket chatroom + +## How to Run + +First ensure that `uv` is installed: +https://docs.astral.sh/uv/getting-started/installation/#standalone-installer + +Now, if you run `uv run pywrangler dev` within this directory, it should use the config +in `wrangler.jsonc` to run the example. + +You can also run `uv run pywrangler deploy` to deploy the example. + +Navigate to `http://localhost:8787` to see the chatroom. diff --git a/15-chatroom/package.json b/15-chatroom/package.json new file mode 100644 index 0000000..538c0d7 --- /dev/null +++ b/15-chatroom/package.json @@ -0,0 +1,13 @@ +{ + "name": "python-websocket-stream-consumer", + "version": "0.0.0", + "private": true, + "scripts": { + "deploy": "uv run pywrangler deploy", + "dev": "uv run pywrangler dev", + "start": "uv run pywrangler dev" + }, + "devDependencies": { + "wrangler": "^4.50.0" + } +} diff --git a/15-chatroom/pyproject.toml b/15-chatroom/pyproject.toml new file mode 100644 index 0000000..339cc35 --- /dev/null +++ b/15-chatroom/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "python-chatroom" +version = "0.1.0" +description = "Python WebSocket chatroom example" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "webtypy>=0.1.7", +] + +[dependency-groups] +dev = [ + "workers-py", + "workers-runtime-sdk" +] diff --git a/15-chatroom/src/chatroom.html b/15-chatroom/src/chatroom.html new file mode 100644 index 0000000..cbc207d --- /dev/null +++ b/15-chatroom/src/chatroom.html @@ -0,0 +1,457 @@ + + + + + + Chatroom + + + +
+
+
+ + Python Workers +
+

๐Ÿ’ฌ Chatroom

+
Room: general
+
WebSockets ยท Durable Objects ยท Hibernation API
+
+ +
Connecting...
+ +
+ +
+
+ +
+
+ + +
+
+
+ + + + diff --git a/15-chatroom/src/entry.py b/15-chatroom/src/entry.py new file mode 100644 index 0000000..a08ebd7 --- /dev/null +++ b/15-chatroom/src/entry.py @@ -0,0 +1,134 @@ +from workers import WorkerEntrypoint, Response, DurableObject +from pathlib import Path +from js import WebSocketPair +import json +from urllib.parse import urlparse +from datetime import datetime, timezone + + +class Chatroom(DurableObject): + """Durable Object that manages a chatroom with WebSocket connections.""" + + def __init__(self, state, env): + super().__init__(state, env) + self.state = state + self.env = env + self.message_history = [] # Limited message history + self.max_history = 50 # Maximum number of messages to keep + + async def fetch(self, request): + """Handle incoming requests to the Durable Object.""" + + # Check if this is a WebSocket upgrade request + upgrade_header = request.headers.get("Upgrade") + if not upgrade_header or upgrade_header.lower() != "websocket": + # If not a WebSocket request, return an error + return Response("Expected WebSocket upgrade", status=400) + + # Create a WebSocket pair + client, server = WebSocketPair.new().object_values() + + # Accept the WebSocket connection - this tells the DO to handle it + self.state.acceptWebSocket(server) + + # Send message history to the newly connected client + if self.message_history: + history_msg = {"type": "history", "messages": self.message_history} + server.send(json.dumps(history_msg)) + + # Send a welcome message + welcome_msg = { + "type": "system", + "text": "Connected to chatroom", + "timestamp": self.get_timestamp(), + } + server.send(json.dumps(welcome_msg)) + + # Return the client-side WebSocket in the response + return Response(None, status=101, web_socket=client) + + async def webSocketMessage(self, ws, message): + """Handle incoming WebSocket messages.""" + try: + data = json.loads(message) + + # Create a message object + msg = { + "type": "message", + "username": data.get("username", "Anonymous"), + "text": data.get("text", ""), + "timestamp": self.get_timestamp(), + } + + # Add to history + self.message_history.append(msg) + if len(self.message_history) > self.max_history: + self.message_history.pop(0) + + # Broadcast to all connected clients + self.broadcast(json.dumps(msg)) + except Exception as e: + print(f"Error handling message: {e}") + + async def webSocketClose(self, ws, code, reason, wasClean): + """Handle WebSocket close events.""" + ws.close(code, reason) + active_connections = len(self.state.getWebSockets()) + print(f"Client disconnected. Active sessions: {active_connections}") + + async def webSocketError(self, ws, error): + """Handle WebSocket error events.""" + ws.close(1011, "WebSocket error") + print(f"WebSocket error: {error}") + + def broadcast(self, message): + """Broadcast a message to all connected clients.""" + # Get all active WebSocket connections from the state + websockets = self.state.getWebSockets() + + # Send to all active sessions + for ws in websockets: + try: + ws.send(message) + except Exception as e: + print(f"Error broadcasting to session: {e}") + + def get_timestamp(self): + """Get current timestamp in ISO format.""" + return datetime.now(timezone.utc).isoformat() + + +class Default(WorkerEntrypoint): + """Main worker entry point that routes requests to the Durable Object.""" + + async def fetch(self, request): + url = urlparse(request.url) + pathname = url.path + + # Serve the HTML page for the root path + if pathname == "/": + html_file = Path(__file__).parent / "chatroom.html" + return Response( + html_file.read_text(), headers={"Content-Type": "text/html"} + ) + + # Handle room requests: /room/ + if pathname.startswith("/room/"): + # Extract room name from path + room_name = pathname[6:] # Remove "/room/" prefix + if not room_name: + return Response("Room name required", status=400) + + # Get the Durable Object namespace + namespace = self.env.CHATROOM + + # Create a unique ID for this room + room_id = namespace.idFromName(room_name) + stub = namespace.get(room_id) + + # Forward the request to the Durable Object + return await stub.fetch(request) + + return Response( + "Not found. Use /room/ to connect to a chatroom.", status=404 + ) diff --git a/15-chatroom/wrangler.jsonc b/15-chatroom/wrangler.jsonc new file mode 100644 index 0000000..c0dac62 --- /dev/null +++ b/15-chatroom/wrangler.jsonc @@ -0,0 +1,29 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "python-chatroom", + "main": "src/entry.py", + "compatibility_date": "2025-11-02", + "compatibility_flags": [ + "python_workers" + ], + "observability": { + "enabled": true + }, + "durable_objects": { + "bindings": [ + { + "name": "CHATROOM", + "class_name": "Chatroom", + "script_name": "python-chatroom" + } + ] + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": [ + "Chatroom" + ] + } + ] +}