Web App 客户端如何与 localhost 进行 socket 通信?
结论
浏览器中的 JavaScript 无法直接连接任意 TCP/UDP socket,也不能在后台创建或修改本地 text file。因此,网页不能直接复刻 VBScript + COM 的工作方式。
WebSocket 可以连接 localhost,但本地程序必须实现 WebSocket 协议。如果第三方 pinpad 软件只提供普通 TCP socket,浏览器就无法直接连接,需要在终端安装一个轻量级的本地桥接程序:
浏览器页面
↕ HTTPS / WSS
本地桥接程序(localhost)
↕ TCP socket、text file 或厂商 SDK
第三方 pinpad 软件
这通常是最合适的跨平台方案。桥接程序可以用 .NET、Java、Go、Rust 或 Node.js 编写,再分别打包到 Windows、macOS 和 Linux。
为什么不能直接使用普通 socket
浏览器没有向网页开放类似下面这样的底层网络接口:
const socket = new TcpSocket("127.0.0.1", 12345);
普通网页主要只能使用浏览器允许的协议,例如:
- HTTP/HTTPS
- WebSocket
- WebRTC
- WebTransport(需要服务端支持相应协议,也不是任意 TCP 客户端)
这是浏览器安全边界的一部分。如果网页可以随意访问底层网络,任何网站都可能扫描访客电脑的端口,连接本地数据库、调试服务或其他程序。
还要注意,Rails 服务器上的 localhost 指向 Rails 所在的服务器,不是远程终端 PC。只有浏览器中运行的代码连接 localhost,指向的才是用户当前使用的终端。
WebSocket 是否可用
可以,但 WebSocket 并不是套在普通 TCP socket 外面的一层浏览器接口。
网页可以像下面这样连接本地 WebSocket 服务:
const socket = new WebSocket("wss://localhost:18443");
socket.addEventListener("open", () => {
socket.send(JSON.stringify({
type: "payment.request",
requestId: crypto.randomUUID(),
amountMinor: 1250,
currency: "GBP"
}));
});
socket.addEventListener("message", event => {
const response = JSON.parse(event.data);
if (response.type === "payment.response") {
console.log("Payment result:", response);
}
});
socket.addEventListener("error", error => {
console.error("Local payment service unavailable", error);
});
如果原有第三方软件只监听普通 TCP 端口,下面的代码不会成功:
new WebSocket("ws://localhost:12345");
即使端口号正确也不行。WebSocket 建立连接前会执行 HTTP Upgrade 握手,而普通 TCP 服务通常无法识别这种握手。
本地桥接程序需要在一侧接受 WebSocket 连接,再在另一侧按照厂商规定的格式发送 TCP 请求并处理响应。
本地桥接程序示例
下面是一个简化的 Node.js 示例,仅用于说明架构。生产环境还需要加入 TLS、身份认证、Origin 校验、超时控制和消息格式校验。
import { WebSocketServer } from "ws";
import net from "node:net";
const webSocketServer = new WebSocketServer({
host: "127.0.0.1",
port: 18443
});
webSocketServer.on("connection", (browserSocket, request) => {
const allowedOrigins = new Set([
"https://payments.example.com"
]);
const origin = request.headers.origin;
if (!allowedOrigins.has(origin)) {
browserSocket.close(1008, "Origin not allowed");
return;
}
browserSocket.on("message", message => {
let requestData;
try {
requestData = JSON.parse(message.toString());
} catch {
browserSocket.send(JSON.stringify({
type: "error",
code: "INVALID_JSON"
}));
return;
}
const vendorSocket = net.createConnection({
host: "127.0.0.1",
port: 9100
});
vendorSocket.setTimeout(30_000);
vendorSocket.on("connect", () => {
const vendorRequest =
`${requestData.requestId}|${requestData.amountMinor}|${requestData.currency}\n`;
vendorSocket.write(vendorRequest);
});
vendorSocket.on("data", data => {
browserSocket.send(JSON.stringify({
type: "payment.response",
requestId: requestData.requestId,
rawResponse: data.toString("utf8")
}));
vendorSocket.end();
});
vendorSocket.on("timeout", () => {
vendorSocket.destroy();
browserSocket.send(JSON.stringify({
type: "payment.response",
requestId: requestData.requestId,
status: "unknown",
error: "PINPAD_TIMEOUT"
}));
});
vendorSocket.on("error", error => {
browserSocket.send(JSON.stringify({
type: "payment.response",
requestId: requestData.requestId,
status: "failed",
error: "PINPAD_CONNECTION_FAILED",
message: error.message
}));
});
});
});
实际使用的请求终止符、编码、长度字段、校验和及响应边界,都必须按照 pinpad 软件供应商的协议实现。不能假定每次触发 data 事件时,收到的都是一个完整响应。
HTTPS 页面与 localhost 的限制
如果托管页面使用 HTTPS,直接连接:
ws://localhost:18443
浏览器可能会将其视为不安全连接或混合内容。不同浏览器和部署策略的处理方式也可能不同,因此不应依赖某个浏览器当前的特殊行为。
比较稳妥的选择有三种:
- 让本地桥接程序提供
wss://localhost,并在终端安装受信任的证书。 - 将系统限定在受管设备和指定浏览器中,通过统一安装程序部署证书及安全策略。
- 让本地桥接程序主动连接云端的
wss://服务,再由网页通过 Rails 后端向对应终端发送指令。
第三种架构如下:
网页 → Rails → 云端 WebSocket → 本地桥接程序 → pinpad
由于本地程序会主动向外建立连接,这种方式通常能避开本地 HTTPS 证书、入站防火墙和浏览器访问 loopback 服务等问题。相应地,系统需要处理服务器中转、终端身份映射和断线恢复,复杂度也会增加。
浏览器针对本地网络访问的安全策略仍在变化。正式部署前,需要在目标浏览器、目标操作系统和实际企业策略下进行测试,不能只看开发环境中能否成功连接。
CORS 不是唯一的安全控制
WebSocket 并不完全遵循普通 fetch 的 CORS 流程,但浏览器会发送 Origin 请求头,因此本地桥接程序必须自行验证该请求头。
至少需要采取以下措施:
- 只监听
127.0.0.1或::1,不要监听0.0.0.0。 - 只允许正式网站的精确 Origin,例如
https://payments.example.com。 - 不要将“请求来自 localhost”视为身份认证。
- 为终端和会话使用短期令牌、随机 nonce 或挑战响应。
- 校验消息类型、金额、币种、长度和取值范围。
- 为连接、请求和设备响应分别设置超时。
- 限制并发支付请求,防止重复扣款。
- 记录
requestId、终端编号和状态变化,但不要记录 PIN、完整卡号或敏感磁道数据。 - 对安装包和自动更新程序进行代码签名。
只检查 Origin 仍然不够,因为其他本地进程可以绕过浏览器,直接连接该端口。
能否通过 JavaScript 创建 text file
普通网页可以使用 Blob 生成文件并触发下载:
const content = "PAYMENT|1250|GBP\n";
const blob = new Blob([content], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "payment-request.txt";
link.click();
URL.revokeObjectURL(url);
但这种方式不适合用于可靠的 pinpad 集成,原因包括:
- 浏览器通常不能在后台把文件写入第三方软件监控的固定目录。
- 下载操作可能需要用户触发。
- 遇到同名文件时,浏览器可能自动重命名。
- 网页无法可靠地等待第三方程序修改响应文件。
- 页面刷新或浏览器重启后,文件选择和目录权限可能失效。
- File System Access API 的浏览器支持、授权方式和企业策略并不统一。
如果必须使用厂商提供的文件接口,也应该由本地桥接程序负责创建请求文件、执行原子重命名、监控响应文件并清理过期文件。网页仍然通过 HTTPS 或 WebSocket 与桥接程序通信。
其他可选方案
如果终端是受控设备,还可以考虑以下方案。
浏览器扩展加 Native Messaging
浏览器扩展先与本地原生程序通信,再由原生程序连接 pinpad。这种方式的安全边界比开放 localhost 端口更清楚,但需要安装扩展,并针对目标浏览器的部署政策分别适配。
Electron 或 Tauri 桌面外壳
将 Web UI 包装成桌面应用。本地部分可以使用 TCP、文件或厂商 SDK,界面仍然采用 HTML、CSS 和 JavaScript。这种方案适合专用收银终端,但需要自行负责应用升级和设备管理。
kiosk 应用加内嵌 WebView
创建一个轻量的终端客户端,用它加载在线 Rails 页面,并通过受控的 native bridge 提供有限的支付 API。不要向网页开放通用文件系统或任意 socket 访问能力。
厂商提供的跨平台 SDK
应先向 pinpad 软件供应商确认是否已经提供:
- 本地 HTTP/WebSocket API
- 跨平台 SDK
- 浏览器扩展
- 云端支付终端 API
- 已认证的支付集成组件
支付设备通常涉及认证和合规要求。优先采用供应商正式支持的接口,通常比自行解析私有协议更稳妥。
推荐实施方式
对于受管的支付终端,建议采用“在线 Web UI + 本地桥接程序”的方案:
- Rails 负责业务流程、订单状态和终端配置。
- 浏览器向本地桥接程序发送结构化支付请求。
- 桥接程序校验来源、令牌、金额和请求编号。
- 桥接程序通过 TCP、文件或厂商 SDK 调用 pinpad。
- 桥接程序将设备结果返回给浏览器。
- 浏览器把确认结果提交给 Rails。
- Rails 使用唯一的
requestId和明确的状态机处理重试与对账。
支付超时不能直接视为支付失败。设备或处理机构可能已经完成交易,只是响应在返回途中丢失。因此,状态至少应区分为:
pending
approved
declined
cancelled
unknown
出现 unknown 状态后,应调用厂商提供的查询或恢复机制,不能直接自动重试扣款。
不必继续依赖 VBScript 和 COM,但仍需保留一个受控的本地组件。这个组件可以设计成跨平台桥接服务、浏览器扩展或桌面外壳,从而兼顾现代 Web UI、本地设备访问和后续的跨平台部署。
备注:内容仅供参考。
