File size: 9,442 Bytes
7a5aa35
9013903
68037ed
09c5b79
 
 
 
 
68037ed
09c5b79
 
 
 
68037ed
 
 
09c5b79
33ffc9c
 
 
 
09c5b79
 
 
 
 
 
 
 
 
 
 
 
 
 
7f60362
09c5b79
 
 
 
 
 
 
7f60362
09c5b79
9206294
0b3e194
09c5b79
 
0b3e194
9206294
 
09c5b79
 
 
 
 
 
33ffc9c
68037ed
9013903
68037ed
 
9013903
09c5b79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33ffc9c
 
09c5b79
e4feb9d
 
33ffc9c
 
 
e4feb9d
09c5b79
 
 
 
 
 
 
 
33ffc9c
 
cecb963
09c5b79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68037ed
7a5aa35
09c5b79
68037ed
7a5aa35
33ffc9c
7a5aa35
68037ed
e4feb9d
7a5aa35
 
 
09c5b79
7a5aa35
 
 
 
e4feb9d
7a5aa35
09c5b79
 
7a5aa35
09c5b79
 
 
cecb963
 
09c5b79
 
 
 
cecb963
aef2282
 
 
09c5b79
cecb963
aef2282
09c5b79
 
 
cecb963
aef2282
 
 
09c5b79
cecb963
 
68037ed
e4feb9d
 
09c5b79
e4feb9d
7a5aa35
 
33ffc9c
09c5b79
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
import { serve } from "bun";
import { type Repo } from "./types.ts";

// --- Configuration Constants ---
const PORT = 7860;
const REFRESH_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes in milliseconds
const MAX_SEARCH_RESULTS = 25;
const DEFAULT_PAGINATION_SIZE = 10;

// Changed to a single URL for packages
const PACKAGE_URL =
  "https://raw.githubusercontent.com/Zigistry/database/refs/heads/main/database/packages.json";
const PROGRAMS_URL =
  "https://raw.githubusercontent.com/Zigistry/database/refs/heads/main/database/programs.json";

// --- CORS Headers ---
const CORS_HEADERS: Record<string, string> = {
  "Content-Type": "application/json",
  "Access-Control-Allow-Origin": "*",
};

// --- Data Store ---
interface DataStore {
  packages: Repo[];
  programs: Repo[];
  sortedPackages: {
    latest: Repo[];
    mostUsed: Repo[];
  };
  sortedPrograms: {
    latest: Repo[];
    mostUsed: Repo[];
  };
  lastLoaded: number; // Timestamp of last successful data load
}

const dataStore: DataStore = {
  packages: [],
  programs: [],
  sortedPackages: { latest: [], mostUsed: [] },
  sortedPrograms: { latest: [], mostUsed: [] },
  lastLoaded: 0,
};

// --- Helper Functions ---
function removeReadmeContent(repos: Repo[]): Repo[] {
  return (repos.map((repo) => {
    const { readme_content, ...rest } = repo; // Destructure to exclude readme_content
    return rest;
  }) as Repo[]);
}

const sortByDate = (a: Repo, b: Repo) =>
  new Date(b.created_at ?? "").getTime() -
  new Date(a.created_at ?? "").getTime();

const sortByUsage = (a: Repo, b: Repo) =>
  (b.stargazers_count ?? 0) - (a.stargazers_count ?? 0);

function filterItems(
  items: Repo[],
  q: string | null,
  filter: string | null
): Repo[] {
  const lowerQ = q?.toLowerCase();
  const lowerFilter = filter?.toLowerCase();

  return items.filter(
    ({ name, full_name, description, topics, readme_content }) => {
      // Apply topic filter first
      if (
        lowerFilter &&
        !topics?.some((t) => t.toLowerCase() === lowerFilter)
      ) {
        return false;
      }

      // If no search query, all items passing the filter are included
      if (!lowerQ) {
        return true;
      }

      // Check if any relevant field includes the search query
      return [name, full_name, description, ...(topics ?? []), readme_content].some(
        (field) => field?.toLowerCase().includes(lowerQ)
      );
    }
  );
}

function getPaginated<T>(items: T[], page = 0, size = DEFAULT_PAGINATION_SIZE): T[] {
  const start = page * size;
  return items.slice(start, start + size);
}

function parseRange(str: string | null, max: number): [number, number] {
  const match = str?.match(/^(\d+)\.\.(\d+)$/);
  let start = 0;
  let end = DEFAULT_PAGINATION_SIZE; // Default range if no match

  if (match) {
    start = parseInt(match[1] ?? "0", 10);
    end = parseInt(match[2] ?? String(DEFAULT_PAGINATION_SIZE), 10);
  }

  return [Math.max(0, start), Math.min(max, end)];
}

// --- Data Loading and Caching ---
async function fetchData<T>(url: string): Promise<T | null> {
  try {
    const response = await fetch(url);
    if (!response.ok) {
      console.error(`Failed to fetch ${url}: ${response.statusText}`);
      return null;
    }
    return (await response.json()) as T;
  } catch (error) {
    console.error(`Error fetching or parsing ${url}:`, error);
    return null;
  }
}

async function loadData() {
  console.log("Attempting to load data...");
  try {
    // Fetch packages (now from a single URL)
    const newPackages = await fetchData<Repo[]>(PACKAGE_URL);

    // Fetch programs
    const newPrograms = await fetchData<Repo[]>(PROGRAMS_URL);

    if (newPackages && newPrograms) {
      dataStore.packages = newPackages;
      dataStore.programs = newPrograms;

      // Pre-sort data after successful load
      dataStore.sortedPackages.latest = [...newPackages].sort(sortByDate);
      dataStore.sortedPackages.mostUsed = [...newPackages].sort(sortByUsage);
      dataStore.sortedPrograms.latest = [...newPrograms].sort(sortByDate);
      dataStore.sortedPrograms.mostUsed = [...newPrograms].sort(sortByUsage);

      dataStore.lastLoaded = Date.now();
      console.log("Data loaded successfully.");
    } else {
      console.warn("Failed to load all data. Retaining old data if available.");
    }
  } catch (error) {
    console.error("Critical error during data loading:", error);
  }
}

async function ensureDataLoaded() {
  if (Date.now() - dataStore.lastLoaded > REFRESH_INTERVAL_MS) {
    console.log("Data is stale, refreshing...");
    await loadData();
  } else if (dataStore.lastLoaded === 0) {
    // Initial load if not already done
    console.log("Initial data load...");
    await loadData();
  }
}

// Perform initial data load immediately on server start
await loadData();

// Set up interval for refreshing data
setInterval(ensureDataLoaded, REFRESH_INTERVAL_MS / 2); // Check more frequently than refresh interval

// --- API Route Handlers ---
function handleSearch(items: Repo[], searchParams: URLSearchParams): Response {
  const q = searchParams.get("q")?.trim() ?? null;
  const filter = searchParams.get("filter")?.trim() ?? null;
  const result = filterItems(items, q, filter).slice(0, MAX_SEARCH_RESULTS);
  return Response.json(removeReadmeContent(result), { headers: CORS_HEADERS });
}

function handleInfiniteScroll(items: Repo[], searchParams: URLSearchParams): Response {
  const page = parseInt(searchParams.get("pageNumber") || "0", 10);
  if (isNaN(page) || page < 0) {
    return Response.json(
      { error: "Invalid page number. Must be a non-negative integer." },
      { status: 400, headers: CORS_HEADERS }
    );
  }
  const result = getPaginated(items, page);
  return Response.json(removeReadmeContent(result), { headers: CORS_HEADERS });
}

function handleSingleItem(
  items: Repo[],
  owner: string,
  repo: string,
  repoFrom: string | null = null
): Response {
  const found = items.find(
    (item) =>
      item.full_name?.toLowerCase() === `${owner}/${repo}` &&
      (repoFrom === null || item.repo_from?.toLowerCase() === repoFrom)
  );

  if (found) {
    // Return the full item, including readme_content, for single item requests
    return Response.json(found, { status: 200, headers: CORS_HEADERS });
  }
  return Response.json(
    { error: "Item not found" },
    { status: 404, headers: CORS_HEADERS }
  );
}

function handleIndexDetails(
  sortedData: { latest: Repo[]; mostUsed: Repo[] },
  searchParams: URLSearchParams
): Response {
  const section = searchParams.get("section");
  if (section !== "latestRepos" && section !== "mostUsed") {
    return Response.json(
      { error: "Invalid section. Must be 'latestRepos' or 'mostUsed'." },
      { status: 400, headers: CORS_HEADERS }
    );
  }

  const sortKey = section === "latestRepos" ? "latest" : "mostUsed";
  const data = sortedData[sortKey];
  const [start, end] = parseRange(searchParams.get("range"), data.length);
  const result = data.slice(start, end);
  return Response.json(removeReadmeContent(result), { headers: CORS_HEADERS });
}

// --- Router Mapping ---
const routes: Record<
  string,
  (url: URL) => Promise<Response> | Response
> = {
  "/api/searchPackages": (url) =>
    handleSearch(dataStore.packages, url.searchParams),
  "/api/searchPrograms": (url) =>
    handleSearch(dataStore.programs, url.searchParams),
  "/api/infiniteScrollPackages": (url) =>
    handleInfiniteScroll(dataStore.packages, url.searchParams),
  "/api/infiniteScrollPrograms": (url) =>
    handleInfiniteScroll(dataStore.programs, url.searchParams),
  "/api/indexDetailsPackages": (url) =>
    handleIndexDetails(dataStore.sortedPackages, url.searchParams),
  "/api/indexDetailsPrograms": (url) =>
    handleIndexDetails(dataStore.sortedPrograms, url.searchParams),
};

// --- Server ---
serve({
  port: PORT,
  async fetch(req) {
    const url = new URL(req.url);
    const { pathname, searchParams } = url;

    // Handle CORS preflight
    if (req.method === "OPTIONS") {
      return new Response(null, {
        status: 204,
        headers: {
          ...CORS_HEADERS,
          "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
          "Access-Control-Allow-Headers": "Content-Type",
        },
      });
    }

    // Ensure data is loaded/refreshed before handling requests
    await ensureDataLoaded();

    // Direct route match
    if (routes[pathname]) {
      return routes[pathname](url);
    }

    // Regex-based routes for single items
    const programMatch = pathname.match(
      /^\/api\/programs\/([^/]+)\/([^/]+)\/([^/]+)$/
    );
    if (programMatch) {
      const repo_from = programMatch[1]?.toLowerCase() ?? "";
      const owner = programMatch[2]?.toLowerCase() ?? "";
      const repo = programMatch[3]?.toLowerCase() ?? "";
      return handleSingleItem(dataStore.programs, owner, repo, repo_from);
    }

    const packageMatch = pathname.match(
      /^\/api\/packages\/([^/]+)\/([^/]+)\/([^/]+)$/
    );
    if (packageMatch) {
      const repo_from = packageMatch[1]?.toLowerCase() ?? "";
      const owner = packageMatch[2]?.toLowerCase() ?? "";
      const repo = packageMatch[3]?.toLowerCase() ?? "";
      return handleSingleItem(dataStore.packages, owner, repo, repo_from);
    }

    // Not found
    return new Response("Not Found", {
      status: 404,
      headers: CORS_HEADERS,
    });
  },
});

console.log(`Server running on http://localhost:${PORT}`);