~james/core

core/web/src/lib/server/indexnow.ts -rw-r--r-- 3.3 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 15 hours ago
                                                                                
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
import * as env from "$app/env/private";
import type { Fetch } from "#lib/fetch.ts";

export const DEFAULT_INDEXNOW_KEY = "d76fae270607bfc761b35c14f3182c9e";
export const DEFAULT_INDEXNOW_HOST = "tangled.org";
export const INDEXNOW_ENDPOINT = "https://api.indexnow.org/indexnow";

export interface IndexNowPayload {
	host: string;
	key: string;
	keyLocation: string;
	urlList: string[];
}

export interface IndexNowOptions {
	host?: string;
	key?: string;
	keyLocation?: string;
	fetch?: Fetch;
}

export interface IndexNowResult {
	success: boolean;
	status?: number;
	error?: string;
}

/**
 * Submits a URL or list of URLs to IndexNow (Bing, Yandex, Naver, Seznam, etc.).
 * Guarantees silent error handling: network or API errors are logged to console
 * without throwing, ensuring user or client flows are never disrupted.
 */
export async function submitToIndexNow(
	urls: string | string[],
	options: IndexNowOptions = {}
): Promise<IndexNowResult> {
	const rawUrls = Array.isArray(urls) ? urls : [urls];
	const validUrls = rawUrls.filter((u) => typeof u === "string" && u.trim().length > 0);

	if (validUrls.length === 0) {
		return { success: false, error: "No URLs provided" };
	}

	const host = options.host || env.INDEXNOW_HOST || DEFAULT_INDEXNOW_HOST;
	const key = options.key || env.INDEXNOW_KEY || DEFAULT_INDEXNOW_KEY;
	const keyLocation = options.keyLocation || `https://${host}/${key}.txt`;
	const customFetch = options.fetch ?? globalThis.fetch;

	// IndexNow allows maximum 10,000 URLs per request
	const MAX_BATCH_SIZE = 10000;
	const urlBatches: string[][] = [];
	for (let i = 0; i < validUrls.length; i += MAX_BATCH_SIZE) {
		urlBatches.push(validUrls.slice(i, i + MAX_BATCH_SIZE));
	}

	let allSuccess = true;
	let lastStatus: number | undefined;

	for (const batch of urlBatches) {
		const payload: IndexNowPayload = {
			host,
			key,
			keyLocation,
			urlList: batch
		};

		try {
			const res = await customFetch(INDEXNOW_ENDPOINT, {
				method: "POST",
				headers: {
					"content-type": "application/json; charset=utf-8"
				},
				body: JSON.stringify(payload)
			});

			lastStatus = res.status;
			// 200 = OK, 202 = Accepted (key validation pending)
			if (!res.ok && res.status !== 202) {
				const body = await res.text().catch(() => "");
				console.warn(
					`[IndexNow] Non-OK response (${res.status}): ${body || res.statusText}`
				);
				allSuccess = false;
			}
		} catch (err) {
			console.warn("[IndexNow] Failed to submit URLs:", err);
			allSuccess = false;
		}
	}

	return {
		success: allSuccess,
		status: lastStatus,
		error: allSuccess
			? undefined
			: `IndexNow submission encountered errors (HTTP ${lastStatus ?? "unknown"})`
	};
}

/**
 * Convenience helper to ping IndexNow when a new profile is created.
 */
export async function notifyProfileCreated(
	handle: string,
	options?: IndexNowOptions
): Promise<IndexNowResult> {
	const host = options?.host || DEFAULT_INDEXNOW_HOST;
	return submitToIndexNow(`https://${host}/${handle}`, options);
}

/**
 * Convenience helper to ping IndexNow when a new repository is created.
 */
export async function notifyRepoCreated(
	handle: string,
	repoSlug: string,
	options?: IndexNowOptions
): Promise<IndexNowResult> {
	const host = options?.host || DEFAULT_INDEXNOW_HOST;
	return submitToIndexNow(`https://${host}/${handle}/${repoSlug}`, options);
}