@rmdes/indiekit-endpoint-lastfm 1.0.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/lib/sync.js ADDED
@@ -0,0 +1,226 @@
1
+ import { LastFmClient } from "./lastfm-client.js";
2
+ import { getCoverUrl, getArtistName, getAlbumName, parseDate, getMbid, getTrackUrl } from "./utils.js";
3
+ import { getAllStats } from "./stats.js";
4
+
5
+ let syncInterval = null;
6
+
7
+ // In-memory cache for stats (accessible to public routes)
8
+ let cachedStats = null;
9
+ let cachedStatsTime = null;
10
+ const STATS_CACHE_TTL = 300_000; // 5 minutes
11
+
12
+ /**
13
+ * Get cached stats (for public API routes that can't access DB)
14
+ * @returns {object|null} - Cached stats or null
15
+ */
16
+ export function getCachedStats() {
17
+ if (!cachedStats) return null;
18
+ if (cachedStatsTime && Date.now() - cachedStatsTime > STATS_CACHE_TTL) {
19
+ return cachedStats; // Return stale cache, sync will refresh
20
+ }
21
+ return cachedStats;
22
+ }
23
+
24
+ /**
25
+ * Update stats cache
26
+ * @param {object} stats - Stats to cache
27
+ */
28
+ export function setCachedStats(stats) {
29
+ cachedStats = stats;
30
+ cachedStatsTime = Date.now();
31
+ }
32
+
33
+ /**
34
+ * Refresh stats cache from database (for when cache is empty)
35
+ * @param {object} db - MongoDB database instance
36
+ * @param {object} limits - Limits for top lists
37
+ * @param {object} client - LastFmClient instance for API-based stats
38
+ * @returns {Promise<object|null>} - Stats or null if failed
39
+ */
40
+ export async function refreshStatsCache(db, limits = {}, client = null) {
41
+ if (!db) return null;
42
+ try {
43
+ const stats = await getAllStats(db, limits, client);
44
+ setCachedStats(stats);
45
+ console.log("[Last.fm] Stats cache refreshed on-demand");
46
+ return stats;
47
+ } catch (err) {
48
+ console.error("[Last.fm] Failed to refresh stats cache:", err.message);
49
+ return null;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Start background sync process
55
+ * @param {object} Indiekit - Indiekit instance
56
+ * @param {object} options - Plugin options
57
+ */
58
+ export function startSync(Indiekit, options) {
59
+ const intervalMs = options.syncInterval || 300_000; // 5 minutes default
60
+
61
+ // Initial sync after a short delay
62
+ setTimeout(() => {
63
+ runSync(Indiekit, options).catch((err) => {
64
+ console.error("[Last.fm] Initial sync error:", err.message);
65
+ });
66
+ }, 5000);
67
+
68
+ // Schedule recurring sync
69
+ syncInterval = setInterval(() => {
70
+ runSync(Indiekit, options).catch((err) => {
71
+ console.error("[Last.fm] Sync error:", err.message);
72
+ });
73
+ }, intervalMs);
74
+
75
+ console.log(
76
+ `[Last.fm] Background sync started (interval: ${intervalMs / 1000}s)`
77
+ );
78
+ }
79
+
80
+ /**
81
+ * Stop background sync
82
+ */
83
+ export function stopSync() {
84
+ if (syncInterval) {
85
+ clearInterval(syncInterval);
86
+ syncInterval = null;
87
+ console.log("[Last.fm] Background sync stopped");
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Run a single sync operation
93
+ * @param {object} Indiekit - Indiekit instance
94
+ * @param {object} options - Plugin options
95
+ * @returns {Promise<object>} - Sync result
96
+ */
97
+ export async function runSync(Indiekit, options) {
98
+ const db = Indiekit.database;
99
+ if (!db) {
100
+ console.log("[Last.fm] No database available, skipping sync");
101
+ return { synced: 0, error: "No database" };
102
+ }
103
+
104
+ const client = new LastFmClient({
105
+ apiKey: options.apiKey,
106
+ username: options.username,
107
+ cacheTtl: 60_000, // Short cache for sync
108
+ });
109
+
110
+ const result = await syncScrobbles(db, client);
111
+
112
+ // Update stats cache after sync
113
+ try {
114
+ const stats = await getAllStats(db, options.limits || {}, client);
115
+ setCachedStats(stats);
116
+ console.log("[Last.fm] Stats cache updated");
117
+ } catch (err) {
118
+ console.error("[Last.fm] Failed to cache stats:", err.message);
119
+ }
120
+
121
+ return result;
122
+ }
123
+
124
+ /**
125
+ * Sync scrobbles to MongoDB
126
+ * @param {object} db - MongoDB database instance
127
+ * @param {LastFmClient} client - Last.fm API client
128
+ * @returns {Promise<object>} - Sync result
129
+ */
130
+ export async function syncScrobbles(db, client) {
131
+ const collection = db.collection("scrobbles");
132
+
133
+ // Create indexes for efficient queries
134
+ await collection.createIndex({ lastfmId: 1 }, { unique: true, sparse: true });
135
+ // Create compound index for deduplication (same track at same time)
136
+ await collection.createIndex(
137
+ { trackTitle: 1, artistName: 1, scrobbledAt: 1 },
138
+ { unique: true }
139
+ );
140
+ // Create index on scrobbledAt for time-based queries
141
+ await collection.createIndex({ scrobbledAt: -1 });
142
+ // Create indexes for aggregation
143
+ await collection.createIndex({ artistName: 1 });
144
+ await collection.createIndex({ albumTitle: 1 });
145
+
146
+ // Get the latest synced scrobble
147
+ const latest = await collection.findOne({}, { sort: { scrobbledAt: -1 } });
148
+ const latestDate = latest?.scrobbledAt || new Date(0);
149
+
150
+ console.log(
151
+ `[Last.fm] Syncing scrobbles since: ${latestDate.toISOString()}`
152
+ );
153
+
154
+ // Fetch new scrobbles
155
+ let newScrobbles;
156
+ if (latestDate.getTime() === 0) {
157
+ // First sync: get recent history (not all-time to avoid rate limits)
158
+ console.log("[Last.fm] First sync, fetching recent scrobbles...");
159
+ newScrobbles = await client.getAllRecentTracks(10); // ~2000 tracks max
160
+ } else {
161
+ // Incremental sync
162
+ newScrobbles = await client.getNewScrobbles(latestDate);
163
+ }
164
+
165
+ if (newScrobbles.length === 0) {
166
+ console.log("[Last.fm] No new scrobbles to sync");
167
+ return { synced: 0 };
168
+ }
169
+
170
+ console.log(`[Last.fm] Found ${newScrobbles.length} new scrobbles`);
171
+
172
+ // Transform to our schema
173
+ const docs = newScrobbles.map((s) => transformScrobble(s));
174
+
175
+ // Upsert each document (in case of duplicates)
176
+ let synced = 0;
177
+ for (const doc of docs) {
178
+ try {
179
+ await collection.updateOne(
180
+ {
181
+ trackTitle: doc.trackTitle,
182
+ artistName: doc.artistName,
183
+ scrobbledAt: doc.scrobbledAt,
184
+ },
185
+ { $set: doc },
186
+ { upsert: true }
187
+ );
188
+ synced++;
189
+ } catch (err) {
190
+ // Ignore duplicate key errors
191
+ if (err.code !== 11000) {
192
+ console.error(`[Last.fm] Error inserting scrobble:`, err.message);
193
+ }
194
+ }
195
+ }
196
+
197
+ console.log(`[Last.fm] Synced ${synced} scrobbles`);
198
+ return { synced };
199
+ }
200
+
201
+ /**
202
+ * Transform Last.fm scrobble to our schema
203
+ * @param {object} scrobble - Last.fm track object
204
+ * @returns {object} - Transformed document
205
+ */
206
+ function transformScrobble(scrobble) {
207
+ const scrobbledAt = parseDate(scrobble.date);
208
+ const artistName = getArtistName(scrobble);
209
+ const albumTitle = getAlbumName(scrobble);
210
+
211
+ return {
212
+ // Create a unique ID from track info and timestamp
213
+ lastfmId: `${artistName}:${scrobble.name}:${scrobbledAt.getTime()}`,
214
+ trackTitle: scrobble.name,
215
+ trackUrl: getTrackUrl(scrobble),
216
+ artistName,
217
+ artistMbid: scrobble.artist?.mbid || null,
218
+ albumTitle,
219
+ albumMbid: scrobble.album?.mbid || null,
220
+ mbid: getMbid(scrobble),
221
+ coverUrl: getCoverUrl(scrobble),
222
+ loved: scrobble.loved === "1",
223
+ scrobbledAt,
224
+ syncedAt: new Date(),
225
+ };
226
+ }
package/lib/utils.js ADDED
@@ -0,0 +1,364 @@
1
+ /**
2
+ * Truncate text with ellipsis
3
+ * @param {string} text - Text to truncate
4
+ * @param {number} [maxLength] - Maximum length
5
+ * @returns {string} - Truncated text
6
+ */
7
+ export function truncate(text, maxLength = 80) {
8
+ if (!text || text.length <= maxLength) return text || "";
9
+ return text.slice(0, maxLength - 1) + "...";
10
+ }
11
+
12
+ /**
13
+ * Get the artist name from a Last.fm track
14
+ * @param {object} track - Last.fm track object
15
+ * @returns {string} - Artist name
16
+ */
17
+ export function getArtistName(track) {
18
+ if (!track) return "Unknown Artist";
19
+
20
+ // Extended format has artist.name
21
+ if (track.artist?.name) {
22
+ return track.artist.name;
23
+ }
24
+
25
+ // Standard format has artist["#text"]
26
+ if (track.artist?.["#text"]) {
27
+ return track.artist["#text"];
28
+ }
29
+
30
+ // Sometimes it's just a string
31
+ if (typeof track.artist === "string") {
32
+ return track.artist;
33
+ }
34
+
35
+ return "Unknown Artist";
36
+ }
37
+
38
+ /**
39
+ * Get the album name from a Last.fm track
40
+ * @param {object} track - Last.fm track object
41
+ * @returns {string|null} - Album name or null
42
+ */
43
+ export function getAlbumName(track) {
44
+ if (!track?.album) return null;
45
+
46
+ if (track.album["#text"]) {
47
+ return track.album["#text"];
48
+ }
49
+
50
+ if (typeof track.album === "string") {
51
+ return track.album;
52
+ }
53
+
54
+ return null;
55
+ }
56
+
57
+ /**
58
+ * Get the best available cover URL from a Last.fm track
59
+ * @param {object} track - Last.fm track object
60
+ * @param {string} [preferredSize] - Size preference: 'small', 'medium', 'large', 'extralarge'
61
+ * @returns {string|null} - Cover URL or null
62
+ */
63
+ export function getCoverUrl(track, preferredSize = "extralarge") {
64
+ if (!track?.image || !Array.isArray(track.image)) return null;
65
+
66
+ // Size priority order
67
+ const sizePriority = ["extralarge", "large", "medium", "small"];
68
+ const startIndex = sizePriority.indexOf(preferredSize);
69
+ const orderedSizes = [
70
+ ...sizePriority.slice(startIndex),
71
+ ...sizePriority.slice(0, startIndex),
72
+ ];
73
+
74
+ for (const size of orderedSizes) {
75
+ const img = track.image.find((i) => i.size === size);
76
+ if (img?.["#text"]) {
77
+ return img["#text"];
78
+ }
79
+ }
80
+
81
+ // Fall back to any available image
82
+ const anyImg = track.image.find((i) => i["#text"]);
83
+ return anyImg?.["#text"] || null;
84
+ }
85
+
86
+ /**
87
+ * Get the track URL on Last.fm
88
+ * @param {object} track - Last.fm track object
89
+ * @returns {string|null} - Track URL or null
90
+ */
91
+ export function getTrackUrl(track) {
92
+ return track?.url || null;
93
+ }
94
+
95
+ /**
96
+ * Get the MusicBrainz ID if available
97
+ * @param {object} track - Last.fm track object
98
+ * @returns {string|null} - MBID or null
99
+ */
100
+ export function getMbid(track) {
101
+ return track?.mbid || null;
102
+ }
103
+
104
+ /**
105
+ * Parse Unix timestamp from Last.fm date object
106
+ * @param {object|string} dateInput - Last.fm date object or ISO string
107
+ * @returns {Date} - JavaScript Date object
108
+ */
109
+ export function parseDate(dateInput) {
110
+ if (!dateInput) return new Date();
111
+
112
+ // If it's a Last.fm date object with uts (Unix timestamp)
113
+ if (dateInput.uts) {
114
+ return new Date(parseInt(dateInput.uts) * 1000);
115
+ }
116
+
117
+ // If it's already a Date or ISO string
118
+ return new Date(dateInput);
119
+ }
120
+
121
+ /**
122
+ * Format duration in seconds to human-readable string
123
+ * @param {number} seconds - Duration in seconds
124
+ * @returns {string} - Formatted duration (e.g., "3:45" or "1h 23m")
125
+ */
126
+ export function formatDuration(seconds) {
127
+ if (!seconds || seconds < 0) return "0:00";
128
+
129
+ const hours = Math.floor(seconds / 3600);
130
+ const minutes = Math.floor((seconds % 3600) / 60);
131
+ const secs = Math.floor(seconds % 60);
132
+
133
+ if (hours > 0) {
134
+ return `${hours}h ${minutes}m`;
135
+ }
136
+
137
+ return `${minutes}:${secs.toString().padStart(2, "0")}`;
138
+ }
139
+
140
+ /**
141
+ * Format total listening time for stats display
142
+ * @param {number} seconds - Total seconds
143
+ * @returns {string} - Human-readable duration
144
+ */
145
+ export function formatTotalTime(seconds) {
146
+ if (!seconds || seconds < 0) return "0 minutes";
147
+
148
+ const hours = Math.floor(seconds / 3600);
149
+ const minutes = Math.floor((seconds % 3600) / 60);
150
+
151
+ if (hours > 24) {
152
+ const days = Math.floor(hours / 24);
153
+ const remainingHours = hours % 24;
154
+ if (remainingHours > 0) {
155
+ return `${days}d ${remainingHours}h`;
156
+ }
157
+ return `${days} days`;
158
+ }
159
+
160
+ if (hours > 0) {
161
+ if (minutes > 0) {
162
+ return `${hours}h ${minutes}m`;
163
+ }
164
+ return `${hours} hours`;
165
+ }
166
+
167
+ return `${minutes} minutes`;
168
+ }
169
+
170
+ /**
171
+ * Format date for display
172
+ * @param {string|Date|object} dateInput - ISO date string, Date object, or Last.fm date
173
+ * @param {string} [locale] - Locale for formatting
174
+ * @returns {string} - Formatted date
175
+ */
176
+ export function formatDate(dateInput, locale = "en") {
177
+ const date = parseDate(dateInput);
178
+ return date.toLocaleDateString(locale, {
179
+ year: "numeric",
180
+ month: "short",
181
+ day: "numeric",
182
+ });
183
+ }
184
+
185
+ /**
186
+ * Format relative time
187
+ * @param {string|Date|object} dateInput - ISO date string, Date object, or Last.fm date
188
+ * @returns {string} - Relative time string
189
+ */
190
+ export function formatRelativeTime(dateInput) {
191
+ const date = parseDate(dateInput);
192
+ const now = new Date();
193
+ const diffMs = now - date;
194
+ const diffMins = Math.floor(diffMs / 60_000);
195
+ const diffHours = Math.floor(diffMs / 3_600_000);
196
+ const diffDays = Math.floor(diffMs / 86_400_000);
197
+
198
+ if (diffMins < 1) return "just now";
199
+ if (diffMins < 60) return `${diffMins}m ago`;
200
+ if (diffHours < 24) return `${diffHours}h ago`;
201
+ if (diffDays < 7) return `${diffDays}d ago`;
202
+
203
+ return formatDate(dateInput);
204
+ }
205
+
206
+ /**
207
+ * Determine the playing status based on track attributes or timestamp
208
+ * @param {object} track - Last.fm track object
209
+ * @returns {string|null} - 'now-playing', 'recently-played', or null
210
+ */
211
+ export function getPlayingStatus(track) {
212
+ // Check if currently playing via Last.fm attribute
213
+ if (track["@attr"]?.nowplaying === "true") {
214
+ return "now-playing";
215
+ }
216
+
217
+ // Otherwise check based on timestamp
218
+ const date = parseDate(track.date);
219
+ const now = new Date();
220
+ const diffMs = now - date;
221
+ const diffMins = diffMs / 60_000;
222
+
223
+ if (diffMins < 60) {
224
+ return "now-playing";
225
+ }
226
+
227
+ if (diffMins < 24 * 60) {
228
+ return "recently-played";
229
+ }
230
+
231
+ return null;
232
+ }
233
+
234
+ /**
235
+ * Format a scrobble entry for API response
236
+ * @param {object} scrobble - Last.fm track object or MongoDB document
237
+ * @param {boolean} [fromDb] - Whether the scrobble is from MongoDB
238
+ * @returns {object} - Formatted scrobble
239
+ */
240
+ export function formatScrobble(scrobble, fromDb = false) {
241
+ if (fromDb) {
242
+ // From MongoDB
243
+ const scrobbledAt = scrobble.scrobbledAt;
244
+ return {
245
+ id: scrobble.lastfmId || scrobble._id?.toString(),
246
+ track: scrobble.trackTitle,
247
+ artist: scrobble.artistName,
248
+ album: scrobble.albumTitle,
249
+ coverUrl: scrobble.coverUrl,
250
+ trackUrl: scrobble.trackUrl,
251
+ mbid: scrobble.mbid,
252
+ loved: scrobble.loved || false,
253
+ scrobbledAt: scrobbledAt.toISOString(),
254
+ relativeTime: formatRelativeTime(scrobbledAt),
255
+ status: getPlayingStatus({ date: scrobbledAt }),
256
+ };
257
+ }
258
+
259
+ // From API
260
+ const scrobbledAt = parseDate(scrobble.date);
261
+ const isNowPlaying = scrobble["@attr"]?.nowplaying === "true";
262
+
263
+ return {
264
+ id: `${scrobble.artist?.mbid || ""}:${scrobble.mbid || scrobble.name}:${scrobbledAt.getTime()}`,
265
+ track: scrobble.name,
266
+ artist: getArtistName(scrobble),
267
+ album: getAlbumName(scrobble),
268
+ coverUrl: getCoverUrl(scrobble),
269
+ trackUrl: getTrackUrl(scrobble),
270
+ mbid: getMbid(scrobble),
271
+ loved: scrobble.loved === "1",
272
+ scrobbledAt: isNowPlaying ? new Date().toISOString() : scrobbledAt.toISOString(),
273
+ relativeTime: isNowPlaying ? "now" : formatRelativeTime(scrobbledAt),
274
+ status: getPlayingStatus(scrobble),
275
+ };
276
+ }
277
+
278
+ /**
279
+ * Format a loved track entry for API response
280
+ * @param {object} track - Last.fm loved track object
281
+ * @returns {object} - Formatted loved track
282
+ */
283
+ export function formatLovedTrack(track) {
284
+ const lovedAt = parseDate(track.date);
285
+
286
+ return {
287
+ id: track.mbid || `${getArtistName(track)}:${track.name}`,
288
+ track: track.name,
289
+ artist: getArtistName(track),
290
+ coverUrl: getCoverUrl(track),
291
+ trackUrl: getTrackUrl(track),
292
+ mbid: getMbid(track),
293
+ lovedAt: lovedAt.toISOString(),
294
+ relativeTime: formatRelativeTime(lovedAt),
295
+ };
296
+ }
297
+
298
+ /**
299
+ * Format a top artist entry for API response
300
+ * @param {object} artist - Last.fm top artist object
301
+ * @param {number} rank - Artist rank
302
+ * @returns {object} - Formatted artist
303
+ */
304
+ export function formatTopArtist(artist, rank) {
305
+ return {
306
+ rank,
307
+ name: artist.name,
308
+ playCount: parseInt(artist.playcount) || 0,
309
+ url: artist.url,
310
+ mbid: artist.mbid || null,
311
+ imageUrl: getCoverUrl(artist),
312
+ };
313
+ }
314
+
315
+ /**
316
+ * Format a top album entry for API response
317
+ * @param {object} album - Last.fm top album object
318
+ * @param {number} rank - Album rank
319
+ * @returns {object} - Formatted album
320
+ */
321
+ export function formatTopAlbum(album, rank) {
322
+ return {
323
+ rank,
324
+ title: album.name,
325
+ artist: album.artist?.name || album.artist?.["#text"] || album.artist,
326
+ playCount: parseInt(album.playcount) || 0,
327
+ url: album.url,
328
+ mbid: album.mbid || null,
329
+ coverUrl: getCoverUrl(album),
330
+ };
331
+ }
332
+
333
+ /**
334
+ * Map Last.fm period to internal period name
335
+ * @param {string} period - Last.fm period (7day, 1month, 3month, 6month, 12month, overall)
336
+ * @returns {string} - Internal period name (week, month, all)
337
+ */
338
+ export function mapPeriodToInternal(period) {
339
+ switch (period) {
340
+ case "7day":
341
+ return "week";
342
+ case "1month":
343
+ case "3month":
344
+ return "month";
345
+ default:
346
+ return "all";
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Map internal period to Last.fm period
352
+ * @param {string} period - Internal period (week, month, all)
353
+ * @returns {string} - Last.fm period
354
+ */
355
+ export function mapPeriodToLastfm(period) {
356
+ switch (period) {
357
+ case "week":
358
+ return "7day";
359
+ case "month":
360
+ return "1month";
361
+ default:
362
+ return "overall";
363
+ }
364
+ }
@@ -0,0 +1,37 @@
1
+ {
2
+ "lastfm": {
3
+ "title": "Last.fm",
4
+ "scrobbles": "Scrobble History",
5
+ "loved": "Loved Tracks",
6
+ "stats": "Statistics",
7
+ "nowPlaying": "Now Playing",
8
+ "recentlyPlayed": "Recently Played",
9
+ "lastPlayed": "Last Played",
10
+ "allTime": "All Time",
11
+ "thisWeek": "This Week",
12
+ "thisMonth": "This Month",
13
+ "trends": "Trends",
14
+ "scrobbleTrend": "Scrobble Trend (30 days)",
15
+ "topArtists": "Top Artists",
16
+ "topAlbums": "Top Albums",
17
+ "plays": "plays",
18
+ "tracks": "tracks",
19
+ "artists": "artists",
20
+ "albums": "albums",
21
+ "scrobbleTime": "Scrobble Time",
22
+ "noRecentPlays": "No music scrobbled recently",
23
+ "noLoved": "No loved tracks yet",
24
+ "viewAll": "View All",
25
+ "viewStats": "View Statistics",
26
+ "sync": "Sync Now",
27
+ "lastSync": "Last sync",
28
+ "error": {
29
+ "connection": "Could not connect to Last.fm",
30
+ "noConfig": "Last.fm endpoint not configured correctly"
31
+ },
32
+ "widget": {
33
+ "description": "View full scrobble activity on the public page",
34
+ "view": "View Public Page"
35
+ }
36
+ }
37
+ }
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@rmdes/indiekit-endpoint-lastfm",
3
+ "version": "1.0.0",
4
+ "description": "Last.fm scrobble and listening activity endpoint for Indiekit. Display listening history, loved tracks, and statistics.",
5
+ "keywords": [
6
+ "indiekit",
7
+ "indiekit-plugin",
8
+ "indieweb",
9
+ "lastfm",
10
+ "last.fm",
11
+ "music",
12
+ "scrobble",
13
+ "listening",
14
+ "activity"
15
+ ],
16
+ "homepage": "https://github.com/rmdes/indiekit-endpoint-lastfm",
17
+ "bugs": {
18
+ "url": "https://github.com/rmdes/indiekit-endpoint-lastfm/issues"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/rmdes/indiekit-endpoint-lastfm.git"
23
+ },
24
+ "author": {
25
+ "name": "Ricardo Mendes",
26
+ "url": "https://rmendes.net"
27
+ },
28
+ "license": "MIT",
29
+ "engines": {
30
+ "node": ">=20"
31
+ },
32
+ "type": "module",
33
+ "main": "index.js",
34
+ "exports": {
35
+ ".": "./index.js"
36
+ },
37
+ "files": [
38
+ "includes",
39
+ "lib",
40
+ "locales",
41
+ "views",
42
+ "index.js"
43
+ ],
44
+ "dependencies": {
45
+ "@indiekit/error": "^1.0.0-beta.25",
46
+ "express": "^5.0.0"
47
+ },
48
+ "peerDependencies": {
49
+ "@indiekit/indiekit": ">=1.0.0-beta.25"
50
+ },
51
+ "publishConfig": {
52
+ "access": "public"
53
+ }
54
+ }