···11export type { StorageAdapter, StorageChange } from './adapter';
22export { WebStorageAdapter } from './web';
33export { BrowserStorageAdapter } from './browser';
44+export { PostMessageStorageAdapter } from './postmessage';
+143
packages/core/src/storage/postmessage.ts
···11+// PostMessage storage adapter for cross-origin iframes
22+// Used by content script in wabac.js proxied iframe which cannot access localStorage
33+// Communicates with shell (parent window) which has localStorage access
44+55+import type { StorageAdapter, StorageChange } from './adapter';
66+77+// Allowed origins for postMessage communication
88+// Note: Use 127.0.0.1 for local dev (RFC 8252 requires loopback IP for OAuth)
99+const ALLOWED_ORIGINS = [
1010+ 'http://127.0.0.1:8081',
1111+ 'https://sure.seams.so',
1212+];
1313+1414+interface PendingRequest {
1515+ resolve: (value: any) => void;
1616+ reject: (error: Error) => void;
1717+}
1818+1919+export class PostMessageStorageAdapter implements StorageAdapter {
2020+ private listeners: Array<(change: StorageChange) => void> = [];
2121+ private pendingRequests: Map<string, PendingRequest> = new Map();
2222+ private targetWindow: Window;
2323+ private targetOrigin: string;
2424+ private boundHandleMessage: (event: MessageEvent) => void;
2525+2626+ constructor(targetWindow: Window = window.parent, targetOrigin: string = 'https://sure.seams.so') {
2727+ this.targetWindow = targetWindow;
2828+ this.targetOrigin = targetOrigin;
2929+3030+ // Bind handler once so we can remove it later
3131+ this.boundHandleMessage = this.handleMessage.bind(this);
3232+3333+ // Listen for messages from shell
3434+ window.addEventListener('message', this.boundHandleMessage);
3535+ }
3636+3737+ private handleMessage(event: MessageEvent): void {
3838+ // Validate origin - only accept messages from allowed origins
3939+ if (!ALLOWED_ORIGINS.includes(event.origin)) {
4040+ return;
4141+ }
4242+4343+ const { type, requestId, annotations, key, newValue, oldValue } = event.data;
4444+4545+ if (type === 'ANNOTATIONS_DATA' && requestId) {
4646+ // Response to GET_ANNOTATIONS request
4747+ const pending = this.pendingRequests.get(requestId);
4848+ if (pending) {
4949+ this.pendingRequests.delete(requestId);
5050+ pending.resolve(annotations);
5151+ }
5252+ } else if (type === 'ANNOTATIONS_UPDATED') {
5353+ // Push notification from shell when storage changes
5454+ console.log('[PostMessageStorageAdapter] Received annotations update');
5555+ const change: StorageChange = {
5656+ key: 'annotations',
5757+ newValue: annotations,
5858+ oldValue: undefined
5959+ };
6060+ this.listeners.forEach(callback => callback(change));
6161+ } else if (type === 'STORAGE_CHANGE') {
6262+ // Generic storage change notification
6363+ const change: StorageChange = { key, newValue, oldValue };
6464+ this.listeners.forEach(callback => callback(change));
6565+ }
6666+ }
6767+6868+ async get(keys: string | string[]): Promise<any> {
6969+ // For now, we only support getting 'annotations'
7070+ // This could be extended to support other keys if needed
7171+ if (keys === 'annotations' || (Array.isArray(keys) && keys.includes('annotations'))) {
7272+ return this.requestAnnotations();
7373+ }
7474+7575+ // For single key
7676+ if (typeof keys === 'string') {
7777+ if (keys === 'annotations') {
7878+ return this.requestAnnotations();
7979+ }
8080+ // Unsupported key - return null
8181+ console.warn('[PostMessageStorageAdapter] Unsupported key:', keys);
8282+ return null;
8383+ }
8484+8585+ // For multiple keys
8686+ const result: Record<string, any> = {};
8787+ for (const key of keys) {
8888+ if (key === 'annotations') {
8989+ result[key] = await this.requestAnnotations();
9090+ } else {
9191+ result[key] = null;
9292+ }
9393+ }
9494+ return result;
9595+ }
9696+9797+ private requestAnnotations(): Promise<any> {
9898+ return new Promise((resolve, reject) => {
9999+ const requestId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
100100+101101+ // Set up timeout
102102+ const timeout = setTimeout(() => {
103103+ this.pendingRequests.delete(requestId);
104104+ console.warn('[PostMessageStorageAdapter] GET_ANNOTATIONS request timed out');
105105+ resolve([]); // Return empty array on timeout rather than rejecting
106106+ }, 5000);
107107+108108+ this.pendingRequests.set(requestId, {
109109+ resolve: (value) => {
110110+ clearTimeout(timeout);
111111+ resolve(value);
112112+ },
113113+ reject: (error) => {
114114+ clearTimeout(timeout);
115115+ reject(error);
116116+ }
117117+ });
118118+119119+ // Send request to shell
120120+ console.log('[PostMessageStorageAdapter] Requesting annotations from shell');
121121+ this.targetWindow.postMessage({
122122+ type: 'GET_ANNOTATIONS',
123123+ requestId
124124+ }, this.targetOrigin);
125125+ });
126126+ }
127127+128128+ async set(_key: string, _value: any): Promise<void> {
129129+ // Content script doesn't write to storage - it's read-only
130130+ // Writes happen through sidebar -> PDS -> storage in shell
131131+ console.warn('[PostMessageStorageAdapter] set() called but content script is read-only');
132132+ }
133133+134134+ onChange(callback: (change: StorageChange) => void): void {
135135+ this.listeners.push(callback);
136136+ }
137137+138138+ destroy(): void {
139139+ window.removeEventListener('message', this.boundHandleMessage);
140140+ this.pendingRequests.clear();
141141+ this.listeners = [];
142142+ }
143143+}
+21-5
proxy/static/extension-callback.html
···55 <title>OAuth Callback</title>
66</head>
77<body>
88- <p>Authenticated. This window will close automatically.</p>
88+ <p>Redirecting...</p>
99 <script>
1010- // Log what we received to help debug
1111- console.log('[extension-callback] Full URL:', window.location.href);
1212- console.log('[extension-callback] Search:', window.location.search);
1313- console.log('[extension-callback] Hash:', window.location.hash);
1010+ // Relay to Chromium extension callback
1111+ const extensionId = 'kjdnjfgcikmlbloojphbkmknfpmfofio';
1212+ const extRedirect = `https://${extensionId}.chromiumapp.org/extension-callback.html`;
1313+1414+ // Check if we are already in the extension context to avoid infinite loops
1515+ const isExtension = window.location.protocol === 'chrome-extension:' ||
1616+ window.location.hostname.endsWith('.chromiumapp.org') ||
1717+ window.location.protocol === 'moz-extension:';
1818+1919+ if (!isExtension) {
2020+ // We are on the web server (relay), so redirect to the extension
2121+ console.log('Relaying to extension:', extRedirect);
2222+ window.location.href = extRedirect + window.location.search + window.location.hash;
2323+ } else {
2424+ // We are in the extension
2525+ console.log('Authentication successful!');
2626+ // The browser should handle closing this window via launchWebAuthFlow,
2727+ // but we can show a message just in case.
2828+ document.querySelector('p').textContent = 'Authenticated. This window will close automatically.';
2929+ }
1430 </script>
1531</body>
1632</html>
···11+# Caddyfile for sure-client-proxy
22+# Serves static files on :8081, reverse proxies CORS proxy on :8082
33+44+# Static file server for wabac.js client
55+:8081 {
66+ root * /app/dist
77+ file_server
88+99+ # Enable gzip compression
1010+ encode gzip
1111+1212+ # CORS headers for service worker and client
1313+ header {
1414+ Access-Control-Allow-Origin *
1515+ Access-Control-Allow-Methods "GET, POST, OPTIONS"
1616+ Access-Control-Allow-Headers "*"
1717+ }
1818+1919+ # Cache static assets
2020+ @static {
2121+ path *.js *.css *.woff *.woff2 *.png *.ico
2222+ }
2323+ header @static Cache-Control "public, max-age=3600"
2424+2525+ # Don't cache HTML (for updates)
2626+ @html {
2727+ path *.html /
2828+ }
2929+ header @html Cache-Control "no-cache"
3030+}
3131+3232+# CORS proxy (reverse proxy to Node.js server)
3333+:8082 {
3434+ reverse_proxy localhost:8083
3535+}
+89
sure-client-proxy/README.md
···11+# Seams Client-Side Proxy POC
22+33+A client-side web proxy using wabac.js, replacing the server-side pywb proxy.
44+55+## Architecture
66+77+Instead of fetching pages server-side (pywb), this approach uses:
88+99+1. **CORS Proxy** (`cors-proxy/`) - Lightweight Node.js server that adds CORS headers
1010+2. **wabac.js Service Worker** - Intercepts requests in the browser and routes through CORS proxy
1111+3. **Script Injection** - `seams-client.js` is injected into proxied pages
1212+1313+The user's browser does all the heavy lifting - the server just adds CORS headers.
1414+1515+## Setup
1616+1717+```bash
1818+# Install dependencies
1919+npm install
2020+2121+# Also install cors-proxy dependencies
2222+cd cors-proxy && npm install && cd ..
2323+```
2424+2525+## Development
2626+2727+```bash
2828+# Start both servers (cors-proxy on 8082, static on 8081)
2929+npm run dev
3030+```
3131+3232+Then visit: http://localhost:8081/#https://example.com
3333+3434+## How It Works
3535+3636+1. User visits `http://localhost:8081/#https://example.com`
3737+2. `loadwabac.js` registers the wabac.js service worker
3838+3. Service worker intercepts requests to `/w/liveproxy/mp_/https://example.com`
3939+4. Requests are routed through `http://localhost:8082/proxy/https://example.com`
4040+5. CORS proxy fetches the page and adds necessary headers
4141+6. `seams-client.js` is injected into the page for annotation functionality
4242+4343+## Directory Structure
4444+4545+```
4646+sure-client-proxy/
4747+├── cors-proxy/
4848+│ ├── index.ts # Hono-based CORS proxy server
4949+│ ├── package.json
5050+│ └── tsconfig.json
5151+│
5252+├── static/
5353+│ ├── index.html # Main page with iframe
5454+│ ├── loadwabac.js # Initialize wabac.js service worker
5555+│ ├── seams-client.js # Injected into proxied pages
5656+│ └── sw.js # wabac.js service worker (copied from npm)
5757+│
5858+├── package.json
5959+└── README.md
6060+```
6161+6262+## Ports
6363+6464+- **8081** - Static site (main page with iframe)
6565+- **8082** - CORS proxy server
6666+6767+## Testing
6868+6969+After running `npm run dev`:
7070+7171+1. Open http://localhost:8081/#https://example.com
7272+2. Open browser DevTools console
7373+3. Should see `[seams-client] Injected! URL: https://example.com` in the console
7474+4. The example.com page should render in the iframe
7575+7676+## CORS Proxy Details
7777+7878+The CORS proxy handles:
7979+8080+- **Redirects**: Returns 200 with `x-redirect-status`, `x-orig-location` headers
8181+- **Cookies**: Proxies via `x-proxy-cookie` and `x-proxy-set-cookie` headers
8282+- **Referer**: Accepts `x-proxy-referer` header
8383+- **User-Agent**: Accepts `x-proxy-user-agent` header
8484+8585+## Next Steps
8686+8787+1. Replace `seams-client.js` placeholder with build from `entrypoints/via-client/main.ts`
8888+2. Deploy CORS proxy to Cloudflare Workers or similar edge runtime
8989+3. Deploy static site to CDN
+234
sure-client-proxy/cors-proxy/index.ts
···11+import { Hono } from 'hono';
22+import { serve } from '@hono/node-server';
33+44+const app = new Hono();
55+66+// Allowed origins for CORS (configurable via environment variable)
77+// Note: Use 127.0.0.1 for local dev (RFC 8252 requires loopback IP for OAuth)
88+const CORS_ALLOWED_ORIGINS = process.env.CORS_ALLOWED_ORIGINS
99+ ? process.env.CORS_ALLOWED_ORIGINS.split(',').map(s => s.trim())
1010+ : [
1111+ 'http://127.0.0.1:8081',
1212+ ];
1313+1414+// Headers to skip when proxying request
1515+const SKIP_REQUEST_HEADERS = new Set([
1616+ 'host',
1717+ 'connection',
1818+ 'x-proxy-referer',
1919+ 'x-proxy-cookie',
2020+ 'x-proxy-user-agent',
2121+]);
2222+2323+// Headers to skip when returning response
2424+const SKIP_RESPONSE_HEADERS = new Set([
2525+ 'transfer-encoding',
2626+ 'content-encoding',
2727+ 'content-length',
2828+ // Frame-busting headers - we need to strip these for iframe embedding
2929+ 'x-frame-options',
3030+ 'content-security-policy',
3131+ 'content-security-policy-report-only',
3232+ // Other security headers that might interfere
3333+ 'cross-origin-opener-policy',
3434+ 'cross-origin-embedder-policy',
3535+ 'cross-origin-resource-policy',
3636+]);
3737+3838+// Handle CORS preflight
3939+app.options('/proxy/*', (c) => {
4040+ const origin = c.req.header('Origin');
4141+ const method = c.req.header('Access-Control-Request-Method');
4242+ const headers = c.req.header('Access-Control-Request-Headers');
4343+4444+ if (CORS_ALLOWED_ORIGINS.length && origin && !CORS_ALLOWED_ORIGINS.includes(origin)) {
4545+ return c.json({ error: 'origin not allowed' }, 403);
4646+ }
4747+4848+ if (origin && method && headers) {
4949+ return new Response(null, {
5050+ headers: {
5151+ 'Access-Control-Allow-Method': method,
5252+ 'Access-Control-Allow-Headers': headers,
5353+ 'Access-Control-Allow-Origin': origin,
5454+ 'Access-Control-Allow-Credentials': 'true',
5555+ },
5656+ });
5757+ }
5858+5959+ return new Response(null, {
6060+ headers: {
6161+ 'Allow': 'GET, HEAD, POST, OPTIONS',
6262+ },
6363+ });
6464+});
6565+6666+// Main proxy handler
6767+app.all('/proxy/*', async (c) => {
6868+ // Extract the target URL from the path
6969+ let proxyUrl = c.req.path.slice('/proxy/'.length);
7070+7171+ // Handle query string
7272+ const queryString = new URL(c.req.url).search;
7373+ if (queryString) {
7474+ proxyUrl += queryString;
7575+ }
7676+7777+ // Handle protocol-relative URLs
7878+ if (proxyUrl.startsWith('//')) {
7979+ proxyUrl = 'https:' + proxyUrl;
8080+ }
8181+8282+ // Validate URL
8383+ try {
8484+ new URL(proxyUrl);
8585+ } catch {
8686+ return c.json({ error: 'Invalid URL' }, 400);
8787+ }
8888+8989+ console.log(`[cors-proxy] Proxying: ${proxyUrl}`);
9090+9191+ // Build proxy request headers
9292+ const proxyHeaders = new Headers();
9393+9494+ for (const [name, value] of c.req.raw.headers) {
9595+ const lowerName = name.toLowerCase();
9696+9797+ // Skip certain headers
9898+ if (SKIP_REQUEST_HEADERS.has(lowerName) || lowerName.startsWith('cf-') || lowerName.startsWith('x-pywb-')) {
9999+ continue;
100100+ }
101101+102102+ proxyHeaders.set(name, value);
103103+ }
104104+105105+ // Handle referer
106106+ const referrer = c.req.header('x-proxy-referer');
107107+ if (referrer) {
108108+ proxyHeaders.set('Referer', referrer);
109109+ try {
110110+ const refOrigin = new URL(referrer).origin;
111111+ const targetOrigin = new URL(proxyUrl).origin;
112112+ if (refOrigin !== targetOrigin) {
113113+ proxyHeaders.set('Origin', refOrigin);
114114+ proxyHeaders.set('Sec-Fetch-Site', 'cross-origin');
115115+ } else {
116116+ proxyHeaders.delete('Origin');
117117+ proxyHeaders.set('Sec-Fetch-Site', 'same-origin');
118118+ }
119119+ } catch {
120120+ // Ignore invalid referrer
121121+ }
122122+ } else {
123123+ proxyHeaders.delete('Origin');
124124+ proxyHeaders.delete('Referer');
125125+ }
126126+127127+ // Handle custom user agent
128128+ const ua = c.req.header('x-proxy-user-agent');
129129+ if (ua) {
130130+ proxyHeaders.set('User-Agent', ua);
131131+ }
132132+133133+ // Handle cookies
134134+ const cookie = c.req.header('x-proxy-cookie');
135135+ if (cookie) {
136136+ proxyHeaders.set('Cookie', cookie);
137137+ }
138138+139139+ // Get request body for non-GET/HEAD requests
140140+ const method = c.req.method;
141141+ const body = (method === 'GET' || method === 'HEAD') ? null : await c.req.raw.body;
142142+143143+ try {
144144+ // Fetch with redirect: manual to handle redirects specially
145145+ const resp = await fetch(proxyUrl, {
146146+ method,
147147+ headers: proxyHeaders,
148148+ body,
149149+ redirect: 'manual',
150150+ });
151151+152152+ // Build response headers
153153+ const responseHeaders = new Headers();
154154+ const exposeHeaders: string[] = [
155155+ 'x-redirect-status',
156156+ 'x-redirect-statusText',
157157+ 'x-proxy-set-cookie',
158158+ 'x-orig-location',
159159+ 'x-orig-ts',
160160+ ];
161161+162162+ for (const [name, value] of resp.headers) {
163163+ const lowerName = name.toLowerCase();
164164+ if (!SKIP_RESPONSE_HEADERS.has(lowerName)) {
165165+ responseHeaders.set(name, value);
166166+ exposeHeaders.push(name);
167167+ }
168168+ }
169169+170170+ // Handle set-cookie
171171+ const setCookie = resp.headers.get('set-cookie');
172172+ if (setCookie) {
173173+ responseHeaders.set('X-Proxy-Set-Cookie', setCookie);
174174+ }
175175+176176+ // Handle redirects specially
177177+ let status: number;
178178+ const statusText = resp.statusText;
179179+180180+ if ([301, 302, 303, 307, 308].includes(resp.status)) {
181181+ responseHeaders.set('x-redirect-status', String(resp.status));
182182+ responseHeaders.set('x-redirect-statusText', resp.statusText);
183183+184184+ const location = resp.headers.get('location');
185185+ if (location) {
186186+ responseHeaders.set('x-orig-location', location);
187187+ }
188188+189189+ // Return 200 so browser doesn't follow redirect
190190+ status = 200;
191191+ } else {
192192+ status = resp.status;
193193+ }
194194+195195+ // Add CORS headers
196196+ const origin = c.req.header('Origin');
197197+ if (origin) {
198198+ responseHeaders.set('Access-Control-Allow-Origin', origin);
199199+ responseHeaders.set('Access-Control-Allow-Credentials', 'true');
200200+ responseHeaders.set('Access-Control-Expose-Headers', exposeHeaders.join(','));
201201+ }
202202+203203+ // Handle error status codes
204204+ let responseBody: ReadableStream<Uint8Array> | string | null;
205205+ if (status > 400 && status !== 404 && !resp.headers.get('memento-datetime')) {
206206+ responseBody = `Sorry, this page could not be loaded (Error Status: ${status})`;
207207+ } else {
208208+ responseBody = resp.body;
209209+ }
210210+211211+ return new Response(responseBody, {
212212+ headers: responseHeaders,
213213+ status,
214214+ statusText,
215215+ });
216216+ } catch (error) {
217217+ console.error('[cors-proxy] Fetch error:', error);
218218+ return c.json({ error: 'Failed to fetch target URL' }, 502);
219219+ }
220220+});
221221+222222+// Health check
223223+app.get('/', (c) => {
224224+ return c.json({ status: 'ok', service: 'seams-cors-proxy' });
225225+});
226226+227227+const port = parseInt(process.env.CORS_PROXY_PORT || '8082', 10);
228228+console.log(`[cors-proxy] Starting server on http://localhost:${port}`);
229229+console.log(`[cors-proxy] Allowed origins: ${CORS_ALLOWED_ORIGINS.join(', ')}`);
230230+231231+serve({
232232+ fetch: app.fetch,
233233+ port,
234234+});