pipane 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,137 @@
1
+ const CHUNK_MARKER = 1;
2
+ const CHUNK_PREFIX = `{"__pipaneDataChannelChunk":${CHUNK_MARKER},`;
3
+ /** Keep physical messages below conservative browser/libdatachannel SCTP limits. */
4
+ export const DATA_CHANNEL_CHUNK_PAYLOAD_BYTES = 12_000;
5
+ export const MAX_DATA_CHANNEL_MESSAGE_BYTES = 16 * 1024;
6
+ export const MAX_DATA_CHANNEL_FRAME_BYTES = 64 * 1024 * 1024;
7
+ export const MAX_DATA_CHANNEL_PENDING_FRAMES = 4;
8
+ export const MAX_DATA_CHANNEL_QUEUED_BYTES = 96 * 1024 * 1024;
9
+ export const DATA_CHANNEL_BUFFER_HIGH_WATER_BYTES = 1024 * 1024;
10
+ export const DATA_CHANNEL_BUFFER_LOW_WATER_BYTES = 256 * 1024;
11
+ const textEncoder = new TextEncoder();
12
+ const fatalTextDecoder = new TextDecoder("utf-8", { fatal: true });
13
+ const MAX_CHUNKS_PER_FRAME = Math.ceil(MAX_DATA_CHANNEL_FRAME_BYTES / DATA_CHANNEL_CHUNK_PAYLOAD_BYTES);
14
+ const CHUNK_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/u;
15
+ const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u;
16
+ /** Encode one logical text frame into conservatively sized DataChannel messages. */
17
+ export function encodeDataChannelFrame(frame, id) {
18
+ if (!CHUNK_ID_PATTERN.test(id))
19
+ throw new Error("DataChannel frame id is invalid");
20
+ const bytes = textEncoder.encode(frame);
21
+ if (bytes.byteLength > MAX_DATA_CHANNEL_FRAME_BYTES)
22
+ throw new Error("DataChannel frame exceeds the reassembly limit");
23
+ if (bytes.byteLength <= DATA_CHANNEL_CHUNK_PAYLOAD_BYTES)
24
+ return [frame];
25
+ const total = Math.ceil(bytes.byteLength / DATA_CHANNEL_CHUNK_PAYLOAD_BYTES);
26
+ const messages = [];
27
+ for (let index = 0; index < total; index++) {
28
+ const start = index * DATA_CHANNEL_CHUNK_PAYLOAD_BYTES;
29
+ const chunk = {
30
+ __pipaneDataChannelChunk: CHUNK_MARKER,
31
+ id,
32
+ index,
33
+ total,
34
+ data: encodeBase64(bytes.subarray(start, start + DATA_CHANNEL_CHUNK_PAYLOAD_BYTES)),
35
+ };
36
+ const message = JSON.stringify(chunk);
37
+ if (textEncoder.encode(message).byteLength > MAX_DATA_CHANNEL_MESSAGE_BYTES) {
38
+ throw new Error("Encoded DataChannel chunk exceeds the physical message limit");
39
+ }
40
+ messages.push(message);
41
+ }
42
+ return messages;
43
+ }
44
+ /** Reassemble carrier chunks while passing ordinary application frames through unchanged. */
45
+ export class DataChannelFrameDecoder {
46
+ pending = new Map();
47
+ accept(message) {
48
+ if (!message.startsWith(CHUNK_PREFIX))
49
+ return message;
50
+ try {
51
+ const chunk = parseChunk(message);
52
+ let pending = this.pending.get(chunk.id);
53
+ if (!pending) {
54
+ if (chunk.index !== 0)
55
+ throw new Error("DataChannel chunk sequence does not start at zero");
56
+ if (this.pending.size >= MAX_DATA_CHANNEL_PENDING_FRAMES)
57
+ throw new Error("Too many DataChannel frames are pending");
58
+ pending = { total: chunk.total, nextIndex: 0, byteLength: 0, chunks: [] };
59
+ this.pending.set(chunk.id, pending);
60
+ }
61
+ if (pending.total !== chunk.total || chunk.index !== pending.nextIndex) {
62
+ throw new Error("DataChannel chunks are inconsistent or out of order");
63
+ }
64
+ const bytes = decodeBase64(chunk.data);
65
+ if (bytes.byteLength === 0 || bytes.byteLength > DATA_CHANNEL_CHUNK_PAYLOAD_BYTES) {
66
+ throw new Error("DataChannel chunk payload size is invalid");
67
+ }
68
+ pending.byteLength += bytes.byteLength;
69
+ if (pending.byteLength > MAX_DATA_CHANNEL_FRAME_BYTES)
70
+ throw new Error("DataChannel frame exceeds the reassembly limit");
71
+ pending.chunks.push(bytes);
72
+ pending.nextIndex++;
73
+ if (pending.nextIndex < pending.total)
74
+ return undefined;
75
+ this.pending.delete(chunk.id);
76
+ const assembled = new Uint8Array(pending.byteLength);
77
+ let offset = 0;
78
+ for (const part of pending.chunks) {
79
+ assembled.set(part, offset);
80
+ offset += part.byteLength;
81
+ }
82
+ return fatalTextDecoder.decode(assembled);
83
+ }
84
+ catch (error) {
85
+ this.pending.clear();
86
+ throw error;
87
+ }
88
+ }
89
+ reset() {
90
+ this.pending.clear();
91
+ }
92
+ }
93
+ function parseChunk(message) {
94
+ let value;
95
+ try {
96
+ value = JSON.parse(message);
97
+ }
98
+ catch {
99
+ throw new Error("DataChannel chunk is not valid JSON");
100
+ }
101
+ if (!value || typeof value !== "object" || Array.isArray(value))
102
+ throw new Error("DataChannel chunk must be an object");
103
+ const chunk = value;
104
+ if (chunk.__pipaneDataChannelChunk !== CHUNK_MARKER
105
+ || typeof chunk.id !== "string" || !CHUNK_ID_PATTERN.test(chunk.id)
106
+ || !Number.isSafeInteger(chunk.index) || chunk.index < 0
107
+ || !Number.isSafeInteger(chunk.total) || chunk.total < 2 || chunk.total > MAX_CHUNKS_PER_FRAME
108
+ || chunk.index >= chunk.total
109
+ || typeof chunk.data !== "string") {
110
+ throw new Error("DataChannel chunk envelope is invalid");
111
+ }
112
+ return chunk;
113
+ }
114
+ function encodeBase64(bytes) {
115
+ let binary = "";
116
+ for (let offset = 0; offset < bytes.byteLength; offset += 4096) {
117
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + 4096));
118
+ }
119
+ return btoa(binary);
120
+ }
121
+ function decodeBase64(value) {
122
+ if (!BASE64_PATTERN.test(value))
123
+ throw new Error("DataChannel chunk payload is not valid base64");
124
+ let binary;
125
+ try {
126
+ binary = atob(value);
127
+ }
128
+ catch {
129
+ throw new Error("DataChannel chunk payload is not valid base64");
130
+ }
131
+ const bytes = new Uint8Array(binary.length);
132
+ for (let index = 0; index < binary.length; index++)
133
+ bytes[index] = binary.charCodeAt(index);
134
+ if (encodeBase64(bytes) !== value)
135
+ throw new Error("DataChannel chunk payload is not canonical base64");
136
+ return bytes;
137
+ }
package/docs/protocol.md CHANGED
@@ -89,6 +89,8 @@ The answer-side implementation accepts only a reliable ordered DataChannel with
89
89
 
90
90
  Authenticated DataChannels carry the existing versioned v1 application frames through the same server connection boundary as local WebSockets. A frame router keeps those application frames isolated from semantic v2 responses on the same ordered channel. Revocation closes active rendezvous routes and matching backend peers, prevents new ticket issuance, and is retained centrally so an offline backend clears stale local ownership when it next registers.
91
91
 
92
+ After the unfragmented authentication exchange, the carrier transparently splits logical frames larger than 12,000 UTF-8 bytes into ordered base64 chunk envelopes no larger than 16 KiB. Browser and backend reassemble at most 64 MiB per logical frame with bounded pending-frame and outgoing-queue memory. Application v1 and semantic v2 decoders therefore continue to receive exactly one complete JSON frame regardless of the negotiated SCTP message-size limit.
93
+
92
94
  ### Semantic backend protocol v2
93
95
 
94
96
  `src/shared/backend-protocol.ts` defines the carrier-neutral request protocol independently from application v1:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pipane",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "A clean web interface for the pi coding agent — chat UI with real-time tool calls, streaming output, session management, and model picker",
5
5
  "private": false,
6
6
  "type": "module",
@@ -47,7 +47,7 @@
47
47
  "dev:server": "tsx watch src/server/server.ts",
48
48
  "dev:client": "vite",
49
49
  "dev:rendezvous": "tsx watch src/rendezvous/server.ts",
50
- "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && concurrently --raw --kill-others-on-fail \"vite build\" \"tsc -p tsconfig.server.json\"",
50
+ "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && concurrently --raw --kill-others-on-fail \"vite build\" \"tsc -p tsconfig.server.json\" && node scripts/write-build-info.js",
51
51
  "start": "NODE_ENV=production node dist/server/server/server.js",
52
52
  "start:rendezvous": "NODE_ENV=production node dist/server/rendezvous/server.js",
53
53
  "prod": "./prod.sh",
@@ -1 +0,0 @@
1
- import{P as L,b as _,s as R,T as S,v as z,c as T}from"./rendezvous-trust-api-C2Zob5i4.js";const I=2;function M(n,e){const t=new URL(n);if(t.protocol==="http:")t.protocol="ws:";else if(t.protocol==="https:")t.protocol="wss:";else if(t.protocol!=="ws:"&&t.protocol!=="wss:")throw new Error(`Unsupported rendezvous URL protocol: ${t.protocol}`);return t.pathname=`/v${I}/rendezvous/${e}`,t.search="",t.hash="",t.toString()}function O(n){return JSON.stringify(n)}function P(n){return N(n)}function N(n,e){const t=B(n);if(!t.ok)return t;const i=t.value;switch(i.type){case"challenge":return a("invalid backend challenge");case"registered":return a("invalid backend registration");case"pairing_opened":return a("invalid pairing acknowledgement");case"pairing_confirmed":return a("invalid pairing confirmation");case"connection_request":return a("invalid connection request");case"backend_connected":if(!c(i.backendId)||!c(i.connectionId))return a("invalid browser connection acknowledgement");break;case"signal":if(!c(i.connectionId)||!A(i.signal))return a("invalid ICE signal");break;case"connection_binding":if(!c(i.connectionId)||!D(i.binding))return a("invalid backend identity binding");break;case"connection_closed":if(!c(i.connectionId)||!c(i.reason))return a("invalid connection closure");break;case"authorization_revoked":return a("invalid authorization revocation");case"error":if(!W(i.code)||!c(i.message)||!U(i.connectionId))return a("invalid rendezvous error");break;default:return x(i.type)}return{ok:!0,value:i}}function B(n){let e;try{e=JSON.parse(n)}catch{return{ok:!1,error:{code:"invalid_json",message:"Message is not valid JSON"}}}return!b(e)||!c(e.type)?a("Message must be an object with a type"):e.protocolVersion!==I?{ok:!1,error:{code:"unsupported_version",message:`Unsupported rendezvous protocol version: ${String(e.protocolVersion)}`}}:{ok:!0,value:e}}function A(n){return!b(n)||!c(n.kind)?!1:n.kind==="description"?(n.type==="offer"||n.type==="answer")&&c(n.sdp):n.kind==="candidate"?c(n.candidate)&&(n.sdpMid===null||c(n.sdpMid))&&(n.sdpMLineIndex===null||Number.isSafeInteger(n.sdpMLineIndex)&&n.sdpMLineIndex>=0):!1}function D(n){return b(n)&&n.version===1&&c(n.backendId)&&c(n.publicKey)&&c(n.connectionId)&&c(n.offerSha256)&&c(n.answerSha256)&&c(n.dtlsFingerprint)&&j(n.expiresAt)&&c(n.signature)}function W(n){return n==="invalid_json"||n==="invalid_message"||n==="unsupported_version"||n==="unknown_message"||n==="backend_offline"||n==="unauthorized_connection"||n==="invalid_ticket"||n==="invalid_pairing"}function a(n){return{ok:!1,error:{code:"invalid_message",message:n}}}function x(n){return{ok:!1,error:{code:"unknown_message",message:`Unknown rendezvous message: ${String(n)}`}}}function b(n){return!!n&&typeof n=="object"&&!Array.isArray(n)}function c(n){return typeof n=="string"&&n.length>0}function U(n){return n===void 0||c(n)}function j(n){return Number.isSafeInteger(n)&&n>0}class V{constructor(e){this.signalListeners=new Set,this.bindingListeners=new Set,this.closedListeners=new Set,this.errorListeners=new Set,this.socket=null,this.endpoint=M(e.url,"browser"),this.backendId=e.backendId,this.ticket=e.ticket,this.createWebSocket=e.createWebSocket??(t=>new WebSocket(t))}get activeConnectionId(){return this.connectionId}connect(){if(this.connectionId)return Promise.resolve(this.connectionId);if(this.connecting)return this.connecting;if(this.connecting=new Promise((t,i)=>{this.resolveConnecting=t,this.rejectConnecting=i}),this.socket?.readyState===WebSocket.OPEN)return this.sendRaw({type:"connect_backend",backendId:this.backendId,ticket:this.ticket}),this.connecting;const e=this.createWebSocket(this.endpoint);return this.socket=e,e.onopen=()=>this.sendRaw({type:"connect_backend",backendId:this.backendId,ticket:this.ticket}),e.onmessage=t=>this.handleMessage(e,String(t.data)),e.onerror=()=>{const t=new Error("Rendezvous WebSocket failed");this.emitError(t),this.failConnection(t)},e.onclose=()=>{if(this.socket!==e)return;this.socket=null;const t=this.connectionId!==void 0;this.connectionId=void 0;const i=new Error("Rendezvous WebSocket closed");if(this.failConnection(i),t)for(const s of this.closedListeners)s(i.message)},this.connecting}onSignal(e){return this.signalListeners.add(e),()=>this.signalListeners.delete(e)}onIdentityBinding(e){return this.bindingListeners.add(e),()=>this.bindingListeners.delete(e)}onConnectionClosed(e){return this.closedListeners.add(e),()=>this.closedListeners.delete(e)}onError(e){return this.errorListeners.add(e),()=>this.errorListeners.delete(e)}sendSignal(e){if(!this.connectionId)throw new Error("Browser rendezvous route is not connected");this.sendRaw({type:"signal",connectionId:this.connectionId,signal:e})}close(e="Browser closed connection"){this.connectionId&&this.socket?.readyState===WebSocket.OPEN&&this.sendRaw({type:"close_connection",connectionId:this.connectionId,reason:e}),this.connectionId=void 0,this.socket?.close(1e3,e),this.socket=null,this.failConnection(new Error(e))}handleMessage(e,t){if(e!==this.socket)return;const i=P(t);if(!i.ok){const r=new Error(i.error.message);this.emitError(r),this.failConnection(r),e.close(1002,"Invalid rendezvous message");return}const s=i.value;switch(s.type){case"backend_connected":if(s.backendId!==this.backendId){const r=new Error("Rendezvous connected an unexpected backend");this.emitError(r),this.failConnection(r),e.close(1002,"Backend identity mismatch");return}this.connectionId=s.connectionId,this.resolveConnecting?.(s.connectionId),this.clearConnecting();break;case"signal":if(s.connectionId!==this.connectionId)return;for(const r of this.signalListeners)r(s.signal);break;case"connection_binding":if(s.connectionId!==this.connectionId)return;for(const r of this.bindingListeners)r(s.binding);break;case"connection_closed":if(s.connectionId!==this.connectionId)return;this.connectionId=void 0;for(const r of this.closedListeners)r(s.reason);break;case"error":this.emitError(s),this.connectionId||this.failConnection(new Error(s.message));break}}sendRaw(e){if(!this.socket||this.socket.readyState!==WebSocket.OPEN)throw new Error("Rendezvous WebSocket is not connected");this.socket.send(O({protocolVersion:I,...e}))}failConnection(e){this.rejectConnecting?.(e),this.clearConnecting()}clearConnecting(){this.connecting=void 0,this.resolveConnecting=void 0,this.rejectConnecting=void 0}emitError(e){for(const t of this.errorListeners)t(e)}}class F{constructor(e){this.frameListeners=new Set,this.connectionListeners=new Set,this.connected=!1,this.manuallyClosed=!1,this.everConnected=!1,this.reconnectAttempt=0,this.rendezvousUrl=e.rendezvousUrl,this.backendId=e.backendId,this.deviceIdentity=e.deviceIdentity,this.authorize=e.authorize,this.iceTransportPolicy=e.iceTransportPolicy??"all",this.createPeerConnection=e.createPeerConnection??(t=>new RTCPeerConnection(t)),this.createRendezvousClient=e.createRendezvousClient??(t=>new V({url:this.rendezvousUrl,backendId:this.backendId,ticket:t})),this.reconnectDelayMs=e.reconnectDelayMs??(t=>Math.min(15e3,500*2**Math.min(t,5))),this.schedule=e.schedule??((t,i)=>setTimeout(t,i))}get isConnected(){return this.connected&&this.channel?.readyState==="open"}get isReconnecting(){return this.reconnectTimer!==void 0}onFrame(e){return this.frameListeners.add(e),()=>this.frameListeners.delete(e)}onConnectionChange(e){return this.connectionListeners.add(e),()=>this.connectionListeners.delete(e)}async connect(e){if(this.isConnected)return;this.manuallyClosed=!1;const t=await this.authorize(),i=J(t.ticket);if(i.backendId!==this.backendId||i.deviceId!==this.deviceIdentity.deviceId)throw new Error("Connection ticket does not match this browser and backend");const s=this.createRendezvousClient(t.ticket),r=this.createPeerConnection({iceServers:t.iceServers,iceTransportPolicy:this.iceTransportPolicy}),u=r.createDataChannel(L,{ordered:!0,protocol:_});this.rendezvous=s,this.peer=r,this.channel=u,await new Promise(async(y,E)=>{let g=!1,p="",k="",w,f,m=!1;const C=[],d=o=>{g||(g=!0,this.closeInternal(),E(o instanceof Error?o:new Error(String(o))))},v=async()=>{if(!(!w||!f||m)){await z(f,{backendId:this.backendId,connectionId:p,offerSdp:k,answerSdp:w,expiresAt:i.expiresAt}),await r.setRemoteDescription({type:"answer",sdp:w}),m=!0;for(const o of C.splice(0))await r.addIceCandidate(o)}};r.onicecandidate=o=>{if(!o.candidate||!p)return;const h=o.candidate.toJSON();s.sendSignal({kind:"candidate",candidate:h.candidate??"",sdpMid:h.sdpMid??null,sdpMLineIndex:h.sdpMLineIndex??null})},r.onconnectionstatechange=()=>{(r.connectionState==="failed"||r.connectionState==="closed")&&(g?this.handleDisconnected():d(new Error(`WebRTC connection ${r.connectionState}`)))},u.onerror=()=>d(new Error("WebRTC DataChannel failed")),u.onclose=()=>{g?this.handleDisconnected():d(new Error("WebRTC DataChannel closed before authentication"))},u.onopen=()=>{R(this.deviceIdentity,t.ticket,f?.signature??"").then(o=>{if(!f)throw new Error("Backend identity binding is missing");u.send(JSON.stringify({protocolVersion:S,type:"authenticate",ticket:t.ticket,bindingSignature:f.signature,deviceSignature:o,pairingSecret:t.pairingSecret}))}).catch(d)},u.onmessage=o=>{const h=String(o.data);if(!this.connected){let l;try{l=JSON.parse(h)}catch{d(new Error("Backend returned an invalid authentication frame"));return}if(l?.protocolVersion!==S||l?.type!=="authenticated"){d(new Error(l?.message||"Backend rejected DataChannel authentication"));return}if(l.deviceId!==this.deviceIdentity.deviceId){d(new Error("Backend authenticated an unexpected browser device"));return}this.connected=!0,this.reconnectAttempt=0,g=!0,this.emitConnectionChange({connected:!0,reconnected:this.everConnected}),this.everConnected=!0,y();return}for(const l of this.frameListeners)l(h)},s.onSignal(o=>{(async()=>{if(o.kind==="description"){if(o.type!=="answer")throw new Error("Browser expected an SDP answer");w=o.sdp,await v();return}const h={candidate:o.candidate,sdpMid:o.sdpMid,sdpMLineIndex:o.sdpMLineIndex};m?await r.addIceCandidate(h):C.push(h)})().catch(d)}),s.onIdentityBinding(o=>{f=o,v().catch(d)}),s.onConnectionClosed(o=>d(new Error(o))),s.onError(o=>d(o instanceof Error?o:new Error(o.message)));try{if(p=await s.connect(),p!==i.connectionId)throw new Error("Rendezvous route does not match the connection ticket");const o=await r.createOffer();if(k=o.sdp??"",!k)throw new Error("Browser WebRTC offer is empty");await r.setLocalDescription(o),s.sendSignal({kind:"description",type:"offer",sdp:k})}catch(o){d(o)}})}send(e){if(!this.isConnected||!this.channel)throw new Error("Backend transport is not connected");this.channel.send(e)}close(e=1e3,t="Client closed"){this.manuallyClosed=!0,this.reconnectTimer&&clearTimeout(this.reconnectTimer),this.reconnectTimer=void 0,this.rendezvous?.close(t),this.closeInternal()}closeInternal(){const e=this.connected;this.connected=!1,this.channel?.close(),this.peer?.close(),this.rendezvous?.close(),this.channel=void 0,this.peer=void 0,this.rendezvous=void 0,e&&this.emitConnectionChange({connected:!1,reconnected:!1})}handleDisconnected(){this.manuallyClosed||!this.connected||(this.closeInternal(),this.scheduleReconnect())}scheduleReconnect(){if(this.manuallyClosed||this.reconnectTimer)return;const e=this.reconnectAttempt++;this.reconnectTimer=this.schedule(()=>{this.reconnectTimer=void 0,this.connect("webrtc").catch(()=>this.scheduleReconnect())},this.reconnectDelayMs(e))}emitConnectionChange(e){for(const t of this.connectionListeners)t(e)}}function J(n){const[e]=n.split(".");if(!e)throw new Error("Connection ticket is malformed");try{const t=e.replace(/-/g,"+").replace(/_/g,"/").padEnd(Math.ceil(e.length/4)*4,"="),i=T(JSON.parse(atob(t)));if(!i)throw new Error("Connection ticket claims are invalid");return i}catch{throw new Error("Connection ticket claims are malformed")}}export{F as W};