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