Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 51 additions & 3 deletions apps/hook/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
handleAnnotateServerReady,
} from "@plannotator/server/annotate";
import { getGitContext, runGitDiff } from "@plannotator/server/git";
import { generateRemoteShareUrl, formatSize } from "@plannotator/server/share-url";

// Embed the built HTML at compile time
// @ts-ignore - Bun import attribute for text
Expand Down Expand Up @@ -79,7 +80,23 @@ if (args[0] === "review") {
sharingEnabled,
shareBaseUrl,
htmlContent: reviewHtmlContent,
onReady: handleReviewServerReady,
onReady: async (url, isRemote, port) => {
handleReviewServerReady(url, isRemote, port);

if (isRemote && sharingEnabled && rawPatch) {
try {
const shareUrl = await generateRemoteShareUrl(rawPatch, shareBaseUrl);
const size = formatSize(new TextEncoder().encode(shareUrl).length);
process.stderr.write(
`\n Open this link on your local machine to review changes:\n` +
` ${shareUrl}\n\n` +
` (${size} — diff only, annotations added in browser)\n\n`
);
} catch {
// Share URL generation failed silently
}
}
},
});

// Wait for user feedback
Expand Down Expand Up @@ -126,7 +143,23 @@ if (args[0] === "review") {
sharingEnabled,
shareBaseUrl,
htmlContent: planHtmlContent,
onReady: handleAnnotateServerReady,
onReady: async (url, isRemote, port) => {
handleAnnotateServerReady(url, isRemote, port);

if (isRemote && sharingEnabled) {
try {
const shareUrl = await generateRemoteShareUrl(markdown, shareBaseUrl);
const size = formatSize(new TextEncoder().encode(shareUrl).length);
process.stderr.write(
`\n Open this link on your local machine to annotate:\n` +
` ${shareUrl}\n\n` +
` (${size} — document only, annotations added in browser)\n\n`
);
} catch {
// Share URL generation failed silently
}
}
},
});

// Wait for user feedback
Expand Down Expand Up @@ -174,8 +207,23 @@ if (args[0] === "review") {
sharingEnabled,
shareBaseUrl,
htmlContent: planHtmlContent,
onReady: (url, isRemote, port) => {
onReady: async (url, isRemote, port) => {
handleServerReady(url, isRemote, port);

if (isRemote && sharingEnabled) {
try {
const shareUrl = await generateRemoteShareUrl(planContent, shareBaseUrl);
const size = formatSize(new TextEncoder().encode(shareUrl).length);
process.stderr.write(
`\n Open this link on your local machine to review the plan:\n` +
` ${shareUrl}\n\n` +
` (${size} — plan only, annotations added in browser)\n\n`
);
} catch {
// Share URL generation failed — the local server is still usable
// via port forwarding as a fallback.
}
}
},
});

Expand Down
141 changes: 141 additions & 0 deletions apps/paste-worker/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* Plannotator Paste Worker
*
* Lightweight KV-backed paste service for short share URLs.
* Stores compressed plan data and returns short IDs.
*
* Routes:
* POST /api/paste - Store compressed data, returns { id, url }
* GET /api/paste/:id - Retrieve stored compressed data
*
* Deploy this worker to paste.plannotator.ai (or your own domain).
* Self-hosters can point PLANNOTATOR_PASTE_URL to their own instance.
*/

interface Env {
PASTE_KV: KVNamespace;
/** Comma-separated allowed origins, defaults to share.plannotator.ai + localhost */
ALLOWED_ORIGINS?: string;
}

const BASE_CORS_HEADERS = {
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Max-Age': '86400',
};

function getAllowedOrigins(env: Env): string[] {
if (env.ALLOWED_ORIGINS) {
return env.ALLOWED_ORIGINS.split(',').map(o => o.trim());
}
return ['https://share.plannotator.ai', 'http://localhost:3001'];
}

function corsHeaders(request: Request, env: Env): Record<string, string> {
const origin = request.headers.get('Origin') ?? '';
const allowed = getAllowedOrigins(env);

if (allowed.includes(origin) || allowed.includes('*')) {
return {
...BASE_CORS_HEADERS,
'Access-Control-Allow-Origin': origin,
};
}

// Don't send CORS headers for disallowed origins — the browser will
// block the response anyway, and partial headers are misleading.
return {};
}

/**
* Generate a short URL-safe ID (8 chars ≈ 48 bits of entropy).
* Uses Web Crypto so it works in the edge runtime.
*/
function generateId(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const bytes = new Uint8Array(8);
crypto.getRandomValues(bytes);
return Array.from(bytes, b => chars[b % chars.length]).join('');
}

export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const cors = corsHeaders(request, env);

// Handle CORS preflight
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: cors });
}

// POST /api/paste — store compressed plan payload, return short ID
if (url.pathname === '/api/paste' && request.method === 'POST') {
try {
const body = (await request.json()) as { data?: unknown };

if (!body.data || typeof body.data !== 'string') {
return Response.json(
{ error: 'Missing or invalid "data" field' },
{ status: 400, headers: cors }
);
}

// 512 KB limit — generous for compressed plan data (most plans are <50 KB)
if (body.data.length > 524_288) {
return Response.json(
{ error: 'Payload too large (max 512 KB compressed)' },
{ status: 413, headers: cors }
);
}

const id = generateId();

// Store with 90-day TTL
await env.PASTE_KV.put(`paste:${id}`, body.data, {
expirationTtl: 90 * 24 * 60 * 60,
});

// Return only the id — the client constructs the full URL using its own
// shareBaseUrl, so self-hosted deployments work without reconfiguration.
return Response.json(
{ id },
{ status: 201, headers: cors }
);
} catch {
return Response.json(
{ error: 'Failed to store paste' },
{ status: 500, headers: cors }
);
}
}

// GET /api/paste/:id — retrieve stored compressed data
const pasteMatch = url.pathname.match(/^\/api\/paste\/([A-Za-z0-9]{6,16})$/);
if (pasteMatch && request.method === 'GET') {
const id = pasteMatch[1];
const data = await env.PASTE_KV.get(`paste:${id}`);

if (!data) {
return Response.json(
{ error: 'Paste not found or expired' },
{ status: 404, headers: cors }
);
}

return Response.json(
{ data },
{
headers: {
...cors,
'Cache-Control': 'public, max-age=3600',
},
}
);
}

return Response.json(
{ error: 'Not found. Valid paths: POST /api/paste, GET /api/paste/:id' },
{ status: 404, headers: cors }
);
},
};
13 changes: 13 additions & 0 deletions apps/paste-worker/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "@plannotator/paste-worker",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20241218.0",
"wrangler": "^3.99.0"
}
}
12 changes: 12 additions & 0 deletions apps/paste-worker/wrangler.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
name = "plannotator-paste"
main = "index.ts"
compatibility_date = "2024-12-01"

[[kv_namespaces]]
binding = "PASTE_KV"
# Run `wrangler kv:namespace create PASTE_KV` to get your IDs and fill them in.
id = "REPLACE_WITH_KV_NAMESPACE_ID"
preview_id = "REPLACE_WITH_PREVIEW_KV_NAMESPACE_ID"

[vars]
ALLOWED_ORIGINS = "https://share.plannotator.ai,http://localhost:3001"
6 changes: 6 additions & 0 deletions packages/editor/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,9 @@ const App: React.FC = () => {
isLoadingShared,
shareUrl,
shareUrlSize,
shortShareUrl,
isGeneratingShortUrl,
shortUrlError,
pendingSharedAnnotations,
sharedGlobalAttachments,
clearPendingSharedAnnotations,
Expand Down Expand Up @@ -1221,6 +1224,9 @@ const App: React.FC = () => {
onClose={() => { setShowExport(false); setInitialExportTab(undefined); }}
shareUrl={shareUrl}
shareUrlSize={shareUrlSize}
shortShareUrl={shortShareUrl}
isGeneratingShortUrl={isGeneratingShortUrl}
shortUrlError={shortUrlError}
annotationsOutput={annotationsOutput}
annotationCount={annotations.length}
taterSprite={taterMode ? <TaterSpritePullup /> : undefined}
Expand Down
2 changes: 2 additions & 0 deletions packages/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import {
import { getRepoInfo } from "./repo";
import { detectProjectName } from "./project";

import { generateRemoteShareUrl, formatSize } from "./share-url";

// Re-export utilities
export { isRemoteSession, getServerPort } from "./remote";
export { openBrowser } from "./browser";
Expand Down
3 changes: 2 additions & 1 deletion packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
"./browser": "./browser.ts",
"./storage": "./storage.ts",
"./git": "./git.ts",
"./repo": "./repo.ts"
"./repo": "./repo.ts",
"./share-url": "./share-url.ts"
},
"files": [
"*.ts"
Expand Down
66 changes: 66 additions & 0 deletions packages/server/share-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* Server-side share URL generation for remote sessions
*
* Generates a share.plannotator.ai URL from plan content so remote users
* can open the review in their local browser without port forwarding.
*
* Uses the same deflate-raw + base64url encoding as the client-side
* sharing utilities in @plannotator/ui.
*/

const DEFAULT_SHARE_BASE = "https://share.plannotator.ai";

// Intentional subset of the canonical SharePayload in @plannotator/ui/utils/sharing.ts.
// Only `p` (plan) is populated server-side; annotations are added in the browser.
// If the canonical format changes, this must be updated to match.
interface SharePayload {
p: string;
a: [];
}

/**
* Generate a share URL from plan markdown content.
*
* Returns the full hash-based URL. For remote sessions, this lets the
* user open the plan in their local browser without any backend needed.
*/
export async function generateRemoteShareUrl(
plan: string,
shareBaseUrl?: string
): Promise<string> {
const base = shareBaseUrl || DEFAULT_SHARE_BASE;

// Build minimal payload (no annotations at server startup)
const payload: SharePayload = { p: plan, a: [] };
const json = JSON.stringify(payload);
const byteArray = new TextEncoder().encode(json);

// Compress using deflate-raw (same as client-side)
const stream = new CompressionStream("deflate-raw");
const writer = stream.writable.getWriter();
writer.write(byteArray);
writer.close();

const buffer = await new Response(stream.readable).arrayBuffer();
const compressed = new Uint8Array(buffer);

// Convert to base64url — use a loop instead of spread to avoid
// RangeError on large plans (spread has a ~65K argument limit).
let binary = "";
for (let i = 0; i < compressed.length; i++) {
binary += String.fromCharCode(compressed[i]);
}
const base64 = btoa(binary);
const hash = base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");

return `${base}/#${hash}`;
}

/**
* Format byte size as human-readable string
*/
export function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const kb = bytes / 1024;
return kb < 100 ? `${kb.toFixed(1)} KB` : `${Math.round(kb)} KB`;
}
Loading