0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Swift→ReactのWebSocket通信で画像を送信する

0
Posted at

はじめに

Swift からテキストと画像を送り、React で追記表示する最小構成です。ブラウザは生 TCP を扱えないので、通信はすべて WebSocket に揃えています。

Swift --WebSocket--> Node :9001 --WebSocket--> React :5173

メッセージは 1 フレーム 1 通の JSON です。

{"type":"text","text":"こんにちは"}
{"type":"image","mime":"image/png","data":"..."}

起動は次のとおりです。

cd typescript && npm run dev
cd swift
swift run SocketClient こんにちは
swift run SocketClient ../samples/venusaur.png
swift run SocketClient ../samples/charizard.png
swift run SocketClient ../samples/blastoise.png

表示は http://127.0.0.1:5173/ です。

成果物

ws_image.gif

ディレクトリ構成

typescript_swift_study/
├── samples/
│   ├── venusaur.png
│   ├── charizard.png
│   └── blastoise.png
├── swift/
│   ├── Package.swift
│   └── Sources/SocketClient/
│       ├── main.swift
│       ├── Payload.swift
│       ├── Message.swift
│       └── WebSocketClient.swift
└── typescript/
    ├── package.json
    ├── tsconfig.json
    ├── src/
    │   ├── server.ts
    │   ├── config.ts
    │   ├── message.ts
    │   └── relay.ts
    └── web/
        ├── index.html
        ├── tsconfig.json
        ├── vite.config.ts
        └── src/
            ├── main.tsx
            ├── App.tsx
            ├── useMessages.ts
            ├── message.ts
            ├── MessageView.tsx
            ├── config.ts
            └── styles.css

コード

Swift

main.swift は起動だけです。引数の解釈、JSON 化、送信は分けています。

swift/Package.swift

// swift-tools-version: 5.9
import PackageDescription

let package = Package(
    name: "SocketClient",
    platforms: [.macOS(.v13)],
    targets: [
        .executableTarget(name: "SocketClient")
    ]
)

swift/Sources/SocketClient/main.swift

let message = Payload.fromCommandLine()
WebSocketClient.send(message.json)
print("sent: \(message.summary)")

swift/Sources/SocketClient/Payload.swift

import Foundation

enum Payload {
    static func fromCommandLine() -> Message {
        let joined = ProcessInfo.processInfo.arguments.dropFirst().joined(separator: " ")
        if joined.isEmpty {
            return .text("hello from Swift")
        }

        let path = (joined as NSString).expandingTildeInPath
        var isDir: ObjCBool = false
        if FileManager.default.fileExists(atPath: path, isDirectory: &isDir),
           !isDir.boolValue,
           let mime = mimeType(for: path),
           let bytes = FileManager.default.contents(atPath: path)
        {
            return .image(path: path, mime: mime, bytes: bytes)
        }

        return .text(joined)
    }

    private static func mimeType(for path: String) -> String? {
        switch URL(fileURLWithPath: path).pathExtension.lowercased() {
        case "png": return "image/png"
        case "jpg", "jpeg": return "image/jpeg"
        case "gif": return "image/gif"
        case "webp": return "image/webp"
        default: return nil
        }
    }
}

swift/Sources/SocketClient/Message.swift

import Foundation

enum Message {
    case text(String)
    case image(path: String, mime: String, bytes: Data)

    var json: String {
        let body: [String: String]
        switch self {
        case .text(let text):
            body = ["type": "text", "text": text]
        case .image(_, let mime, let bytes):
            body = [
                "type": "image",
                "mime": mime,
                "data": bytes.base64EncodedString(),
            ]
        }
        return String(data: try! JSONSerialization.data(withJSONObject: body), encoding: .utf8)!
    }

    var summary: String {
        switch self {
        case .text(let text):
            return text
        case .image(let path, _, let bytes):
            return "image \(path) (\(bytes.count) bytes)"
        }
    }
}

swift/Sources/SocketClient/WebSocketClient.swift

import Foundation

enum WebSocketClient {
    static func send(_ json: String, to url: URL = URL(string: "ws://127.0.0.1:9001")!) {
        let session = URLSession(configuration: .default)
        let task = session.webSocketTask(with: url)
        task.resume()

        let sent = DispatchSemaphore(value: 0)
        task.send(.string(json)) { error in
            if let error {
                fputs("send failed: \(error)\n", stderr)
                exit(1)
            }
            sent.signal()
        }

        if sent.wait(timeout: .now() + 10) == .timedOut {
            fputs("send timeout\n", stderr)
            exit(1)
        }

        task.cancel(with: .normalClosure, reason: nil)
        session.finishTasksAndInvalidate()
    }
}

Node(中継)

受け取ったフレームを、送り主以外へ broadcast します。

typescript/package.json

{
  "name": "socket-server",
  "private": true,
  "type": "module",
  "scripts": {
    "start": "tsx src/server.ts",
    "web": "vite --config web/vite.config.ts",
    "dev": "concurrently -k \"npm:start\" \"npm:web\""
  },
  "dependencies": {
    "react": "^19.1.1",
    "react-dom": "^19.1.1",
    "ws": "^8.18.3"
  },
  "devDependencies": {
    "@types/node": "^22.18.8",
    "@types/react": "^19.1.13",
    "@types/react-dom": "^19.1.9",
    "@types/ws": "^8.18.1",
    "@vitejs/plugin-react": "^5.0.4",
    "concurrently": "^9.2.1",
    "tsx": "^4.20.6",
    "typescript": "^5.9.3",
    "vite": "^7.1.7"
  }
}

typescript/tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "types": ["node"],
    "skipLibCheck": true
  },
  "include": ["src"]
}

typescript/src/server.ts

import { startRelay } from "./relay.js";

startRelay();

typescript/src/config.ts

export const HOST = "127.0.0.1";
export const WS_PORT = 9001;
export const MAX_PAYLOAD = 20 * 1024 * 1024;

typescript/src/message.ts

export function summarize(raw: string): string {
  try {
    const parsed = JSON.parse(raw) as {
      type?: string;
      text?: string;
      mime?: string;
      data?: string;
    };
    if (parsed.type === "image") {
      return `image ${parsed.mime ?? ""} (${parsed.data?.length ?? 0} chars b64)`;
    }
    if (parsed.type === "text") {
      return parsed.text ?? "";
    }
  } catch {
    // keep raw
  }
  return raw;
}

typescript/src/relay.ts

import { WebSocketServer, WebSocket } from "ws";
import { HOST, MAX_PAYLOAD, WS_PORT } from "./config.js";
import { summarize } from "./message.js";

function broadcast(wss: WebSocketServer, text: string, except: WebSocket) {
  for (const client of wss.clients) {
    if (client !== except && client.readyState === WebSocket.OPEN) {
      client.send(text);
    }
  }
}

export function startRelay() {
  const wss = new WebSocketServer({ host: HOST, port: WS_PORT, maxPayload: MAX_PAYLOAD });

  wss.on("listening", () => {
    console.log(`WebSocket listening on ws://${HOST}:${WS_PORT}`);
  });

  wss.on("connection", (socket, request) => {
    const peer = request.socket.remoteAddress ?? "unknown";
    console.log(`connected: ${peer}`);

    socket.on("message", (data) => {
      const text = String(data);
      console.log(`[${peer}] ${summarize(text)}`);
      broadcast(wss, text, socket);
    });

    socket.on("close", () => {
      console.log(`disconnected: ${peer}`);
    });

    socket.on("error", (err) => {
      console.error(`socket error (${peer}):`, err.message);
    });
  });
}

React(表示)

受信した JSON を解釈し、テキストと画像を追記します。

typescript/web/index.html

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>messages</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

typescript/web/tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "jsx": "react-jsx",
    "strict": true,
    "skipLibCheck": true,
    "types": ["vite/client"]
  },
  "include": ["src", "vite.config.ts"]
}

typescript/web/vite.config.ts

import path from "node:path";
import { fileURLToPath } from "node:url";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

const root = path.dirname(fileURLToPath(import.meta.url));

export default defineConfig({
  root,
  plugins: [react()],
  server: {
    host: "127.0.0.1",
    port: 5173,
  },
});

typescript/web/src/main.tsx

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles.css";

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

typescript/web/src/App.tsx

import { MessageView } from "./MessageView";
import { useMessages } from "./useMessages";

export function App() {
  const messages = useMessages();

  return (
    <main>
      {messages.length === 0 ? <p>待機中</p> : null}
      {messages.map((message, index) => (
        <MessageView key={index} message={message} />
      ))}
    </main>
  );
}

typescript/web/src/useMessages.ts

import { useEffect, useState } from "react";
import { WS_URL } from "./config";
import { parseMessage, type Message } from "./message";

export function useMessages() {
  const [messages, setMessages] = useState<Message[]>([]);

  useEffect(() => {
    const ws = new WebSocket(WS_URL);
    ws.onmessage = (event) => {
      setMessages((prev) => [...prev, parseMessage(String(event.data))]);
    };
    return () => {
      ws.onmessage = null;
      if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
        ws.close();
      }
    };
  }, []);

  return messages;
}

typescript/web/src/message.ts

export type Message =
  | { kind: "text"; text: string }
  | { kind: "image"; mime: string; data: string };

export function parseMessage(raw: string): Message {
  try {
    const parsed = JSON.parse(raw) as {
      type?: string;
      text?: string;
      mime?: string;
      data?: string;
    };
    if (parsed.type === "image" && parsed.data) {
      return { kind: "image", mime: parsed.mime ?? "image/png", data: parsed.data };
    }
    if (parsed.type === "text") {
      return { kind: "text", text: parsed.text ?? "" };
    }
  } catch {
    // keep as plain text
  }
  return { kind: "text", text: raw };
}

typescript/web/src/MessageView.tsx

import type { Message } from "./message";

export function MessageView({ message }: { message: Message }) {
  if (message.kind === "image") {
    return <img src={`data:${message.mime};base64,${message.data}`} alt="" />;
  }
  return <div>{message.text}</div>;
}

typescript/web/src/config.ts

export const WS_URL = "ws://127.0.0.1:9001";

typescript/web/src/styles.css

:root {
  color-scheme: light;
}

body {
  margin: 1rem;
  color: #111;
  background: #fff;
  font-family: sans-serif;
}

img {
  max-width: 320px;
  height: auto;
  display: block;
  margin: 0.5rem 0;
}
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?