mirror of
https://github.com/LukeHagar/crossws.git
synced 2025-12-06 12:27:46 +00:00
feat: experimental sse adapter (#62)
This commit is contained in:
62
docs/2.adapters/sse.md
Normal file
62
docs/2.adapters/sse.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
---
|
||||||
|
icon: oui:token-event
|
||||||
|
---
|
||||||
|
|
||||||
|
# SSE
|
||||||
|
|
||||||
|
> Integrate CrossWS with [server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events).
|
||||||
|
|
||||||
|
If your deployment server is incapable of of handling WebSocket upgrades but support standard web API ([`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) and [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response)) you can integrate crossws to act as a one way (server to client) handler using [server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events).
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> This is an experimental adapter and works only with a limited subset of CrossWS functionalities.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> Instead of [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) client you need to use [`EventSource`](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) as client to connect such server.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import sseAdapter from "crossws/adapters/sse";
|
||||||
|
|
||||||
|
const sse = sseAdapter({
|
||||||
|
hooks: {
|
||||||
|
upgrade(request) {
|
||||||
|
// Handle upgrade logic
|
||||||
|
// You can return a custom response to abort
|
||||||
|
// You can return { headers } to override default headers
|
||||||
|
},
|
||||||
|
open(peer) {
|
||||||
|
// Use this hook to send messages to peer
|
||||||
|
peer.send("hello!");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Inside your Web compatible server handler:
|
||||||
|
|
||||||
|
```js
|
||||||
|
async fetch(request) {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
|
||||||
|
// Handle SSE
|
||||||
|
if (url.pathname === "/sse" && request.headers.get("accept") === "text/event-stream") {
|
||||||
|
return sse.fetch(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("server is up!")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In order to connect to the server, you need to use [`EventSource`](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) as client:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const ev = new EventSource("http://<server>/sse");
|
||||||
|
|
||||||
|
ev.addEventListener("message", (event) => {
|
||||||
|
console.log(event.data); // hello!
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
::read-more
|
||||||
|
See [`playground/sse.ts`](https://github.com/unjs/crossws/tree/main/playground/sse.ts) for demo and [`src/adapters/sse.ts`](https://github.com/unjs/crossws/tree/main/src/adapters/sse.ts) for implementation.
|
||||||
|
::
|
||||||
@@ -75,6 +75,7 @@
|
|||||||
"play:cf-durable": "wrangler dev --port 3001 -c test/fixture/wrangler-durable.toml",
|
"play:cf-durable": "wrangler dev --port 3001 -c test/fixture/wrangler-durable.toml",
|
||||||
"play:deno": "deno run -A test/fixture/deno.ts",
|
"play:deno": "deno run -A test/fixture/deno.ts",
|
||||||
"play:node": "jiti test/fixture/node.ts",
|
"play:node": "jiti test/fixture/node.ts",
|
||||||
|
"play:sse": "bun test/fixture/sse.ts",
|
||||||
"play:uws": "jiti test/fixture/uws.ts",
|
"play:uws": "jiti test/fixture/uws.ts",
|
||||||
"release": "pnpm test && pnpm build && changelogen --release && npm publish && git push --follow-tags",
|
"release": "pnpm test && pnpm build && changelogen --release && npm publish && git push --follow-tags",
|
||||||
"test": "pnpm lint && pnpm test:types && vitest run",
|
"test": "pnpm lint && pnpm test:types && vitest run",
|
||||||
@@ -87,6 +88,7 @@
|
|||||||
"@cloudflare/workers-types": "^4.20240729.0",
|
"@cloudflare/workers-types": "^4.20240729.0",
|
||||||
"@deno/types": "^0.0.1",
|
"@deno/types": "^0.0.1",
|
||||||
"@types/bun": "^1.1.6",
|
"@types/bun": "^1.1.6",
|
||||||
|
"@types/eventsource": "^1.1.15",
|
||||||
"@types/node": "^22.1.0",
|
"@types/node": "^22.1.0",
|
||||||
"@types/web": "^0.0.153",
|
"@types/web": "^0.0.153",
|
||||||
"@types/ws": "^8.5.12",
|
"@types/ws": "^8.5.12",
|
||||||
@@ -96,6 +98,7 @@
|
|||||||
"consola": "^3.2.3",
|
"consola": "^3.2.3",
|
||||||
"eslint": "^9.8.0",
|
"eslint": "^9.8.0",
|
||||||
"eslint-config-unjs": "^0.3.2",
|
"eslint-config-unjs": "^0.3.2",
|
||||||
|
"eventsource": "^2.0.2",
|
||||||
"execa": "^9.3.0",
|
"execa": "^9.3.0",
|
||||||
"get-port-please": "^3.1.2",
|
"get-port-please": "^3.1.2",
|
||||||
"h3": "^1.12.0",
|
"h3": "^1.12.0",
|
||||||
|
|||||||
17
pnpm-lock.yaml
generated
17
pnpm-lock.yaml
generated
@@ -20,6 +20,9 @@ importers:
|
|||||||
'@types/bun':
|
'@types/bun':
|
||||||
specifier: ^1.1.6
|
specifier: ^1.1.6
|
||||||
version: 1.1.6
|
version: 1.1.6
|
||||||
|
'@types/eventsource':
|
||||||
|
specifier: ^1.1.15
|
||||||
|
version: 1.1.15
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.1.0
|
specifier: ^22.1.0
|
||||||
version: 22.1.0
|
version: 22.1.0
|
||||||
@@ -47,6 +50,9 @@ importers:
|
|||||||
eslint-config-unjs:
|
eslint-config-unjs:
|
||||||
specifier: ^0.3.2
|
specifier: ^0.3.2
|
||||||
version: 0.3.2(eslint@9.8.0)(typescript@5.5.4)
|
version: 0.3.2(eslint@9.8.0)(typescript@5.5.4)
|
||||||
|
eventsource:
|
||||||
|
specifier: ^2.0.2
|
||||||
|
version: 2.0.2
|
||||||
execa:
|
execa:
|
||||||
specifier: ^9.3.0
|
specifier: ^9.3.0
|
||||||
version: 9.3.0
|
version: 9.3.0
|
||||||
@@ -1110,6 +1116,9 @@ packages:
|
|||||||
'@types/estree@1.0.5':
|
'@types/estree@1.0.5':
|
||||||
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
|
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
|
||||||
|
|
||||||
|
'@types/eventsource@1.1.15':
|
||||||
|
resolution: {integrity: sha512-XQmGcbnxUNa06HR3VBVkc9+A2Vpi9ZyLJcdS5dwaQQ/4ZMWFO+5c90FnMUpbtMZwB/FChoYHwuVg8TvkECacTA==}
|
||||||
|
|
||||||
'@types/mdast@3.0.15':
|
'@types/mdast@3.0.15':
|
||||||
resolution: {integrity: sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==}
|
resolution: {integrity: sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==}
|
||||||
|
|
||||||
@@ -1713,6 +1722,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
eventsource@2.0.2:
|
||||||
|
resolution: {integrity: sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
execa@5.1.1:
|
execa@5.1.1:
|
||||||
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
|
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -3874,6 +3887,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/estree@1.0.5': {}
|
'@types/estree@1.0.5': {}
|
||||||
|
|
||||||
|
'@types/eventsource@1.1.15': {}
|
||||||
|
|
||||||
'@types/mdast@3.0.15':
|
'@types/mdast@3.0.15':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/unist': 2.0.10
|
'@types/unist': 2.0.10
|
||||||
@@ -4712,6 +4727,8 @@ snapshots:
|
|||||||
|
|
||||||
esutils@2.0.3: {}
|
esutils@2.0.3: {}
|
||||||
|
|
||||||
|
eventsource@2.0.2: {}
|
||||||
|
|
||||||
execa@5.1.1:
|
execa@5.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
cross-spawn: 7.0.3
|
cross-spawn: 7.0.3
|
||||||
|
|||||||
108
src/adapters/sse.ts
Normal file
108
src/adapters/sse.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events
|
||||||
|
|
||||||
|
import { WebSocketServer as _WebSocketServer } from "ws";
|
||||||
|
import { Peer } from "../peer";
|
||||||
|
import {
|
||||||
|
AdapterOptions,
|
||||||
|
AdapterInstance,
|
||||||
|
defineWebSocketAdapter,
|
||||||
|
} from "../types";
|
||||||
|
import { AdapterHookable } from "../hooks";
|
||||||
|
import { adapterUtils, toBufferLike } from "../_utils";
|
||||||
|
|
||||||
|
export interface SSEAdapter extends AdapterInstance {
|
||||||
|
fetch(req: Request): Promise<Response>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SSEOptions extends AdapterOptions {}
|
||||||
|
|
||||||
|
export default defineWebSocketAdapter<SSEAdapter, SSEOptions>(
|
||||||
|
(options = {}) => {
|
||||||
|
const hooks = new AdapterHookable(options);
|
||||||
|
const peers = new Set<SSEPeer>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...adapterUtils(peers),
|
||||||
|
fetch: async (request: Request) => {
|
||||||
|
const _res = await hooks.callHook("upgrade", request);
|
||||||
|
if (_res instanceof Response) {
|
||||||
|
return _res;
|
||||||
|
}
|
||||||
|
|
||||||
|
const peer = new SSEPeer({ peers, sse: { request, hooks } });
|
||||||
|
|
||||||
|
let headers: HeadersInit = {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
};
|
||||||
|
if (_res?.headers) {
|
||||||
|
headers = new Headers(headers);
|
||||||
|
for (const [key, value] of new Headers(_res.headers)) {
|
||||||
|
headers.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(peer._sseStream, { ..._res, headers });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
class SSEPeer extends Peer<{
|
||||||
|
peers: Set<SSEPeer>;
|
||||||
|
sse: {
|
||||||
|
request: Request;
|
||||||
|
hooks: AdapterHookable;
|
||||||
|
};
|
||||||
|
}> {
|
||||||
|
_sseStream: ReadableStream;
|
||||||
|
_sseStreamController?: ReadableStreamDefaultController;
|
||||||
|
constructor(internal: SSEPeer["_internal"]) {
|
||||||
|
super(internal);
|
||||||
|
this._sseStream = new ReadableStream({
|
||||||
|
start: (controller) => {
|
||||||
|
this._sseStreamController = controller;
|
||||||
|
this._internal.sse.hooks.callHook("open", this);
|
||||||
|
},
|
||||||
|
cancel: () => {
|
||||||
|
this._internal.sse.hooks.callHook("close", this);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get url() {
|
||||||
|
return this._internal.sse.request.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
get headers() {
|
||||||
|
return this._internal.sse.request.headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
send(message: any) {
|
||||||
|
let data = toBufferLike(message);
|
||||||
|
if (typeof data !== "string") {
|
||||||
|
// eslint-disable-next-line unicorn/prefer-code-point
|
||||||
|
data = btoa(String.fromCharCode(...new Uint8Array(data)));
|
||||||
|
}
|
||||||
|
this._sseStreamController?.enqueue(`event: message\ndata: ${data}\n\n`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
publish(topic: string, message: any) {
|
||||||
|
const data = toBufferLike(message);
|
||||||
|
for (const peer of this._internal.peers) {
|
||||||
|
if (peer !== this && peer._topics.has(topic)) {
|
||||||
|
peer._sseStreamController?.enqueue(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this._sseStreamController?.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
terminate() {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,7 +103,11 @@ export function wsConnect(
|
|||||||
return Object.assign(connectPromise, res) as Promise<typeof res>;
|
return Object.assign(connectPromise, res) as Promise<typeof res>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function wsTestsExec(cmd: string, opts: Parameters<typeof wsTests>[1]) {
|
export function wsTestsExec(
|
||||||
|
cmd: string,
|
||||||
|
opts: Parameters<typeof wsTests>[1],
|
||||||
|
tests = wsTests,
|
||||||
|
) {
|
||||||
let childProc: ExecaRes;
|
let childProc: ExecaRes;
|
||||||
let url: string;
|
let url: string;
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -132,5 +136,5 @@ export function wsTestsExec(cmd: string, opts: Parameters<typeof wsTests>[1]) {
|
|||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await childProc.kill();
|
await childProc.kill();
|
||||||
});
|
});
|
||||||
wsTests(() => url, opts);
|
tests(() => url, opts);
|
||||||
}
|
}
|
||||||
|
|||||||
19
test/adapters/sse.test.ts
Normal file
19
test/adapters/sse.test.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, test, expect } from "vitest";
|
||||||
|
import { wsTestsExec } from "../_utils";
|
||||||
|
import EventSource from "eventsource";
|
||||||
|
|
||||||
|
describe("sse", () => {
|
||||||
|
wsTestsExec("bun run ./sse.ts", { adapter: "sse" }, (getURL, opts) => {
|
||||||
|
test("connects to the server", async () => {
|
||||||
|
const url = getURL().replace("ws", "http");
|
||||||
|
const ev = new EventSource(url);
|
||||||
|
const messages: string[] = [];
|
||||||
|
ev.addEventListener("message", (event) => {
|
||||||
|
messages.push(event.data);
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => ev.addEventListener("open", resolve));
|
||||||
|
ev.close();
|
||||||
|
expect(messages).toMatchObject(["Welcome to the server #1!"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,191 +1,221 @@
|
|||||||
export default /* html */ `
|
export default function indexTemplate(opts: { sse?: boolean } = {}) {
|
||||||
<!doctype html>
|
return /* html */ `
|
||||||
<html lang="en" data-theme="dark">
|
<!doctype html>
|
||||||
<head>
|
<html lang="en" data-theme="dark">
|
||||||
<title>CrossWS Test Page</title>
|
<head>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<title>CrossWS Test Page</title>
|
||||||
<style>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
body {
|
<style>
|
||||||
background-color: #1a1a1a;
|
body {
|
||||||
}
|
background-color: #1a1a1a;
|
||||||
</style>
|
}
|
||||||
<script type="module">
|
</style>
|
||||||
// https://github.com/vuejs/petite-vue
|
<script type="module">
|
||||||
import {
|
// https://github.com/vuejs/petite-vue
|
||||||
createApp,
|
import {
|
||||||
reactive,
|
createApp,
|
||||||
nextTick,
|
reactive,
|
||||||
} from "https://esm.sh/petite-vue@0.4.1";
|
nextTick,
|
||||||
|
} from "https://esm.sh/petite-vue@0.4.1";
|
||||||
|
|
||||||
let ws;
|
const store = reactive({
|
||||||
|
message: "",
|
||||||
const store = reactive({
|
messages: [],
|
||||||
message: "",
|
|
||||||
messages: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const scroll = () => {
|
|
||||||
nextTick(() => {
|
|
||||||
const el = document.querySelector("#messages");
|
|
||||||
el.scrollTop = el.scrollHeight;
|
|
||||||
el.scrollTo({
|
|
||||||
top: el.scrollHeight,
|
|
||||||
behavior: "smooth",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const format = async () => {
|
const scroll = () => {
|
||||||
for (const message of store.messages) {
|
nextTick(() => {
|
||||||
if (!message._fmt && message.text.startsWith("{")) {
|
const el = document.querySelector("#messages");
|
||||||
message._fmt = true;
|
el.scrollTop = el.scrollHeight;
|
||||||
const { codeToHtml } = await import("https://esm.sh/shiki@1.0.0");
|
el.scrollTo({
|
||||||
const str = JSON.stringify(JSON.parse(message.text), null, 2);
|
top: el.scrollHeight,
|
||||||
message.formattedText = await codeToHtml(str, {
|
behavior: "smooth",
|
||||||
lang: "json",
|
|
||||||
theme: "dark-plus",
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const format = async () => {
|
||||||
|
for (const message of store.messages) {
|
||||||
|
if (!message._fmt && message.text.startsWith("{")) {
|
||||||
|
message._fmt = true;
|
||||||
|
const { codeToHtml } = await import("https://esm.sh/shiki@1.0.0");
|
||||||
|
const str = JSON.stringify(JSON.parse(message.text), null, 2);
|
||||||
|
message.formattedText = await codeToHtml(str, {
|
||||||
|
lang: "json",
|
||||||
|
theme: "dark-plus",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const log = (user, ...args) => {
|
const log = (user, ...args) => {
|
||||||
console.log("[ws]", user, ...args);
|
console.log("[ws]", user, ...args);
|
||||||
store.messages.push({
|
store.messages.push({
|
||||||
text: args.join(" "),
|
text: args.join(" "),
|
||||||
formattedText: "",
|
formattedText: "",
|
||||||
user: user,
|
user: user,
|
||||||
date: new Date().toLocaleString(),
|
date: new Date().toLocaleString(),
|
||||||
});
|
});
|
||||||
scroll();
|
scroll();
|
||||||
format();
|
format();
|
||||||
};
|
};
|
||||||
|
|
||||||
const connect = async () => {
|
let ws;
|
||||||
const isSecure = location.protocol === "https:";
|
const connectWS = async () => {
|
||||||
const url = (isSecure ? "wss://" : "ws://") + location.host + "/_ws";
|
const isSecure = location.protocol === "https:";
|
||||||
if (ws) {
|
const url = (isSecure ? "wss://" : "ws://") + location.host + "/_ws";
|
||||||
log("ws", "Closing previous connection before reconnecting...");
|
if (ws) {
|
||||||
ws.close();
|
log("ws", "Closing previous connection before reconnecting...");
|
||||||
clear();
|
ws.close();
|
||||||
|
clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
log("ws", "Connecting to", url, "...");
|
||||||
|
ws = new WebSocket(url);
|
||||||
|
|
||||||
|
ws.addEventListener("message", async (event) => {
|
||||||
|
const data = typeof event.data === "string" ? event.data : await event.data.text();
|
||||||
|
const { user = "system", message = "" } = data.startsWith("{")
|
||||||
|
? JSON.parse(data)
|
||||||
|
: { message: data };
|
||||||
|
log(
|
||||||
|
user,
|
||||||
|
typeof message === "string" ? message : JSON.stringify(message),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve) => ws.addEventListener("open", resolve));
|
||||||
|
log("ws", "Connected!");
|
||||||
|
};
|
||||||
|
|
||||||
|
let sse;
|
||||||
|
const connectSSE = async () => {
|
||||||
|
const url = "/sse";
|
||||||
|
if (sse) {
|
||||||
|
log("sse", "Closing previous connection before reconnecting...");
|
||||||
|
sse.close();
|
||||||
|
clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
log("sse", "Connecting to", url, "...");
|
||||||
|
sse = new EventSource(url);
|
||||||
|
|
||||||
|
sse.addEventListener("message", async (event) => {
|
||||||
|
console.log(event)
|
||||||
|
const data = typeof event.data === "string" ? event.data : await event.data.text();
|
||||||
|
const { user = "system", message = "" } = data.startsWith("{")
|
||||||
|
? JSON.parse(data)
|
||||||
|
: { message: data };
|
||||||
|
log(
|
||||||
|
user,
|
||||||
|
typeof message === "string" ? message : JSON.stringify(message),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
log("sse", "Connected!");
|
||||||
}
|
}
|
||||||
|
|
||||||
log("ws", "Connecting to", url, "...");
|
const connect = ${opts.sse ? "connectSSE" : "connectWS"};
|
||||||
ws = new WebSocket(url);
|
|
||||||
|
|
||||||
ws.addEventListener("message", async (event) => {
|
const clear = () => {
|
||||||
const data = typeof event.data === "string" ? event.data : await event.data.text();
|
store.messages.splice(0, store.messages.length);
|
||||||
const { user = "system", message = "" } = data.startsWith("{")
|
log("system", "previous messages cleared");
|
||||||
? JSON.parse(data)
|
};
|
||||||
: { message: data };
|
|
||||||
log(
|
|
||||||
user,
|
|
||||||
typeof message === "string" ? message : JSON.stringify(message),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise((resolve) => ws.addEventListener("open", resolve));
|
const send = () => {
|
||||||
log("ws", "Connected!");
|
console.log("sending message...");
|
||||||
};
|
if (store.message) {
|
||||||
|
ws.send(store.message);
|
||||||
|
}
|
||||||
|
store.message = "";
|
||||||
|
};
|
||||||
|
|
||||||
const clear = () => {
|
const ping = () => {
|
||||||
store.messages.splice(0, store.messages.length);
|
log("ws", "Sending ping");
|
||||||
log("system", "previous messages cleared");
|
ws.send("ping");
|
||||||
};
|
};
|
||||||
|
|
||||||
const send = () => {
|
createApp({
|
||||||
console.log("sending message...");
|
store,
|
||||||
if (store.message) {
|
send,
|
||||||
ws.send(store.message);
|
ping,
|
||||||
}
|
clear,
|
||||||
store.message = "";
|
connect,
|
||||||
};
|
rand: Math.random(),
|
||||||
|
}).mount();
|
||||||
|
|
||||||
const ping = () => {
|
await connect();
|
||||||
log("ws", "Sending ping");
|
</script>
|
||||||
ws.send("ping");
|
</head>
|
||||||
};
|
<body class="h-screen flex flex-col justify-between">
|
||||||
|
<main v-scope="{}">
|
||||||
createApp({
|
<!-- Messages -->
|
||||||
store,
|
<div id="messages" class="flex-grow flex flex-col justify-end px-4 py-8">
|
||||||
send,
|
<div class="flex items-center mb-4" v-for="message in store.messages">
|
||||||
ping,
|
<div class="flex flex-col">
|
||||||
clear,
|
<p class="text-gray-500 mb-1 text-xs ml-10">{{ message.user }}</p>
|
||||||
connect,
|
<div class="flex items-center">
|
||||||
rand: Math.random(),
|
<img
|
||||||
}).mount();
|
:src="'https://www.gravatar.com/avatar/' + encodeURIComponent(message.user + rand) + '?s=512&d=monsterid'"
|
||||||
|
alt="Avatar"
|
||||||
await connect();
|
class="w-8 h-8 rounded-full"
|
||||||
</script>
|
/>
|
||||||
</head>
|
<div class="ml-2 bg-gray-800 rounded-lg p-2">
|
||||||
<body class="h-screen flex flex-col justify-between">
|
<p
|
||||||
<main v-scope="{}">
|
v-if="message.formattedText"
|
||||||
<!-- Messages -->
|
class="overflow-x-scroll"
|
||||||
<div id="messages" class="flex-grow flex flex-col justify-end px-4 py-8">
|
v-html="message.formattedText"
|
||||||
<div class="flex items-center mb-4" v-for="message in store.messages">
|
></p>
|
||||||
<div class="flex flex-col">
|
<p v-else class="text-white">{{ message.text }}</p>
|
||||||
<p class="text-gray-500 mb-1 text-xs ml-10">{{ message.user }}</p>
|
</div>
|
||||||
<div class="flex items-center">
|
|
||||||
<img
|
|
||||||
:src="'https://www.gravatar.com/avatar/' + encodeURIComponent(message.user + rand) + '?s=512&d=monsterid'"
|
|
||||||
alt="Avatar"
|
|
||||||
class="w-8 h-8 rounded-full"
|
|
||||||
/>
|
|
||||||
<div class="ml-2 bg-gray-800 rounded-lg p-2">
|
|
||||||
<p
|
|
||||||
v-if="message.formattedText"
|
|
||||||
class="overflow-x-scroll"
|
|
||||||
v-html="message.formattedText"
|
|
||||||
></p>
|
|
||||||
<p v-else class="text-white">{{ message.text }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-gray-500 mt-1 text-xs ml-10">{{ message.date }}</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-gray-500 mt-1 text-xs ml-10">{{ message.date }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Chatbox -->
|
<!-- Chatbox -->
|
||||||
<div
|
<div
|
||||||
class="bg-gray-800 px-4 py-2 flex items-center justify-between fixed bottom-0 w-full"
|
class="bg-gray-800 px-4 py-2 flex items-center justify-between fixed bottom-0 w-full"
|
||||||
>
|
>
|
||||||
<div class="w-full min-w-6">
|
<div class="w-full min-w-6">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Type your message..."
|
placeholder="Type your message..."
|
||||||
class="w-full rounded-l-lg px-4 py-2 bg-gray-700 text-white focus:outline-none focus:ring focus:border-blue-300"
|
class="w-full rounded-l-lg px-4 py-2 bg-gray-700 text-white focus:outline-none focus:ring focus:border-blue-300"
|
||||||
@keydown.enter="send"
|
@keydown.enter="send"
|
||||||
v-model="store.message"
|
v-model="store.message"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex">
|
||||||
|
<button
|
||||||
|
class="bg-blue-500 hover:bg-blue-600 text-white py-2 px-4"
|
||||||
|
@click="send"
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="bg-blue-500 hover:bg-blue-600 text-white py-2 px-4"
|
||||||
|
@click="ping"
|
||||||
|
>
|
||||||
|
Ping
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="bg-blue-500 hover:bg-blue-600 text-white py-2 px-4"
|
||||||
|
@click="connect"
|
||||||
|
>
|
||||||
|
Reconnect
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded-r-lg"
|
||||||
|
@click="clear"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex">
|
</main>
|
||||||
<button
|
</body>
|
||||||
class="bg-blue-500 hover:bg-blue-600 text-white py-2 px-4"
|
</html>
|
||||||
@click="send"
|
`.trim();
|
||||||
>
|
}
|
||||||
Send
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="bg-blue-500 hover:bg-blue-600 text-white py-2 px-4"
|
|
||||||
@click="ping"
|
|
||||||
>
|
|
||||||
Ping
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="bg-blue-500 hover:bg-blue-600 text-white py-2 px-4"
|
|
||||||
@click="connect"
|
|
||||||
>
|
|
||||||
Reconnect
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded-r-lg"
|
|
||||||
@click="clear"
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>\
|
|
||||||
`.trim();
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Adapter, AdapterInstance, defineHooks } from "../../src/index.ts";
|
import { Adapter, AdapterInstance, defineHooks } from "../../src/index.ts";
|
||||||
|
|
||||||
export const getIndexHTML = () =>
|
export const getIndexHTML = (opts?: { sse?: boolean }) =>
|
||||||
import("./_index.html.ts").then((r) => r.default);
|
import("./_index.html.ts").then((r) => r.default(opts));
|
||||||
|
|
||||||
export function createDemo<T extends Adapter<any, any>>(
|
export function createDemo<T extends Adapter<any, any>>(
|
||||||
adapter: T,
|
adapter: T,
|
||||||
|
|||||||
26
test/fixture/sse.ts
Normal file
26
test/fixture/sse.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// You can run this demo using `npm run play:sse` in repo
|
||||||
|
|
||||||
|
import sseAdapter from "../../src/adapters/sse";
|
||||||
|
import { createDemo, getIndexHTML, handleDemoRoutes } from "./_shared";
|
||||||
|
|
||||||
|
const ws = createDemo(sseAdapter);
|
||||||
|
|
||||||
|
Bun.serve({
|
||||||
|
port: process.env.PORT || 3001,
|
||||||
|
hostname: "localhost",
|
||||||
|
async fetch(request) {
|
||||||
|
const response = handleDemoRoutes(ws, request);
|
||||||
|
if (response) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle SSE
|
||||||
|
if (request.headers.get("accept") === "text/event-stream") {
|
||||||
|
return ws.fetch(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(await getIndexHTML({ sse: true }), {
|
||||||
|
headers: { "Content-Type": "text/html" },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user