Files

451 lines
13 KiB
JavaScript

import { createServer } from "node:http";
import { tweetLegacyFixture } from "./fixtures/twitter.ts";
const portValue = process.env.TWITTER_LITE_MOCK_RELAY_PORT;
const port = Number(portValue);
if (!portValue || !Number.isInteger(port) || port < 1) {
throw new Error("TWITTER_LITE_MOCK_RELAY_PORT must be a valid port");
}
const failedRetryQueries = new Set();
const rootByTarget = new Map([
["1001", "1000"],
["2001", "2000"],
["3001", "3000"],
]);
const targetByRoot = new Map([
["1000", "1001"],
["2000", "2001"],
["3000", "3001"],
]);
const failedThreadRoots = new Set();
const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
const tweetResult = (
id,
text,
username = "yuta",
{ conversationId = id, inReplyTo, replyCount = 1 } = {},
) => ({
rest_id: id,
legacy: tweetLegacyFixture(id, text, {
conversationId,
inReplyTo,
replyCount,
}),
core: {
user_results: {
result: {
rest_id: "42",
legacy: { screen_name: username, name: "Yuta" },
},
},
},
});
const tweet = (id, text, username = "yuta", options) => ({
entryId: `tweet-${id}`,
content: {
itemContent: {
tweet_results: {
result: tweetResult(id, text, username, options),
},
},
},
});
const cursor = (value) => ({
entryId: "cursor-bottom",
content: { cursorType: "Bottom", value },
});
const send = (response, payload, status = 200, headers = {}) => {
response.writeHead(status, {
"content-type": "application/json; charset=utf-8",
...headers,
});
response.end(JSON.stringify(payload));
};
const fail = (response, status, message, headers) =>
send(response, { errors: [{ message }] }, status, headers);
const variablesFrom = (url) => {
const encoded = url.searchParams.get("variables");
if (!encoded) throw new Error("variables are required");
const variables = JSON.parse(encoded);
if (!isRecord(variables)) throw new Error("variables must be an object");
return variables;
};
const jsonBodyFrom = async (request) => {
let body = "";
for await (const chunk of request) {
body += chunk;
if (body.length > 1_000_000) throw new Error("request body is too large");
}
const parsed = JSON.parse(body);
if (!isRecord(parsed)) throw new Error("JSON body must be an object");
return parsed;
};
const timeline = (entries) => ({ instructions: [{ entries }] });
const server = createServer(async (request, response) => {
const url = new URL(request.url ?? "/", `http://127.0.0.1:${port}`);
if (request.method === "GET" && url.pathname === "/profiles") {
send(response, { profiles: ["e2e", "alternate"] });
return;
}
const operation = [
"UserByScreenName",
"UserTweets",
"TweetDetail",
"SearchTimeline",
"ListsManagementPageTimeline",
"ListLatestTweetsTimeline",
].find((name) => url.pathname.endsWith(`/${name}`));
if (!operation) {
fail(response, 501, "unsupported read operation");
return;
}
const expectedMethod = operation === "SearchTimeline" ? "POST" : "GET";
if (request.method !== expectedMethod) {
fail(response, 405, `expected ${expectedMethod}`, {
allow: expectedMethod,
});
return;
}
const profileName = request.headers["x-profile-name"];
if (profileName !== "e2e" && profileName !== "alternate") {
fail(response, 403, "invalid profile");
return;
}
let variables;
try {
variables = variablesFrom(url);
} catch {
fail(response, 400, "malformed variables");
return;
}
if (operation === "UserByScreenName") {
if (typeof variables.screen_name !== "string" || variables.screen_name.length === 0) {
fail(response, 400, "screen_name is required");
return;
}
send(response, {
data: {
user: {
result: {
rest_id: "42",
legacy: { screen_name: variables.screen_name, name: "Yuta" },
},
},
},
});
return;
}
if (operation === "UserTweets") {
const expectedCursor = variables.cursor === undefined || variables.cursor === "user-next";
if (variables.userId !== "42" || variables.count !== 20 || !expectedCursor) {
fail(response, 400, "invalid user timeline variables");
return;
}
const secondPage = variables.cursor === "user-next";
const firstPageText = profileName === "alternate" ? "alternate profile page 1" : "user page 1";
const entries = secondPage
? [tweet("1010", "user page 2")]
: [
tweet("1001", firstPageText, "yuta", {
conversationId: "1000",
}),
cursor("user-next"),
];
send(response, {
data: {
user: { result: { timeline: { timeline: timeline(entries) } } },
},
});
return;
}
if (operation === "ListsManagementPageTimeline") {
if (variables.count !== 100) {
fail(response, 400, "invalid lists management variables");
return;
}
const features = url.searchParams.get("features");
try {
if (!features || !isRecord(JSON.parse(features))) throw new Error();
} catch {
fail(response, 400, "invalid lists management feature lock");
return;
}
send(response, {
data: {
viewer: {
list_management_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
clientEventInfo: {
component: "suggest_owned_subscribed_list",
},
items: [
{
item: {
itemContent: {
list: {
id_str: "1234567890",
name:
profileName === "alternate" ? "Alternate List" : "My E2E List",
description: "Accounts selected for testing",
member_count: 10,
mode: "Public",
created_at: 1_704_067_200_000,
user_results: {
result: {
rest_id: "42",
core: {
screen_name: "yuta",
name: "Yuta",
},
},
},
},
},
},
},
],
},
},
],
},
],
},
},
},
},
errors: [{ message: "optional banner field failed" }],
});
return;
}
if (operation === "ListLatestTweetsTimeline") {
const expectedCursor = variables.cursor === undefined || variables.cursor === "list-next";
if (
typeof variables.listId !== "string" ||
!/^\d+$/.test(variables.listId) ||
variables.count !== 20 ||
!expectedCursor
) {
fail(response, 400, "invalid list timeline variables");
return;
}
const secondPage = variables.cursor === "list-next";
const entries = secondPage
? [tweet("3010", "list page 2", "listed")]
: [tweet("3001", "list page 1", "listed"), cursor("list-next")];
send(response, {
data: {
list: { tweets_timeline: { timeline: timeline(entries) } },
},
});
return;
}
if (operation === "TweetDetail") {
const focalTweetId = variables.focalTweetId;
if (typeof focalTweetId !== "string" || !/^\d+$/.test(focalTweetId)) {
fail(response, 400, "invalid focalTweetId");
return;
}
const features = url.searchParams.get("features");
const fieldToggles = url.searchParams.get("fieldToggles");
try {
if (!features || !isRecord(JSON.parse(features))) throw new Error();
if (!fieldToggles || !isRecord(JSON.parse(fieldToggles))) {
throw new Error();
}
} catch {
fail(response, 400, "invalid TweetDetail feature locks");
return;
}
const targetRoot = rootByTarget.get(focalTweetId);
if (targetRoot && variables.cursor === undefined) {
const noReplies = focalTweetId === "3001";
const text = noReplies
? "no replies"
: focalTweetId === "1001"
? "user page 1"
: "retry focal";
const result = tweetResult(focalTweetId, text, "focus", {
conversationId: targetRoot,
inReplyTo: targetRoot,
replyCount: noReplies ? 0 : 1,
});
send(response, {
data: {
tweetResult: { result },
threaded_conversation_with_injections_v2: {
instructions: [
{
entries: [
tweet(focalTweetId, text, "focus", {
conversationId: targetRoot,
inReplyTo: targetRoot,
replyCount: noReplies ? 0 : 1,
}),
],
},
],
},
},
});
return;
}
const targetId = targetByRoot.get(focalTweetId);
if (!targetId) {
fail(response, 400, "unsupported TweetDetail fixture");
return;
}
const expectedCursor = `thread-next:${focalTweetId}`;
if (variables.cursor !== undefined && variables.cursor !== expectedCursor) {
fail(response, 400, "invalid thread cursor");
return;
}
if (variables.cursor && focalTweetId === "3000") {
fail(response, 500, "zero-reply thread must not request a continuation");
return;
}
if (variables.cursor && focalTweetId === "2000" && !failedThreadRoots.has(focalTweetId)) {
failedThreadRoots.add(focalTweetId);
fail(response, 503, "transient thread fixture failure");
return;
}
const entries = variables.cursor
? [
tweet(`${focalTweetId}3`, "thread page 2", "reply2", {
conversationId: focalTweetId,
inReplyTo: targetId,
}),
]
: focalTweetId === "3000"
? [
tweet(focalTweetId, "thread root", "root", {
conversationId: focalTweetId,
}),
tweet(targetId, "no replies", "focus", {
conversationId: focalTweetId,
inReplyTo: focalTweetId,
replyCount: 0,
}),
cursor(expectedCursor),
]
: [
tweet(focalTweetId, "thread root", "root", {
conversationId: focalTweetId,
}),
tweet(targetId, focalTweetId === "1000" ? "user page 1" : "retry focal", "focus", {
conversationId: focalTweetId,
inReplyTo: focalTweetId,
}),
tweet(`${focalTweetId}2`, "thread page 1", "reply1", {
conversationId: focalTweetId,
inReplyTo: targetId,
}),
cursor(expectedCursor),
];
send(response, {
data: {
tweetResult: {
result: tweetResult(focalTweetId, "thread root", "root", {
conversationId: focalTweetId,
}),
},
threaded_conversation_with_injections_v2: {
instructions: [{ entries }],
},
},
});
if (variables.cursor && focalTweetId === "2000") {
failedThreadRoots.delete(focalTweetId);
}
return;
}
const contentType = request.headers["content-type"] ?? "";
if (!/^application\/json(?:\s*;|$)/i.test(contentType)) {
fail(response, 400, "SearchTimeline requires JSON");
return;
}
let body;
try {
body = await jsonBodyFrom(request);
} catch {
fail(response, 400, "malformed JSON body");
return;
}
const rawQuery = variables.rawQuery;
const expectedCursor =
variables.cursor === undefined || variables.cursor === `search-next:${rawQuery}`;
if (
typeof rawQuery !== "string" ||
rawQuery.length === 0 ||
variables.count !== 20 ||
variables.querySource !== "typed_query" ||
!["Top", "Latest"].includes(variables.product) ||
!expectedCursor ||
!isRecord(body.features) ||
typeof body.queryId !== "string" ||
body.queryId.length === 0 ||
Object.hasOwn(body, "variables")
) {
fail(response, 400, "invalid search request");
return;
}
const secondPage = variables.cursor !== undefined;
if (secondPage && rawQuery.startsWith("retry-") && !failedRetryQueries.has(rawQuery)) {
failedRetryQueries.add(rawQuery);
fail(response, 503, "transient fixture failure");
return;
}
if (secondPage && rawQuery.startsWith("slow-")) {
await new Promise((resolve) => setTimeout(resolve, 1_200));
}
const follows = rawQuery.includes("filter:follows") ? "follows" : "all";
const label = `${profileName === "alternate" ? "alternate · " : ""}${variables.product} · ${follows}`;
const id = encodeURIComponent(rawQuery);
const entries = secondPage
? [tweet(`s-${id}-2`, `${label} page 2`)]
: [tweet(`s-${id}-1`, `${label} page 1`), cursor(`search-next:${rawQuery}`)];
send(response, {
data: {
search_by_raw_query: {
search_timeline: { timeline: timeline(entries) },
},
},
});
});
server.listen(port, "127.0.0.1");