Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions examples/tweet-stream/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# X tweet stream

This example searches recent public X posts with Xquik. It broadcasts new
matches to every Socket.IO client.

The server emits 2 events:

- `buffer` contains the 10 most recent posts when a client connects.
- `tweet` contains each new post found after polling.

## Run the example

Use Node.js 18 or newer. Create an API key in Xquik, then run:

```bash
npm install
export XQUIK_API_KEY=xq_your_api_key_here
npm start
```

The Socket.IO server listens on port `3000` by default.

## Configuration

| Variable | Required | Default | Purpose |
| ------------------------ | -------- | --------------------------- | ------------------------------------------------------------------------------------------------- |
| `XQUIK_API_KEY` | Yes | None | Authenticates the [Xquik tweet search API](https://docs.xquik.com/api-reference/x/search-tweets). |
| `XQUIK_QUERY` | No | `"socket.io" OR javascript` | Sets the X search query. |
| `XQUIK_POLL_INTERVAL_MS` | No | `60000` | Sets the poll interval. The minimum is 10 seconds. |
| `PORT` | No | `3000` | Sets the Socket.IO server port. |

The example requests up to 10 latest posts per poll. It excludes replies and
reposts. Later requests use the newest post ID to avoid replaying old results.

Treat post text as untrusted data. Render it as text, not HTML.

## Test the source

```bash
npm test
```
59 changes: 33 additions & 26 deletions examples/tweet-stream/index.js
Original file line number Diff line number Diff line change
@@ -1,34 +1,41 @@
const { createTweetSource, parsePollInterval } = require("./xquik");

const Twitter = require('node-tweet-stream');
const twitter = new Twitter({
consumer_key: process.env.TWITTER_CONSUMER_KEY,
consumer_secret: process.env.TWITTER_CONSUMER_SECRET,
token: process.env.TWITTER_TOKEN,
token_secret: process.env.TWITTER_TOKEN_SECRET
let tweets = [];
const MAX_TWEETS = 10;
const source = createTweetSource({
apiKey: process.env.XQUIK_API_KEY,
query: process.env.XQUIK_QUERY || '"socket.io" OR javascript',
});

const io = require('socket.io')(process.env.PORT || 3000, {
const pollInterval = parsePollInterval(process.env.XQUIK_POLL_INTERVAL_MS);
const io = require("socket.io")(process.env.PORT || 3000, {
cors: {
origin: true
}
origin: true,
},
});
let polling = false;

twitter.track('socket.io');
twitter.track('javascript');

let tweets = [];
const MAX_TWEETS = 10;

io.on('connect', socket => {
socket.emit('buffer', tweets);
io.on("connection", (socket) => {
socket.emit("buffer", tweets);
});

twitter.on('tweet', tweet => {
io.emit('tweet', tweet);
tweets.unshift(tweet);
tweets = tweets.slice(0, MAX_TWEETS);
});
async function poll() {
if (polling) {
return;
}

twitter.on('error', err => {
console.error(err);
});
polling = true;
try {
for (const tweet of await source.fetchLatest()) {
io.emit("tweet", tweet);
tweets.unshift(tweet);
}
tweets = tweets.slice(0, MAX_TWEETS);
} catch (err) {
console.error(`Xquik request failed: ${err.message}`);
} finally {
polling = false;
}
}

poll();
setInterval(poll, pollInterval);
12 changes: 8 additions & 4 deletions examples/tweet-stream/package.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
{
"name": "socket.io-tweet-stream",
"version": "1.0.0",
"description": "tweets about socket.io and javascript",
"description": "Stream X posts about Socket.IO with Xquik",
"private": true,
"main": "index.js",
"scripts": {
"start": "node index.js"
"start": "node index.js",
"test": "node --test test.js"
},
"keywords": [
"socket.io",
Expand All @@ -13,8 +15,10 @@
],
"author": "Damien Arrachequesne",
"license": "MIT",
"engines": {
"node": ">=18"
},
"dependencies": {
"node-tweet-stream": "^2.0.1",
"socket.io": "^4.0.0"
"socket.io": "^4.8.3"
}
}
160 changes: 160 additions & 0 deletions examples/tweet-stream/test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
const assert = require("node:assert/strict");
const { describe, it } = require("node:test");
const { createTweetSource, parsePollInterval } = require("./xquik");

const tweet = {
id: "2036000000000000001",
text: "Socket.IO test",
createdAt: "2026-08-24T00:00:00.000Z",
likeCount: 3,
replyCount: 2,
retweetCount: 1,
quoteCount: 0,
viewCount: 20,
author: {
id: "123",
username: "socketio",
name: "Socket.IO",
profilePicture: "https://example.com/avatar.png",
},
};

describe("Xquik tweet source", () => {
it("builds an authenticated latest search request", async () => {
let request;
const source = createTweetSource({
apiKey: "secret",
query: '"socket.io" OR javascript',
fetchImpl: async (...args) => {
request = args;
return response({ tweets: [tweet] });
},
});

assert.deepEqual(await source.fetchLatest(), [tweet]);
const [url, options] = request;
assert.equal(
url.origin + url.pathname,
"https://xquik.com/api/v1/x/tweets/search",
);
assert.equal(url.searchParams.get("q"), '"socket.io" OR javascript');
assert.equal(url.searchParams.get("queryType"), "Latest");
assert.equal(url.searchParams.get("limit"), "10");
assert.equal(url.searchParams.get("replies"), "exclude");
assert.equal(url.searchParams.get("retweets"), "exclude");
assert.equal(url.searchParams.has("sinceId"), false);
assert.deepEqual(options.headers, { "x-api-key": "secret" });
assert.ok(options.signal instanceof AbortSignal);
});

it("uses the newest ID on the next request", async () => {
const urls = [];
const source = createTweetSource({
apiKey: "secret",
query: "socket.io",
fetchImpl: async (url) => {
urls.push(url);
return response({ tweets: [tweet] });
},
});

assert.deepEqual(await source.fetchLatest(), [tweet]);
assert.deepEqual(await source.fetchLatest(), []);

assert.equal(urls[1].searchParams.get("sinceId"), tweet.id);
});

it("sorts tweets, removes duplicates, and limits public fields", async () => {
const older = { ...tweet, id: "2036000000000000000", private: "drop me" };
const source = createTweetSource({
apiKey: "secret",
query: "socket.io",
fetchImpl: async () => response({ tweets: [tweet, older, older] }),
});

const result = await source.fetchLatest();

assert.deepEqual(
result.map((item) => item.id),
[older.id, tweet.id],
);
assert.equal("private" in result[0], false);
});

it("drops malformed tweet rows and unsafe counts", async () => {
const source = createTweetSource({
apiKey: "secret",
query: "socket.io",
fetchImpl: async () =>
response({
tweets: [
null,
{ id: "not-an-id", text: "bad" },
{ id: 2036000000000000002, text: "unsafe numeric ID" },
{ ...tweet, likeCount: -1, viewCount: Number.MAX_SAFE_INTEGER + 1 },
],
}),
});

const result = await source.fetchLatest();

assert.equal(result.length, 1);
assert.equal(result[0].likeCount, null);
assert.equal(result[0].viewCount, null);
});

it("rejects missing configuration", () => {
assert.throws(
() => createTweetSource({ apiKey: "", query: "socket.io" }),
/XQUIK_API_KEY is required/,
);
assert.throws(
() => createTweetSource({ apiKey: "secret", query: "" }),
/XQUIK_QUERY must not be empty/,
);
});

it("reports HTTP status without exposing the response body", async () => {
const source = createTweetSource({
apiKey: "secret",
query: "socket.io",
fetchImpl: async () =>
response({ error: "sensitive upstream detail" }, 429),
});

await assert.rejects(source.fetchLatest(), /^Error: HTTP 429$/);
});

it("rejects unexpected response bodies", async () => {
const source = createTweetSource({
apiKey: "secret",
query: "socket.io",
fetchImpl: async () => response({ results: [] }),
});

await assert.rejects(source.fetchLatest(), /Unexpected response shape/);
});

it("validates the polling interval", () => {
assert.equal(parsePollInterval(undefined), 60_000);
assert.equal(parsePollInterval("10000"), 10_000);
assert.throws(
() => parsePollInterval("9999"),
/XQUIK_POLL_INTERVAL_MS must be an integer of at least 10000/,
);
assert.throws(
() => parsePollInterval("not-a-number"),
/XQUIK_POLL_INTERVAL_MS must be an integer of at least 10000/,
);
});
});

function response(body, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
async json() {
return body;
},
};
}
Loading