index.ts 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  1. import { Tweet, TwitterClient } from './twitter.ts';
  2. import { SqliteStore, Store } from './store.ts';
  3. import { StaticTokenProvider } from './token.ts';
  4. import { timelineAsHTML, timelineAsJSON } from './format.ts';
  5. import { z } from "https://deno.land/x/zod@v3.8.0/mod.ts";
  6. const MAX_INT = "9223372036854775807";
  7. class Server {
  8. constructor(
  9. readonly store: Store,
  10. readonly client: TwitterClient,
  11. ) {}
  12. async getLatestTweets(username: string, interval: number = 600): Promise<Tweet[]> {
  13. const storedAuthorId = this.store.getAuthorId(username);
  14. const latestTweet = storedAuthorId === null ? null : this.store.getLatestTweet(storedAuthorId);
  15. let authorId = storedAuthorId;
  16. let timeline = await this.client.getTimeline(username, latestTweet?.id_str);
  17. console.log(`Fetched ${timeline.length} new tweets`);
  18. for (const tweet of timeline) {
  19. this.store.saveTweet(tweet);
  20. }
  21. const newerThan = new Date();
  22. newerThan.setSeconds(newerThan.getSeconds() - interval);
  23. if (timeline.length === 0) {
  24. if (storedAuthorId === null) {
  25. return [];
  26. } else {
  27. timeline = this.store.getLatestTweets(storedAuthorId, MAX_INT, newerThan);
  28. }
  29. } else {
  30. const lastTweet = timeline[timeline.length - 1];
  31. const olderTweets = this.store.getLatestTweets(lastTweet.user.id_str, lastTweet.id_str, newerThan);
  32. timeline = timeline.concat(olderTweets);
  33. authorId = lastTweet.user.id_str;
  34. }
  35. timeline = timeline.filter(({ in_reply_to_user_id_str }) => {
  36. return in_reply_to_user_id_str === null || in_reply_to_user_id_str === authorId;
  37. });
  38. return timeline;
  39. }
  40. async start() {
  41. const server = Deno.listen({ port: 8000 });
  42. console.log("Server running at http://localhost:8000");
  43. for await (const conn of server) {
  44. this.serveHttp(conn);
  45. }
  46. }
  47. async serveHttp(conn: Deno.Conn) {
  48. const httpConn = Deno.serveHttp(conn);
  49. for await (const requestEvent of httpConn) {
  50. this.handle(requestEvent)
  51. .then(response => requestEvent.respondWith(response))
  52. .catch(err => console.error(err));
  53. }
  54. }
  55. async handle(requestEvent: Deno.RequestEvent): Promise<Response> {
  56. const url = new URL(requestEvent.request.url);
  57. const path = url.pathname;
  58. if (path === '/__proxy') {
  59. const target = url.searchParams.get('target');
  60. return this.handleProxy(requestEvent.request.headers, target);
  61. } else if (path.startsWith('/timeline/')) {
  62. const username = path.substring(10);
  63. return this.handleTimeline(username);
  64. } else if (path.startsWith('/json/')) {
  65. const username = path.substring(6);
  66. return this.handleJson(username);
  67. } else {
  68. return new Response('Not found', {
  69. status: 404,
  70. });
  71. }
  72. }
  73. async handleTimeline(username: string): Promise<Response> {
  74. const tweets = await this.getLatestTweets(username, 24 * 60 * 60);
  75. console.time('renderHTML');
  76. const html = timelineAsHTML(tweets);
  77. console.timeEnd('renderHTML');
  78. return new Response(html, {
  79. status: 200,
  80. headers: {
  81. 'Content-Type': 'text/html; charset=utf-8'
  82. }
  83. });
  84. }
  85. async handleJson(username: string): Promise<Response> {
  86. const tweets = await this.getLatestTweets(username, 24 * 60 * 60);
  87. console.time('renderJSON');
  88. const html = timelineAsJSON(username, tweets);
  89. console.timeEnd('renderJSON');
  90. return new Response(html, {
  91. status: 200,
  92. headers: {
  93. 'Content-Type': 'application/feed+json; charset=utf-8'
  94. }
  95. });
  96. }
  97. async handleProxy(headers: Headers, target: string | null): Promise<Response> {
  98. if (target !== null) {
  99. console.log(`Proxying ${target}`);
  100. try {
  101. const proxyRequest = await fetch(target, {
  102. headers: headers,
  103. });
  104. return new Response(proxyRequest.body, {
  105. status: proxyRequest.status,
  106. headers: proxyRequest.headers,
  107. });
  108. } catch (e) {
  109. return new Response('Bad request', {
  110. status: 400,
  111. });
  112. }
  113. } else {
  114. return new Response('Not found', {
  115. status: 404,
  116. });
  117. }
  118. }
  119. }
  120. const ConfigSchema = z.strictObject({
  121. bearer: z.string(),
  122. store: z.string(),
  123. });
  124. const configFile = Deno.args.length === 0 ? 'config.json' : Deno.args[0];
  125. const json = JSON.parse(Deno.readTextFileSync(configFile));
  126. const config = ConfigSchema.parse(json);
  127. const fetcher = new Server(
  128. new SqliteStore(config.store),
  129. new TwitterClient(new StaticTokenProvider(config.bearer)),
  130. );
  131. fetcher.start();