refactor(webclient): change loading view, add mutex connect for sharing.

Hee Sung Son committed Nov 3, 2025 at 10:47 UTC 53c4231f425ea34c79a72b05ad8cfcdf9d237be0
5 files changed +690 -131
Dockerfile.frontend
+1
@@ -13,6 +13,7 @@ RUN GOOS=js GOARCH=wasm go build -trimpath -ldflags="-s -w" -o bin/main.wasm ./c
13 FROM nginx
14
15 COPY --from=builder /src/cmd/webclient/index.html /usr/share/nginx/html/index.html
16 +COPY --from=builder /src/cmd/webclient/portal.mp4 /usr/share/nginx/html/portal.mp4
17 COPY --from=builder /src/cmd/webclient/service-worker.js /usr/share/nginx/html/service-worker.js
18 COPY --from=builder /src/cmd/webclient/wasm_exec.js /usr/share/nginx/html/wasm_exec.js
19 COPY --from=builder /src/bin/main.wasm /usr/share/nginx/html/main.wasm
cmd/webclient/index.html
+341 -95
@@ -1,112 +1,358 @@
1 <!DOCTYPE html>
2 <html>
3 - <head>
4 - <meta charset="UTF-8">
5 - <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 - <title>Portal Proxy Gateway</title>
7 - </head>
8 - <body>
9 - <script>
10 - // In-app browser detection and redirect handler
11 - (function() {
12 - const userAgent = navigator.userAgent.toLowerCase();
13 - const finalUrl = window.location.href;
14 -
15 - // Detect various in-app browsers
16 - const isKakao = /kakaotalk/i.test(userAgent);
17 - const isNaver = /naver/i.test(userAgent);
18 - const isFacebook = /fb|fbav|fban/i.test(userAgent);
19 - const isInstagram = /instagram/i.test(userAgent);
20 - const isLine = /line/i.test(userAgent);
21 - const isInAppBrowser = isKakao || isNaver || isFacebook || isInstagram || isLine;
22 - const isAndroid = /android/.test(userAgent);
23 - const isIOS = /ipad|iphone|ipod/.test(userAgent);
24 -
25 - if (!isInAppBrowser) {
26 - return; // Not in-app browser, proceed normally
3 +
4 +<head>
5 + <meta charset="UTF-8">
6 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
7 + <title>Portal Proxy Gateway</title>
8 + <link rel="stylesheet" as="style" crossorigin
9 + href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
10 + <style>
11 + body {
12 + margin: 0;
13 + padding: 0;
14 + font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
15 + background: #000;
16 + }
17 +
18 + #loading-screen {
19 + position: fixed;
20 + top: 0;
21 + left: 0;
22 + width: 100%;
23 + height: 100%;
24 + background: #000;
25 + display: flex;
26 + flex-direction: column;
27 + justify-content: center;
28 + align-items: center;
29 + z-index: 9999;
30 + transition: opacity 0.5s ease-out;
31 + }
32 +
33 + #loading-screen.hide {
34 + opacity: 0;
35 + pointer-events: none;
36 + }
37 +
38 + #loading-logo {
39 + position: relative;
40 + max-width: 500px;
41 + width: 90%;
42 + margin-bottom: 40px;
43 + /* 컨테이너 쿼리를 위한 설정 */
44 + container-type: inline-size;
45 + }
46 +
47 + #loading-logo video {
48 + width: 100%;
49 + height: auto;
50 + display: block;
51 + }
52 +
53 + .logo-overlay {
54 + position: absolute;
55 + top: 0;
56 + left: 0;
57 + width: 100%;
58 + height: 100%;
59 + display: flex;
60 + flex-direction: column;
61 + justify-content: space-between;
62 + align-items: center;
63 + padding: 10% 1%;
64 + box-sizing: border-box;
65 + pointer-events: none;
66 + }
67 +
68 + .logo-title {
69 + font-family: 'Pretendard', sans-serif;
70 + /* 51.5px at 500px container = 51.5/500*100 = 10.3cqw */
71 + font-size: 10.3cqw;
72 + font-weight: 600;
73 + color: #ffffff;
74 + letter-spacing: 0.20em;
75 + text-align: center;
76 + text-shadow: 0 0 20px rgba(255, 255, 255, 0.5);
77 + margin: 0;
78 + }
79 +
80 + .logo-subtitle {
81 + font-family: 'Pretendard', sans-serif;
82 + /* 18px at 500px container = 18/500*100 = 3.6cqw */
83 + font-size: 3.6cqw;
84 + font-weight: 600;
85 + color: #ffffff;
86 + letter-spacing: -0.05em;
87 + text-align: center;
88 + text-shadow: 0 0 15px rgba(255, 255, 255, 0.4);
89 + margin: 0;
90 + }
91 +
92 + .loading-bar-container {
93 + width: 300px;
94 + max-width: 80%;
95 + height: 4px;
96 + background: rgba(255, 255, 255, 0.1);
97 + border-radius: 2px;
98 + overflow: hidden;
99 + position: relative;
100 + }
101 +
102 + .loading-bar {
103 + height: 100%;
104 + background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
105 + border-radius: 2px;
106 + animation: loading 1.5s ease-in-out infinite;
107 + }
108 +
109 + @keyframes loading {
110 + 0% {
111 + width: 0%;
112 + margin-left: 0%;
113 + }
114 +
115 + 50% {
116 + width: 50%;
117 + margin-left: 25%;
118 + }
119 +
120 + 100% {
121 + width: 0%;
122 + margin-left: 100%;
123 + }
124 + }
125 +
126 + .loading-text {
127 + color: rgba(255, 255, 255, 0.7);
128 + margin-top: 20px;
129 + font-size: 14px;
130 + text-align: center;
131 + transition: color 0.3s ease;
132 + }
133 +
134 + .loading-text.error {
135 + color: #ef4444;
136 + }
137 + </style>
138 +</head>
139 +
140 +<body>
141 + <!-- Loading Screen -->
142 + <div id="loading-screen">
143 + <div id="loading-logo">
144 + <video autoplay loop muted playsinline>
145 + <source src="/portal.mp4" type="video/mp4">
146 + </video>
147 + <div class="logo-overlay">
148 + <h1 class="logo-title">PORTAL</h1>
149 + <p class="logo-subtitle">LOCAL TO WEB. INSTANT ACCESS.</p>
150 + </div>
151 + </div>
152 + <div class="loading-bar-container" id="loading-bar-container">
153 + <div class="loading-bar"></div>
154 + </div>
155 + <div class="loading-text" id="loading-text">Initializing Portal Network...</div>
156 + </div>
157 +
158 + <script>
159 + // In-app browser detection and redirect handler
160 + (function () {
161 + const userAgent = navigator.userAgent.toLowerCase();
162 + const finalUrl = window.location.href;
163 +
164 + // Detect various in-app browsers
165 + const isKakao = /kakaotalk/i.test(userAgent);
166 + const isNaver = /naver/i.test(userAgent);
167 + const isFacebook = /fb|fbav|fban/i.test(userAgent);
168 + const isInstagram = /instagram/i.test(userAgent);
169 + const isLine = /line/i.test(userAgent);
170 + const isInAppBrowser = isKakao || isNaver || isFacebook || isInstagram || isLine;
171 + const isAndroid = /android/.test(userAgent);
172 + const isIOS = /ipad|iphone|ipod/.test(userAgent);
173 +
174 + if (!isInAppBrowser) {
175 + return; // Not in-app browser, proceed normally
176 + }
177 +
178 + // Handle redirection to external browser
179 + if (isAndroid) {
180 + if (isKakao) {
181 + location.href = 'kakaotalk://web/openExternal?url=' + encodeURIComponent(finalUrl);
182 + } else {
183 + // Use Intent to open in Chrome or default browser
184 + const intentUrl = 'intent://' + finalUrl.replace(/^https?:\/\//, '') + '#Intent;scheme=https;action=android.intent.action.VIEW;end';
185 + location.href = intentUrl;
186 }
28 -
29 - // Handle redirection to external browser
30 - if (isAndroid) {
31 - if (isKakao) {
32 - location.href = 'kakaotalk://web/openExternal?url=' + encodeURIComponent(finalUrl);
33 - } else {
34 - // Use Intent to open in Chrome or default browser
35 - const intentUrl = 'intent://' + finalUrl.replace(/^https?:\/\//, '') + '#Intent;scheme=https;action=android.intent.action.VIEW;end';
36 - location.href = intentUrl;
37 - }
38 - return; // Stop execution
39 - } else if (isIOS) {
40 - if (isKakao) {
41 - location.href = 'kakaotalk://web/openExternal?url=' + encodeURIComponent(finalUrl);
42 - // Set up auto-close listener for iOS KakaoTalk
43 - document.addEventListener("visibilitychange", () => {
44 - if(document.visibilityState == "visible") {
45 - location.href = 'kakaoweb://closeBrowser';
46 - }
47 - });
48 - } else {
49 - // For other iOS in-app browsers, try to open in Safari
50 - // Display a message to user with a link
51 - document.body.innerHTML = `
52 - <div style="padding: 20px; text-align: center; font-family: sans-serif;">
53 - <h2>Open in External Browser</h2>
54 - <p>This page needs to be opened in an external browser.</p>
55 - <p><a href="${finalUrl}" target="_blank" style="display: inline-block; margin: 20px 0; padding: 15px 30px; background: #007AFF; color: white; text-decoration: none; border-radius: 8px; font-size: 16px;">Open in Safari</a></p>
56 - <p style="font-size: 14px; color: #666;">Or select "Open in Safari" or "Open in External Browser" from the menu in the upper right corner.</p>
57 - </div>
58 - `;
59 - }
60 - return; // Stop execution
187 + return; // Stop execution
188 + } else if (isIOS) {
189 + if (isKakao) {
190 + location.href = 'kakaotalk://web/openExternal?url=' + encodeURIComponent(finalUrl);
191 + // Set up auto-close listener for iOS KakaoTalk
192 + document.addEventListener("visibilitychange", () => {
193 + if (document.visibilityState == "visible") {
194 + location.href = 'kakaoweb://closeBrowser';
195 + }
196 + });
197 + } else {
198 + // For other iOS in-app browsers, show message in loading screen
199 + updateLoadingText('⚠️ Please open in external browser (Safari)');
200 + document.getElementById('loading-text').classList.add('error');
201 }
62 - })();
63 - </script>
64 - <h1>Portal Proxy Gateway</h1>
65 - <hr/>
66 - <p id="status">Please wait for the service worker to register...</p>
67 - <script>
68 - async function registerServiceWorker() {
69 - const reloadFn = async () => {
70 - const resp = await fetch('/e8c2c70c-ec4a-40b2-b8af-d5638264f831');
202 + return; // Stop execution
203 + }
204 + })();
205 + </script>
206 + <script>
207 + // Update loading text
208 + function updateLoadingText(message, isError = false) {
209 + const loadingText = document.getElementById('loading-text');
210 + if (loadingText) {
211 + loadingText.textContent = message;
212 + if (isError) {
213 + loadingText.classList.add('error');
214 + } else {
215 + loadingText.classList.remove('error');
216 + }
217 + }
218 + }
219 +
220 + // Show error in loading text
221 + function showError(error, context = '') {
222 + let errorMessage = '';
223 + if (typeof error === 'string') {
224 + errorMessage = error;
225 + } else if (error instanceof Error) {
226 + errorMessage = `${error.message}`;
227 + } else {
228 + errorMessage = 'An error occurred';
229 + }
230 +
231 + if (context) {
232 + errorMessage = `⚠️ ${context}: ${errorMessage}`;
233 + } else {
234 + errorMessage = `⚠️ ${errorMessage}`;
235 + }
236 +
237 + updateLoadingText(errorMessage, true);
238 + console.error(`[Portal Error - ${context}]`, error);
239 + }
240 +
241 + // Global error handler
242 + window.addEventListener('error', (event) => {
243 + showError(event.error || event.message, 'Error');
244 + event.preventDefault();
245 + });
246 +
247 + // Unhandled promise rejection handler
248 + window.addEventListener('unhandledrejection', (event) => {
249 + showError(event.reason, 'Promise Error');
250 + event.preventDefault();
251 + });
252 +
253 + // Listen for errors from Service Worker
254 + if ('serviceWorker' in navigator) {
255 + navigator.serviceWorker.addEventListener('message', (event) => {
256 + if (event.data && event.data.type === 'SW_ERROR') {
257 + const error = event.data.error;
258 + showError(error.message, 'Service Worker Error');
259 + }
260 + });
261 + }
262 +
263 + async function registerServiceWorker() {
264 + let retryCount = 0;
265 + const maxRetries = 30; // 3초 (30 × 100ms)
266 +
267 + const checkWASMReady = async () => {
268 + try {
269 + const resp = await fetch('/e8c2c70c-ec4a-40b2-b8af-d5638264f831', {
270 + cache: 'no-store'
271 + });
272 const text = await resp.text();
273 +
274 if (text === 'ACK-e8c2c70c-ec4a-40b2-b8af-d5638264f831') {
73 - showStatus('Service Worker is ready.', 'success');
74 - window.location.reload();
75 - } else if (text === 'NAK-e8c2c70c-ec4a-40b2-b8af-d5638264f831'){
76 - setTimeout(reloadFn, 100);
77 - } else {
78 - showStatus('Something went wrong.', 'error');
79 - setTimeout(()=>{
275 + updateLoadingText('Portal Network Ready!');
276 + setTimeout(() => {
277 window.location.reload();
81 - }, 1000);
278 + }, 500);
279 + } else if (text === 'NAK-e8c2c70c-ec4a-40b2-b8af-d5638264f831') {
280 + retryCount++;
281 + if (retryCount > maxRetries) {
282 + throw new Error(`WASM initialization timeout after ${maxRetries} retries`);
283 + }
284 + updateLoadingText(`Initializing WASM... (${retryCount}/${maxRetries})`);
285 + setTimeout(checkWASMReady, 100);
286 + } else {
287 + if (text.includes('<!DOCTYPE') || text.includes('<html>')) {
288 + throw new Error('Service Worker not active - please refresh');
289 + } else {
290 + throw new Error(`Unexpected response: ${text.substring(0, 50)}`);
291 + }
292 }
293 + } catch (error) {
294 + showError(error, 'Connection');
295 }
296 + }
297
85 - try {
86 - if (!('serviceWorker'in navigator)) {
87 - throw new Error('This browser does not support Service Worker.');
298 + const waitForController = () => {
299 + return new Promise((resolve, reject) => {
300 + if (navigator.serviceWorker.controller) {
301 + console.log('[Portal] Service Worker already active');
302 + resolve();
303 + return;
304 }
305
90 - showStatus('Registering Service Worker...', 'loading');
306 + let timeoutId;
307 + const onControllerChange = () => {
308 + console.log('[Portal] Service Worker activated');
309 + clearTimeout(timeoutId);
310 + navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange);
311 + resolve();
312 + };
313
92 - // Register service worker
93 - const registration = await navigator.serviceWorker.register('/service-worker.js', {
94 - scope: '/'
95 - });
96 - showStatus('Service Worker registered successfully, Wait a moment...', 'success');
97 - setTimeout(reloadFn, 100);
98 - } catch (error) {
99 - showStatus('Service Worker registration failed: ' + error.message, 'error');
314 + navigator.serviceWorker.addEventListener('controllerchange', onControllerChange);
315 +
316 + timeoutId = setTimeout(() => {
317 + navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange);
318 + reject(new Error('Service Worker activation timeout'));
319 + }, 5000);
320 + });
321 + };
322 +
323 + try {
324 + if (!('serviceWorker' in navigator)) {
325 + throw new Error('Service Worker not supported in this browser');
326 }
101 - }
327
103 - function showStatus(message, status) {
104 - const statusElement = document.getElementById('status');
105 - statusElement.textContent = message;
106 - statusElement.className = status;
328 + updateLoadingText('Registering Service Worker...');
329 +
330 + const registration = await navigator.serviceWorker.register('/service-worker.js', {
331 + scope: '/'
332 + });
333 +
334 + console.log('[Portal] Service Worker registered');
335 +
336 + updateLoadingText('Activating Service Worker...');
337 +
338 + await navigator.serviceWorker.ready;
339 + console.log('[Portal] Service Worker ready');
340 +
341 + updateLoadingText('Waiting for activation...');
342 +
343 + await waitForController();
344 +
345 + updateLoadingText('Connecting to Portal Network...');
346 + console.log('[Portal] Checking WASM initialization...');
347 +
348 + setTimeout(checkWASMReady, 100);
349 + } catch (error) {
350 + showError(error, 'Initialization');
351 }
352 + }
353 +
354 + registerServiceWorker();
355 + </script>
356 +</body>
357
109 - registerServiceWorker();
110 - </script>
111 - </body>
112 -</html>
358 +</html>
\ No newline at end of file
cmd/webclient/main_js.go
+222 -4
@@ -21,7 +21,9 @@ import (
21
22 "github.com/gorilla/websocket"
23 "github.com/gosuda/portal/cmd/webclient/httpjs"
24 + "github.com/gosuda/portal/portal/core/cryptoops"
25 "github.com/gosuda/portal/sdk"
26 + "github.com/hashicorp/yamux"
27 "github.com/rs/zerolog"
28 "github.com/rs/zerolog/log"
29 "golang.org/x/net/idna"
@@ -30,17 +32,229 @@ import (
32 var (
33 bootstrapServers = []string{"ws://localhost:4017/relay", "wss://portal.gosuda.org/relay"}
34 rdClient *sdk.RDClient
35 +
36 + // Connection pool for reusing encrypted channels with yamux multiplexing
37 + muxSessions sync.Map // map[string]*muxSession
38 + muxSessionsLock sync.Mutex
39 +)
40 +
41 +const (
42 + // poolCleanupInterval is how often to clean up stale mux sessions
43 + poolCleanupInterval = 1 * time.Minute
44 + // poolIdleTimeout is how long a mux session can be idle before cleanup
45 + poolIdleTimeout = 5 * time.Minute
46 )
47
48 +// muxSession wraps a yamux session over an encrypted connection
49 +type muxSession struct {
50 + session *yamux.Session
51 + leaseID string
52 + cred *cryptoops.Credential
53 + refCount int
54 + lastUsed time.Time
55 + mu sync.Mutex
56 + closed bool
57 +}
58 +
59 +// getOrCreateMuxSession gets an existing yamux session or creates a new one with E2EE handshake
60 +func getOrCreateMuxSession(ctx context.Context, leaseID string) (*muxSession, error) {
61 + // Try to get existing session from pool
62 + if val, ok := muxSessions.Load(leaseID); ok {
63 + session := val.(*muxSession)
64 + session.mu.Lock()
65 + defer session.mu.Unlock()
66 +
67 + // Check if session is still alive
68 + if !session.closed && !session.session.IsClosed() {
69 + session.refCount++
70 + session.lastUsed = time.Now()
71 +
72 + log.Info().
73 + Str("leaseID", leaseID).
74 + Int("refCount", session.refCount).
75 + Msg("[WebClient] Reusing existing mux session (no key exchange)")
76 +
77 + return session, nil
78 + }
79 +
80 + // Session is dead, remove it
81 + log.Warn().Str("leaseID", leaseID).Msg("[WebClient] Existing mux session is closed, creating new one")
82 + muxSessions.Delete(leaseID)
83 + }
84 +
85 + // Create new session with double-checked locking
86 + muxSessionsLock.Lock()
87 + defer muxSessionsLock.Unlock()
88 +
89 + // Double-check after acquiring lock
90 + if val, ok := muxSessions.Load(leaseID); ok {
91 + session := val.(*muxSession)
92 + session.mu.Lock()
93 + defer session.mu.Unlock()
94 + if !session.closed && !session.session.IsClosed() {
95 + session.refCount++
96 + session.lastUsed = time.Now()
97 + return session, nil
98 + }
99 + muxSessions.Delete(leaseID)
100 + }
101 +
102 + log.Info().
103 + Str("leaseID", leaseID).
104 + Msg("[WebClient] Creating new encrypted channel with E2EE key exchange")
105 +
106 + // Create new credential for this connection
107 + cred := sdk.NewCredential()
108 +
109 + // Establish encrypted connection (this does the expensive key exchange)
110 + rdConn, err := rdClient.Dial(cred, leaseID, "http/1.1")
111 + if err != nil {
112 + return nil, fmt.Errorf("failed to dial: %w", err)
113 + }
114 +
115 + // Create yamux client session on top of the encrypted connection
116 + yamuxConfig := yamux.DefaultConfig()
117 + yamuxConfig.Logger = nil // Disable yamux logging
118 + yamuxSession, err := yamux.Client(rdConn, yamuxConfig)
119 + if err != nil {
120 + rdConn.Close()
121 + return nil, fmt.Errorf("failed to create yamux session: %w", err)
122 + }
123 +
124 + session := &muxSession{
125 + session: yamuxSession,
126 + leaseID: leaseID,
127 + cred: cred,
128 + refCount: 1,
129 + lastUsed: time.Now(),
130 + closed: false,
131 + }
132 +
133 + muxSessions.Store(leaseID, session)
134 +
135 + log.Info().
136 + Str("leaseID", leaseID).
137 + Msg("[WebClient] Yamux session created successfully over encrypted channel")
138 +
139 + return session, nil
140 +}
141 +
142 +// openStream opens a new stream on the mux session
143 +func (m *muxSession) openStream() (net.Conn, error) {
144 + m.mu.Lock()
145 + defer m.mu.Unlock()
146 +
147 + if m.closed || m.session.IsClosed() {
148 + return nil, fmt.Errorf("mux session is closed")
149 + }
150 +
151 + stream, err := m.session.OpenStream()
152 + if err != nil {
153 + return nil, fmt.Errorf("failed to open yamux stream: %w", err)
154 + }
155 +
156 + log.Debug().
157 + Str("leaseID", m.leaseID).
158 + Uint32("streamID", stream.StreamID()).
159 + Msg("[WebClient] Opened new stream on existing mux session")
160 +
161 + return stream, nil
162 +}
163 +
164 +// release decrements the reference count
165 +func (m *muxSession) release() {
166 + m.mu.Lock()
167 + defer m.mu.Unlock()
168 +
169 + m.refCount--
170 + m.lastUsed = time.Now()
171 +
172 + log.Debug().
173 + Str("leaseID", m.leaseID).
174 + Int("refCount", m.refCount).
175 + Msg("[WebClient] Released mux session reference")
176 +}
177 +
178 +// close closes the mux session
179 +func (m *muxSession) close() error {
180 + m.mu.Lock()
181 + defer m.mu.Unlock()
182 +
183 + if m.closed {
184 + return nil
185 + }
186 +
187 + m.closed = true
188 + return m.session.Close()
189 +}
190 +
191 +// cleanupIdleMuxSessions periodically cleans up idle mux sessions
192 +func cleanupIdleMuxSessions() {
193 + ticker := time.NewTicker(poolCleanupInterval)
194 + defer ticker.Stop()
195 +
196 + for range ticker.C {
197 + now := time.Now()
198 + var toDelete []string
199 +
200 + muxSessions.Range(func(key, value interface{}) bool {
201 + leaseID := key.(string)
202 + session := value.(*muxSession)
203 +
204 + session.mu.Lock()
205 + idle := now.Sub(session.lastUsed)
206 + shouldDelete := (session.refCount == 0 && idle > poolIdleTimeout) || session.closed || session.session.IsClosed()
207 + session.mu.Unlock()
208 +
209 + if shouldDelete {
210 + toDelete = append(toDelete, leaseID)
211 + }
212 +
213 + return true
214 + })
215 +
216 + for _, leaseID := range toDelete {
217 + if val, ok := muxSessions.LoadAndDelete(leaseID); ok {
218 + session := val.(*muxSession)
219 + session.close()
220 + log.Info().
221 + Str("leaseID", leaseID).
222 + Msg("[WebClient] Cleaned up idle mux session")
223 + }
224 + }
225 + }
226 +}
227 +
228 var rdDialer = func(ctx context.Context, network, address string) (net.Conn, error) {
229 address = strings.TrimSuffix(address, ":80")
230 address = strings.TrimSuffix(address, ":443")
38 - cred := sdk.NewCredential()
39 - conn, err := rdClient.Dial(cred, address, "http/1.1")
231 +
232 + // Get or create mux session (does key exchange only once)
233 + session, err := getOrCreateMuxSession(ctx, address)
234 + if err != nil {
235 + return nil, fmt.Errorf("failed to get mux session: %w", err)
236 + }
237 +
238 + // Open a new stream on the mux session (no key exchange, just new yamux stream)
239 + stream, err := session.openStream()
240 if err != nil {
41 - return nil, err
241 + // If stream opening fails, try to create a new session
242 + log.Warn().Err(err).Str("leaseID", address).Msg("[WebClient] Failed to open stream, removing stale session")
243 + muxSessions.Delete(address)
244 +
245 + // Retry with a fresh session
246 + session, err = getOrCreateMuxSession(ctx, address)
247 + if err != nil {
248 + return nil, fmt.Errorf("failed to get mux session after retry: %w", err)
249 + }
250 +
251 + stream, err = session.openStream()
252 + if err != nil {
253 + return nil, fmt.Errorf("failed to open stream after retry: %w", err)
254 + }
255 }
43 - return conn, nil
256 +
257 + return stream, nil
258 }
259
260 var client = &http.Client{
@@ -514,6 +728,10 @@ func main() {
728 }
729 defer rdClient.Close()
730
731 + // Start cleanup goroutine for idle mux sessions
732 + go cleanupIdleMuxSessions()
733 + log.Info().Msg("[WebClient] Started mux session cleanup goroutine")
734 +
735 // Initialize WebSocket manager
736 wsManager := NewWebSocketManager()
737 proxy := &Proxy{
cmd/webclient/portal.mp4
Binary files /dev/null and b/cmd/webclient/portal.mp4 differ
cmd/webclient/service-worker.js
+126 -32
@@ -4,70 +4,164 @@ const wasm_URL = "/main.wasm";
4 importScripts(wasm_exec_URL);
5
6 let loading = false;
7 +let initError = null;
8 +
9 +// Send error to all clients
10 +async function notifyClientsOfError(error) {
11 + const clients = await self.clients.matchAll();
12 + const errorMessage = {
13 + type: 'SW_ERROR',
14 + error: {
15 + name: error.name,
16 + message: error.message,
17 + stack: error.stack
18 + }
19 + };
20 +
21 + for (const client of clients) {
22 + client.postMessage(errorMessage);
23 + }
24 +}
25
26 async function init() {
27 if (loading) return;
28 loading = true;
29 try {
30 await runWASM();
31 + initError = null;
32 } catch (error) {
14 - console.error("Error initializing WASM:", error);
33 + console.error("[SW] Error initializing WASM:", error);
34 + initError = error;
35 + await notifyClientsOfError(error);
36 + throw error; // Re-throw to prevent further processing
37 + } finally {
38 + loading = false;
39 }
16 - loading = false;
40 }
41
42 async function runWASM() {
20 - if (typeof __go_jshttp !== 'undefined') return;
21 -
22 - const go = new Go();
23 - const cache = await caches.open("WASM_Cache_v1");
24 - let wasm_file;
25 - const cache_wasm = await cache.match(wasm_URL);
26 - if (cache_wasm) {
27 - wasm_file = await cache_wasm.arrayBuffer();
28 - } else {
29 - wasm_file = await (await fetch(wasm_URL)).arrayBuffer();
43 + if (typeof __go_jshttp !== 'undefined') {
44 + return;
45 + }
46 +
47 + try {
48 + const go = new Go();
49 +
50 + const cache = await caches.open("WASM_Cache_v1");
51 +
52 + let wasm_file;
53 + const cache_wasm = await cache.match(wasm_URL);
54 +
55 + if (cache_wasm) {
56 + wasm_file = await cache_wasm.arrayBuffer();
57 + } else {
58 + const response = await fetch(wasm_URL);
59 + if (!response.ok) {
60 + throw new Error(`Failed to fetch WASM: ${response.status} ${response.statusText}`);
61 + }
62 + wasm_file = await response.arrayBuffer();
63 + }
64 +
65 + const instance = await WebAssembly.instantiate(wasm_file, go.importObject);
66 +
67 + go.run(instance.instance);
68 +
69 + } catch (error) {
70 + console.error('[SW] WASM initialization failed:', error);
71 + throw new Error(`WASM Initialization: ${error.message}`);
72 }
31 - const instance = await WebAssembly.instantiate(wasm_file, go.importObject);
32 - go.run(instance.instance);
73 }
74
75 self.addEventListener('install', (e) => {
76 self.skipWaiting();
77 async function LoadCache() {
38 - const cache = await caches.open("WASM_Cache_v1");
39 - await cache.addAll([
40 - wasm_URL,
41 - wasm_exec_URL,
42 - ]);
78 + try {
79 + const cache = await caches.open("WASM_Cache_v1");
80 + await cache.addAll([
81 + wasm_URL,
82 + wasm_exec_URL,
83 + ]);
84 + } catch (error) {
85 + console.error('[SW] Cache loading failed:', error);
86 + throw new Error(`Cache Loading: ${error.message}`);
87 + }
88 }
89 e.waitUntil(LoadCache());
90 });
91
47 -self.addEventListener('activate', async (e) => {
48 - await init();
49 - await self.clients.claim();
92 +self.addEventListener('activate', (e) => {
93 + e.waitUntil((async () => {
94 + try {
95 + // Claim clients first to take control immediately
96 + await self.clients.claim();
97 +
98 + // Then initialize WASM in background (don't block activation)
99 + init().catch(error => {
100 + console.error('[SW] WASM initialization failed after activation:', error);
101 + notifyClientsOfError(error);
102 + });
103 +
104 + } catch (error) {
105 + console.error('[SW] Activation failed:', error);
106 + await notifyClientsOfError(error);
107 + }
108 + })());
109 });
110
52 -self.addEventListener('fetch', async (e) => {
53 - console.log(e.request);
111 +self.addEventListener('fetch', (e) => {
112 const url = new URL(e.request.url);
113
114 + // Skip non-origin requests
115 if (url.origin !== self.location.origin) {
116 e.respondWith(fetch(e.request));
117 return;
118 }
119
61 - if (typeof __go_jshttp == 'undefined') {
62 - await init();
120 + // Health check endpoint - check WASM status
121 + if (url.pathname === '/e8c2c70c-ec4a-40b2-b8af-d5638264f831') {
122 + e.respondWith((async () => {
123 +
124 + // Try to initialize if not ready
125 + if (typeof __go_jshttp === 'undefined' && !loading) {
126 + try {
127 + await init();
128 + } catch (error) {
129 + console.error('[SW] Health check init failed:', error);
130 + }
131 + }
132 +
133 + // Return status based on WASM availability
134 + if (typeof __go_jshttp !== 'undefined') {
135 + return new Response("ACK-e8c2c70c-ec4a-40b2-b8af-d5638264f831", { status: 200 });
136 + } else {
137 + return new Response("NAK-e8c2c70c-ec4a-40b2-b8af-d5638264f831", { status: 503 });
138 + }
139 + })());
140 + return;
141 }
142
65 - if (url.pathname === '/e8c2c70c-ec4a-40b2-b8af-d5638264f831') {
66 - if (typeof __go_jshttp == 'undefined') {
67 - e.respondWith(new Response("NAK-e8c2c70c-ec4a-40b2-b8af-d5638264f831", { status: 500 }))
68 - return;
69 - }
70 - e.respondWith(new Response("ACK-e8c2c70c-ec4a-40b2-b8af-d5638264f831", { status: 200 }));
143 + // Serve portal.mp4 from cache or fetch from origin
144 + if (url.pathname === '/portal.mp4') {
145 + e.respondWith((async () => {
146 + try {
147 + // Try to get from cache first
148 + const cache = await caches.open("WASM_Cache_v1");
149 + const cachedResponse = await cache.match('/portal.mp4');
150 + if (cachedResponse) {
151 + return cachedResponse;
152 + }
153 +
154 + // Fetch from network and cache it
155 + const response = await fetch(e.request);
156 + if (response.ok) {
157 + cache.put('/portal.mp4', response.clone());
158 + }
159 + return response;
160 + } catch (error) {
161 + console.error('Failed to fetch portal.mp4:', error);
162 + return new Response('Not Found', { status: 404 });
163 + }
164 + })());
165 return;
166 }
167