nuxt-ai-ready 0.7.15 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "nuxt": ">=4.0.0"
5
5
  },
6
6
  "configKey": "aiReady",
7
- "version": "0.7.15",
7
+ "version": "0.8.0",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.2",
10
10
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -67,7 +67,121 @@ function hookNuxtSeoProLicense() {
67
67
  }
68
68
  }
69
69
 
70
- function createCrawlerState(dbPath, llmsFullTxtPath, siteInfo, llmsTxtConfig) {
70
+ const INDEXNOW_HOSTS = ["api.indexnow.org", "www.bing.com"];
71
+ function getIndexNowEndpoints() {
72
+ const testEndpoint = process.env.INDEXNOW_TEST_ENDPOINT;
73
+ if (testEndpoint) {
74
+ return [testEndpoint];
75
+ }
76
+ return INDEXNOW_HOSTS.map((host) => `https://${host}/indexnow`);
77
+ }
78
+ function buildIndexNowBody(routes, key, siteUrl) {
79
+ const urlList = routes.map(
80
+ (route) => route.startsWith("http") ? route : `${siteUrl}${route}`
81
+ );
82
+ return {
83
+ host: new URL(siteUrl).host,
84
+ key,
85
+ keyLocation: `${siteUrl}/${key}.txt`,
86
+ urlList
87
+ };
88
+ }
89
+ async function submitToIndexNow(routes, key, siteUrl, options) {
90
+ if (!siteUrl) {
91
+ return { success: false, error: "Site URL not configured" };
92
+ }
93
+ const fetchFn = options?.fetchFn ?? globalThis.fetch;
94
+ const log = options?.logger;
95
+ const body = buildIndexNowBody(routes, key, siteUrl);
96
+ const endpoints = getIndexNowEndpoints();
97
+ let lastError;
98
+ for (const endpoint of endpoints) {
99
+ log?.debug(`[indexnow] Submitting ${body.urlList.length} URLs to ${endpoint}`);
100
+ const response = await fetchFn(endpoint, {
101
+ method: "POST",
102
+ headers: { "Content-Type": "application/json" },
103
+ body: JSON.stringify(body)
104
+ }).then((r) => r.ok ? { ok: true } : { error: `HTTP ${r.status}` }).catch((err) => ({ error: err.message }));
105
+ if ("error" in response) {
106
+ lastError = response.error;
107
+ if (lastError?.includes("429")) {
108
+ log?.warn(`[indexnow] Rate limited on ${endpoint}, trying fallback...`);
109
+ continue;
110
+ }
111
+ log?.warn(`[indexnow] Submission failed on ${endpoint}: ${lastError}`);
112
+ return { success: false, error: lastError, host: endpoint };
113
+ }
114
+ log?.debug(`[indexnow] Successfully submitted ${body.urlList.length} URLs via ${endpoint}`);
115
+ return { success: true, host: endpoint };
116
+ }
117
+ return {
118
+ success: false,
119
+ error: lastError || "All endpoints rate limited",
120
+ host: endpoints[endpoints.length - 1]
121
+ };
122
+ }
123
+ function comparePageHashes(currentPages, prevMeta) {
124
+ if (!prevMeta?.pages) {
125
+ return { changed: [], added: [], removed: [] };
126
+ }
127
+ const prevHashes = new Map(prevMeta.pages.map((p) => [p.route, p.hash]));
128
+ const currentRoutes = new Set(currentPages.map((p) => p.route));
129
+ const changed = [];
130
+ const added = [];
131
+ for (const page of currentPages) {
132
+ const prevHash = prevHashes.get(page.route);
133
+ if (!prevHash) {
134
+ added.push(page.route);
135
+ } else if (prevHash !== page.hash) {
136
+ changed.push(page.route);
137
+ }
138
+ }
139
+ const removed = [];
140
+ for (const route of prevHashes.keys()) {
141
+ if (!currentRoutes.has(route)) {
142
+ removed.push(route);
143
+ }
144
+ }
145
+ return { changed, added, removed };
146
+ }
147
+
148
+ async function fetchPreviousMeta(siteUrl, indexNowKey) {
149
+ const metaUrl = `${siteUrl}/__ai-ready/pages.meta.json`;
150
+ logger.debug(`[indexnow] Fetching previous meta from ${metaUrl}`);
151
+ const prevMeta = await fetch(metaUrl).then((r) => r.ok ? r.json() : null).catch(() => null);
152
+ if (!prevMeta?.pages) {
153
+ logger.info("[indexnow] First deploy or no previous meta - skipping IndexNow");
154
+ return null;
155
+ }
156
+ const keyUrl = `${siteUrl}/${indexNowKey}.txt`;
157
+ const keyLive = await fetch(keyUrl).then((r) => r.ok).catch(() => false);
158
+ if (!keyLive) {
159
+ logger.info("[indexnow] Key file not live yet - skipping IndexNow");
160
+ return null;
161
+ }
162
+ return prevMeta;
163
+ }
164
+ async function handleStaticIndexNow(currentPages, siteUrl, indexNowKey, prevMeta) {
165
+ const { changed, added } = comparePageHashes(currentPages, prevMeta);
166
+ const totalChanged = changed.length + added.length;
167
+ if (totalChanged === 0) {
168
+ logger.debug("[indexnow] No content changes detected");
169
+ return;
170
+ }
171
+ logger.info(`[indexnow] Submitting ${totalChanged} changed pages (${changed.length} modified, ${added.length} new)`);
172
+ const result = await submitToIndexNow(
173
+ [...changed, ...added],
174
+ indexNowKey,
175
+ siteUrl,
176
+ { logger }
177
+ );
178
+ if (result.success) {
179
+ logger.info(`[indexnow] Successfully notified search engines of ${totalChanged} changes`);
180
+ } else {
181
+ logger.warn(`[indexnow] Failed to submit: ${result.error}`);
182
+ }
183
+ }
184
+ function createCrawlerState(dbPath, llmsFullTxtPath, siteInfo, llmsTxtConfig, indexNowKey) {
71
185
  return {
72
186
  prerenderedRoutes: /* @__PURE__ */ new Set(),
73
187
  errorRoutes: /* @__PURE__ */ new Set(),
@@ -76,7 +190,8 @@ function createCrawlerState(dbPath, llmsFullTxtPath, siteInfo, llmsTxtConfig) {
76
190
  dbPath,
77
191
  llmsFullTxtPath,
78
192
  siteInfo,
79
- llmsTxtConfig
193
+ llmsTxtConfig,
194
+ indexNowKey
80
195
  };
81
196
  }
82
197
  async function initCrawler(state) {
@@ -227,11 +342,11 @@ async function prerenderRoute(nitro, route) {
227
342
  nitro._prerenderedRoutes.push(_route);
228
343
  return stat(filePath);
229
344
  }
230
- function setupPrerenderHandler(dbPath, siteInfo, llmsTxtConfig) {
345
+ function setupPrerenderHandler(dbPath, siteInfo, llmsTxtConfig, indexNowKey) {
231
346
  const nuxt = useNuxt();
232
347
  nuxt.hooks.hook("nitro:init", async (nitro) => {
233
348
  const llmsFullTxtPath = join(nitro.options.output.publicDir, "llms-full.txt");
234
- const state = createCrawlerState(dbPath, llmsFullTxtPath, siteInfo, llmsTxtConfig);
349
+ const state = createCrawlerState(dbPath, llmsFullTxtPath, siteInfo, llmsTxtConfig, indexNowKey);
235
350
  let initPromise = null;
236
351
  nitro.hooks.hook("prerender:generate", async (route) => {
237
352
  if (route.error) {
@@ -294,6 +409,23 @@ function setupPrerenderHandler(dbPath, siteInfo, llmsTxtConfig) {
294
409
  const dumpPath = join(publicDataDir, "pages.dump");
295
410
  await writeFile(dumpPath, dumpData, "utf-8");
296
411
  logger.debug(`Created database dump at __ai-ready/pages.dump (${(dumpData.length / 1024).toFixed(1)}kb compressed)`);
412
+ const pageHashes = pages.filter((p) => p.contentHash).map((p) => ({ route: p.route, hash: p.contentHash }));
413
+ let prevMeta = null;
414
+ if (state.indexNowKey && state.siteInfo?.url) {
415
+ prevMeta = await fetchPreviousMeta(state.siteInfo.url, state.indexNowKey);
416
+ }
417
+ const buildId = Date.now().toString(36);
418
+ const metaContent = JSON.stringify({
419
+ buildId,
420
+ pageCount: pages.length,
421
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
422
+ pages: pageHashes
423
+ });
424
+ await writeFile(join(publicDataDir, "pages.meta.json"), metaContent, "utf-8");
425
+ logger.debug(`Wrote build metadata: buildId=${buildId}, ${pageHashes.length} page hashes`);
426
+ if (state.indexNowKey && state.siteInfo?.url && prevMeta) {
427
+ await handleStaticIndexNow(pageHashes, state.siteInfo.url, state.indexNowKey, prevMeta);
428
+ }
297
429
  }
298
430
  const llmsStats = await prerenderRoute(nitro, "/llms.txt");
299
431
  const llmsFullStats = await stat(state.llmsFullTxtPath);
@@ -666,9 +798,6 @@ export const errorRoutes = []`;
666
798
  runtimeSyncSecret: config.runtimeSyncSecret,
667
799
  indexNowKey
668
800
  };
669
- nuxt.options.nitro.plugins = nuxt.options.nitro.plugins || [];
670
- if (runtimeSyncEnabled)
671
- nuxt.options.nitro.plugins.push(resolve("./runtime/server/plugins/sitemap-seeder"));
672
801
  addServerHandler({
673
802
  middleware: true,
674
803
  handler: resolve("./runtime/server/middleware/markdown.prerender")
@@ -721,7 +850,7 @@ export const errorRoutes = []`;
721
850
  name: siteConfig.name,
722
851
  url: siteConfig.url,
723
852
  description: siteConfig.description
724
- }, mergedLlmsTxt);
853
+ }, mergedLlmsTxt, indexNowKey);
725
854
  }
726
855
  nuxt.options.nitro.routeRules = nuxt.options.nitro.routeRules || {};
727
856
  nuxt.options.nitro.routeRules["/llms.txt"] = { headers: { "Content-Type": "text/plain; charset=utf-8" } };
@@ -1,6 +1,6 @@
1
1
  export { useDatabase } from './server/db/index.js';
2
2
  export type { DatabaseAdapter } from './server/db/index.js';
3
- export { countPages, getSitemapSeededAt, getStaleRoutes, isPageFresh, pruneStaleRoutes, queryPages, searchPages, seedRoutes, setSitemapSeededAt, streamPages, upsertPage, } from './server/db/queries.js';
3
+ export { countPages, getStaleRoutes, isPageFresh, pruneStaleRoutes, queryPages, searchPages, seedRoutes, streamPages, upsertPage, } from './server/db/queries.js';
4
4
  export type { CountPagesOptions, PageData, PageEntry, PageRow, QueryPagesOptions, SearchPagesOptions, SearchResult, StreamPagesOptions, UpsertPageInput, } from './server/db/queries.js';
5
5
  export { indexPage, indexPageByRoute } from './server/utils/indexPage.js';
6
6
  export type { IndexPageOptions, IndexPageResult } from './server/utils/indexPage.js';
@@ -1,14 +1,12 @@
1
1
  export { useDatabase } from "./server/db/index.js";
2
2
  export {
3
3
  countPages,
4
- getSitemapSeededAt,
5
4
  getStaleRoutes,
6
5
  isPageFresh,
7
6
  pruneStaleRoutes,
8
7
  queryPages,
9
8
  searchPages,
10
9
  seedRoutes,
11
- setSitemapSeededAt,
12
10
  streamPages,
13
11
  upsertPage
14
12
  } from "./server/db/queries.js";
@@ -154,6 +154,11 @@ export declare function markIndexNowSynced(event: H3Event | undefined, routes: s
154
154
  * Uses atomic SQL to handle concurrent updates safely
155
155
  */
156
156
  export declare function updateIndexNowStats(event: H3Event | undefined, submitted: number, error?: string): Promise<void>;
157
+ /**
158
+ * Batch update for IndexNow: mark pages synced + update stats
159
+ * Runs all queries in parallel for speed
160
+ */
161
+ export declare function batchIndexNowUpdate(event: H3Event | undefined, routes: string[], submitted: number): Promise<void>;
157
162
  export interface IndexNowStats {
158
163
  totalSubmitted: number;
159
164
  lastSubmittedAt: number | null;
@@ -363,6 +363,32 @@ export async function updateIndexNowStats(event, submitted, error) {
363
363
  );
364
364
  }
365
365
  }
366
+ export async function batchIndexNowUpdate(event, routes, submitted) {
367
+ const db = await getDb(event);
368
+ if (!db || routes.length === 0)
369
+ return;
370
+ const now = Date.now();
371
+ const placeholders = routes.map(() => "?").join(",");
372
+ await Promise.all([
373
+ // Mark pages as synced
374
+ db.exec(
375
+ `UPDATE ai_ready_pages SET indexnow_synced_at = ? WHERE route IN (${placeholders})`,
376
+ [now, ...routes]
377
+ ),
378
+ // Atomic increment total submitted
379
+ db.exec(`
380
+ INSERT INTO _ai_ready_info (id, value) VALUES ('indexnow_total_submitted', ?)
381
+ ON CONFLICT(id) DO UPDATE SET value = CAST((CAST(value AS INTEGER) + ?) AS TEXT)
382
+ `, [String(submitted), submitted]),
383
+ // Update last submitted timestamp
384
+ db.exec(
385
+ "INSERT OR REPLACE INTO _ai_ready_info (id, value) VALUES (?, ?)",
386
+ ["indexnow_last_submitted_at", String(now)]
387
+ ),
388
+ // Clear any previous error
389
+ db.exec("DELETE FROM _ai_ready_info WHERE id = ?", ["indexnow_last_error"])
390
+ ]);
391
+ }
366
392
  export async function getIndexNowStats(event) {
367
393
  const db = await getDb(event);
368
394
  if (!db)
@@ -13,7 +13,8 @@ export interface DatabaseAdapter {
13
13
  */
14
14
  export declare function createAdapter(connector: Connector): DatabaseAdapter;
15
15
  /**
16
- * Initialize database schema with version checking and lazy restore
16
+ * Initialize database schema with version checking
17
+ * Note: Restore logic moved to cron for reliability on Cloudflare
17
18
  */
18
19
  export declare function initSchema(db: DatabaseAdapter): Promise<void>;
19
20
  /**
@@ -98,6 +99,7 @@ export interface DumpRow {
98
99
  indexed: number;
99
100
  source: string;
100
101
  last_seen_at: number | null;
102
+ indexnow_synced_at: number | null;
101
103
  }
102
104
  /**
103
105
  * Export database as compressed dump (base64 gzip) using batched streaming
@@ -106,5 +108,7 @@ export interface DumpRow {
106
108
  export declare function exportDbDump(db: DatabaseAdapter): Promise<string>;
107
109
  /**
108
110
  * Import dump into database
111
+ * Sets indexed=1, last_seen_at=indexed_at, indexnow_synced_at=indexed_at
112
+ * (pages from dump were already indexed and synced during build, no need to re-notify)
109
113
  */
110
114
  export declare function importDbDump(db: DatabaseAdapter, rows: DumpRow[]): Promise<void>;
@@ -35,33 +35,6 @@ export async function initSchema(db) {
35
35
  "INSERT OR REPLACE INTO _ai_ready_info (id, version) VALUES (?, ?)",
36
36
  ["schema", SCHEMA_VERSION]
37
37
  );
38
- if (!import.meta.prerender && !import.meta.dev) {
39
- await restoreFromDumpIfEmpty(db);
40
- }
41
- }
42
- async function restoreFromDumpIfEmpty(db) {
43
- const row = await db.first("SELECT COUNT(*) as count FROM ai_ready_pages");
44
- if (row && row.count > 0)
45
- return;
46
- try {
47
- let dumpData = null;
48
- const cfEnv = globalThis.__env__;
49
- if (cfEnv?.ASSETS) {
50
- const response = await cfEnv.ASSETS.fetch("https://placeholder/__ai-ready/pages.dump");
51
- if (response.ok)
52
- dumpData = await response.text();
53
- }
54
- if (!dumpData) {
55
- dumpData = await globalThis.$fetch("/__ai-ready/pages.dump", {
56
- responseType: "text"
57
- });
58
- }
59
- if (!dumpData)
60
- return;
61
- const rows = await decompressFromBase64(dumpData);
62
- await importDbDump(db, rows);
63
- } catch {
64
- }
65
38
  }
66
39
  async function checkSchemaVersion(db) {
67
40
  const info = await db.first(
@@ -147,7 +120,7 @@ export async function exportDbDump(db) {
147
120
  let first = true;
148
121
  while (true) {
149
122
  const rows = await db.all(`
150
- SELECT route, route_key, title, description, markdown, headings, keywords, content_hash, updated_at, indexed_at, is_error, indexed, source, last_seen_at
123
+ SELECT route, route_key, title, description, markdown, headings, keywords, content_hash, updated_at, indexed_at, is_error, indexed, source, last_seen_at, indexnow_synced_at
151
124
  FROM ai_ready_pages
152
125
  ORDER BY route
153
126
  LIMIT ${DUMP_BATCH_SIZE} OFFSET ${offset}
@@ -179,8 +152,8 @@ export async function importDbDump(db, rows) {
179
152
  for (const row of rows) {
180
153
  const source = row.source || "prerender";
181
154
  await db.exec(`
182
- INSERT OR REPLACE INTO ai_ready_pages (route, route_key, title, description, markdown, headings, keywords, content_hash, updated_at, indexed_at, is_error, indexed, source, last_seen_at)
183
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, NULL)
184
- `, [row.route, row.route_key, row.title, row.description, row.markdown, row.headings, row.keywords, row.content_hash || null, row.updated_at, row.indexed_at, row.is_error, source]);
155
+ INSERT OR REPLACE INTO ai_ready_pages (route, route_key, title, description, markdown, headings, keywords, content_hash, updated_at, indexed_at, is_error, indexed, source, last_seen_at, indexnow_synced_at)
156
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)
157
+ `, [row.route, row.route_key, row.title, row.description, row.markdown, row.headings, row.keywords, row.content_hash || null, row.updated_at, row.indexed_at, row.is_error, source, row.indexed_at, row.indexed_at]);
185
158
  }
186
159
  }
@@ -14,5 +14,5 @@ export default eventHandler(async (event) => {
14
14
  }
15
15
  }
16
16
  const limit = query.limit ? Number(query.limit) : 100;
17
- return syncToIndexNow(event, limit);
17
+ return syncToIndexNow(event, limit, { useWaitUntil: true });
18
18
  });
@@ -26,13 +26,18 @@ interface DebugInfo {
26
26
  total: number;
27
27
  indexed: number;
28
28
  pending: number;
29
- sitemapSeededAt: string | null;
29
+ errors: number;
30
30
  };
31
31
  indexNow?: {
32
32
  pending: number;
33
33
  totalSubmitted: number;
34
34
  lastSubmittedAt: string | null;
35
35
  lastError: string | null;
36
+ backoff?: {
37
+ until: string;
38
+ minutesRemaining: number;
39
+ attempt: number;
40
+ } | null;
36
41
  };
37
42
  indexNowLog?: Array<{
38
43
  id: number;
@@ -58,6 +63,13 @@ interface DebugInfo {
58
63
  pageCount: number;
59
64
  source: string;
60
65
  };
66
+ buildInfo?: {
67
+ storedBuildId: string | null;
68
+ dumpBuildId: string | null;
69
+ dumpPageCount: number | null;
70
+ isStale: boolean;
71
+ dumpCreatedAt: string | null;
72
+ };
61
73
  virtualModules: {
62
74
  pageDataModule: {
63
75
  available: boolean;
@@ -1,6 +1,7 @@
1
1
  import { createError, eventHandler, setHeader } from "h3";
2
2
  import { useRuntimeConfig } from "nitropack/runtime";
3
- import { countPages, countPagesNeedingIndexNowSync, getIndexNowLog, getIndexNowStats, getRecentCronRuns, getSitemapSeededAt, queryPages } from "../db/queries.js";
3
+ import { useDatabase } from "../db/index.js";
4
+ import { countPages, countPagesNeedingIndexNowSync, getIndexNowLog, getIndexNowStats, getRecentCronRuns, queryPages } from "../db/queries.js";
4
5
  export default eventHandler(async (event) => {
5
6
  const runtimeConfig = useRuntimeConfig(event)["nuxt-ai-ready"];
6
7
  if (!runtimeConfig.debug) {
@@ -107,19 +108,20 @@ export default eventHandler(async (event) => {
107
108
  let indexNowInfo;
108
109
  let indexNowLogInfo;
109
110
  let cronRunsInfo;
111
+ let buildInfo;
110
112
  if (!isDev && !isPrerender && !dbError) {
111
113
  try {
112
- const [total, pending, sitemapSeededAt, cronRuns] = await Promise.all([
114
+ const [total, pending, errors, cronRuns] = await Promise.all([
113
115
  countPages(event),
114
116
  countPages(event, { where: { pending: true } }),
115
- getSitemapSeededAt(event),
117
+ countPages(event, { where: { hasError: true } }),
116
118
  getRecentCronRuns(event, 20)
117
119
  ]);
118
120
  runtimeSyncInfo = {
119
121
  total,
120
122
  indexed: total - pending,
121
123
  pending,
122
- sitemapSeededAt: sitemapSeededAt ? new Date(sitemapSeededAt).toISOString() : null
124
+ errors
123
125
  };
124
126
  cronRunsInfo = cronRuns.map((run) => ({
125
127
  id: run.id,
@@ -139,11 +141,26 @@ export default eventHandler(async (event) => {
139
141
  getIndexNowStats(event),
140
142
  getIndexNowLog(event, 20)
141
143
  ]);
144
+ const db2 = await useDatabase(event);
145
+ const backoffRow = await db2.first("SELECT value FROM _ai_ready_info WHERE id = ?", ["indexnow_backoff"]);
146
+ let backoffInfo = null;
147
+ if (backoffRow) {
148
+ const parsed = JSON.parse(backoffRow.value);
149
+ const now = Date.now();
150
+ if (parsed.until > now) {
151
+ backoffInfo = {
152
+ until: new Date(parsed.until).toISOString(),
153
+ minutesRemaining: Math.ceil((parsed.until - now) / 6e4),
154
+ attempt: parsed.attempt
155
+ };
156
+ }
157
+ }
142
158
  indexNowInfo = {
143
159
  pending: indexNowPending,
144
160
  totalSubmitted: indexNowStats.totalSubmitted,
145
161
  lastSubmittedAt: indexNowStats.lastSubmittedAt ? new Date(indexNowStats.lastSubmittedAt).toISOString() : null,
146
- lastError: indexNowStats.lastError
162
+ lastError: indexNowStats.lastError,
163
+ backoff: backoffInfo
147
164
  };
148
165
  indexNowLogInfo = indexNowLogEntries.map((entry) => ({
149
166
  id: entry.id,
@@ -153,6 +170,33 @@ export default eventHandler(async (event) => {
153
170
  error: entry.error
154
171
  }));
155
172
  }
173
+ const db = await useDatabase(event);
174
+ const storedRow = await db.first("SELECT value FROM _ai_ready_info WHERE id = ?", ["build_id"]);
175
+ const storedBuildId = storedRow?.value || null;
176
+ let dumpMeta = null;
177
+ const cfEnv2 = event.context?.cloudflare?.env;
178
+ if (cfEnv2?.ASSETS?.fetch) {
179
+ const metaResponse = await cfEnv2.ASSETS.fetch(new Request("https://assets.local/__ai-ready/pages.meta.json")).catch(() => null);
180
+ if (metaResponse?.ok)
181
+ dumpMeta = await metaResponse.json().catch(() => null);
182
+ }
183
+ if (!dumpMeta) {
184
+ dumpMeta = await globalThis.$fetch("/__ai-ready/pages.meta.json").catch(() => null);
185
+ }
186
+ buildInfo = {
187
+ storedBuildId,
188
+ dumpBuildId: dumpMeta?.buildId || null,
189
+ dumpPageCount: dumpMeta?.pageCount || null,
190
+ isStale: dumpMeta ? storedBuildId !== dumpMeta.buildId : false,
191
+ dumpCreatedAt: dumpMeta?.createdAt || null
192
+ };
193
+ if (buildInfo.isStale) {
194
+ issues.push(`Build ID mismatch: DB has "${storedBuildId || "none"}", dump has "${dumpMeta?.buildId}"`);
195
+ suggestions.push("Cron will mark pages pending on next run, or manually trigger /__ai-ready/cron");
196
+ } else if (!storedBuildId && dumpMeta) {
197
+ issues.push("No build ID stored in DB - data may need restore");
198
+ suggestions.push("Wait for cron to run, or manually trigger /__ai-ready/cron");
199
+ }
156
200
  } catch (err) {
157
201
  issues.push(`Runtime stats error: ${err.message || String(err)}`);
158
202
  }
@@ -186,6 +230,7 @@ export default eventHandler(async (event) => {
186
230
  indexNow: indexNowInfo,
187
231
  indexNowLog: indexNowLogInfo,
188
232
  cronRuns: cronRunsInfo,
233
+ buildInfo,
189
234
  pageData: {
190
235
  source,
191
236
  pageCount: pages.length,
@@ -0,0 +1,20 @@
1
+ import type { H3Event } from 'h3';
2
+ export interface BuildMeta {
3
+ buildId: string;
4
+ pageCount: number;
5
+ createdAt: string;
6
+ }
7
+ export interface StaleCheckResult {
8
+ action: 'none' | 'restored' | 'marked_pending';
9
+ buildId?: string;
10
+ dbCount: number;
11
+ dumpCount?: number;
12
+ reason?: string;
13
+ }
14
+ /**
15
+ * Check if data is stale and handle restore/mark-pending
16
+ * Called at start of cron - handles:
17
+ * 1. Empty DB → restore from dump
18
+ * 2. Build ID changed → mark all pages pending for recheck
19
+ */
20
+ export declare function checkAndHandleStale(event: H3Event): Promise<StaleCheckResult>;
@@ -0,0 +1,100 @@
1
+ import { useRuntimeConfig } from "nitropack/runtime";
2
+ import { useDatabase } from "../db/index.js";
3
+ import { countPages } from "../db/queries.js";
4
+ import { decompressFromBase64, importDbDump } from "../db/shared.js";
5
+ import { logger } from "../logger.js";
6
+ async function fetchBuildMeta(event) {
7
+ const cfEnv = event.context?.cloudflare?.env;
8
+ if (cfEnv?.ASSETS?.fetch) {
9
+ const response = await cfEnv.ASSETS.fetch(new Request("https://assets.local/__ai-ready/pages.meta.json")).catch(() => null);
10
+ if (response?.ok) {
11
+ return response.json().catch(() => null);
12
+ }
13
+ }
14
+ return globalThis.$fetch("/__ai-ready/pages.meta.json").catch(() => null);
15
+ }
16
+ async function fetchDump(event) {
17
+ const cfEnv = event.context?.cloudflare?.env;
18
+ let dumpData = null;
19
+ if (cfEnv?.ASSETS?.fetch) {
20
+ const response = await cfEnv.ASSETS.fetch(new Request("https://assets.local/__ai-ready/pages.dump")).catch(() => null);
21
+ if (response?.ok) {
22
+ dumpData = await response.text();
23
+ }
24
+ }
25
+ if (!dumpData) {
26
+ dumpData = await globalThis.$fetch("/__ai-ready/pages.dump", { responseType: "text" }).catch(() => null);
27
+ }
28
+ if (!dumpData)
29
+ return null;
30
+ return decompressFromBase64(dumpData);
31
+ }
32
+ async function getStoredBuildId(event) {
33
+ const db = await useDatabase(event);
34
+ const row = await db.first("SELECT value FROM _ai_ready_info WHERE id = ?", ["build_id"]);
35
+ return row?.value || null;
36
+ }
37
+ async function setStoredBuildId(event, buildId) {
38
+ const db = await useDatabase(event);
39
+ await db.exec("INSERT OR REPLACE INTO _ai_ready_info (id, value) VALUES (?, ?)", ["build_id", buildId]);
40
+ }
41
+ async function markAllPagesPending(event) {
42
+ const db = await useDatabase(event);
43
+ await db.exec("UPDATE ai_ready_pages SET indexed = 0 WHERE is_error = 0");
44
+ return countPages(event, { where: { pending: true } });
45
+ }
46
+ export async function checkAndHandleStale(event) {
47
+ const config = useRuntimeConfig()["nuxt-ai-ready"];
48
+ const debug = config.debug;
49
+ const dbCount = await countPages(event);
50
+ const storedBuildId = await getStoredBuildId(event);
51
+ const meta = await fetchBuildMeta(event);
52
+ if (debug) {
53
+ logger.info(`[stale-check] DB count: ${dbCount}, stored buildId: ${storedBuildId || "none"}, dump buildId: ${meta?.buildId || "none"}`);
54
+ }
55
+ if (!meta) {
56
+ if (debug)
57
+ logger.info("[stale-check] No build metadata found, skipping stale check");
58
+ return { action: "none", dbCount, reason: "no_dump_metadata" };
59
+ }
60
+ if (dbCount === 0) {
61
+ if (debug)
62
+ logger.info("[stale-check] DB empty, restoring from dump...");
63
+ const rows = await fetchDump(event);
64
+ if (!rows) {
65
+ logger.warn("[stale-check] Failed to fetch dump for restore");
66
+ return { action: "none", dbCount: 0, dumpCount: meta.pageCount, reason: "dump_fetch_failed" };
67
+ }
68
+ const db = await useDatabase(event);
69
+ await importDbDump(db, rows);
70
+ await setStoredBuildId(event, meta.buildId);
71
+ logger.info(`[stale-check] Restored ${rows.length} pages from dump (buildId: ${meta.buildId})`);
72
+ return {
73
+ action: "restored",
74
+ buildId: meta.buildId,
75
+ dbCount: rows.length,
76
+ dumpCount: meta.pageCount
77
+ };
78
+ }
79
+ if (storedBuildId !== meta.buildId) {
80
+ if (debug)
81
+ logger.info(`[stale-check] Build ID changed (${storedBuildId} \u2192 ${meta.buildId}), marking pages pending...`);
82
+ const pendingCount = await markAllPagesPending(event);
83
+ await setStoredBuildId(event, meta.buildId);
84
+ logger.info(`[stale-check] Marked ${pendingCount} pages as pending for recheck (buildId: ${meta.buildId})`);
85
+ return {
86
+ action: "marked_pending",
87
+ buildId: meta.buildId,
88
+ dbCount,
89
+ dumpCount: meta.pageCount
90
+ };
91
+ }
92
+ if (debug)
93
+ logger.info("[stale-check] Build ID unchanged, no action needed");
94
+ return {
95
+ action: "none",
96
+ buildId: meta.buildId,
97
+ dbCount,
98
+ dumpCount: meta.pageCount
99
+ };
100
+ }
@@ -1,4 +1,5 @@
1
1
  import type { H3Event } from 'h3';
2
+ export type { BuildMeta, IndexNowSubmitResult, PageHashMeta } from '../../../shared/indexnow.js';
2
3
  export interface IndexNowResult {
3
4
  success: boolean;
4
5
  submitted: number;
@@ -7,18 +8,23 @@ export interface IndexNowResult {
7
8
  backoff?: boolean;
8
9
  }
9
10
  /**
10
- * Submit URLs to IndexNow API
11
+ * Submit URLs to IndexNow API with fallback on rate limit
12
+ * Wrapper around shared implementation with runtime-specific fetch
11
13
  */
12
14
  export declare function submitToIndexNow(routes: string[], config: {
13
15
  key: string;
14
- host?: string;
15
16
  }, siteUrl: string): Promise<{
16
17
  success: boolean;
17
18
  error?: string;
19
+ host?: string;
18
20
  }>;
21
+ export interface SyncToIndexNowOptions {
22
+ /** Use waitUntil for background DB updates (default: false in cron, true otherwise) */
23
+ useWaitUntil?: boolean;
24
+ }
19
25
  /**
20
26
  * Submit pending pages to IndexNow
21
27
  * Queries DB for pages needing sync, submits, marks as synced
22
28
  * Implements exponential backoff on 429 rate limit errors
23
29
  */
24
- export declare function syncToIndexNow(event: H3Event | undefined, limit?: number): Promise<IndexNowResult>;
30
+ export declare function syncToIndexNow(event: H3Event | undefined, limit?: number, options?: SyncToIndexNowOptions): Promise<IndexNowResult>;
@@ -1,11 +1,12 @@
1
1
  import { getSiteConfig } from "#site-config/server/composables";
2
2
  import { useRuntimeConfig } from "nitropack/runtime";
3
+ import { submitToIndexNow as submitToIndexNowShared } from "../../../shared/indexnow";
3
4
  import { useDatabase } from "../db/index.js";
4
5
  import {
6
+ batchIndexNowUpdate,
5
7
  countPagesNeedingIndexNowSync,
6
8
  getPagesNeedingIndexNowSync,
7
9
  logIndexNowSubmission,
8
- markIndexNowSynced,
9
10
  updateIndexNowStats
10
11
  } from "../db/queries.js";
11
12
  import { logger } from "../logger.js";
@@ -31,34 +32,25 @@ async function setBackoffInfo(event, info) {
31
32
  }
32
33
  }
33
34
  export async function submitToIndexNow(routes, config, siteUrl) {
34
- if (!siteUrl) {
35
- return { success: false, error: "Site URL not configured" };
36
- }
37
- const host = config.host || "api.indexnow.org";
38
- const endpoint = `https://${host}/indexnow`;
39
- const urlList = routes.map(
40
- (route) => route.startsWith("http") ? route : `${siteUrl}${route}`
41
- );
42
- const body = {
43
- host: new URL(siteUrl).host,
44
- key: config.key,
45
- keyLocation: `${siteUrl}/${config.key}.txt`,
46
- urlList
35
+ const runtimeFetch = async (input, init) => {
36
+ const url = typeof input === "string" ? input : input.toString();
37
+ const body = init?.body ? JSON.parse(init.body) : void 0;
38
+ const result = await $fetch.raw(url, {
39
+ method: init?.method,
40
+ headers: init?.headers,
41
+ body
42
+ }).catch((err) => ({ _error: err.message }));
43
+ if (result && "_error" in result) {
44
+ return { ok: false, status: 500, statusText: result._error };
45
+ }
46
+ return { ok: result.status >= 200 && result.status < 300, status: result.status };
47
47
  };
48
- logger.debug(`[indexnow] Submitting ${urlList.length} URLs to ${endpoint}`);
49
- const response = await $fetch(endpoint, {
50
- method: "POST",
51
- headers: { "Content-Type": "application/json" },
52
- body
53
- }).catch((err) => ({ error: err.message }));
54
- if (response && typeof response === "object" && "error" in response) {
55
- logger.warn(`[indexnow] Submission failed: ${response.error}`);
56
- return { success: false, error: response.error };
57
- }
58
- logger.debug(`[indexnow] Successfully submitted ${urlList.length} URLs`);
59
- return { success: true };
48
+ return submitToIndexNowShared(routes, config.key, siteUrl, {
49
+ fetchFn: runtimeFetch,
50
+ logger
51
+ });
60
52
  }
61
- export async function syncToIndexNow(event, limit = 100) {
53
+ export async function syncToIndexNow(event, limit = 100, options) {
62
54
  const config = useRuntimeConfig(event)["nuxt-ai-ready"];
63
55
  const siteConfig = getSiteConfig(event);
64
56
  if (!config.indexNowKey) {
@@ -71,43 +63,55 @@ export async function syncToIndexNow(event, limit = 100) {
71
63
  if (backoff && Date.now() < backoff.until) {
72
64
  const waitMinutes = Math.ceil((backoff.until - Date.now()) / 6e4);
73
65
  logger.debug(`[indexnow] In backoff period, ${waitMinutes}m remaining`);
74
- const remaining2 = await countPagesNeedingIndexNowSync(event);
66
+ const remaining = await countPagesNeedingIndexNowSync(event);
75
67
  return {
76
68
  success: false,
77
69
  submitted: 0,
78
- remaining: remaining2,
70
+ remaining,
79
71
  error: `Rate limited, retry in ${waitMinutes}m`,
80
72
  backoff: true
81
73
  };
82
74
  }
83
- const pages = await getPagesNeedingIndexNowSync(event, limit);
75
+ const [totalPending, pages] = await Promise.all([
76
+ countPagesNeedingIndexNowSync(event),
77
+ getPagesNeedingIndexNowSync(event, limit)
78
+ ]);
84
79
  if (pages.length === 0) {
85
80
  return { success: true, submitted: 0, remaining: 0 };
86
81
  }
87
82
  const routes = pages.map((p) => p.route);
88
83
  const result = await submitToIndexNow(routes, { key: config.indexNowKey }, siteConfig.url);
89
- if (config.debug) {
90
- await logIndexNowSubmission(event, routes.length, result.success, result.error);
91
- }
92
- if (result.success) {
93
- await setBackoffInfo(event, null);
94
- await markIndexNowSynced(event, routes);
95
- await updateIndexNowStats(event, routes.length);
96
- } else {
97
- await updateIndexNowStats(event, 0, result.error);
98
- if (result.error?.includes("429")) {
99
- const attempt = backoff ? Math.min(backoff.attempt + 1, BACKOFF_MINUTES.length - 1) : 0;
100
- const backoffMinutes = BACKOFF_MINUTES[attempt] ?? 60;
101
- const until = Date.now() + backoffMinutes * 60 * 1e3;
102
- await setBackoffInfo(event, { until, attempt });
103
- logger.warn(`[indexnow] Rate limited, backing off for ${backoffMinutes}m (attempt ${attempt + 1})`);
84
+ const dbUpdates = async () => {
85
+ if (config.debug) {
86
+ await logIndexNowSubmission(event, routes.length, result.success, result.error);
87
+ }
88
+ if (result.success) {
89
+ await setBackoffInfo(event, null);
90
+ await batchIndexNowUpdate(event, routes, routes.length);
91
+ logger.debug(`[indexnow] DB updated: ${routes.length} pages marked synced via ${result.host}`);
92
+ } else {
93
+ await updateIndexNowStats(event, 0, result.error);
94
+ if (result.error?.includes("429")) {
95
+ const attempt = backoff ? Math.min(backoff.attempt + 1, BACKOFF_MINUTES.length - 1) : 0;
96
+ const backoffMinutes = BACKOFF_MINUTES[attempt] ?? 60;
97
+ const until = Date.now() + backoffMinutes * 60 * 1e3;
98
+ await setBackoffInfo(event, { until, attempt });
99
+ logger.warn(`[indexnow] Rate limited, backing off for ${backoffMinutes}m (attempt ${attempt + 1})`);
100
+ }
104
101
  }
102
+ };
103
+ if (options?.useWaitUntil && event?.waitUntil) {
104
+ event.waitUntil(dbUpdates().catch(
105
+ (err) => logger.error(`[indexnow] Background DB update failed: ${err.message}`)
106
+ ));
107
+ } else {
108
+ await dbUpdates();
105
109
  }
106
- const remaining = await countPagesNeedingIndexNowSync(event);
110
+ const submitted = result.success ? routes.length : 0;
107
111
  return {
108
112
  success: result.success,
109
- submitted: result.success ? routes.length : 0,
110
- remaining,
113
+ submitted,
114
+ remaining: Math.max(0, totalPending - submitted),
111
115
  error: result.error,
112
116
  backoff: !result.success && result.error?.includes("429")
113
117
  };
@@ -1,6 +1,12 @@
1
1
  import type { H3Event } from 'h3';
2
+ import type { StaleCheckResult } from './checkStale.js';
2
3
  export interface CronResult {
3
4
  runId?: number | null;
5
+ stale?: StaleCheckResult;
6
+ sitemap?: {
7
+ seeded: number;
8
+ pruned: number;
9
+ };
4
10
  index?: {
5
11
  indexed: number;
6
12
  remaining: number;
@@ -1,7 +1,10 @@
1
1
  import { useEvent, useRuntimeConfig } from "nitropack/runtime";
2
- import { cleanupOldCronRuns, completeCronRun, startCronRun } from "../db/queries.js";
2
+ import { cleanupOldCronRuns, completeCronRun, pruneStaleRoutes, seedRoutes, startCronRun } from "../db/queries.js";
3
+ import { logger } from "../logger.js";
3
4
  import { batchIndexPages } from "./batchIndex.js";
5
+ import { checkAndHandleStale } from "./checkStale.js";
4
6
  import { syncToIndexNow } from "./indexnow.js";
7
+ import { fetchSitemapUrls } from "./sitemap.js";
5
8
  function getEvent(providedEvent) {
6
9
  if (providedEvent)
7
10
  return providedEvent;
@@ -20,8 +23,34 @@ export async function runCron(providedEvent, options) {
20
23
  return {};
21
24
  }
22
25
  const config = useRuntimeConfig()["nuxt-ai-ready"];
26
+ const debug = config.debug;
27
+ const startTime = Date.now();
23
28
  const results = {};
24
29
  const allErrors = [];
30
+ if (debug) {
31
+ logger.info(`[cron] Starting cron run (batchSize: ${options?.batchSize ?? config.runtimeSync.batchSize}, indexNow: ${!!config.indexNowKey})`);
32
+ }
33
+ if (config.runtimeSync.enabled) {
34
+ results.stale = await checkAndHandleStale(event).catch((err) => {
35
+ console.warn("[ai-ready:cron] Stale check failed:", err.message);
36
+ allErrors.push(`stale-check: ${err.message}`);
37
+ return { action: "none", dbCount: 0, reason: err.message };
38
+ });
39
+ if (debug && results.stale) {
40
+ logger.info(`[cron] Stale check: ${results.stale.action} (db: ${results.stale.dbCount}, dump: ${results.stale.dumpCount ?? "n/a"})`);
41
+ }
42
+ }
43
+ if (config.runtimeSync.enabled) {
44
+ const sitemapResult = await seedFromSitemap(event, config, debug).catch((err) => {
45
+ console.warn("[ai-ready:cron] Sitemap seeding failed:", err.message);
46
+ allErrors.push(`sitemap: ${err.message}`);
47
+ return { seeded: 0, pruned: 0 };
48
+ });
49
+ results.sitemap = sitemapResult;
50
+ if (debug && (sitemapResult.seeded > 0 || sitemapResult.pruned > 0)) {
51
+ logger.info(`[cron] Sitemap: seeded ${sitemapResult.seeded}, pruned ${sitemapResult.pruned}`);
52
+ }
53
+ }
25
54
  const runId = await startCronRun(event);
26
55
  results.runId = runId;
27
56
  if (config.runtimeSync.enabled) {
@@ -39,6 +68,9 @@ export async function runCron(providedEvent, options) {
39
68
  if (indexResult.errors.length > 0) {
40
69
  allErrors.push(...indexResult.errors);
41
70
  }
71
+ if (debug) {
72
+ logger.info(`[cron] Index: ${indexResult.indexed} pages (${indexResult.remaining} remaining${indexResult.errors.length > 0 ? `, ${indexResult.errors.length} errors` : ""})`);
73
+ }
42
74
  }
43
75
  if (config.indexNowKey) {
44
76
  const indexNowResult = await syncToIndexNow(event, 100).catch((err) => {
@@ -53,6 +85,10 @@ export async function runCron(providedEvent, options) {
53
85
  if (indexNowResult.error) {
54
86
  allErrors.push(`IndexNow: ${indexNowResult.error}`);
55
87
  }
88
+ if (debug) {
89
+ const status = indexNowResult.error ? `error: ${indexNowResult.error}` : `${indexNowResult.submitted} submitted (${indexNowResult.remaining} remaining)`;
90
+ logger.info(`[cron] IndexNow: ${status}`);
91
+ }
56
92
  }
57
93
  if (runId) {
58
94
  await completeCronRun(event, runId, {
@@ -64,5 +100,39 @@ export async function runCron(providedEvent, options) {
64
100
  });
65
101
  await cleanupOldCronRuns(event, 50);
66
102
  }
103
+ if (debug) {
104
+ const duration = Date.now() - startTime;
105
+ const parts = [];
106
+ if (results.stale?.action !== "none")
107
+ parts.push(results.stale?.action);
108
+ if (results.index?.indexed)
109
+ parts.push(`${results.index.indexed} indexed`);
110
+ if (results.indexNow?.submitted)
111
+ parts.push(`${results.indexNow.submitted} submitted to IndexNow`);
112
+ if (allErrors.length > 0)
113
+ parts.push(`${allErrors.length} errors`);
114
+ logger.info(`[cron] Complete in ${duration}ms${parts.length > 0 ? `: ${parts.join(", ")}` : ""}`);
115
+ }
67
116
  return results;
68
117
  }
118
+ async function seedFromSitemap(event, config, debug) {
119
+ const { pruneTtl } = config.runtimeSync;
120
+ const urls = await fetchSitemapUrls(event);
121
+ if (urls.length === 0) {
122
+ if (debug)
123
+ logger.info("[cron] Sitemap: no URLs found");
124
+ return { seeded: 0, pruned: 0 };
125
+ }
126
+ if (debug)
127
+ logger.info(`[cron] Sitemap: found ${urls.length} URLs`);
128
+ const routes = urls.map((u) => {
129
+ const url = new URL(u.loc);
130
+ return url.pathname;
131
+ }).filter((route) => !route.includes("."));
132
+ const seeded = await seedRoutes(event, routes);
133
+ let pruned = 0;
134
+ if (pruneTtl > 0) {
135
+ pruned = await pruneStaleRoutes(event, pruneTtl);
136
+ }
137
+ return { seeded, pruned };
138
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxt-ai-ready",
3
3
  "type": "module",
4
- "version": "0.7.15",
4
+ "version": "0.8.0",
5
5
  "description": "Best practice AI & LLM discoverability for Nuxt sites.",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",
@@ -1,2 +0,0 @@
1
- declare const _default: import("nitropack/types").NitroAppPlugin;
2
- export default _default;
@@ -1,58 +0,0 @@
1
- import { defineNitroPlugin, useRuntimeConfig } from "nitropack/runtime";
2
- import { getSitemapSeededAt, pruneStaleRoutes, seedRoutes, setSitemapSeededAt } from "../db/queries.js";
3
- import { logger } from "../logger.js";
4
- import { fetchSitemapUrls } from "../utils/sitemap.js";
5
- let seeding = null;
6
- export default defineNitroPlugin((nitro) => {
7
- if (import.meta.prerender)
8
- return;
9
- nitro.hooks.hook("request", async (event) => {
10
- if (!seeding) {
11
- seeding = seedFromSitemap(event).catch((err) => {
12
- if (err.message?.includes("D1 binding") || err.message?.includes("[d1]")) {
13
- const config = useRuntimeConfig()["nuxt-ai-ready"];
14
- if (config.debug) {
15
- const debugInfo = {
16
- hasGlobalEnv: !!globalThis.__env__,
17
- globalEnvKeys: globalThis.__env__ ? Object.keys(globalThis.__env__) : [],
18
- hasCloudflareContext: !!event.context?.cloudflare,
19
- error: err.message
20
- };
21
- logger.info("[sitemap-seeder] D1 binding debug:", JSON.stringify(debugInfo, null, 2));
22
- }
23
- logger.info("[sitemap-seeder] Skipping runtime seeding - using static pages data");
24
- } else {
25
- logger.error("[sitemap-seeder] Failed to seed:", err);
26
- }
27
- });
28
- }
29
- });
30
- });
31
- async function seedFromSitemap(event) {
32
- const config = useRuntimeConfig()["nuxt-ai-ready"];
33
- const { ttl, pruneTtl } = config.runtimeSync;
34
- const seededAt = await getSitemapSeededAt(event);
35
- if (seededAt && ttl > 0) {
36
- const age = (Date.now() - seededAt) / 1e3;
37
- if (age < ttl) {
38
- logger.debug(`[sitemap-seeder] Sitemap fresh (${Math.round(age)}s old), skipping`);
39
- return;
40
- }
41
- }
42
- const urls = await fetchSitemapUrls(event);
43
- if (urls.length === 0) {
44
- return;
45
- }
46
- const routes = urls.map((u) => {
47
- const url = new URL(u.loc);
48
- return url.pathname;
49
- }).filter((route) => !route.includes("."));
50
- await seedRoutes(event, routes);
51
- await setSitemapSeededAt(event, Date.now());
52
- logger.info(`[sitemap-seeder] Seeded ${routes.length} routes from sitemap`);
53
- if (pruneTtl > 0) {
54
- const pruned = await pruneStaleRoutes(event, pruneTtl);
55
- if (pruned > 0)
56
- logger.info(`[sitemap-seeder] Pruned ${pruned} stale routes`);
57
- }
58
- }