diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2f0cdc0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,3 @@ +common 里存放环境的js 如果有参数 ?env=test 就走test api +如果没有参数默认是 线上的api +如果env=local 就是本地的api \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2cf40db --- /dev/null +++ b/Makefile @@ -0,0 +1,33 @@ +PORT ?= 30000 +HOST ?= 127.0.0.1 +PAGE ?= / + +.PHONY: run +run: + @URL="http://$(HOST):$(PORT)$(PAGE)"; \ + echo "Serving H5 at $$URL"; \ + if curl -fsS "http://$(HOST):$(PORT)/__h5_dev_server__/health" 2>/dev/null | grep -q "ok"; then \ + echo "H5 dev server is already running on http://$(HOST):$(PORT)/"; \ + open "$$URL"; \ + exit 0; \ + fi; \ + if curl -fsS "http://$(HOST):$(PORT)/" >/dev/null 2>&1; then \ + echo "Port $(PORT) is occupied by a server without the H5 API proxy."; \ + echo "Stop that process, then run make run again."; \ + exit 1; \ + fi; \ + python3 scripts/h5_dev_server.py --host "$(HOST)" --port "$(PORT)" --directory "$$(pwd)" & \ + SERVER_PID=$$!; \ + trap 'kill $$SERVER_PID 2>/dev/null || true' INT TERM EXIT; \ + for i in 1 2 3 4 5 6 7 8 9 10; do \ + if curl -fsS "http://$(HOST):$(PORT)/__h5_dev_server__/health" >/dev/null 2>&1; then \ + break; \ + fi; \ + sleep 0.2; \ + done; \ + if ! curl -fsS "http://$(HOST):$(PORT)/__h5_dev_server__/health" | grep -q "ok"; then \ + echo "H5 dev server is not ready on http://$(HOST):$(PORT)/"; \ + exit 1; \ + fi; \ + open "$$URL"; \ + wait $$SERVER_PID diff --git a/common/api.js b/common/api.js new file mode 100644 index 0000000..91e9d66 --- /dev/null +++ b/common/api.js @@ -0,0 +1,179 @@ +var test_api = "https://api-test.global-interaction.com/"; +var local_api = "http://localhost:13000/"; +var default_api = "https://api.global-interaction.com/"; + +(function () { + var API_ENV_KEY = "hyapp_h5_env"; + var ACCESS_TOKEN_KEY = "hyapp_access_token"; + var APP_CODE_KEY = "hyapp_app_code"; + var memoryEnv = ""; + var memoryAccessToken = ""; + var memoryAppCode = ""; + + function readQuery(name) { + return new URLSearchParams(window.location.search).get(name); + } + + function normalizeBaseURL(value) { + return String(value || "").replace(/\/+$/, "") + "/"; + } + + function isLocalDevOrigin() { + var host = window.location.hostname; + return host === "127.0.0.1" || host === "localhost" || host === "::1"; + } + + function shouldPersist() { + return !isLocalDevOrigin(); + } + + function clearLocalDevCache() { + if (shouldPersist()) return; + window.localStorage.removeItem(API_ENV_KEY); + window.localStorage.removeItem(ACCESS_TOKEN_KEY); + window.localStorage.removeItem(APP_CODE_KEY); + } + + function devProxyBase(path) { + return normalizeBaseURL(window.location.origin + path); + } + + function resolveEnv() { + var env = readQuery("env"); + if (env === "test" || env === "local") { + memoryEnv = env; + if (shouldPersist()) window.localStorage.setItem(API_ENV_KEY, env); + return env; + } + if (memoryEnv) return memoryEnv; + if (!shouldPersist()) return ""; + return window.localStorage.getItem(API_ENV_KEY) || ""; + } + + function resolveBaseURL() { + var env = resolveEnv(); + if (env === "test") return isLocalDevOrigin() ? devProxyBase("/__api_test__/") : normalizeBaseURL(test_api); + if (env === "local") return isLocalDevOrigin() ? devProxyBase("/__api_local__/") : normalizeBaseURL(local_api); + return normalizeBaseURL(default_api); + } + + function buildURL(path, query) { + var url = new URL(path.replace(/^\/+/, ""), resolveBaseURL()); + if (query) { + Object.keys(query).forEach(function (key) { + var value = query[key]; + if (value === undefined || value === null || value === "") return; + url.searchParams.set(key, value); + }); + } + return url.toString(); + } + + function getAccessToken() { + if (memoryAccessToken) return memoryAccessToken; + if (!shouldPersist()) return ""; + return window.localStorage.getItem(ACCESS_TOKEN_KEY) || ""; + } + + function getAppCode() { + var queryAppCode = readQuery("app_code") || readQuery("appCode"); + if (queryAppCode) { + memoryAppCode = queryAppCode; + if (shouldPersist()) window.localStorage.setItem(APP_CODE_KEY, queryAppCode); + return queryAppCode; + } + if (memoryAppCode) return memoryAppCode; + if (!shouldPersist()) return "lalu"; + return window.localStorage.getItem(APP_CODE_KEY) || "lalu"; + } + + function setAccessToken(token) { + memoryAccessToken = token || ""; + if (token) { + if (shouldPersist()) window.localStorage.setItem(ACCESS_TOKEN_KEY, token); + return; + } + if (shouldPersist()) window.localStorage.removeItem(ACCESS_TOKEN_KEY); + } + + function setAppCode(appCode) { + memoryAppCode = appCode || ""; + if (appCode) { + if (shouldPersist()) window.localStorage.setItem(APP_CODE_KEY, appCode); + return; + } + if (shouldPersist()) window.localStorage.removeItem(APP_CODE_KEY); + } + + function parseResponse(response) { + return response.text().then(function (text) { + var payload = text ? JSON.parse(text) : {}; + if (!response.ok) { + var httpError = new Error(payload.message || response.statusText || "request_failed"); + httpError.status = response.status; + httpError.payload = payload; + throw httpError; + } + var code = payload && payload.code; + var isErrorCode = (typeof code === "number" && code !== 0) || (typeof code === "string" && code !== "" && code !== "OK"); + if (isErrorCode) { + var apiError = new Error(payload.message || "api_request_failed"); + apiError.code = payload.code; + apiError.request_id = payload.request_id; + apiError.payload = payload; + throw apiError; + } + return payload && Object.prototype.hasOwnProperty.call(payload, "data") ? payload.data : payload; + }); + } + + function request(path, options) { + var opts = options || {}; + var headers = Object.assign({}, opts.headers || {}); + var token = getAccessToken(); + var appCode = getAppCode(); + if (token) headers.Authorization = "Bearer " + token; + if (appCode) headers["X-App-Code"] = appCode; + if (!shouldPersist()) headers["Cache-Control"] = "no-cache"; + + var body = opts.body; + if (body && typeof body === "object" && !(body instanceof FormData)) { + headers["Content-Type"] = headers["Content-Type"] || "application/json"; + body = JSON.stringify(body); + } + + return window.fetch(buildURL(path, opts.query), { + method: opts.method || "GET", + headers: headers, + body: body, + cache: shouldPersist() ? "default" : "no-store", + credentials: opts.credentials || "omit" + }).then(parseResponse); + } + + window.HyAppAPI = { + baseURL: resolveBaseURL, + buildURL: buildURL, + getAccessToken: getAccessToken, + getAppCode: getAppCode, + setAccessToken: setAccessToken, + setAppCode: setAppCode, + request: request, + get: function (path, query) { + return request(path, { method: "GET", query: query }); + }, + post: function (path, body, query) { + return request(path, { method: "POST", body: body, query: query }); + }, + put: function (path, body, query) { + return request(path, { method: "PUT", body: body, query: query }); + }, + patch: function (path, body, query) { + return request(path, { method: "PATCH", body: body, query: query }); + }, + delete: function (path, query) { + return request(path, { method: "DELETE", query: query }); + } + }; + clearLocalDevCache(); +})(); diff --git a/common/jsbrdge.js b/common/jsbrdge.js new file mode 100644 index 0000000..3a8cd90 --- /dev/null +++ b/common/jsbrdge.js @@ -0,0 +1,8 @@ +(function () { + if (!window.HyAppBridge) { + var script = document.createElement("script"); + var current = document.currentScript && document.currentScript.src; + script.src = current ? current.replace(/jsbrdge\.js(?:\?.*)?$/, "jsbridge.js") : "/common/jsbridge.js"; + document.head.appendChild(script); + } +})(); diff --git a/common/jsbridge.js b/common/jsbridge.js new file mode 100644 index 0000000..4c926de --- /dev/null +++ b/common/jsbridge.js @@ -0,0 +1,43 @@ +(function () { + function post(action, payload) { + var message = JSON.stringify({ action: action, payload: payload || {} }); + + if (window.flutter_inappwebview && window.flutter_inappwebview.callHandler) { + return window.flutter_inappwebview.callHandler("HyAppBridge", action, payload || {}); + } + if (window.HyAppBridgeChannel && window.HyAppBridgeChannel.postMessage) { + window.HyAppBridgeChannel.postMessage(message); + return Promise.resolve(); + } + if (window.FlutterBridge && window.FlutterBridge.postMessage) { + window.FlutterBridge.postMessage(message); + return Promise.resolve(); + } + if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.HyAppBridge) { + window.webkit.messageHandlers.HyAppBridge.postMessage({ action: action, payload: payload || {} }); + return Promise.resolve(); + } + + window.dispatchEvent(new CustomEvent("hyapp:bridge-message", { detail: { action: action, payload: payload || {} } })); + return Promise.resolve(); + } + + window.HyAppBridge = { + post: post, + back: function () { + return post("back"); + }, + openUser: function (userId) { + return post("openUser", { user_id: String(userId || "") }); + }, + openRoom: function (roomId) { + return post("openRoom", { room_id: String(roomId || "") }); + }, + openRankNote: function (boardType) { + return post("openRankNote", { board_type: String(boardType || "") }); + }, + ready: function (payload) { + return post("ready", payload || {}); + } + }; +})(); diff --git a/common/params.js b/common/params.js new file mode 100644 index 0000000..c6e52a3 --- /dev/null +++ b/common/params.js @@ -0,0 +1,72 @@ +(function () { + var TOKEN_KEYS = ["token", "access_token", "accessToken"]; + var APP_CODE_KEYS = ["app_code", "appCode"]; + var USER_READY_EVENT = "hyapp:user-ready"; + var USER_ERROR_EVENT = "hyapp:user-error"; + + function query() { + var params = new URLSearchParams(window.location.search); + var hash = window.location.hash || ""; + if (hash.indexOf("?") >= 0) { + new URLSearchParams(hash.split("?").slice(1).join("?")).forEach(function (value, key) { + if (!params.has(key)) params.set(key, value); + }); + } + return params; + } + + function first(params, keys) { + for (var i = 0; i < keys.length; i += 1) { + var value = params.get(keys[i]); + if (value) return value; + } + return ""; + } + + function parse() { + var params = query(); + var token = first(params, TOKEN_KEYS); + var appCode = first(params, APP_CODE_KEYS) || window.localStorage.getItem("hyapp_app_code") || "lalu"; + + if (window.HyAppAPI) { + if (token) window.HyAppAPI.setAccessToken(token); + if (appCode) window.HyAppAPI.setAppCode(appCode); + } + + return { + token: token || (window.HyAppAPI && window.HyAppAPI.getAccessToken ? window.HyAppAPI.getAccessToken() : ""), + app_code: appCode, + raw: params + }; + } + + function emit(name, detail) { + window.dispatchEvent(new CustomEvent(name, { detail: detail })); + } + + function loadUser() { + var current = parse(); + if (!current.token || !window.HyAppAPI) { + return Promise.resolve(null); + } + return window.HyAppAPI.get("/api/v1/users/me/overview").then(function (userInfo) { + current.user = userInfo; + window.HyAppParams.current = current; + emit(USER_READY_EVENT, current); + return current; + }).catch(function (error) { + current.error = error; + window.HyAppParams.current = current; + emit(USER_ERROR_EVENT, current); + throw error; + }); + } + + window.HyAppParams = { + parse: parse, + loadUser: loadUser, + current: parse(), + USER_READY_EVENT: USER_READY_EVENT, + USER_ERROR_EVENT: USER_ERROR_EVENT + }; +})(); diff --git a/docs/Flutter-H5-排行榜对接文档.md b/docs/Flutter-H5-排行榜对接文档.md new file mode 100644 index 0000000..cb5e55c --- /dev/null +++ b/docs/Flutter-H5-排行榜对接文档.md @@ -0,0 +1,267 @@ +# Flutter H5 排行榜对接文档 + +本文说明 Flutter App 如何打开 H5 排行榜、向 H5 传递登录态,以及接收 H5 通过 JSBridge 发回的导航事件。 + +## 入口地址 + +本地开发: + +```text +http://127.0.0.1:30000/rank/index.html?env=local&token={access_token}&app_code=lalu +``` + +测试环境: + +```text +https://{h5-test-domain}/rank/index.html?env=test&token={access_token}&app_code=lalu +``` + +线上环境: + +```text +https://{h5-domain}/rank/index.html?token={access_token}&app_code=lalu +``` + +`env` 规则: + +| 参数 | 说明 | +| --- | --- | +| 不传 | H5 请求 `https://api.global-interaction.com/` | +| `env=test` | H5 请求 `https://api-test.global-interaction.com/` | +| `env=local` | H5 请求 `http://localhost:13000/`,用于本机 gateway 联调 | + +## URL 参数 + +| 参数 | 必填 | 说明 | +| --- | --- | --- | +| `token` | 是 | App 登录后拿到的 access token。H5 会作为 `Authorization: Bearer ` 调后端。 | +| `access_token` | 否 | `token` 的兼容别名。 | +| `accessToken` | 否 | `token` 的兼容别名。 | +| `app_code` | 是 | App 编码,当前传 `lalu`。H5 会作为 `X-App-Code` 请求头。 | +| `appCode` | 否 | `app_code` 的兼容别名。 | +| `env` | 否 | API 环境切换,见上表。 | + +H5 启动后会调用: + +```http +GET /api/v1/users/me/overview +Authorization: Bearer +X-App-Code: lalu +``` + +这个接口用于确认登录态并缓存当前用户信息。排行榜数据使用同一个 token 和 app code 请求。 + +## H5 页面能力 + +排行榜页面地址: + +```text +/rank/index.html +``` + +页面内包含三个榜单,通过 tab 切换: + +| H5 Tab | 后端 `board_type` | 说明 | +| --- | --- | --- | +| `Wealth` | `sent` | 财富榜,按用户送礼总金币排行 | +| `Room` | `room` | 房间榜,按房间收礼总金币排行 | +| `Charm` | `received` | 魅力榜,按用户收礼总金币排行 | + +时间维度: + +| H5 Tab | 后端 `period` | +| --- | --- | +| `Daily` | `today` | +| `Weekly` | `week` | +| `Monthly` | `month` | + +排行榜接口: + +```http +GET /api/v1/activities/user-leaderboards?board_type=sent&period=today&page=1&page_size=20 +Authorization: Bearer +X-App-Code: lalu +``` + +H5 会分页加载,`page_size=20`。如果无 token 或接口失败,页面会展示 Figma 默认占位数据,不阻塞页面打开。 + +## Flutter JSBridge + +H5 会按以下优先级尝试发送消息: + +1. `window.flutter_inappwebview.callHandler("HyAppBridge", action, payload)` +2. `window.HyAppBridgeChannel.postMessage(jsonString)` +3. `window.FlutterBridge.postMessage(jsonString)` +4. `window.webkit.messageHandlers.HyAppBridge.postMessage({ action, payload })` + +Flutter 只需要实现其中一种即可。推荐统一注册 `HyAppBridge`。 + +### 消息格式 + +除 `flutter_inappwebview.callHandler` 外,postMessage 的消息是 JSON 字符串: + +```json +{ + "action": "openUser", + "payload": { + "user_id": "10001" + } +} +``` + +### H5 发出的 action + +| action | payload | 触发场景 | Flutter 行为 | +| --- | --- | --- | --- | +| `ready` | `{ "page": "rank" }` | H5 初始化完成 | 可关闭加载态 | +| `back` | `{}` | 用户点击左上角返回 | 关闭 WebView 或返回上一页 | +| `openUser` | `{ "user_id": "10001" }` | 点击财富榜/魅力榜用户头像、榜单项 | 打开用户详情页 | +| `openRoom` | `{ "room_id": "room_id" }` | 点击房间榜房间头像、榜单项 | 进入或打开房间详情 | +| `openRankNote` | `{ "board_type": "sent" }` | 点击右上角问号 | 当前 H5 已内置说明页;Flutter 可只打点,不需要跳转 | + +`board_type` 取值: + +| 值 | 说明 | +| --- | --- | +| `sent` | 财富榜 | +| `room` | 房间榜 | +| `received` | 魅力榜 | + +## Flutter 示例 + +### flutter_inappwebview + +```dart +final url = Uri.parse( + 'http://127.0.0.1:30000/rank/index.html' + '?env=local' + '&token=${Uri.encodeComponent(accessToken)}' + '&app_code=lalu', +); + +InAppWebView( + initialUrlRequest: URLRequest(url: WebUri(url.toString())), + onWebViewCreated: (controller) { + controller.addJavaScriptHandler( + handlerName: 'HyAppBridge', + callback: (args) async { + final action = args.isNotEmpty ? args[0] as String : ''; + final payload = args.length > 1 && args[1] is Map + ? Map.from(args[1] as Map) + : {}; + + switch (action) { + case 'ready': + // 关闭 loading + break; + case 'back': + Navigator.of(context).maybePop(); + break; + case 'openUser': + final userId = payload['user_id']?.toString() ?? ''; + if (userId.isNotEmpty) { + // TODO: 打开用户详情页 + } + break; + case 'openRoom': + final roomId = payload['room_id']?.toString() ?? ''; + if (roomId.isNotEmpty) { + // TODO: 打开房间或房间详情页 + } + break; + case 'openRankNote': + // H5 内部已展示说明页,这里可做埋点 + break; + } + return {'ok': true}; + }, + ); + }, +) +``` + +### webview_flutter JavaScriptChannel + +如果使用 `webview_flutter`,可以注入 `HyAppBridgeChannel`: + +```dart +final controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..addJavaScriptChannel( + 'HyAppBridgeChannel', + onMessageReceived: (message) { + final data = jsonDecode(message.message) as Map; + final action = data['action']?.toString() ?? ''; + final payload = data['payload'] is Map + ? Map.from(data['payload'] as Map) + : {}; + + switch (action) { + case 'ready': + break; + case 'back': + Navigator.of(context).maybePop(); + break; + case 'openUser': + final userId = payload['user_id']?.toString() ?? ''; + break; + case 'openRoom': + final roomId = payload['room_id']?.toString() ?? ''; + break; + } + }, + ) + ..loadRequest(Uri.parse(rankUrl)); +``` + +## 后端依赖 + +H5 依赖以下 Gateway HTTP 接口: + +| 接口 | 说明 | +| --- | --- | +| `GET /api/v1/users/me/overview` | 解析 token 后获取当前用户信息 | +| `GET /api/v1/activities/user-leaderboards` | 获取排行榜 | + +业务接口返回 envelope: + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_xxx", + "data": {} +} +``` + +H5 也兼容旧的 `code: 0` 成功格式。 + +## 本地联调 + +在 `hyapp-h5` 目录运行: + +```bash +make run +``` + +默认打开: + +```text +http://127.0.0.1:30000/rank/index.html?env=local +``` + +Flutter 真机调本机服务时,`env=local` 对 H5 来说会请求 `http://localhost:13000/`。如果 WebView 运行在真机上,`localhost` 指向手机自身,不是开发机。真机联调建议: + +1. 使用测试环境 `env=test`。 +2. 或把 H5/API 部署到同一局域网可访问地址。 +3. 或后续给 H5 增加可配置 `api_base` 参数。 + +## 验收点 + +1. Flutter 打开 H5 时带上 `token` 和 `app_code`。 +2. H5 首次加载后发出 `ready`。 +3. 点击左上角返回,Flutter 收到 `back` 并关闭 WebView。 +4. 点击财富榜/魅力榜用户,Flutter 收到 `openUser`。 +5. 点击房间榜房间,Flutter 收到 `openRoom`。 +6. 切换 `Wealth / Room / Charm` 和 `Daily / Weekly / Monthly` 时,H5 正确请求排行榜接口。 +7. 点击右上角问号,H5 展示说明页,返回后仍停留在排行榜页。 diff --git a/index.html b/index.html deleted file mode 100644 index 58c9bdf..0000000 --- a/index.html +++ /dev/null @@ -1 +0,0 @@ -111 diff --git a/rank/assets/charm-bg-a.png b/rank/assets/charm-bg-a.png new file mode 100644 index 0000000..a4fafc3 Binary files /dev/null and b/rank/assets/charm-bg-a.png differ diff --git a/rank/assets/charm-bg-b.png b/rank/assets/charm-bg-b.png new file mode 100644 index 0000000..9f564c2 Binary files /dev/null and b/rank/assets/charm-bg-b.png differ diff --git a/rank/assets/coin.png b/rank/assets/coin.png new file mode 100644 index 0000000..f61a28e Binary files /dev/null and b/rank/assets/coin.png differ diff --git a/rank/assets/default-avatar-large.png b/rank/assets/default-avatar-large.png new file mode 100644 index 0000000..819ed4f Binary files /dev/null and b/rank/assets/default-avatar-large.png differ diff --git a/rank/assets/default-avatar-mid.png b/rank/assets/default-avatar-mid.png new file mode 100644 index 0000000..af10b2e Binary files /dev/null and b/rank/assets/default-avatar-mid.png differ diff --git a/rank/assets/default-avatar-small.png b/rank/assets/default-avatar-small.png new file mode 100644 index 0000000..9d45cc5 Binary files /dev/null and b/rank/assets/default-avatar-small.png differ diff --git a/rank/assets/note-bg.png b/rank/assets/note-bg.png new file mode 100644 index 0000000..96c9acb Binary files /dev/null and b/rank/assets/note-bg.png differ diff --git a/rank/assets/note-border.png b/rank/assets/note-border.png new file mode 100644 index 0000000..511ae04 Binary files /dev/null and b/rank/assets/note-border.png differ diff --git a/rank/assets/period-active.png b/rank/assets/period-active.png new file mode 100644 index 0000000..f86a692 Binary files /dev/null and b/rank/assets/period-active.png differ diff --git a/rank/assets/reward.png b/rank/assets/reward.png new file mode 100644 index 0000000..d1d152e Binary files /dev/null and b/rank/assets/reward.png differ diff --git a/rank/assets/side-frame-blue.png b/rank/assets/side-frame-blue.png new file mode 100644 index 0000000..63b1d1c Binary files /dev/null and b/rank/assets/side-frame-blue.png differ diff --git a/rank/assets/side-frame-red.png b/rank/assets/side-frame-red.png new file mode 100644 index 0000000..8c67f19 Binary files /dev/null and b/rank/assets/side-frame-red.png differ diff --git a/rank/assets/top-frame.png b/rank/assets/top-frame.png new file mode 100644 index 0000000..31edfac Binary files /dev/null and b/rank/assets/top-frame.png differ diff --git a/rank/assets/vip.png b/rank/assets/vip.png new file mode 100644 index 0000000..7e33063 Binary files /dev/null and b/rank/assets/vip.png differ diff --git a/rank/assets/wealth-bg.png b/rank/assets/wealth-bg.png new file mode 100644 index 0000000..32de4f4 Binary files /dev/null and b/rank/assets/wealth-bg.png differ diff --git a/rank/caifu.html b/rank/caifu.html deleted file mode 100644 index e69de29..0000000 diff --git a/rank/index.html b/rank/index.html new file mode 100644 index 0000000..40a2682 --- /dev/null +++ b/rank/index.html @@ -0,0 +1,481 @@ + + + + + + Ranking + + + +
+
+ + +
+ +
+
+
+
+
+ +
+ +
+ +
Note
+
+
+ + + +
+
+
+
Ranking Rewards
+
+ + + +
+
+
+
+
+ +
+ + + + + + + diff --git a/rank/meili.html b/rank/meili.html deleted file mode 100644 index e69de29..0000000 diff --git a/rank/room.html b/rank/room.html deleted file mode 100644 index e69de29..0000000 diff --git a/scripts/h5_dev_server.py b/scripts/h5_dev_server.py new file mode 100644 index 0000000..8b9bbc8 --- /dev/null +++ b/scripts/h5_dev_server.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import http.server +import os +import socketserver +import urllib.error +import urllib.parse +import urllib.request + + +PROXIES = { + "/__api_test__/": "https://api-test.global-interaction.com/", + "/__api_local__/": "http://localhost:13000/", +} + +HOP_BY_HOP_HEADERS = { + "connection", + "content-length", + "host", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +} + + +class H5DevHandler(http.server.SimpleHTTPRequestHandler): + def do_OPTIONS(self) -> None: + if self.proxy_base() is None: + self.send_response(204) + self.end_headers() + return + self.send_response(204) + self.send_cors_headers() + self.end_headers() + + def do_GET(self) -> None: + if self.path == "/__h5_dev_server__/health": + self.send_response(200) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", "2") + self.end_headers() + self.wfile.write(b"ok") + return + self.handle_request() + + def do_HEAD(self) -> None: + if self.path == "/__h5_dev_server__/health": + self.send_response(200) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", "2") + self.end_headers() + return + if self.proxy_base() is None: + super().do_HEAD() + return + self.proxy_request() + + def do_POST(self) -> None: + self.handle_request() + + def do_PUT(self) -> None: + self.handle_request() + + def do_PATCH(self) -> None: + self.handle_request() + + def do_DELETE(self) -> None: + self.handle_request() + + def handle_request(self) -> None: + if self.proxy_base() is None: + super().do_GET() + return + self.proxy_request() + + def proxy_base(self) -> tuple[str, str] | None: + parsed = urllib.parse.urlsplit(self.path) + for prefix, target in PROXIES.items(): + if parsed.path.startswith(prefix): + return prefix, target + return None + + def proxy_request(self) -> None: + match = self.proxy_base() + if match is None: + self.send_error(404) + return + prefix, target_base = match + parsed = urllib.parse.urlsplit(self.path) + target_path = parsed.path[len(prefix):] + target_url = urllib.parse.urljoin(target_base, target_path) + if parsed.query: + target_url = target_url + "?" + parsed.query + + body = None + if self.command in {"POST", "PUT", "PATCH"}: + length = int(self.headers.get("Content-Length", "0") or "0") + body = self.rfile.read(length) if length > 0 else b"" + + headers = { + key: value + for key, value in self.headers.items() + if key.lower() not in HOP_BY_HOP_HEADERS + } + headers["Accept-Encoding"] = "identity" + + request = urllib.request.Request( + target_url, + data=body, + headers=headers, + method=self.command, + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + self.send_response(response.status) + self.copy_response_headers(response.headers) + self.send_cors_headers() + self.end_headers() + self.write_body(response.read()) + except urllib.error.HTTPError as error: + self.send_response(error.code) + self.copy_response_headers(error.headers) + self.send_cors_headers() + self.end_headers() + self.write_body(error.read()) + except Exception as error: + self.send_response(502) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_cors_headers() + self.end_headers() + message = str(error).replace('"', '\\"') + self.write_body((f'{{"code":"PROXY_ERROR","message":"{message}"}}').encode("utf-8")) + + def write_body(self, body: bytes) -> None: + if self.command == "HEAD": + return + try: + self.wfile.write(body) + except BrokenPipeError: + return + + def copy_response_headers(self, headers: http.client.HTTPMessage) -> None: + for key, value in headers.items(): + if key.lower() in HOP_BY_HOP_HEADERS: + continue + if key.lower() == "content-encoding": + continue + self.send_header(key, value) + + def send_cors_headers(self) -> None: + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Authorization,Content-Type,X-App-Code,X-Request-ID") + + def end_headers(self) -> None: + self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") + self.send_header("Pragma", "no-cache") + self.send_header("Expires", "0") + super().end_headers() + + +class ReusableTCPServer(socketserver.ThreadingTCPServer): + allow_reuse_address = True + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=30000) + parser.add_argument("--directory", default=os.getcwd()) + args = parser.parse_args() + + handler = lambda *handler_args, **handler_kwargs: H5DevHandler( + *handler_args, + directory=args.directory, + **handler_kwargs, + ) + with ReusableTCPServer((args.host, args.port), handler) as server: + print(f"Serving H5 at http://{args.host}:{args.port}/") + print("API proxy: /__api_test__/ -> https://api-test.global-interaction.com/") + print("API proxy: /__api_local__/ -> http://localhost:13000/") + server.serve_forever() + + +if __name__ == "__main__": + main()