PAYATHON 2026

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

浏览器可能会将其视为不安全连接或混合内容。不同浏览器和部署策略的处理方式也可能不同,因此不应依赖某个浏览器当前的特殊行为。

比较稳妥的选择有三种:

  1. 让本地桥接程序提供 wss://localhost,并在终端安装受信任的证书。
  2. 将系统限定在受管设备和指定浏览器中,通过统一安装程序部署证书及安全策略。
  3. 让本地桥接程序主动连接云端的 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 + 本地桥接程序”的方案:

  1. Rails 负责业务流程、订单状态和终端配置。
  2. 浏览器向本地桥接程序发送结构化支付请求。
  3. 桥接程序校验来源、令牌、金额和请求编号。
  4. 桥接程序通过 TCP、文件或厂商 SDK 调用 pinpad。
  5. 桥接程序将设备结果返回给浏览器。
  6. 浏览器把确认结果提交给 Rails。
  7. Rails 使用唯一的 requestId 和明确的状态机处理重试与对账。

支付超时不能直接视为支付失败。设备或处理机构可能已经完成交易,只是响应在返回途中丢失。因此,状态至少应区分为:

pending
approved
declined
cancelled
unknown

出现 unknown 状态后,应调用厂商提供的查询或恢复机制,不能直接自动重试扣款。

不必继续依赖 VBScript 和 COM,但仍需保留一个受控的本地组件。这个组件可以设计成跨平台桥接服务、浏览器扩展或桌面外壳,从而兼顾现代 Web UI、本地设备访问和后续的跨平台部署。

备注:内容仅供参考。