feat(webclient): add HTTP client/server bridge via JS fetch and update UI

Implement a new httpjs package with Request and Response structs to handle HTTP requests using the browser's fetch API, including streaming body support. Add HTTPResponseToJSResponse function for converting standard HTTP responses to JS fetch responses. Update index.html with responsive CSS styling and backdrop filters for the Portal WebClient interface, enabling seamless HTTP operations in the web environment. This enhances the webclient's capability to interact with external HTTP services directly from the browser.

lemon-mint committed Oct 30, 2025 at 15:56 UTC 5ff73ed9c52f605e2a7cd746081a39b57fd3c36f
3 files changed +819 -3
cmd/webclient/httpjs/http_js.go
+513
@@ -1 +1,514 @@
1 package httpjs
2 +
3 +import (
4 + "bufio"
5 + "bytes"
6 + "errors"
7 + "io"
8 + "net/http"
9 + "net/textproto"
10 + "strings"
11 + "syscall/js"
12 +
13 + "github.com/gosuda/portal/cmd/webclient/streamjs"
14 +)
15 +
16 +var (
17 + ErrRequestFailed = errors.New("request failed")
18 + ErrAborted = errors.New("request aborted")
19 +)
20 +
21 +var (
22 + _fetch = js.Global().Get("fetch")
23 + _Headers = js.Global().Get("Headers")
24 + _Response = js.Global().Get("Response")
25 + _ArrayBuffer = js.Global().Get("ArrayBuffer")
26 + _Uint8Array = js.Global().Get("Uint8Array")
27 + _Promise = js.Global().Get("Promise")
28 + _Object = js.Global().Get("Object")
29 + _Array = js.Global().Get("Array")
30 + _Error = js.Global().Get("Error")
31 +)
32 +
33 +// Request represents an HTTP request that will be sent via fetch API
34 +type Request struct {
35 + Method string
36 + URL string
37 + Headers map[string]string
38 + Body []byte
39 +}
40 +
41 +// Response represents an HTTP response with streaming body support
42 +type Response struct {
43 + StatusCode int
44 + Headers map[string]string
45 + Body *streamjs.ReadableStream
46 +
47 + jsResponse js.Value
48 + bodyReader io.ReadCloser // Store the underlying reader for ReadAll
49 +}
50 +
51 +// NewRequest creates a new HTTP request
52 +func NewRequest(method, url string) *Request {
53 + return &Request{
54 + Method: method,
55 + URL: url,
56 + Headers: make(map[string]string),
57 + }
58 +}
59 +
60 +// SetHeader sets a request header
61 +func (r *Request) SetHeader(key, value string) {
62 + r.Headers[key] = value
63 +}
64 +
65 +// SetBody sets the request body from a byte slice
66 +func (r *Request) SetBody(body []byte) {
67 + r.Body = body
68 +}
69 +
70 +// Do executes the HTTP request and returns a Response
71 +func (r *Request) Do() (*Response, error) {
72 + // Create fetch options
73 + opts := _Object.New()
74 + opts.Set("method", r.Method)
75 +
76 + // Set headers
77 + if len(r.Headers) > 0 {
78 + jsHeaders := _Headers.New()
79 + for key, value := range r.Headers {
80 + jsHeaders.Call("append", key, value)
81 + }
82 + opts.Set("headers", jsHeaders)
83 + }
84 +
85 + // Set body if present (convert to ArrayBuffer)
86 + if len(r.Body) > 0 {
87 + buffer := _ArrayBuffer.New(len(r.Body))
88 + array := _Uint8Array.New(buffer)
89 + js.CopyBytesToJS(array, r.Body)
90 + opts.Set("body", buffer)
91 + }
92 +
93 + // Create channels for async result
94 + resultCh := make(chan *Response, 1)
95 + errCh := make(chan error, 1)
96 +
97 + // Execute fetch
98 + fetchPromise := _fetch.Invoke(r.URL, opts)
99 +
100 + // Handle response
101 + var thenFunc, catchFunc js.Func
102 +
103 + thenFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
104 + defer thenFunc.Release()
105 +
106 + jsResp := args[0]
107 +
108 + // Parse response
109 + resp := &Response{
110 + StatusCode: jsResp.Get("status").Int(),
111 + Headers: make(map[string]string),
112 + jsResponse: jsResp,
113 + }
114 +
115 + // Extract headers
116 + jsHeaders := jsResp.Get("headers")
117 + entriesIter := jsHeaders.Call("entries")
118 +
119 + for {
120 + next := entriesIter.Call("next")
121 + if next.Get("done").Bool() {
122 + break
123 + }
124 + entry := next.Get("value")
125 + key := entry.Index(0).String()
126 + value := entry.Index(1).String()
127 + resp.Headers[key] = value
128 + }
129 +
130 + // Get body as ReadableStream
131 + jsBody := jsResp.Get("body")
132 + if !jsBody.IsNull() && !jsBody.IsUndefined() {
133 + // Create a Go reader that reads from JS ReadableStream
134 + reader := &jsStreamReader{
135 + jsReader: jsBody.Call("getReader"),
136 + }
137 + resp.bodyReader = reader
138 + resp.Body = streamjs.NewReadableStream(reader)
139 + }
140 +
141 + resultCh <- resp
142 + return nil
143 + })
144 +
145 + catchFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
146 + defer catchFunc.Release()
147 +
148 + if len(args) > 0 {
149 + errMsg := args[0].Get("message").String()
150 + errCh <- errors.New(errMsg)
151 + } else {
152 + errCh <- ErrRequestFailed
153 + }
154 + return nil
155 + })
156 +
157 + fetchPromise.Call("then", thenFunc).Call("catch", catchFunc)
158 +
159 + // Wait for result
160 + select {
161 + case resp := <-resultCh:
162 + return resp, nil
163 + case err := <-errCh:
164 + return nil, err
165 + }
166 +}
167 +
168 +// jsStreamReader implements io.ReadCloser by reading from a JS ReadableStream
169 +type jsStreamReader struct {
170 + jsReader js.Value
171 + closed bool
172 +}
173 +
174 +func (r *jsStreamReader) Read(p []byte) (n int, err error) {
175 + if r.closed {
176 + return 0, io.EOF
177 + }
178 +
179 + // Create channels for async read
180 + resultCh := make(chan readResult, 1)
181 +
182 + // Call read() on the reader
183 + readPromise := r.jsReader.Call("read")
184 +
185 + var thenFunc js.Func
186 + thenFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
187 + defer thenFunc.Release()
188 +
189 + result := args[0]
190 + done := result.Get("done").Bool()
191 +
192 + if done {
193 + resultCh <- readResult{n: 0, err: io.EOF}
194 + return nil
195 + }
196 +
197 + // Get the chunk (Uint8Array)
198 + chunk := result.Get("value")
199 + if chunk.IsNull() || chunk.IsUndefined() {
200 + resultCh <- readResult{n: 0, err: nil}
201 + return nil
202 + }
203 +
204 + // Copy data from JS to Go
205 + length := chunk.Get("byteLength").Int()
206 + if length == 0 {
207 + resultCh <- readResult{n: 0, err: nil}
208 + return nil
209 + }
210 +
211 + // Copy as much as we can fit in p
212 + copyLen := length
213 + if copyLen > len(p) {
214 + copyLen = len(p)
215 + }
216 +
217 + // Create a temporary Uint8Array view if we need to copy partial data
218 + if copyLen < length {
219 + chunk = _Uint8Array.New(chunk.Get("buffer"), chunk.Get("byteOffset"), copyLen)
220 + }
221 +
222 + js.CopyBytesToGo(p[:copyLen], chunk)
223 + resultCh <- readResult{n: copyLen, err: nil}
224 + return nil
225 + })
226 +
227 + readPromise.Call("then", thenFunc)
228 +
229 + // Wait for result
230 + res := <-resultCh
231 + return res.n, res.err
232 +}
233 +
234 +func (r *jsStreamReader) Close() error {
235 + if r.closed {
236 + return nil
237 + }
238 + r.closed = true
239 +
240 + // Cancel the reader
241 + if !r.jsReader.IsNull() && !r.jsReader.IsUndefined() {
242 + r.jsReader.Call("cancel")
243 + }
244 + return nil
245 +}
246 +
247 +type readResult struct {
248 + n int
249 + err error
250 +}
251 +
252 +// ReadAll reads the entire response body into a byte slice
253 +func (resp *Response) ReadAll() ([]byte, error) {
254 + if resp.bodyReader == nil {
255 + return []byte{}, nil
256 + }
257 +
258 + var buf bytes.Buffer
259 + buffer := make([]byte, 4096)
260 +
261 + for {
262 + n, err := resp.bodyReader.Read(buffer)
263 + if n > 0 {
264 + buf.Write(buffer[:n])
265 + }
266 + if err == io.EOF {
267 + break
268 + }
269 + if err != nil {
270 + return nil, err
271 + }
272 + }
273 +
274 + return buf.Bytes(), nil
275 +}
276 +
277 +// Close closes the response body stream
278 +func (resp *Response) Close() error {
279 + if resp.Body != nil {
280 + resp.Body.Close()
281 + }
282 + return nil
283 +}
284 +
285 +// Get performs a GET request
286 +func Get(url string) (*Response, error) {
287 + req := NewRequest("GET", url)
288 + return req.Do()
289 +}
290 +
291 +// Post performs a POST request with the given body
292 +func Post(url string, contentType string, body []byte) (*Response, error) {
293 + req := NewRequest("POST", url)
294 + if contentType != "" {
295 + req.SetHeader("Content-Type", contentType)
296 + }
297 + req.SetBody(body)
298 + return req.Do()
299 +}
300 +
301 +// Put performs a PUT request with the given body
302 +func Put(url string, contentType string, body []byte) (*Response, error) {
303 + req := NewRequest("PUT", url)
304 + if contentType != "" {
305 + req.SetHeader("Content-Type", contentType)
306 + }
307 + req.SetBody(body)
308 + return req.Do()
309 +}
310 +
311 +// Delete performs a DELETE request
312 +func Delete(url string) (*Response, error) {
313 + req := NewRequest("DELETE", url)
314 + return req.Do()
315 +}
316 +
317 +// JSRequestToHTTPRequest converts a JavaScript Request object to net/http.Request
318 +func JSRequestToHTTPRequest(jsReq js.Value) (*http.Request, error) {
319 + // Get method and URL
320 + method := jsReq.Get("method").String()
321 + url := jsReq.Get("url").String()
322 +
323 + // Read body as ArrayBuffer
324 + var bodyReader io.Reader
325 + jsBody := jsReq.Get("body")
326 +
327 + if !jsBody.IsNull() && !jsBody.IsUndefined() {
328 + // Create promise to read body
329 + bodyPromise := jsReq.Call("arrayBuffer")
330 +
331 + bodyChan := make(chan []byte, 1)
332 + errChan := make(chan error, 1)
333 +
334 + var successFunc, failFunc js.Func
335 + successFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
336 + defer successFunc.Release()
337 +
338 + jsBodyArray := _Uint8Array.New(args[0])
339 + bodyBuffer := make([]byte, jsBodyArray.Get("byteLength").Int())
340 + js.CopyBytesToGo(bodyBuffer, jsBodyArray)
341 + bodyChan <- bodyBuffer
342 + return nil
343 + })
344 +
345 + failFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
346 + defer failFunc.Release()
347 +
348 + if len(args) > 0 {
349 + errChan <- errors.New(args[0].String())
350 + } else {
351 + errChan <- errors.New("failed to read request body")
352 + }
353 + return nil
354 + })
355 +
356 + bodyPromise.Call("then", successFunc).Call("catch", failFunc)
357 +
358 + select {
359 + case body := <-bodyChan:
360 + bodyReader = bytes.NewReader(body)
361 + case err := <-errChan:
362 + return nil, err
363 + }
364 + } else {
365 + bodyReader = bytes.NewReader([]byte{})
366 + }
367 +
368 + // Create HTTP request
369 + httpReq, err := http.NewRequest(method, url, bodyReader)
370 + if err != nil {
371 + return nil, err
372 + }
373 +
374 + // Convert headers
375 + jsHeaders := _Array.Call("from", jsReq.Get("headers").Call("entries"))
376 + headersLen := jsHeaders.Length()
377 +
378 + var headerBuilder strings.Builder
379 + for i := 0; i < headersLen; i++ {
380 + entry := jsHeaders.Index(i)
381 + if entry.Length() < 2 {
382 + continue
383 + }
384 +
385 + key := entry.Index(0).String()
386 + value := entry.Index(1).String()
387 +
388 + headerBuilder.WriteString(key)
389 + headerBuilder.WriteString(": ")
390 + headerBuilder.WriteString(value)
391 + headerBuilder.WriteString("\r\n")
392 + }
393 + headerBuilder.WriteString("\r\n")
394 +
395 + // Parse headers using textproto
396 + tpr := textproto.NewReader(bufio.NewReader(strings.NewReader(headerBuilder.String())))
397 + mimeHeader, err := tpr.ReadMIMEHeader()
398 + if err != nil {
399 + return nil, err
400 + }
401 + httpReq.Header = http.Header(mimeHeader)
402 +
403 + return httpReq, nil
404 +}
405 +
406 +// HTTPResponseToJSResponse converts an http.Response to a JavaScript Response object with streaming support
407 +func HTTPResponseToJSResponse(httpResp *http.Response) js.Value {
408 + // Create JS headers object
409 + jsHeaders := _Object.New()
410 + for key, values := range httpResp.Header {
411 + if len(values) > 0 {
412 + jsHeaders.Set(key, values[0])
413 + }
414 + }
415 +
416 + // Create streaming body using ReadableStream
417 + var jsBody js.Value
418 + if httpResp.Body != nil {
419 + stream := streamjs.NewReadableStream(httpResp.Body)
420 + jsBody = stream.Value
421 + } else {
422 + jsBody = js.Null()
423 + }
424 +
425 + // Create response options
426 + jsOptions := _Object.New()
427 + jsOptions.Set("status", httpResp.StatusCode)
428 + jsOptions.Set("statusText", httpResp.Status)
429 + jsOptions.Set("headers", jsHeaders)
430 +
431 + // Create and return JS Response
432 + return _Response.New(jsBody, jsOptions)
433 +}
434 +
435 +// ServeHTTPAsyncWithStreaming handles an HTTP request asynchronously and returns a streaming JS Response
436 +func ServeHTTPAsyncWithStreaming(handler http.Handler, jsReq js.Value) js.Value {
437 + return _Promise.New(js.FuncOf(func(this js.Value, args []js.Value) interface{} {
438 + resolve := args[0]
439 + reject := args[1]
440 +
441 + go func() {
442 + // Convert JS Request to http.Request
443 + httpReq, err := JSRequestToHTTPRequest(jsReq)
444 + if err != nil {
445 + reject.Invoke(_Error.New(err.Error()))
446 + return
447 + }
448 +
449 + // Create a pipe for streaming response
450 + pr, pw := io.Pipe()
451 +
452 + // Create custom ResponseWriter that writes to pipe
453 + respWriter := &streamingResponseWriter{
454 + pipeWriter: pw,
455 + header: make(http.Header),
456 + statusCode: 200,
457 + }
458 +
459 + // Serve HTTP in goroutine
460 + go func() {
461 + defer pw.Close()
462 + defer func() {
463 + if r := recover(); r != nil {
464 + // Handle panic
465 + respWriter.statusCode = http.StatusInternalServerError
466 + pw.CloseWithError(errors.New("internal server error"))
467 + }
468 + }()
469 +
470 + handler.ServeHTTP(respWriter, httpReq)
471 + }()
472 +
473 + // Create http.Response with streaming body
474 + httpResp := &http.Response{
475 + StatusCode: respWriter.statusCode,
476 + Status: http.StatusText(respWriter.statusCode),
477 + Header: respWriter.header,
478 + Body: pr,
479 + }
480 +
481 + // Convert to JS Response with streaming
482 + jsResp := HTTPResponseToJSResponse(httpResp)
483 + resolve.Invoke(jsResp)
484 + }()
485 +
486 + return nil
487 + }))
488 +}
489 +
490 +// streamingResponseWriter implements http.ResponseWriter for streaming responses
491 +type streamingResponseWriter struct {
492 + pipeWriter *io.PipeWriter
493 + header http.Header
494 + statusCode int
495 + wroteHeader bool
496 +}
497 +
498 +func (w *streamingResponseWriter) Header() http.Header {
499 + return w.header
500 +}
501 +
502 +func (w *streamingResponseWriter) Write(b []byte) (int, error) {
503 + if !w.wroteHeader {
504 + w.WriteHeader(http.StatusOK)
505 + }
506 + return w.pipeWriter.Write(b)
507 +}
508 +
509 +func (w *streamingResponseWriter) WriteHeader(statusCode int) {
510 + if !w.wroteHeader {
511 + w.statusCode = statusCode
512 + w.wroteHeader = true
513 + }
514 +}
cmd/webclient/index.html
+136 -2
@@ -1,11 +1,145 @@
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 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>
75 </head>
76 <body>
8 - <h1>Portal WebClient</h1>
9 - <hr/>
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>
85 +
86 + <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 +
99 + async function registerServiceWorker() {
100 + try {
101 + if (!('serviceWorker' in navigator)) {
102 + throw new Error('이 브라우저는 Service Worker를 지원하지 않습니다.');
103 + }
104 +
105 + updateStatus('Service Worker 등록 중...');
106 +
107 + // Register service worker
108 + const registration = await navigator.serviceWorker.register('/service-worker.js', {
109 + scope: '/'
110 + });
111 +
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);
126 + } catch (error) {
127 + console.error('Service Worker registration failed:', error);
128 + updateStatus('❌ Service Worker 등록 실패: ' + error.message, 'error');
129 + }
130 + }
131 +
132 + // Start registration
133 + registerServiceWorker();
134 +
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 + });
143 + </script>
144 </body>
145 </html>
\ No newline at end of file
cmd/webclient/main_js.go
+170 -1
@@ -1,12 +1,181 @@
1 package main
2
3 -import "runtime"
3 +import (
4 + "net/http"
5 + "runtime"
6 + "syscall/js"
7 +
8 + "github.com/gosuda/portal/cmd/webclient/httpjs"
9 +)
10
11 func main() {
12 if runtime.Compiler == "tinygo" || runtime.GOARCH != "wasm" {
13 return
14 }
15
16 + // Create HTTP handler
17 + mux := http.NewServeMux()
18 +
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>
68 +
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 + })
132 +
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 + })
139 +
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 + })
150 +
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)
156 +
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 + })
164 +
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{} {
167 + if len(args) < 1 {
168 + return js.Global().Get("Promise").Call("reject",
169 + js.Global().Get("Error").New("required parameter JSRequest missing"))
170 + }
171 +
172 + jsReq := args[0]
173 + return httpjs.ServeHTTPAsyncWithStreaming(mux, jsReq)
174 + }))
175 +
176 + println("✅ Portal HTTP handler registered as _portal_http")
177 +
178 + // Keep the program running
179 ch := make(chan struct{})
180 <-ch
181 }