feat: refactor webclient into portal proxy

Simplify the webclient HTML by removing styled UI elements, updating title to "Portal Proxy", and changing status messages to English. Refactor the Go code to remove the embedded test route and replace main function with proxy client initialization using sdk and zerolog, connecting to local relay at ws://localhost:4017/relay for streamlined proxy functionality.

lemon-mint committed Oct 30, 2025 at 23:00 UTC 2207501b2bdf7f763487523d8b167564617ef6e8
3 files changed +127 -312
cmd/webclient/index.html
+18 -117
@@ -3,143 +3,44 @@
3 <head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 - <title>Portal WebClient</title>
7 - <style>
8 - body {
9 - font-family: Arial, sans-serif;
10 - max-width: 800px;
11 - margin: 50px auto;
12 - padding: 20px;
13 - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
14 - color: white;
15 - }
16 - .container {
17 - background: rgba(255, 255, 255, 0.1);
18 - backdrop-filter: blur(10px);
19 - border-radius: 15px;
20 - padding: 30px;
21 - box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37);
22 - }
23 - h1 {
24 - margin-top: 0;
25 - text-align: center;
26 - }
27 - #status {
28 - padding: 15px;
29 - margin: 20px 0;
30 - background: rgba(255, 255, 255, 0.2);
31 - border-radius: 8px;
32 - text-align: center;
33 - }
34 - .loading {
35 - display: inline-block;
36 - width: 20px;
37 - height: 20px;
38 - border: 3px solid rgba(255,255,255,.3);
39 - border-radius: 50%;
40 - border-top-color: #fff;
41 - animation: spin 1s ease-in-out infinite;
42 - margin-right: 10px;
43 - }
44 - @keyframes spin {
45 - to { transform: rotate(360deg); }
46 - }
47 - .success {
48 - color: #4ade80;
49 - }
50 - .error {
51 - color: #f87171;
52 - }
53 - button {
54 - background: rgba(255, 255, 255, 0.3);
55 - color: white;
56 - border: 2px solid rgba(255, 255, 255, 0.5);
57 - padding: 12px 24px;
58 - border-radius: 8px;
59 - cursor: pointer;
60 - font-size: 16px;
61 - transition: all 0.3s;
62 - display: block;
63 - margin: 20px auto;
64 - }
65 - button:hover {
66 - background: rgba(255, 255, 255, 0.5);
67 - transform: translateY(-2px);
68 - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
69 - }
70 - button:disabled {
71 - opacity: 0.5;
72 - cursor: not-allowed;
73 - }
74 - </style>
6 + <title>Portal Proxy</title>
7 </head>
8 <body>
77 - <div class="container">
78 - <h1>🚀 Portal WebClient</h1>
79 - <div id="status">
80 - <div class="loading"></div>
81 - <span id="status-text">Service Worker 등록 중...</span>
82 - </div>
83 - <button id="start-btn" disabled>테스트 페이지로 이동</button>
84 - </div>
9 + <h1>Portal Proxy</h1>
10 + <hr/>
11 + <p id="status">Please wait for the service worker to register...</p>
12
13 <script>
87 - const statusEl = document.getElementById('status');
88 - const statusText = document.getElementById('status-text');
89 - const startBtn = document.getElementById('start-btn');
90 -
91 - function updateStatus(text, type = 'loading') {
92 - statusText.textContent = text;
93 - statusEl.className = type;
94 - if (type === 'loading') {
95 - statusEl.innerHTML = '<div class="loading"></div><span id="status-text">' + text + '</span>';
96 - }
97 - }
98 -
14 async function registerServiceWorker() {
15 try {
16 if (!('serviceWorker' in navigator)) {
102 - throw new Error('이 브라우저는 Service Worker를 지원하지 않습니다.');
17 + throw new Error('This browser does not support Service Worker.');
18 }
19
105 - updateStatus('Service Worker 등록 중...');
20 + showStatus('Registering Service Worker...', 'loading');
21
22 // Register service worker
23 const registration = await navigator.serviceWorker.register('/service-worker.js', {
24 scope: '/'
25 });
26 + showStatus('Service Worker registered successfully', 'success');
27
112 - updateStatus('Service Worker 활성화 대기 중...');
113 -
114 - // Wait for service worker to be ready
115 - await navigator.serviceWorker.ready;
116 -
117 - updateStatus('✅ Service Worker 활성화 완료!', 'success');
118 -
119 - // Enable the start button
120 - startBtn.disabled = false;
121 - startBtn.onclick = () => {
122 - window.location.href = '/';
123 - };
124 -
125 - console.log('Service Worker registered successfully:', registration);
28 + setTimeout(() => {
29 + showStatus('Reloading page...', 'loading');
30 + location.reload();
31 + }, 500);
32 } catch (error) {
127 - console.error('Service Worker registration failed:', error);
128 - updateStatus('❌ Service Worker 등록 실패: ' + error.message, 'error');
33 + showStatus('Service Worker registration failed: ' + error.message, 'error');
34 }
35 }
36
132 - // Start registration
133 - registerServiceWorker();
37 + function showStatus(message, status) {
38 + const statusElement = document.getElementById('status');
39 + statusElement.textContent = message;
40 + statusElement.className = status;
41 + }
42
135 - // Handle service worker updates
136 - navigator.serviceWorker.addEventListener('controllerchange', () => {
137 - console.log('Service Worker controller changed');
138 - updateStatus('🔄 Service Worker 업데이트됨. 새로고침 중...', 'success');
139 - setTimeout(() => {
140 - window.location.reload();
141 - }, 1000);
142 - });
43 + registerServiceWorker();
44 </script>
45 </body>
46 </html>
\ No newline at end of file
cmd/webclient/main_js.go
+67 -153
@@ -1,181 +1,95 @@
1 package main
2
3 import (
4 + "context"
5 + "io"
6 + "net"
7 "net/http"
8 + "os"
9 "runtime"
10 + "strings"
11 "syscall/js"
12 + "time"
13
14 "github.com/gosuda/portal/cmd/webclient/httpjs"
15 + "github.com/gosuda/portal/sdk"
16 + "github.com/rs/zerolog"
17 + "github.com/rs/zerolog/log"
18 )
19
11 -func main() {
12 - if runtime.Compiler == "tinygo" || runtime.GOARCH != "wasm" {
13 - return
14 - }
15 -
16 - // Create HTTP handler
17 - mux := http.NewServeMux()
20 +var (
21 + bootstrapServers = []string{"ws://localhost:4017/relay"}
22 + rdClient *sdk.RDClient
23 + initDone chan struct{} = make(chan struct{}, 1)
24 +)
25
19 - // Register test route
20 - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
21 - w.Header().Set("Content-Type", "text/html; charset=utf-8")
22 - w.WriteHeader(http.StatusOK)
23 - w.Write([]byte(`
24 -<!DOCTYPE html>
25 -<html>
26 -<head>
27 - <meta charset="UTF-8">
28 - <title>Portal WebClient - HTTP JS Test</title>
29 - <style>
30 - body {
31 - font-family: Arial, sans-serif;
32 - max-width: 800px;
33 - margin: 50px auto;
34 - padding: 20px;
35 - }
36 - .test-section {
37 - margin: 20px 0;
38 - padding: 15px;
39 - border: 1px solid #ddd;
40 - border-radius: 5px;
41 - }
42 - button {
43 - padding: 10px 20px;
44 - margin: 5px;
45 - cursor: pointer;
46 - }
47 - #output {
48 - background: #f5f5f5;
49 - padding: 10px;
50 - margin-top: 10px;
51 - border-radius: 3px;
52 - white-space: pre-wrap;
53 - font-family: monospace;
54 - }
55 - </style>
56 -</head>
57 -<body>
58 - <h1>🚀 Portal WebClient - HTTP JS Test</h1>
59 - <p>Service Worker와 Go WASM이 성공적으로 로드되었습니다!</p>
60 -
61 - <div class="test-section">
62 - <h2>HTTP 요청 테스트</h2>
63 - <button onclick="testGet()">GET 요청</button>
64 - <button onclick="testPost()">POST 요청</button>
65 - <button onclick="testStream()">스트리밍 테스트</button>
66 - <div id="output"></div>
67 - </div>
26 +var client = &http.Client{
27 + Timeout: time.Second * 30,
28 + Transport: &http.Transport{
29 + MaxIdleConns: 1000,
30 + MaxIdleConnsPerHost: 100,
31 + DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
32 + address = strings.TrimSuffix(address, ":80")
33 + cred := sdk.NewCredential()
34 + conn, err := rdClient.Dial(cred, address, "http/1.1")
35 + if err != nil {
36 + return nil, err
37 + }
38 + return conn, nil
39 + },
40 + },
41 +}
42
69 - <script>
70 - const output = document.getElementById('output');
71 -
72 - function log(message) {
73 - output.textContent += message + '\n';
74 - }
75 -
76 - async function testGet() {
77 - output.textContent = '';
78 - log('GET 요청 테스트 시작...');
79 - try {
80 - const response = await fetch('/api/test');
81 - log('Status: ' + response.status);
82 - log('Headers: ' + JSON.stringify(Object.fromEntries(response.headers)));
83 - const text = await response.text();
84 - log('Body: ' + text);
85 - } catch (err) {
86 - log('Error: ' + err.message);
87 - }
88 - }
89 -
90 - async function testPost() {
91 - output.textContent = '';
92 - log('POST 요청 테스트 시작...');
93 - try {
94 - const data = { message: 'Hello from client!' };
95 - const response = await fetch('/api/echo', {
96 - method: 'POST',
97 - headers: { 'Content-Type': 'application/json' },
98 - body: JSON.stringify(data)
99 - });
100 - log('Status: ' + response.status);
101 - const text = await response.text();
102 - log('Body: ' + text);
103 - } catch (err) {
104 - log('Error: ' + err.message);
105 - }
106 - }
107 -
108 - async function testStream() {
109 - output.textContent = '';
110 - log('스트리밍 테스트 시작...');
111 - try {
112 - const response = await fetch('/api/stream');
113 - const reader = response.body.getReader();
114 - const decoder = new TextDecoder();
115 -
116 - while (true) {
117 - const { done, value } = await reader.read();
118 - if (done) break;
119 - const chunk = decoder.decode(value, { stream: true });
120 - log('Chunk: ' + chunk);
121 - }
122 - log('스트리밍 완료!');
123 - } catch (err) {
124 - log('Error: ' + err.message);
125 - }
126 - }
127 - </script>
128 -</body>
129 -</html>
130 - `))
131 - })
43 +type Proxy struct {
44 +}
45
133 - // Test API endpoint
134 - mux.HandleFunc("/api/test", func(w http.ResponseWriter, r *http.Request) {
135 - w.Header().Set("Content-Type", "application/json")
136 - w.WriteHeader(http.StatusOK)
137 - w.Write([]byte(`{"status":"success","message":"HTTP JS binding is working!"}`))
138 - })
46 +func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
47 + log.Info().Msgf("Proxying request to %s", r.URL.String())
48 + r.URL.Host = "UOJ4VGIKICVKHXFURAE67GUHMMELUAUU3I37NCAAPHAAHBMPYDNQ"
49 + resp, err := client.Do(r)
50 + if err != nil {
51 + log.Error().Err(err).Msg("Failed to proxy request")
52 + return
53 + }
54 + defer resp.Body.Close()
55
140 - // Echo endpoint
141 - mux.HandleFunc("/api/echo", func(w http.ResponseWriter, r *http.Request) {
142 - w.Header().Set("Content-Type", "application/json")
143 - body := make([]byte, 1024)
144 - n, _ := r.Body.Read(body)
145 - w.WriteHeader(http.StatusOK)
146 - w.Write([]byte(`{"received":`))
147 - w.Write(body[:n])
148 - w.Write([]byte(`}`))
149 - })
56 + for key, value := range resp.Header {
57 + w.Header()[key] = value
58 + }
59 + w.WriteHeader(resp.StatusCode)
60 + io.Copy(w, resp.Body)
61 +}
62
151 - // Streaming endpoint
152 - mux.HandleFunc("/api/stream", func(w http.ResponseWriter, r *http.Request) {
153 - w.Header().Set("Content-Type", "text/plain")
154 - w.Header().Set("Transfer-Encoding", "chunked")
155 - w.WriteHeader(http.StatusOK)
63 +func main() {
64 + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339})
65 + var err error
66
157 - for i := 1; i <= 5; i++ {
158 - w.Write([]byte("Chunk " + string(rune('0'+i)) + "\n"))
159 - if f, ok := w.(http.Flusher); ok {
160 - f.Flush()
161 - }
162 - }
163 - })
67 + rdClient, err = sdk.NewClient(
68 + sdk.WithBootstrapServers(bootstrapServers),
69 + sdk.WithDialer(WebSocketDialerJS()),
70 + )
71 + if err != nil {
72 + panic(err)
73 + }
74 + defer rdClient.Close()
75
165 - // Expose HTTP handler to JavaScript as _portal_http
166 - js.Global().Set("_portal_http", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
76 + // Expose HTTP handler to JavaScript as _portal_proxy
77 + js.Global().Set("_portal_proxy", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
78 if len(args) < 1 {
79 return js.Global().Get("Promise").Call("reject",
80 js.Global().Get("Error").New("required parameter JSRequest missing"))
81 }
82
83 jsReq := args[0]
173 - return httpjs.ServeHTTPAsyncWithStreaming(mux, jsReq)
84 + return httpjs.ServeHTTPAsyncWithStreaming(&Proxy{}, jsReq)
85 }))
86
176 - println("✅ Portal HTTP handler registered as _portal_http")
87 + println("Portal proxy handler registered as _portal_proxy")
88
178 - // Keep the program running
179 - ch := make(chan struct{})
89 + if runtime.Compiler == "tinygo" || runtime.GOARCH != "wasm" {
90 + return
91 + }
92 + // Wait
93 + ch := make(chan bool)
94 <-ch
95 }
cmd/webclient/service-worker.js
+42 -42
@@ -1,20 +1,20 @@
1 -// 1. WASM 실행 환경 임포트
2 -// (CDN을 사용하거나 로컬 경로를 사용할 수 있습니다)
1 +// 1. Import WASM execution environment
2 +// (You can use CDN or local path)
3 // const wasm_exec_URL = "https://cdn.jsdelivr.net/gh/golang/go@go1.19/misc/wasm/wasm_exec.js";
4 -const wasm_exec_URL = "/wasm_exec.js";
4 +const wasm_exec_URL = "/wasm_exec.js";
5 importScripts(wasm_exec_URL);
6
7 -// --- 전역 상수 및 변수 ---
7 +// --- Global constants and variables ---
8
9 const wasm_URL = "/main.wasm";
10 -// importScripts와 경로 일치
10 +// Path matching with importScripts
11 const CACHE_NAME = "WASM_Cache_v1";
12
13 -// WASM 로딩 상태를 관리하기 위한 Promise (중복 로드 방지)
13 +// Promise to manage WASM loading state (prevents duplicate loading)
14 let wasmReadyPromise = null;
15
16 /**
17 - * Go WASM을 로드하고 실행합니다.
17 + * Loads and executes Go WASM.
18 */
19 async function runWASM() {
20 const go = new Go();
@@ -24,49 +24,49 @@ async function runWASM() {
24 const cache_wasm = await cache.match(wasm_URL);
25
26 if (cache_wasm) {
27 - console.log("Service Worker: 캐시에서 WASM 로드 중...");
27 + console.log("Service Worker: Loading WASM from cache...");
28 wasm_file = await cache_wasm.arrayBuffer();
29 } else {
30 - console.warn("Service Worker: 캐시에 WASM이 없습니다. 네트워크에서 가져옵니다...");
30 + console.warn("Service Worker: WASM not in cache. Fetching from network...");
31 const resp = await fetch(wasm_URL);
32 wasm_file = await resp.arrayBuffer();
33 await cache.put(wasm_URL, new Response(wasm_file.slice(0)));
34 }
35
36 - console.log("Service Worker: WebAssembly 인스턴스화...");
36 + console.log("Service Worker: Instantiating WebAssembly...");
37 const { instance } = await WebAssembly.instantiate(wasm_file, go.importObject);
38
39 - // go.run()은 Go의 main()을 실행하고,
40 - // _portal_http 콜백이 등록되면 리턴합니다.
39 + // go.run() executes Go's main() and returns
40 + // when _portal_proxy callback is registered
41 go.run(instance);
42 - console.log("Service Worker: Go WASM 실행 완료. _portal_http가 준비되었습니다.");
42 + console.log("Service Worker: Go WASM execution complete. _portal_proxy is ready.");
43 }
44
45 /**
46 - * runWASM()이 한 번만 실행되도록 보장하는 래퍼 함수입니다.
47 - * @returns {Promise<void>} WASM이 준비되면 resolve되는 Promise
46 + * Wrapper function that ensures runWASM() is executed only once.
47 + * @returns {Promise<void>} Promise that resolves when WASM is ready
48 */
49 function getWasmReady() {
50 if (!wasmReadyPromise) {
51 - console.log("Service Worker: WASM 로딩 시작...");
51 + console.log("Service Worker: Starting WASM loading...");
52 wasmReadyPromise = runWASM().catch(err => {
53 - console.error("Service Worker: WASM 실행 실패:", err);
54 - wasmReadyPromise = null; // 실패 시 다음 요청에서 재시도 허용
55 - throw err; // 에러를 호출자(fetch 핸들러)에게 전파
53 + console.error("Service Worker: WASM execution failed:", err);
54 + wasmReadyPromise = null; // Allow retry on next request if failed
55 + throw err; // Propagate error to caller (fetch handler)
56 });
57 }
58 return wasmReadyPromise;
59 }
60
61
62 -// --- 1. 설치 (Install) 이벤트 리스너 ---
62 +// --- 1. Install event listener ---
63 self.addEventListener('install', (event) => {
64 - console.log('Service Worker: 설치 중...');
64 + console.log('Service Worker: Installing...');
65
66 event.waitUntil(
67 (async () => {
68 const cache = await caches.open(CACHE_NAME);
69 - console.log('Service Worker: 필수 에셋 캐싱 중...');
69 + console.log('Service Worker: Caching essential assets...');
70 await cache.addAll([
71 wasm_URL,
72 wasm_exec_URL,
@@ -76,48 +76,48 @@ self.addEventListener('install', (event) => {
76 );
77 });
78
79 -// --- 2. 활성화 (Activate) 이벤트 리스너 ---
79 +// --- 2. Activate event listener ---
80 self.addEventListener('activate', (event) => {
81 - console.log('Service Worker: 활성화 됨.');
81 + console.log('Service Worker: Activated.');
82
83 event.waitUntil(
84 (async () => {
85 await self.clients.claim();
86 - // WASM을 미리 로드하여 다음 fetch 요청에 대비
87 - console.log('Service Worker: Go WASM 선제적 로딩 시작...');
86 + // Preload WASM to prepare for next fetch requests
87 + console.log('Service Worker: Starting Go WASM preloading...');
88 await getWasmReady();
89 - console.log('Service Worker: Go WASM 선제적 로딩 완료.');
89 + console.log('Service Worker: Go WASM preloading complete.');
90 })()
91 );
92 });
93
94
95 -// --- 3. 페치 (Fetch) 이벤트 리스너 ---
96 -// 모든 요청을 Go 핸들러로 전달합니다.
95 +// --- 3. Fetch event listener ---
96 +// Pass all requests to Go handler.
97 self.addEventListener('fetch', (event) => {
98 const url = new URL(event.request.url);
99 - console.log(`Service Worker: Go 핸들러로 요청 전달: ${url.pathname}`);
99 + console.log(`Service Worker: Forwarding request to Portal Proxy handler: ${url.pathname}`);
100
101 event.respondWith((async () => {
102 try {
103 - // WASM이 준비될 때까지 기다림
104 - await getWasmReady();
103 + // Wait until WASM is ready
104 + await getWasmReady();
105
106 - if (typeof _portal_http !== 'undefined') {
107 - // WASM이 준비되었고 핸들러 함수가 존재함
108 - const resp = await _portal_http(event.request);
106 + if (typeof _portal_proxy !== 'undefined') {
107 + // WASM is ready and handler function exists
108 + const resp = await _portal_proxy(event.request);
109 return resp;
110 } else {
111 - // getWasmReady()가 성공했는데도 함수가 없는 비정상 상황
112 - console.error("Service Worker: WASM 로드는 성공했으나 _portal_http가 정의되지 않았습니다.");
113 - return new Response("WASM 핸들러를 사용할 수 없습니다.", { status: 500 });
111 + // Abnormal situation where function doesn't exist even though getWasmReady() succeeded
112 + console.error("Service Worker: WASM loading succeeded but _portal_proxy is not defined.");
113 + return new Response("WASM handler is not available.", { status: 500 });
114 }
115 } catch (err) {
116 - // 1. getWasmReady() 실패 (WASM 로드/실행 실패)
117 - // 2. _portal_http(event.request) 실패 (Go 핸들러 내부 에러)
118 - console.error(`Service Worker: Go 핸들러 처리 실패 (네트워크로 폴백): ${err}`, event.request.url);
116 + // 1. getWasmReady() failure (WASM load/execution failure)
117 + // 2. _portal_http(event.request) failure (Go handler internal error)
118 + console.error(`Service Worker: Portal Proxy handler processing failed (falling back to network): ${err}`, event.request.url);
119
120 - // WASM 핸들러 실패 시 네트워크로 폴백
120 + // Fallback to network when WASM handler fails
121 return fetch(event.request);
122 }
123 })());