From 07b41151557eb546a5bb05d37df71a0e6e69786a Mon Sep 17 00:00:00 2001 From: kriptoburak Date: Mon, 24 Aug 2026 07:35:22 +0300 Subject: [PATCH] docs(examples): repair tweet stream with Xquik --- examples/tweet-stream/README.md | 41 ++++++++ examples/tweet-stream/index.js | 59 ++++++----- examples/tweet-stream/package.json | 12 ++- examples/tweet-stream/test.js | 160 +++++++++++++++++++++++++++++ examples/tweet-stream/xquik.js | 130 +++++++++++++++++++++++ 5 files changed, 372 insertions(+), 30 deletions(-) create mode 100644 examples/tweet-stream/README.md create mode 100644 examples/tweet-stream/test.js create mode 100644 examples/tweet-stream/xquik.js diff --git a/examples/tweet-stream/README.md b/examples/tweet-stream/README.md new file mode 100644 index 0000000000..cfc53277a2 --- /dev/null +++ b/examples/tweet-stream/README.md @@ -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 +``` diff --git a/examples/tweet-stream/index.js b/examples/tweet-stream/index.js index 3426869a7c..46832093eb 100644 --- a/examples/tweet-stream/index.js +++ b/examples/tweet-stream/index.js @@ -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); diff --git a/examples/tweet-stream/package.json b/examples/tweet-stream/package.json index f8453c5f6e..c0f7995084 100644 --- a/examples/tweet-stream/package.json +++ b/examples/tweet-stream/package.json @@ -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", @@ -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" } } diff --git a/examples/tweet-stream/test.js b/examples/tweet-stream/test.js new file mode 100644 index 0000000000..1ed107a6f5 --- /dev/null +++ b/examples/tweet-stream/test.js @@ -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; + }, + }; +} diff --git a/examples/tweet-stream/xquik.js b/examples/tweet-stream/xquik.js new file mode 100644 index 0000000000..6bef8eb087 --- /dev/null +++ b/examples/tweet-stream/xquik.js @@ -0,0 +1,130 @@ +const SEARCH_ENDPOINT = "https://xquik.com/api/v1/x/tweets/search"; +const MAX_TWEETS = 10; +const MAX_SEEN_TWEETS = 100; + +function createTweetSource({ apiKey, query, fetchImpl = globalThis.fetch }) { + if (typeof apiKey !== "string" || apiKey.trim() === "") { + throw new Error("XQUIK_API_KEY is required"); + } + if (typeof query !== "string" || query.trim() === "") { + throw new Error("XQUIK_QUERY must not be empty"); + } + if (typeof fetchImpl !== "function") { + throw new Error("Node.js 18 or newer is required"); + } + + let newestId; + const seenIds = new Set(); + + return { + async fetchLatest() { + const url = new URL(SEARCH_ENDPOINT); + url.searchParams.set("q", query); + url.searchParams.set("queryType", "Latest"); + url.searchParams.set("limit", String(MAX_TWEETS)); + url.searchParams.set("replies", "exclude"); + url.searchParams.set("retweets", "exclude"); + if (newestId) { + url.searchParams.set("sinceId", newestId); + } + + const response = await fetchImpl(url, { + headers: { "x-api-key": apiKey }, + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const payload = await response.json(); + if (!payload || !Array.isArray(payload.tweets)) { + throw new Error("Unexpected response shape"); + } + + const uniqueTweets = new Map(); + for (const value of payload.tweets) { + const tweet = normalizeTweet(value); + if (tweet && !seenIds.has(tweet.id)) { + uniqueTweets.set(tweet.id, tweet); + } + } + + const result = [...uniqueTweets.values()].sort(compareTweetIds); + if (result.length > 0) { + const candidateId = result.at(-1).id; + if (!newestId || BigInt(candidateId) > BigInt(newestId)) { + newestId = candidateId; + } + for (const tweet of result) { + seenIds.add(tweet.id); + } + while (seenIds.size > MAX_SEEN_TWEETS) { + seenIds.delete(seenIds.values().next().value); + } + } + return result; + }, + }; +} + +function normalizeTweet(value) { + if (!value || typeof value !== "object") { + return null; + } + + const id = typeof value.id === "string" ? value.id : ""; + if (!/^\d{15,20}$/.test(id) || typeof value.text !== "string") { + return null; + } + + return { + id, + text: value.text, + createdAt: stringOrNull(value.createdAt), + likeCount: countOrNull(value.likeCount), + replyCount: countOrNull(value.replyCount), + retweetCount: countOrNull(value.retweetCount), + quoteCount: countOrNull(value.quoteCount), + viewCount: countOrNull(value.viewCount), + author: normalizeAuthor(value.author), + }; +} + +function normalizeAuthor(value) { + if (!value || typeof value !== "object") { + return null; + } + + return { + id: stringOrNull(value.id), + username: stringOrNull(value.username), + name: stringOrNull(value.name), + profilePicture: stringOrNull(value.profilePicture), + }; +} + +function stringOrNull(value) { + return typeof value === "string" ? value : null; +} + +function countOrNull(value) { + return Number.isSafeInteger(value) && value >= 0 ? value : null; +} + +function compareTweetIds(left, right) { + const leftId = BigInt(left.id); + const rightId = BigInt(right.id); + return leftId < rightId ? -1 : leftId > rightId ? 1 : 0; +} + +function parsePollInterval(value) { + const interval = Number(value || 60_000); + if (!Number.isInteger(interval) || interval < 10_000) { + throw new Error( + "XQUIK_POLL_INTERVAL_MS must be an integer of at least 10000", + ); + } + return interval; +} + +module.exports = { createTweetSource, parsePollInterval };