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: '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 = `${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')