diff --git a/README.md b/README.md index bdf11ef..c7c2ebc 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,8 @@ Your site appears at `https://sites.wisp.place/{your-did}/{site-name}` or your c - **`/apps/main-app`** - Main backend (OAuth, site management, custom domains) - **`/apps/hosting-service`** - Microservice that serves cached sites from disk -- **`/cli`** - Rust CLI for direct PDS uploads +- **`/cli`** - CLI for direct PDS uploads as well as serving with firehose updates +- **`/rust-cli`** - Deprecated Rust CLI for direct PDS uploads with firehose updates - **`/apps/main-app/public`** - React frontend - **`/packages`** - Shared packages @@ -61,7 +62,10 @@ bun run hosting:dev # CLI cd cli -cargo build +bun install +bun run index.ts +bun build +node dist/index.js ``` ## Features diff --git a/agents.md b/agents.md index a7e44d0..b07492a 100644 --- a/agents.md +++ b/agents.md @@ -97,7 +97,7 @@ The main differences between place.wisp.fs and place.wisp.subfs: - place.wisp.fs has a required site field - place.wisp.fs#subfs has an optional flat field that place.wisp.subfs#subfs doesn't have -The project is a monorepo. The package handler it uses for the typescript side is Bun. For the Rust cli, it is cargo. +The project is a monorepo. The package handler it uses is bun. Please when you want to add a package, which is never unless told to, do bun add ..., please do not try to edit package.json yourself. ### Typescript Bun Workspace Layout @@ -133,7 +133,7 @@ There are two typescript apps ### CLI -**`cli/`** - Rust CLI using Jacquard (AT Protocol library) +**`cli/`** - TypeScript CLI using commander, clack. - Direct PDS uploads without interacting with main-app - Can also do the same firehose watching, caching, and serving hosting-service does, just without domain management diff --git a/apps/hosting-service/src/lib/redirects.ts b/apps/hosting-service/src/lib/redirects.ts index 97de051..30b6870 100644 --- a/apps/hosting-service/src/lib/redirects.ts +++ b/apps/hosting-service/src/lib/redirects.ts @@ -1,402 +1,17 @@ import { readFile } from 'fs/promises'; import { existsSync } from 'fs'; - -export interface RedirectRule { - from: string; - to: string; - status: number; - force: boolean; - conditions?: { - country?: string[]; - language?: string[]; - role?: string[]; - cookie?: string[]; - }; - // For pattern matching - fromPattern?: RegExp; - fromParams?: string[]; // Named parameters from the pattern - queryParams?: Record; // Expected query parameters -} - -export interface RedirectMatch { - rule: RedirectRule; - targetPath: string; - status: number; -} - -// Maximum number of redirect rules to prevent DoS attacks -const MAX_REDIRECT_RULES = 1000; - -/** - * Parse a _redirects file into an array of redirect rules - */ -export function parseRedirectsFile(content: string): RedirectRule[] { - const lines = content.split('\n'); - const rules: RedirectRule[] = []; - - for (let lineNum = 0; lineNum < lines.length; lineNum++) { - const lineRaw = lines[lineNum]; - if (!lineRaw) continue; - - const line = lineRaw.trim(); - - // Skip empty lines and comments - if (!line || line.startsWith('#')) { - continue; - } - - // Enforce max rules limit - if (rules.length >= MAX_REDIRECT_RULES) { - console.warn(`Redirect rules limit reached (${MAX_REDIRECT_RULES}), ignoring remaining rules`); - break; - } - - try { - const rule = parseRedirectLine(line); - if (rule && rule.fromPattern) { - rules.push(rule); - } - } catch (err) { - console.warn(`Failed to parse redirect rule on line ${lineNum + 1}: ${line}`, err); - } - } - - return rules; -} - -/** - * Parse a single redirect rule line - * Format: /from [query_params] /to [status] [conditions] - */ -function parseRedirectLine(line: string): RedirectRule | null { - // Split by whitespace, but respect quoted strings (though not commonly used) - const parts = line.split(/\s+/); - - if (parts.length < 2) { - return null; - } - - let idx = 0; - const from = parts[idx++]; - - if (!from) { - return null; - } - - let status = 301; // Default status - let force = false; - const conditions: NonNullable = {}; - const queryParams: Record = {}; - - // Parse query parameters that come before the destination path - // They look like: key=:value (and don't start with /) - while (idx < parts.length) { - const part = parts[idx]; - if (!part) { - idx++; - continue; - } - - // If it starts with / or http, it's the destination path - if (part.startsWith('/') || part.startsWith('http://') || part.startsWith('https://')) { - break; - } - - // If it contains = and comes before the destination, it's a query param - if (part.includes('=')) { - const splitIndex = part.indexOf('='); - const key = part.slice(0, splitIndex); - const value = part.slice(splitIndex + 1); - - if (key && value) { - queryParams[key] = value; - } - idx++; - } else { - // Not a query param, must be destination or something else - break; - } - } - - // Next part should be the destination - if (idx >= parts.length) { - return null; - } - - const to = parts[idx++]; - if (!to) { - return null; - } - - // Parse remaining parts for status code and conditions - for (let i = idx; i < parts.length; i++) { - const part = parts[i]; - - if (!part) continue; - - // Check for status code (with optional ! for force) - if (/^\d+!?$/.test(part)) { - if (part.endsWith('!')) { - force = true; - status = parseInt(part.slice(0, -1)); - } else { - status = parseInt(part); - } - continue; - } - - // Check for condition parameters (Country=, Language=, Role=, Cookie=) - if (part.includes('=')) { - const splitIndex = part.indexOf('='); - const key = part.slice(0, splitIndex); - const value = part.slice(splitIndex + 1); - - if (!key || !value) continue; - - const keyLower = key.toLowerCase(); - - if (keyLower === 'country') { - conditions.country = value.split(',').map(v => v.trim().toLowerCase()); - } else if (keyLower === 'language') { - conditions.language = value.split(',').map(v => v.trim().toLowerCase()); - } else if (keyLower === 'role') { - conditions.role = value.split(',').map(v => v.trim()); - } else if (keyLower === 'cookie') { - conditions.cookie = value.split(',').map(v => v.trim().toLowerCase()); - } - } - } - - // Parse the 'from' pattern - const { pattern, params } = convertPathToRegex(from); - - return { - from, - to, - status, - force, - conditions: Object.keys(conditions).length > 0 ? conditions : undefined, - queryParams: Object.keys(queryParams).length > 0 ? queryParams : undefined, - fromPattern: pattern, - fromParams: params, - }; -} - -/** - * Convert a path pattern with placeholders and splats to a regex - * Examples: - * /blog/:year/:month/:day -> captures year, month, day - * /news/* -> captures splat - */ -function convertPathToRegex(pattern: string): { pattern: RegExp; params: string[] } { - const params: string[] = []; - let regexStr = '^'; - - // Split by query string if present - const pathPart = pattern.split('?')[0] || pattern; - - // Escape special regex characters except * and : - let escaped = pathPart.replace(/[.+^${}()|[\]\\]/g, '\\$&'); - - // Replace :param with named capture groups - escaped = escaped.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (match, paramName) => { - params.push(paramName); - // Match path segment (everything except / and ?) - return '([^/?]+)'; - }); - - // Replace * with splat capture (matches everything including /) - if (escaped.includes('*')) { - escaped = escaped.replace(/\*/g, '(.*)'); - params.push('splat'); - } - - regexStr += escaped; - - // Make trailing slash optional - if (!regexStr.endsWith('.*')) { - regexStr += '/?'; - } - - regexStr += '$'; - - return { - pattern: new RegExp(regexStr), - params, - }; -} - -/** - * Match a request path against redirect rules with loop detection - */ -export function matchRedirectRule( - requestPath: string, - rules: RedirectRule[], - context?: { - queryParams?: Record; - headers?: Record; - cookies?: Record; - }, - visitedPaths: Set = new Set() -): RedirectMatch | null { - // Normalize path: ensure leading slash, remove trailing slash (except for root) - let normalizedPath = requestPath.startsWith('/') ? requestPath : `/${requestPath}`; - - // Detect redirect loops - if (visitedPaths.has(normalizedPath)) { - console.warn(`Redirect loop detected for path: ${normalizedPath}`); - return null; - } - - // Track this path to detect loops - visitedPaths.add(normalizedPath); - - // Limit redirect chain depth to 10 - if (visitedPaths.size > 10) { - console.warn(`Redirect chain too deep (>10) for path: ${normalizedPath}`); - return null; - } - - for (const rule of rules) { - // Check query parameter conditions first (if any) - if (rule.queryParams) { - // If rule requires query params but none provided, skip this rule - if (!context?.queryParams) { - continue; - } - - // Check that all required query params are present - // The value in rule.queryParams is either a literal or a placeholder (:name) - const queryMatches = Object.entries(rule.queryParams).every(([key, expectedValue]) => { - const actualValue = context.queryParams?.[key]; - - // Query param must exist - if (actualValue === undefined) { - return false; - } - - // If expected value is a placeholder (:name), any value is acceptable - // If it's a literal, it must match exactly - if (expectedValue && !expectedValue.startsWith(':')) { - return actualValue === expectedValue; - } - - return true; - }); - - if (!queryMatches) { - continue; - } - } - - // Check conditional redirects (country, language, role, cookie) - if (rule.conditions) { - if (rule.conditions.country && context?.headers) { - const cfCountry = context.headers['cf-ipcountry']; - const xCountry = context.headers['x-country']; - const country = (cfCountry?.toLowerCase() || xCountry?.toLowerCase()); - if (!country || !rule.conditions.country.includes(country)) { - continue; - } - } - - if (rule.conditions.language && context?.headers) { - const acceptLang = context.headers['accept-language']; - if (!acceptLang) { - continue; - } - // Parse accept-language header (simplified) - const langs = acceptLang.split(',').map(l => { - const langPart = l.split(';')[0]; - return langPart ? langPart.trim().toLowerCase() : ''; - }).filter(l => l !== ''); - const hasMatch = rule.conditions.language.some(lang => - langs.some(l => l === lang || l.startsWith(lang + '-')) - ); - if (!hasMatch) { - continue; - } - } - - if (rule.conditions.cookie && context?.cookies) { - const hasCookie = rule.conditions.cookie.some(cookieName => - context.cookies && cookieName in context.cookies - ); - if (!hasCookie) { - continue; - } - } - - // Role-based redirects would need JWT verification - skip for now - if (rule.conditions.role) { - continue; - } - } - - // Match the path pattern - const match = rule.fromPattern?.exec(normalizedPath); - if (!match) { - continue; - } - - // Build the target path by replacing placeholders - let targetPath = rule.to; - - // Replace captured parameters (with URL encoding) - if (rule.fromParams && match.length > 1) { - for (let i = 0; i < rule.fromParams.length; i++) { - const paramName = rule.fromParams[i]; - const paramValue = match[i + 1]; - - if (!paramName || !paramValue) continue; - - // URL encode captured values to prevent invalid URLs - const encodedValue = encodeURIComponent(paramValue); - - if (paramName === 'splat') { - // For splats, preserve slashes by re-decoding them - const splatValue = encodedValue.replace(/%2F/g, '/'); - targetPath = targetPath.replace(':splat', splatValue); - } else { - targetPath = targetPath.replace(`:${paramName}`, encodedValue); - } - } - } - - // Handle query parameter replacements (with URL encoding) - if (rule.queryParams && context?.queryParams) { - for (const [key, placeholder] of Object.entries(rule.queryParams)) { - const actualValue = context.queryParams[key]; - if (actualValue && placeholder && placeholder.startsWith(':')) { - const paramName = placeholder.slice(1); - if (paramName) { - // URL encode query parameter values - const encodedValue = encodeURIComponent(actualValue); - targetPath = targetPath.replace(`:${paramName}`, encodedValue); - } - } - } - } - - // Preserve query string for 200, 301, 302 redirects (unless target already has one) - if ([200, 301, 302].includes(rule.status) && context?.queryParams && !targetPath.includes('?')) { - const queryString = Object.entries(context.queryParams) - .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) - .join('&'); - if (queryString) { - targetPath += `?${queryString}`; - } - } - - return { - rule, - targetPath, - status: rule.status, - }; - } - - return null; -} +import { parseRedirectsFile, type RedirectRule } from '@wisp/fs-utils'; + +// Re-export everything from the shared package +export { + parseRedirectsFile, + matchRedirectRule, + parseCookies, + parseQueryString, + type RedirectRule, + type RedirectMatch, + type MatchRedirectContext, +} from '@wisp/fs-utils'; /** * Load redirect rules from a cached site @@ -404,7 +19,7 @@ export function matchRedirectRule( export async function loadRedirectRules(did: string, rkey: string): Promise { const CACHE_DIR = process.env.CACHE_DIR || './cache/sites'; const redirectsPath = `${CACHE_DIR}/${did}/${rkey}/_redirects`; - + if (!existsSync(redirectsPath)) { return []; } @@ -417,43 +32,3 @@ export async function loadRedirectRules(did: string, rkey: string): Promise { - if (!cookieHeader) return {}; - - const cookies: Record = {}; - const parts = cookieHeader.split(';'); - - for (const part of parts) { - const [key, ...valueParts] = part.split('='); - if (key && valueParts.length > 0) { - cookies[key.trim()] = valueParts.join('=').trim(); - } - } - - return cookies; -} - -/** - * Parse query string into object - */ -export function parseQueryString(url: string): Record { - const queryStart = url.indexOf('?'); - if (queryStart === -1) return {}; - - const queryString = url.slice(queryStart + 1); - const params: Record = {}; - - for (const pair of queryString.split('&')) { - const [key, value] = pair.split('='); - if (key) { - params[decodeURIComponent(key)] = value ? decodeURIComponent(value) : ''; - } - } - - return params; -} - diff --git a/apps/main-app/public/index.tsx b/apps/main-app/public/index.tsx deleted file mode 100644 index 5218736..0000000 --- a/apps/main-app/public/index.tsx +++ /dev/null @@ -1,759 +0,0 @@ -import React, { useState, useRef, useEffect } from 'react' -import { createRoot } from 'react-dom/client' -import { ArrowRight } from 'lucide-react' -import Layout from '@public/layouts' -import { Button } from '@public/components/ui/button' -import { Card } from '@public/components/ui/card' -import { BlueskyPostList, BlueskyProfile, BlueskyPost, AtProtoProvider, useLatestRecord, type AtProtoStyles, type FeedPostRecord } from 'atproto-ui' - -//Credit to https://tangled.org/@jakelazaroff.com/actor-typeahead -interface Actor { - handle: string - avatar?: string - displayName?: string -} - -interface ActorTypeaheadProps { - children: React.ReactElement> - host?: string - rows?: number - onSelect?: (handle: string) => void - autoSubmit?: boolean -} - -const ActorTypeahead: React.FC = ({ - children, - host = 'https://public.api.bsky.app', - rows = 5, - onSelect, - autoSubmit = false -}) => { - const [actors, setActors] = useState([]) - const [index, setIndex] = useState(-1) - const [pressed, setPressed] = useState(false) - const [isOpen, setIsOpen] = useState(false) - const containerRef = useRef(null) - const inputRef = useRef(null) - const lastQueryRef = useRef('') - const previousValueRef = useRef('') - const preserveIndexRef = useRef(false) - - const handleInput = async (e: React.FormEvent) => { - const query = e.currentTarget.value - - // Check if the value actually changed (filter out arrow key events) - if (query === previousValueRef.current) { - return - } - previousValueRef.current = query - - if (!query) { - setActors([]) - setIndex(-1) - setIsOpen(false) - lastQueryRef.current = '' - return - } - - // Store the query for this request - const currentQuery = query - lastQueryRef.current = currentQuery - - try { - const url = new URL('xrpc/app.bsky.actor.searchActorsTypeahead', host) - url.searchParams.set('q', query) - url.searchParams.set('limit', `${rows}`) - - const res = await fetch(url) - const json = await res.json() - - // Only update if this is still the latest query - if (lastQueryRef.current === currentQuery) { - setActors(json.actors || []) - // Only reset index if we're not preserving it - if (!preserveIndexRef.current) { - setIndex(-1) - } - preserveIndexRef.current = false - setIsOpen(true) - } - } catch (error) { - console.error('Failed to fetch actors:', error) - if (lastQueryRef.current === currentQuery) { - setActors([]) - setIsOpen(false) - } - } - } - - const handleKeyDown = (e: React.KeyboardEvent) => { - const navigationKeys = ['ArrowDown', 'ArrowUp', 'PageDown', 'PageUp', 'Enter', 'Escape'] - - // Mark that we should preserve the index for navigation keys - if (navigationKeys.includes(e.key)) { - preserveIndexRef.current = true - } - - if (!isOpen || actors.length === 0) return - - switch (e.key) { - case 'ArrowDown': - e.preventDefault() - setIndex((prev) => { - const newIndex = prev < 0 ? 0 : Math.min(prev + 1, actors.length - 1) - return newIndex - }) - break - case 'PageDown': - e.preventDefault() - setIndex(actors.length - 1) - break - case 'ArrowUp': - e.preventDefault() - setIndex((prev) => { - const newIndex = prev < 0 ? 0 : Math.max(prev - 1, 0) - return newIndex - }) - break - case 'PageUp': - e.preventDefault() - setIndex(0) - break - case 'Escape': - e.preventDefault() - setActors([]) - setIndex(-1) - setIsOpen(false) - break - case 'Enter': - if (index >= 0 && index < actors.length) { - e.preventDefault() - selectActor(actors[index].handle) - } - break - } - } - - const selectActor = (handle: string) => { - if (inputRef.current) { - inputRef.current.value = handle - } - setActors([]) - setIndex(-1) - setIsOpen(false) - onSelect?.(handle) - - // Auto-submit the form if enabled - if (autoSubmit && inputRef.current) { - const form = inputRef.current.closest('form') - if (form) { - // Use setTimeout to ensure the value is set before submission - setTimeout(() => { - form.requestSubmit() - }, 0) - } - } - } - - const handleFocusOut = (e: React.FocusEvent) => { - if (pressed) return - setActors([]) - setIndex(-1) - setIsOpen(false) - } - - // Clone the input element and add our event handlers - const input = React.cloneElement(children, { - ref: (el: HTMLInputElement) => { - inputRef.current = el - // Preserve the original ref if it exists - const originalRef = (children as any).ref - if (typeof originalRef === 'function') { - originalRef(el) - } else if (originalRef) { - originalRef.current = el - } - }, - onInput: (e: React.FormEvent) => { - handleInput(e) - children.props.onInput?.(e) - }, - onKeyDown: (e: React.KeyboardEvent) => { - handleKeyDown(e) - children.props.onKeyDown?.(e) - }, - onBlur: (e: React.FocusEvent) => { - handleFocusOut(e) - children.props.onBlur?.(e) - }, - autoComplete: 'off' - } as any) - - return ( -
- {input} - {isOpen && actors.length > 0 && ( -
    setPressed(true)} - onMouseUp={() => { - setPressed(false) - inputRef.current?.focus() - }} - > - {actors.map((actor, i) => ( -
  • - -
  • - ))} -
- )} -
- ) -} - -const LatestPostWithPrefetch: React.FC<{ did: string }> = ({ did }) => { - const { record, rkey, loading } = useLatestRecord( - did, - 'app.bsky.feed.post' - ) - - if (loading) return Loading… - if (!record || !rkey) return No posts yet. - - return -} - -function App() { - const [showForm, setShowForm] = useState(false) - const [checkingAuth, setCheckingAuth] = useState(true) - const [screenshots, setScreenshots] = useState([]) - const inputRef = useRef(null) - - useEffect(() => { - // Check authentication status on mount - const checkAuth = async () => { - try { - const response = await fetch('/api/auth/status', { - credentials: 'include' - }) - const data = await response.json() - if (data.authenticated) { - // User is already authenticated, redirect to editor - window.location.href = '/editor' - return - } - // If not authenticated, clear any stale cookies - document.cookie = 'did=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax' - } catch (error) { - console.error('Auth check failed:', error) - // Clear cookies on error as well - document.cookie = 'did=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax' - } finally { - setCheckingAuth(false) - } - } - - checkAuth() - }, []) - - useEffect(() => { - // Fetch screenshots list - const fetchScreenshots = async () => { - try { - const response = await fetch('/api/screenshots') - const data = await response.json() - setScreenshots(data.screenshots || []) - } catch (error) { - console.error('Failed to fetch screenshots:', error) - } - } - - fetchScreenshots() - }, []) - - useEffect(() => { - if (showForm) { - setTimeout(() => inputRef.current?.focus(), 500) - } - }, [showForm]) - - if (checkingAuth) { - return ( -
-
-
- ) - } - - return ( - <> -
- {/* Header */} -
-
-
- wisp.place - - wisp.place - -
-
- - Read the Docs - - -
-
-
- - {/* Hero Section */} -
-
- {/* Main Headline */} -

- Deploy Anywhere. -

-

- For Free. Forever. -

- - {/* Subheadline */} -

- The easiest way to deploy and orchestrate static sites. - Push updates instantly. Host on our infrastructure or yours. - All powered by AT Protocol. -

- - {/* CTA Buttons */} -
-
-
- - -
-
- -
-
{ - e.preventDefault() - try { - const handle = - inputRef.current?.value - const res = await fetch( - '/api/auth/signin', - { - method: 'POST', - headers: { - 'Content-Type': - 'application/json' - }, - body: JSON.stringify({ - handle - }) - } - ) - if (!res.ok) - throw new Error( - 'Request failed' - ) - const data = await res.json() - if (data.url) { - window.location.href = data.url - } else { - alert('Unexpected response') - } - } catch (error) { - console.error( - 'Login failed:', - error - ) - // Clear any invalid cookies - document.cookie = 'did=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax' - alert('Authentication failed') - } - }} - className="space-y-3" - > - { - if (inputRef.current) { - inputRef.current.value = handle - } - }} - > - - - -
-
-
-
-
- - {/* How It Works */} -
-
-

- How it works -

-
-
-
- 01 -
-
-

- Drop in your files -

-

- Upload your site through our dashboard or push with the CLI. - Everything gets stored directly in your AT Protocol account. -

-
-
-
-
- 02 -
-
-

- We handle the rest -

-

- Your site goes live instantly on our global CDN. - Custom domains, HTTPS, caching—all automatic. -

-
-
-
-
- 03 -
-
-

- Push updates instantly -

-

- Ship changes in seconds. Update through the dashboard, - run wisp-cli deploy, or wire up your CI/CD pipeline. -

-
-
-
-
-
- - {/* Site Gallery */} - - - {/* CTA Section */} -
-
-
-

- Follow on Bluesky for updates -

-
-
- - - -
- - - - - - -
-
-
-
- - {/* Ready to Deploy CTA */} -
-
-

- Ready to deploy? -

-

- Host your static site on your own AT Protocol - account today -

- -
-
- - {/* Footer */} - -
- - ) -} - -const root = createRoot(document.getElementById('elysia')!) -root.render( - - - - - -) diff --git a/apps/main-app/public/landingpage.html b/apps/main-app/public/landingpage.html index 355c415..b6f68e8 100644 --- a/apps/main-app/public/landingpage.html +++ b/apps/main-app/public/landingpage.html @@ -3,21 +3,21 @@ - wisp.place - + wisp.place - Static Hosting on AT Protocol + - - + + - - + + @@ -30,7 +30,7 @@ - + -
-
-
-

wisp.place

- - SIGN IN WITH AT PROTOCOL - -

Deploy static sites like it's localhost.

- -
-

are you a nerd? From your terminal to your server, nothing in between.

-
- curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli -
-
- wisp-cli deploy alice.bsky.social --site MyBlog +
+
+ + +
+
+ +
+
+ Deploy static sites in seconds +

Ship fast without complexity

+

+ Static hosting for developers who value simplicity. Your data, your PDS, your rules. +

+
+ +

or $ npx create-wisp

+
+
+
+ +
+
+ +

Three steps. No config files.

+
+
+
01
+

Sign in with Bluesky

+

Authenticate with your AT Protocol identity. No new accounts needed.

+
+
+
02
+

Upload your files

+

Drag and drop or use the CLI. Your site data goes directly to your PDS.

+ npx wispctl deploy +
+
+
03
+

You're live

+

Instantly available on our global CDN. Add a custom domain anytime.

+
+
+
+
+ +
+
+
+
+
+ + +
-

host on our infrastructure for free
- or use wisp-cli to host on your own infra with seamless deployments

-

need docs? docs.wisp.place

+ terminal +
+
+
$ npx wispctl deploy
+
+
◇ Authenticated as @nekomimi.pet
+
◇ Found 12 files (24 KB)
+
◇ Uploading to PDS...
+
+
✓ Deployed successfully
+
https://sites.wisp.place/nekomimi.pet/mysite
-
+ + +
+
+ +

Everything you need. Nothing you don't.

+
+
+
⚡
+

Global CDN

+

Sites cached at edge locations worldwide. Sub-100ms latency everywhere.

+
+
+
🔒
+

Custom Domains

+

Point your domain with automatic SSL. Just add a CNAME record.

+
+
+
📦
+

Full Ownership

+

Your site data lives in your PDS. Export or migrate anytime.

+
+
+
🚀
+

Instant Updates

+

Push and it's live. No build queues, no waiting around.

+
+
+
+
+ +
+
+ +

Command line first

+

Full control from your terminal. Integrates with your existing workflow.

+ +
+
+

Deploy from anywhere

+

Git hooks, CI/CD pipelines, makefiles. Drop in one command.

+
+
+

Self-host option

+

Run your own server. Watches the firehose, pulls updates automatically.

+
+
+

Zero config

+

No YAML, no JSON. Sensible defaults that just work.

+
+
+

Local preview

+

Serve any wisp site locally for development and testing.

+
+
+ + +
+
+ +
+
+

Ready to deploy?

+

Free forever. No credit card required.

+ +
+
- - diff --git a/apps/main-app/src/index.ts b/apps/main-app/src/index.ts index 3cf16c9..ebb2040 100644 --- a/apps/main-app/src/index.ts +++ b/apps/main-app/src/index.ts @@ -132,7 +132,7 @@ export const app = new Elysia({ set.headers['Content-Type'] = 'text/html; charset=utf-8' const html = await Bun.file('./apps/main-app/public/landingpage.html').text() - return html.replace('{{ATPROTO_LOGIN_URL}}', atprotoLoginUrl) + return html.replaceAll('{{ATPROTO_LOGIN_URL}}', atprotoLoginUrl) }) .use(authRoutes(client, cookieSecret)) .use(wispRoutes(client, cookieSecret)) diff --git a/cli/.gitignore b/cli/.gitignore index f0df49f..85c9746 100644 --- a/cli/.gitignore +++ b/cli/.gitignore @@ -1,4 +1,5 @@ test-site +.wisp-serve # dependencies (bun install) node_modules diff --git a/cli/commands/serve.ts b/cli/commands/serve.ts index e732414..4ea9cf6 100644 --- a/cli/commands/serve.ts +++ b/cli/commands/serve.ts @@ -9,7 +9,7 @@ import { join, extname } from 'path'; import { lookup } from 'mime-types'; import { pull } from './pull.ts'; import { createSpinner, pc } from '../lib/progress.ts'; -import { parseRedirectsFile, matchRedirectRule, parseQueryString, type RedirectRule } from '../lib/redirects.ts'; +import { parseRedirectsFile, matchRedirectRule, parseQueryString, type RedirectRule } from '@wisp/fs-utils'; import { isBun } from '../lib/runtime.ts'; import { BunFirehose } from '../lib/firehose.ts'; @@ -373,10 +373,10 @@ export async function serve( let firehoseHandle: { destroy: () => void }; if (isBun) { - // Use BunFirehose for Bun (native WebSocket) + // Use BunFirehose for Bun const bunFirehose = new BunFirehose({ idResolver, - service: pdsEndpoint.replace('https://', 'wss://').replace('http://', 'ws://'), + service: pdsEndpoint, filterCollections: ['place.wisp.fs', 'place.wisp.settings'], handleEvent: firehoseHandleEvent, onError: firehoseOnError, @@ -384,10 +384,10 @@ export async function serve( bunFirehose.start(); firehoseHandle = { destroy: () => bunFirehose.destroy() }; } else { - // Use @atproto/sync Firehose for Node.js (uses ws library) + // Use @atproto/sync Firehose for Node.js const nodeFirehose = new Firehose({ idResolver, - service: pdsEndpoint.replace('https://', 'wss://').replace('http://', 'ws://'), + service: pdsEndpoint, filterCollections: ['place.wisp.fs', 'place.wisp.settings'], handleEvent: firehoseHandleEvent, onError: firehoseOnError, diff --git a/cli/package.json b/cli/package.json index ce6fcad..6f660ad 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,12 +1,11 @@ { - "name": "create-wisp", - "version": "1.0.5", + "name": "wispctl", + "version": "1.0.6", "description": "CLI for wisp.place - deploy static sites to the AT Protocol", "type": "module", "main": "./dist/index.js", "bin": { - "create-wisp": "dist/index.js", - "wisp-cli": "dist/index.js" + "wispctl": "dist/index.js" }, "files": [ "dist" diff --git a/package.json b/package.json index 7bec374..f0abf84 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "screenshot": "bun run apps/main-app/scripts/screenshot-sites.ts", "hosting:dev": "cd apps/hosting-service && npm run dev", "hosting:start": "cd apps/hosting-service && npm run start", - "codegen": "./scripts/codegen.sh" + "codegen": "./scripts/codegen.sh", + "publish:cli": "cd cli && bun run build && npm publish && cd ../packages/create-wisp && npm publish" }, "trustedDependencies": [ "@parcel/watcher", diff --git a/packages/@wisp/fs-utils/package.json b/packages/@wisp/fs-utils/package.json index 0844097..eb2479a 100644 --- a/packages/@wisp/fs-utils/package.json +++ b/packages/@wisp/fs-utils/package.json @@ -25,6 +25,10 @@ "./subfs-split": { "types": "./src/subfs-split.ts", "default": "./src/subfs-split.ts" + }, + "./redirects": { + "types": "./src/redirects.ts", + "default": "./src/redirects.ts" } }, "dependencies": { diff --git a/packages/@wisp/fs-utils/src/index.ts b/packages/@wisp/fs-utils/src/index.ts index 0ad045e..4c14a64 100644 --- a/packages/@wisp/fs-utils/src/index.ts +++ b/packages/@wisp/fs-utils/src/index.ts @@ -10,3 +10,7 @@ export { createManifest } from './manifest'; // Subfs splitting utilities export { estimateDirectorySize, findLargeDirectories, replaceDirectoryWithSubfs, splitDirectoryIntoChunks } from './subfs-split'; + +// Redirects parsing and matching +export type { RedirectRule, RedirectMatch, MatchRedirectContext } from './redirects'; +export { parseRedirectsFile, matchRedirectRule, parseCookies, parseQueryString } from './redirects'; diff --git a/apps/hosting-service/src/lib/redirects.test.ts b/packages/@wisp/fs-utils/src/redirects.test.ts similarity index 98% rename from apps/hosting-service/src/lib/redirects.test.ts rename to packages/@wisp/fs-utils/src/redirects.test.ts index f61d5a3..f84639e 100644 --- a/apps/hosting-service/src/lib/redirects.test.ts +++ b/packages/@wisp/fs-utils/src/redirects.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'bun:test' +import { describe, it, expect } from 'bun:test'; import { parseRedirectsFile, matchRedirectRule } from './redirects'; describe('parseRedirectsFile', () => { @@ -185,31 +185,29 @@ describe('matchRedirectRule', () => { /jobs/* /careers/:splat `; const rules = parseRedirectsFile(content); - + const match1 = matchRedirectRule('/jobs/customer-ninja', rules); expect(match1?.targetPath).toBe('/careers/support'); - + const match2 = matchRedirectRule('/jobs/developer', rules); expect(match2?.targetPath).toBe('/careers/developer'); }); it('should handle SPA routing pattern', () => { const rules = parseRedirectsFile('/* /index.html 200'); - - // Should match any path + const match1 = matchRedirectRule('/about', rules); expect(match1).toBeTruthy(); expect(match1?.targetPath).toBe('/index.html'); expect(match1?.status).toBe(200); - + const match2 = matchRedirectRule('/users/123/profile', rules); expect(match2).toBeTruthy(); expect(match2?.targetPath).toBe('/index.html'); expect(match2?.status).toBe(200); - + const match3 = matchRedirectRule('/', rules); expect(match3).toBeTruthy(); expect(match3?.targetPath).toBe('/index.html'); }); }); - diff --git a/cli/lib/redirects.ts b/packages/@wisp/fs-utils/src/redirects.ts similarity index 61% rename from cli/lib/redirects.ts rename to packages/@wisp/fs-utils/src/redirects.ts index ffbed8f..d9e1733 100644 --- a/cli/lib/redirects.ts +++ b/packages/@wisp/fs-utils/src/redirects.ts @@ -1,7 +1,3 @@ -/** - * _redirects file parsing - adapted from hosting-service - */ - export interface RedirectRule { from: string; to: string; @@ -26,6 +22,9 @@ export interface RedirectMatch { const MAX_REDIRECT_RULES = 1000; +/** + * Parse a _redirects file into an array of redirect rules + */ export function parseRedirectsFile(content: string): RedirectRule[] { const lines = content.split('\n'); const rules: RedirectRule[] = []; @@ -35,12 +34,18 @@ export function parseRedirectsFile(content: string): RedirectRule[] { if (!lineRaw) continue; const line = lineRaw.trim(); - if (!line || line.startsWith('#')) continue; - if (rules.length >= MAX_REDIRECT_RULES) break; + + if (!line || line.startsWith('#')) { + continue; + } + + if (rules.length >= MAX_REDIRECT_RULES) { + break; + } try { const rule = parseRedirectLine(line); - if (rule?.fromPattern) { + if (rule && rule.fromPattern) { rules.push(rule); } } catch { @@ -51,42 +56,68 @@ export function parseRedirectsFile(content: string): RedirectRule[] { return rules; } +/** + * Parse a single redirect rule line + * Format: /from [query_params] /to [status] [conditions] + */ function parseRedirectLine(line: string): RedirectRule | null { const parts = line.split(/\s+/); - if (parts.length < 2) return null; + + if (parts.length < 2) { + return null; + } let idx = 0; const from = parts[idx++]; - if (!from) return null; + + if (!from) { + return null; + } let status = 301; let force = false; const conditions: NonNullable = {}; const queryParams: Record = {}; - // Parse query parameters before destination + // Parse query parameters that come before the destination path while (idx < parts.length) { const part = parts[idx]; - if (!part) { idx++; continue; } - if (part.startsWith('/') || part.startsWith('http://') || part.startsWith('https://')) break; + if (!part) { + idx++; + continue; + } + + if (part.startsWith('/') || part.startsWith('http://') || part.startsWith('https://')) { + break; + } + if (part.includes('=')) { const splitIndex = part.indexOf('='); const key = part.slice(0, splitIndex); const value = part.slice(splitIndex + 1); - if (key && value) queryParams[key] = value; + + if (key && value) { + queryParams[key] = value; + } idx++; } else { break; } } - if (idx >= parts.length) return null; + if (idx >= parts.length) { + return null; + } + const to = parts[idx++]; - if (!to) return null; + if (!to) { + return null; + } - // Parse status and conditions + // Parse remaining parts for status code and conditions for (let i = idx; i < parts.length; i++) { const part = parts[i]; + if (!part) continue; if (/^\d+!?$/.test(part)) { @@ -103,9 +134,11 @@ function parseRedirectLine(line: string): RedirectRule | null { const splitIndex = part.indexOf('='); const key = part.slice(0, splitIndex); const value = part.slice(splitIndex + 1); + if (!key || !value) continue; const keyLower = key.toLowerCase(); + if (keyLower === 'country') { conditions.country = value.split(',').map(v => v.trim().toLowerCase()); } else if (keyLower === 'language') { @@ -132,11 +165,18 @@ function parseRedirectLine(line: string): RedirectRule | null { }; } +/** + * Convert a path pattern with placeholders and splats to a regex + * Examples: + * /blog/:year/:month/:day -> captures year, month, day + * /news/* -> captures splat + */ function convertPathToRegex(pattern: string): { pattern: RegExp; params: string[] } { const params: string[] = []; let regexStr = '^'; const pathPart = pattern.split('?')[0] || pattern; + let escaped = pathPart.replace(/[.+^${}()|[\]\\]/g, '\\$&'); escaped = escaped.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_match, paramName) => { @@ -150,125 +190,213 @@ function convertPathToRegex(pattern: string): { pattern: RegExp; params: string[ } regexStr += escaped; + if (!regexStr.endsWith('.*')) { regexStr += '/?'; } + regexStr += '$'; - return { pattern: new RegExp(regexStr), params }; + return { + pattern: new RegExp(regexStr), + params, + }; } +export interface MatchRedirectContext { + queryParams?: Record; + headers?: Record; + cookies?: Record; +} + +/** + * Match a request path against redirect rules with loop detection + */ export function matchRedirectRule( requestPath: string, rules: RedirectRule[], - context?: { - queryParams?: Record; - headers?: Record; - cookies?: Record; - }, + context?: MatchRedirectContext, visitedPaths: Set = new Set() ): RedirectMatch | null { let normalizedPath = requestPath.startsWith('/') ? requestPath : `/${requestPath}`; - if (visitedPaths.has(normalizedPath)) return null; + if (visitedPaths.has(normalizedPath)) { + return null; + } + visitedPaths.add(normalizedPath); - if (visitedPaths.size > 10) return null; + + if (visitedPaths.size > 10) { + return null; + } for (const rule of rules) { - // Check query params + // Check query parameter conditions first if (rule.queryParams) { - if (!context?.queryParams) continue; + if (!context?.queryParams) { + continue; + } + const queryMatches = Object.entries(rule.queryParams).every(([key, expectedValue]) => { const actualValue = context.queryParams?.[key]; - if (actualValue === undefined) return false; + + if (actualValue === undefined) { + return false; + } + if (expectedValue && !expectedValue.startsWith(':')) { return actualValue === expectedValue; } + return true; }); - if (!queryMatches) continue; + + if (!queryMatches) { + continue; + } } - // Check conditions + // Check conditional redirects (country, language, role, cookie) if (rule.conditions) { if (rule.conditions.country && context?.headers) { - const country = context.headers['cf-ipcountry']?.toLowerCase() || context.headers['x-country']?.toLowerCase(); - if (!country || !rule.conditions.country.includes(country)) continue; + const cfCountry = context.headers['cf-ipcountry']; + const xCountry = context.headers['x-country']; + const country = cfCountry?.toLowerCase() || xCountry?.toLowerCase(); + if (!country || !rule.conditions.country.includes(country)) { + continue; + } } + if (rule.conditions.language && context?.headers) { const acceptLang = context.headers['accept-language']; - if (!acceptLang) continue; - const langs = acceptLang.split(',').map(l => l.split(';')[0]?.trim().toLowerCase() || '').filter(Boolean); - const hasMatch = rule.conditions.language.some(lang => langs.some(l => l === lang || l.startsWith(lang + '-'))); - if (!hasMatch) continue; + if (!acceptLang) { + continue; + } + const langs = acceptLang + .split(',') + .map(l => { + const langPart = l.split(';')[0]; + return langPart ? langPart.trim().toLowerCase() : ''; + }) + .filter(l => l !== ''); + const hasMatch = rule.conditions.language.some(lang => + langs.some(l => l === lang || l.startsWith(lang + '-')) + ); + if (!hasMatch) { + continue; + } } + if (rule.conditions.cookie && context?.cookies) { - const hasCookie = rule.conditions.cookie.some(cookieName => context.cookies && cookieName in context.cookies); - if (!hasCookie) continue; + const hasCookie = rule.conditions.cookie.some( + cookieName => context.cookies && cookieName in context.cookies + ); + if (!hasCookie) { + continue; + } + } + + // Role-based redirects would need JWT verification - skip for now + if (rule.conditions.role) { + continue; } - if (rule.conditions.role) continue; } const match = rule.fromPattern?.exec(normalizedPath); - if (!match) continue; + if (!match) { + continue; + } let targetPath = rule.to; + // Replace captured parameters if (rule.fromParams && match.length > 1) { for (let i = 0; i < rule.fromParams.length; i++) { const paramName = rule.fromParams[i]; const paramValue = match[i + 1]; + if (!paramName || !paramValue) continue; const encodedValue = encodeURIComponent(paramValue); + if (paramName === 'splat') { - targetPath = targetPath.replace(':splat', encodedValue.replace(/%2F/g, '/')); + const splatValue = encodedValue.replace(/%2F/g, '/'); + targetPath = targetPath.replace(':splat', splatValue); } else { targetPath = targetPath.replace(`:${paramName}`, encodedValue); } } } + // Handle query parameter replacements if (rule.queryParams && context?.queryParams) { for (const [key, placeholder] of Object.entries(rule.queryParams)) { const actualValue = context.queryParams[key]; - if (actualValue && placeholder?.startsWith(':')) { + if (actualValue && placeholder && placeholder.startsWith(':')) { const paramName = placeholder.slice(1); if (paramName) { - targetPath = targetPath.replace(`:${paramName}`, encodeURIComponent(actualValue)); + const encodedValue = encodeURIComponent(actualValue); + targetPath = targetPath.replace(`:${paramName}`, encodedValue); } } } } - return { rule, targetPath, status: rule.status }; + // Preserve query string for 200, 301, 302 redirects (unless target already has one) + if ([200, 301, 302].includes(rule.status) && context?.queryParams && !targetPath.includes('?')) { + const queryString = Object.entries(context.queryParams) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) + .join('&'); + if (queryString) { + targetPath += `?${queryString}`; + } + } + + return { + rule, + targetPath, + status: rule.status, + }; } return null; } +/** + * Parse cookies from Cookie header + */ export function parseCookies(cookieHeader?: string): Record { if (!cookieHeader) return {}; + const cookies: Record = {}; - for (const part of cookieHeader.split(';')) { + const parts = cookieHeader.split(';'); + + for (const part of parts) { const [key, ...valueParts] = part.split('='); if (key && valueParts.length > 0) { cookies[key.trim()] = valueParts.join('=').trim(); } } + return cookies; } +/** + * Parse query string into object + */ export function parseQueryString(url: string): Record { const queryStart = url.indexOf('?'); if (queryStart === -1) return {}; + const queryString = url.slice(queryStart + 1); const params: Record = {}; + for (const pair of queryString.split('&')) { const [key, value] = pair.split('='); if (key) { params[decodeURIComponent(key)] = value ? decodeURIComponent(value) : ''; } } + return params; } diff --git a/packages/create-wisp/bin.js b/packages/create-wisp/bin.js new file mode 100644 index 0000000..6772162 --- /dev/null +++ b/packages/create-wisp/bin.js @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import 'wispctl/dist/index.js'; diff --git a/packages/create-wisp/package.json b/packages/create-wisp/package.json new file mode 100644 index 0000000..a9bf81d --- /dev/null +++ b/packages/create-wisp/package.json @@ -0,0 +1,15 @@ +{ + "name": "create-wisp", + "version": "1.0.6", + "description": "CLI for wisp.place - deploy static sites to the AT Protocol", + "type": "module", + "bin": { + "create-wisp": "bin.js" + }, + "files": [ + "bin.js" + ], + "dependencies": { + "wispctl": "^1.0.6" + } +}