@nshipster/sosumi 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/src/index.ts ADDED
@@ -0,0 +1,507 @@
1
+ import { StreamableHTTPTransport } from "@hono/mcp"
2
+ import { Hono } from "hono"
3
+ import { cache } from "hono/cache"
4
+ import { cors } from "hono/cors"
5
+ import { HTTPException } from "hono/http-exception"
6
+ import { trimTrailingSlash } from "hono/trailing-slash"
7
+ import {
8
+ decodeExternalTargetPath,
9
+ ExternalAccessError,
10
+ extractExternalDocumentationBasePath,
11
+ fetchExternalDocCJSON,
12
+ validateExternalDocumentationUrl,
13
+ } from "./lib/external"
14
+ import { NotFoundError } from "./lib/fetch"
15
+ import {
16
+ fetchHIGPageData,
17
+ fetchHIGTableOfContents,
18
+ renderHIGFromJSON,
19
+ renderHIGTableOfContents,
20
+ } from "./lib/hig"
21
+ import { createMcpServer } from "./lib/mcp"
22
+ import { fetchJSONData, renderFromJSON } from "./lib/reference"
23
+ import { searchAppleDeveloperDocs } from "./lib/search"
24
+ import { generateAppleDocUrl, isValidAppleDocUrl, normalizeDocumentationPath } from "./lib/url"
25
+ import { fetchVideoTranscriptMarkdown, TranscriptNotFoundError } from "./lib/video"
26
+
27
+ interface Env {
28
+ ASSETS: Fetcher
29
+ NODE_ENV: string
30
+ EXTERNAL_DOC_HOST_ALLOWLIST?: string
31
+ EXTERNAL_DOC_HOST_BLOCKLIST?: string
32
+ }
33
+
34
+ const app = new Hono<{ Bindings: Env }>()
35
+ const mcpServerCache = new Map<string, ReturnType<typeof createMcpServer>>()
36
+
37
+ function getMcpServer(env: Env) {
38
+ const allowlist = env.EXTERNAL_DOC_HOST_ALLOWLIST ?? ""
39
+ const blocklist = env.EXTERNAL_DOC_HOST_BLOCKLIST ?? ""
40
+ const cacheKey = `${allowlist}\n---\n${blocklist}`
41
+ const cached = mcpServerCache.get(cacheKey)
42
+ if (cached) {
43
+ return cached
44
+ }
45
+
46
+ const server = createMcpServer({
47
+ EXTERNAL_DOC_HOST_ALLOWLIST: env.EXTERNAL_DOC_HOST_ALLOWLIST,
48
+ EXTERNAL_DOC_HOST_BLOCKLIST: env.EXTERNAL_DOC_HOST_BLOCKLIST,
49
+ })
50
+ mcpServerCache.set(cacheKey, server)
51
+ return server
52
+ }
53
+
54
+ app.use("*", async (c, next) => {
55
+ await next()
56
+
57
+ // Security headers
58
+ c.header("X-Content-Type-Options", "nosniff")
59
+ c.header("X-Frame-Options", "DENY")
60
+ c.header("X-XSS-Protection", "1; mode=block")
61
+ c.header("Referrer-Policy", "strict-origin-when-cross-origin")
62
+ c.header("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
63
+
64
+ // Performance headers
65
+ c.header("Vary", "Accept")
66
+
67
+ // Development-specific headers
68
+ if (c.env.NODE_ENV === "development") {
69
+ c.header("Cache-Control", "no-store")
70
+ }
71
+ })
72
+
73
+ app.use("*", cors())
74
+
75
+ app.use(trimTrailingSlash())
76
+
77
+ app.use("*", async (c, next) => {
78
+ if (c.env.NODE_ENV !== "development") {
79
+ cache({
80
+ cacheName: "sosumi-cache",
81
+ cacheControl: "max-age=86400", // 24 hours
82
+ })
83
+ }
84
+ await next()
85
+ })
86
+
87
+ app.all("/mcp", async (c) => {
88
+ const mcpServer = getMcpServer(c.env)
89
+ const transport = new StreamableHTTPTransport()
90
+ await mcpServer.connect(transport)
91
+ return transport.handleRequest(c)
92
+ })
93
+
94
+ app.get("/bot", (c) => c.redirect("/#bot", 302))
95
+
96
+ app.get("/search", async (c) => {
97
+ const query = c.req.query("q")?.trim() ?? ""
98
+ if (!query) {
99
+ throw new HTTPException(400, {
100
+ message: "Missing search query. Provide ?q=...",
101
+ })
102
+ }
103
+
104
+ const searchResponse = await searchAppleDeveloperDocs(query)
105
+ if (c.req.header("Accept")?.includes("application/json")) {
106
+ return c.json(searchResponse, 200, {
107
+ "Content-Type": "application/json; charset=utf-8",
108
+ "Cache-Control": "public, max-age=300, s-maxage=600",
109
+ })
110
+ }
111
+
112
+ if (searchResponse.results.length === 0) {
113
+ return c.text(`No results found for "${query}"`, 200, {
114
+ "Content-Type": "text/plain; charset=utf-8",
115
+ "Cache-Control": "public, max-age=300, s-maxage=600",
116
+ })
117
+ }
118
+
119
+ const summary =
120
+ `Found ${searchResponse.results.length} result(s) for "${query}":\n\n` +
121
+ searchResponse.results
122
+ .map(
123
+ (result, index) =>
124
+ `${index + 1}. ${result.title}\n ${result.url}\n ${result.description || "No description"}`,
125
+ )
126
+ .join("\n\n")
127
+
128
+ return c.text(summary, 200, {
129
+ "Content-Type": "text/plain; charset=utf-8",
130
+ "Cache-Control": "public, max-age=300, s-maxage=600",
131
+ })
132
+ })
133
+
134
+ app.get("/documentation/*", async (c) => {
135
+ const path = c.req.path
136
+
137
+ // Normalize path and generate Apple Developer URL
138
+ const normalizedPath = normalizeDocumentationPath(path.replace("/documentation/", ""))
139
+ const appleUrl = generateAppleDocUrl(normalizedPath)
140
+
141
+ // Validate the URL is a proper Apple documentation URL
142
+ if (!isValidAppleDocUrl(appleUrl)) {
143
+ const errorResponse = new Response(
144
+ `# Invalid Apple Documentation URL
145
+
146
+ The URL \`${appleUrl}\` is not a valid Apple Developer documentation page.
147
+
148
+ ## Supported URL Patterns
149
+
150
+ This service only works with Apple Developer documentation URLs:
151
+
152
+ - \`https://developer.apple.com/documentation/*\`
153
+
154
+ ## Examples
155
+
156
+ - [Swift Documentation](https://sosumi.ai/documentation/swift)
157
+ - [SwiftUI Documentation](https://sosumi.ai/documentation/swiftui)
158
+ - [UIKit Documentation](https://sosumi.ai/documentation/uikit)
159
+
160
+ ---
161
+ *[sosumi.ai](https://sosumi.ai) - Making Apple docs AI-readable*`,
162
+ {
163
+ status: 400,
164
+ headers: { "Content-Type": "text/markdown; charset=utf-8" },
165
+ },
166
+ )
167
+ throw new HTTPException(400, { res: errorResponse })
168
+ }
169
+
170
+ const jsonData = await fetchJSONData(path)
171
+ const markdown = await renderFromJSON(jsonData, appleUrl)
172
+
173
+ // Validate that we got meaningful content
174
+ if (!markdown || markdown.trim().length < 100) {
175
+ throw new HTTPException(502, {
176
+ message:
177
+ "The Apple documentation page loaded but contained insufficient content. This may be a temporary issue with the page.",
178
+ })
179
+ }
180
+
181
+ const headers = {
182
+ "Content-Type": "text/markdown; charset=utf-8",
183
+ "Content-Location": appleUrl,
184
+ "Cache-Control": "public, max-age=3600, s-maxage=86400",
185
+ ETag: `"${Buffer.from(markdown).toString("base64").slice(0, 16)}"`,
186
+ }
187
+
188
+ if (c.req.header("Accept")?.includes("application/json")) {
189
+ return c.json(
190
+ {
191
+ url: appleUrl,
192
+ content: markdown,
193
+ },
194
+ 200,
195
+ { ...headers, "Content-Type": "application/json; charset=utf-8" },
196
+ )
197
+ }
198
+
199
+ return c.text(markdown, 200, {
200
+ ...headers,
201
+ "Content-Type": "text/markdown; charset=utf-8",
202
+ })
203
+ })
204
+
205
+ app.get("/external/*", async (c) => {
206
+ const path = c.req.path
207
+ const rawTarget = decodeExternalTargetPath(path)
208
+ const targetUrl = validateExternalDocumentationUrl(rawTarget)
209
+
210
+ const jsonData = await fetchExternalDocCJSON(targetUrl, c.env)
211
+ const externalBasePath = extractExternalDocumentationBasePath(targetUrl)
212
+ const markdown = await renderFromJSON(jsonData, targetUrl.toString(), {
213
+ externalOrigin: `${targetUrl.origin}${externalBasePath}`,
214
+ })
215
+
216
+ if (!markdown || markdown.trim().length < 100) {
217
+ throw new HTTPException(502, {
218
+ message:
219
+ "The external documentation page loaded but contained insufficient content. This may be a temporary issue with the page.",
220
+ })
221
+ }
222
+
223
+ const headers = {
224
+ "Content-Type": "text/markdown; charset=utf-8",
225
+ "Content-Location": targetUrl.toString(),
226
+ "Cache-Control": "public, max-age=3600, s-maxage=86400",
227
+ ETag: `"${Buffer.from(markdown).toString("base64").slice(0, 16)}"`,
228
+ }
229
+
230
+ if (c.req.header("Accept")?.includes("application/json")) {
231
+ return c.json(
232
+ {
233
+ url: targetUrl.toString(),
234
+ content: markdown,
235
+ },
236
+ 200,
237
+ { ...headers, "Content-Type": "application/json; charset=utf-8" },
238
+ )
239
+ }
240
+
241
+ return c.text(markdown, 200, {
242
+ ...headers,
243
+ "Content-Type": "text/markdown; charset=utf-8",
244
+ })
245
+ })
246
+
247
+ app.get("/design/human-interface-guidelines", async (c) => {
248
+ // Handle the table of contents for HIG
249
+ const tocData = await fetchHIGTableOfContents()
250
+ const markdown = await renderHIGTableOfContents(tocData)
251
+
252
+ // Validate that we got meaningful content
253
+ if (!markdown || markdown.trim().length < 100) {
254
+ throw new HTTPException(502, {
255
+ message:
256
+ "The HIG table of contents loaded but contained insufficient content. This may be a temporary issue.",
257
+ })
258
+ }
259
+
260
+ const sourceUrl = "https://developer.apple.com/design/human-interface-guidelines/"
261
+ const headers = {
262
+ "Content-Type": "text/markdown; charset=utf-8",
263
+ "Content-Location": sourceUrl,
264
+ "Cache-Control": "public, max-age=3600, s-maxage=86400",
265
+ ETag: `"${Buffer.from(markdown).toString("base64").slice(0, 16)}"`,
266
+ }
267
+
268
+ if (c.req.header("Accept")?.includes("application/json")) {
269
+ return c.json(
270
+ {
271
+ url: sourceUrl,
272
+ content: markdown,
273
+ },
274
+ 200,
275
+ { ...headers, "Content-Type": "application/json; charset=utf-8" },
276
+ )
277
+ }
278
+
279
+ return c.text(markdown, 200, headers)
280
+ })
281
+
282
+ app.get("/design/human-interface-guidelines/:path{.+}", async (c) => {
283
+ const higPath = c.req.param("path")
284
+ if (!higPath) {
285
+ // This should be caught by the route above, but just in case
286
+ throw new HTTPException(400, {
287
+ message: "Invalid HIG path",
288
+ })
289
+ }
290
+
291
+ const jsonData = await fetchHIGPageData(higPath)
292
+ const sourceUrl = `https://developer.apple.com/design/human-interface-guidelines/${higPath}`
293
+ const markdown = await renderHIGFromJSON(jsonData, sourceUrl)
294
+
295
+ // Validate that we got meaningful content
296
+ if (!markdown || markdown.trim().length < 100) {
297
+ throw new HTTPException(502, {
298
+ message:
299
+ "The HIG page loaded but contained insufficient content. This may be a temporary issue with the page.",
300
+ })
301
+ }
302
+
303
+ const headers = {
304
+ "Content-Type": "text/markdown; charset=utf-8",
305
+ "Content-Location": sourceUrl,
306
+ "Cache-Control": "public, max-age=3600, s-maxage=86400",
307
+ ETag: `"${Buffer.from(markdown).toString("base64").slice(0, 16)}"`,
308
+ }
309
+
310
+ if (c.req.header("Accept")?.includes("application/json")) {
311
+ return c.json(
312
+ {
313
+ url: sourceUrl,
314
+ content: markdown,
315
+ },
316
+ 200,
317
+ { ...headers, "Content-Type": "application/json; charset=utf-8" },
318
+ )
319
+ }
320
+
321
+ return c.text(markdown, 200, headers)
322
+ })
323
+
324
+ app.get("/videos/play/:collection/:id", async (c) => {
325
+ const collection = c.req.param("collection")
326
+ const id = c.req.param("id")
327
+
328
+ if (!/^[a-z0-9-]+$/i.test(collection) || !/^\d+$/.test(id)) {
329
+ throw new HTTPException(400, {
330
+ message:
331
+ "Invalid video path. Supported format: /videos/play/COLLECTION/VIDEO_ID (for example, /videos/play/wwdc2021/10133).",
332
+ })
333
+ }
334
+
335
+ const sourceUrl = `https://developer.apple.com/videos/play/${collection}/${id}/`
336
+
337
+ let markdown: string
338
+ try {
339
+ markdown = await fetchVideoTranscriptMarkdown(sourceUrl, collection, id)
340
+ } catch (error) {
341
+ if (error instanceof TranscriptNotFoundError) {
342
+ throw new HTTPException(404, {
343
+ message: "Transcript not found. Some Apple Developer videos may not include a transcript.",
344
+ })
345
+ }
346
+ throw error
347
+ }
348
+
349
+ if (!markdown || markdown.trim().length < 100) {
350
+ throw new HTTPException(502, {
351
+ message:
352
+ "The video transcript loaded but contained insufficient content. This may be a temporary issue with the page.",
353
+ })
354
+ }
355
+
356
+ const headers = {
357
+ "Content-Type": "text/markdown; charset=utf-8",
358
+ "Content-Location": sourceUrl,
359
+ "Cache-Control": "public, max-age=3600, s-maxage=86400",
360
+ ETag: `"${Buffer.from(markdown).toString("base64").slice(0, 16)}"`,
361
+ }
362
+
363
+ if (c.req.header("Accept")?.includes("application/json")) {
364
+ return c.json(
365
+ {
366
+ url: sourceUrl,
367
+ content: markdown,
368
+ },
369
+ 200,
370
+ { ...headers, "Content-Type": "application/json; charset=utf-8" },
371
+ )
372
+ }
373
+
374
+ return c.text(markdown, 200, headers)
375
+ })
376
+
377
+ // Catch-all route for any other requests - returns 404
378
+ app.all("*", (c) => {
379
+ return c.text(
380
+ `# Not Found
381
+
382
+ The requested resource was not found on this server.
383
+
384
+ This service only works with Apple Developer documentation URLs:
385
+ - \`https://sosumi.ai/documentation/*\`
386
+
387
+ ---
388
+ *[sosumi.ai](https://sosumi.ai) - Making Apple docs AI-readable*`,
389
+ 404,
390
+ { "Content-Type": "text/markdown; charset=utf-8" },
391
+ )
392
+ })
393
+
394
+ app.onError((err, c) => {
395
+ console.error("Error occurred:", err)
396
+
397
+ if (err instanceof HTTPException) {
398
+ // Get the custom response
399
+ return err.getResponse()
400
+ }
401
+
402
+ if (err instanceof NotFoundError) {
403
+ const accept = c.req.header("Accept")
404
+ if (accept?.includes("application/json")) {
405
+ return c.json(
406
+ {
407
+ error: "Documentation not found",
408
+ message: "The requested Apple Developer documentation page does not exist.",
409
+ },
410
+ 404,
411
+ )
412
+ }
413
+
414
+ return c.text(
415
+ `# Not Found
416
+
417
+ The requested Apple Developer documentation page does not exist.
418
+
419
+ ## What you can try:
420
+
421
+ 1. **Check the URL** - Make sure the path is correct
422
+ 2. **Browse from a parent page** - Try starting from a higher-level documentation page
423
+ 3. **Search Apple Developer Documentation** - Use Apple's official search
424
+
425
+ ## Examples of valid URLs:
426
+
427
+ - [Swift Documentation](https://sosumi.ai/documentation/swift)
428
+ - [SwiftUI Documentation](https://sosumi.ai/documentation/swiftui)
429
+ - [UIKit Documentation](https://sosumi.ai/documentation/uikit)
430
+
431
+ ---
432
+ *[sosumi.ai](https://sosumi.ai) - Making Apple docs AI-readable*`,
433
+ 404,
434
+ { "Content-Type": "text/markdown; charset=utf-8" },
435
+ )
436
+ }
437
+
438
+ if (err instanceof ExternalAccessError) {
439
+ const accept = c.req.header("Accept")
440
+ if (accept?.includes("application/json")) {
441
+ return c.json(
442
+ {
443
+ error: "External documentation access denied",
444
+ message: err.message,
445
+ },
446
+ { status: err.status as 400 | 403 | 404 },
447
+ )
448
+ }
449
+
450
+ return c.text(
451
+ `# External Documentation Access Denied
452
+
453
+ ${err.message}
454
+
455
+ ## Opt-out controls supported
456
+
457
+ - \`robots.txt\` disallow for \`sosumi-ai\` (or \`*\`)
458
+ - \`X-Robots-Tag\` response directives such as \`noai\`, \`noimageai\`, \`noindex\`
459
+ - Local operator host controls: \`EXTERNAL_DOC_HOST_ALLOWLIST\`, \`EXTERNAL_DOC_HOST_BLOCKLIST\`
460
+
461
+ ---
462
+ *[sosumi.ai](https://sosumi.ai) - Making docs AI-readable*`,
463
+ err.status as 400 | 403 | 404,
464
+ { "Content-Type": "text/markdown; charset=utf-8" },
465
+ )
466
+ }
467
+
468
+ // Handle unexpected errors
469
+ const accept = c.req.header("Accept")
470
+ if (accept?.includes("application/json")) {
471
+ return c.json(
472
+ {
473
+ error: "Service temporarily unavailable",
474
+ message:
475
+ "We encountered an unexpected issue while processing your request. Please try again in a few moments.",
476
+ },
477
+ 500,
478
+ )
479
+ }
480
+
481
+ return c.text(
482
+ `# Service Temporarily Unavailable
483
+
484
+ We encountered an unexpected issue while processing your request.
485
+
486
+ ## What you can try:
487
+
488
+ 1. **Wait a moment and try again** - This is often a temporary issue
489
+ 2. **Check the URL** - Make sure you're using a valid Apple Developer documentation URL
490
+ 3. **Try a different page** - Some pages may have temporary issues
491
+
492
+ ## Examples of valid URLs:
493
+
494
+ - [Swift Documentation](https://sosumi.ai/documentation/swift)
495
+ - [SwiftUI Documentation](https://sosumi.ai/documentation/swiftui)
496
+ - [UIKit Documentation](https://sosumi.ai/documentation/uikit)
497
+
498
+ If this issue persists, please report it to <info@sosumi.ai>.
499
+
500
+ ---
501
+ *[sosumi.ai](https://sosumi.ai) - Making Apple docs AI-readable*`,
502
+ 500,
503
+ { "Content-Type": "text/markdown; charset=utf-8" },
504
+ )
505
+ })
506
+
507
+ export default app
@@ -0,0 +1,106 @@
1
+ import { normalizeDocumentationPath } from "./url"
2
+
3
+ const VIDEO_PATH_RE = /^\/videos\/play\/([a-z0-9-]+)\/(\d+)\/?$/i
4
+
5
+ export interface CliParseResult {
6
+ help: boolean
7
+ flags: {
8
+ json: boolean
9
+ }
10
+ positionals: string[]
11
+ }
12
+
13
+ export function parseCliArgs(argv: string[]): CliParseResult {
14
+ const flags = { json: false }
15
+ const positionals: string[] = []
16
+
17
+ for (let i = 0; i < argv.length; i += 1) {
18
+ const arg = argv[i]
19
+ if (arg === "--json") {
20
+ flags.json = true
21
+ continue
22
+ }
23
+ if (arg === "--base-url") {
24
+ throw new Error("--base-url is no longer supported. CLI runs using local src logic.")
25
+ }
26
+ if (arg === "-h" || arg === "--help") {
27
+ return { help: true, flags, positionals: [] }
28
+ }
29
+ if (arg.startsWith("--")) {
30
+ throw new Error(`Unknown option: ${arg}`)
31
+ }
32
+ positionals.push(arg)
33
+ }
34
+
35
+ return { help: false, flags, positionals }
36
+ }
37
+
38
+ function normalizeHigPath(pathname: string): string {
39
+ if (pathname === "/design/human-interface-guidelines/") {
40
+ return "/design/human-interface-guidelines"
41
+ }
42
+ return pathname.endsWith("/") ? pathname.slice(0, -1) : pathname
43
+ }
44
+
45
+ export function resolveFetchEndpoint(input: string): string {
46
+ const trimmed = input.trim()
47
+ if (!trimmed) {
48
+ throw new Error("Fetch input cannot be empty")
49
+ }
50
+
51
+ if (/^https?:\/\//i.test(trimmed)) {
52
+ const target = new URL(trimmed)
53
+ if (target.protocol !== "https:") {
54
+ throw new Error("Only https URLs are supported")
55
+ }
56
+
57
+ if (target.hostname === "developer.apple.com") {
58
+ if (target.pathname.startsWith("/documentation/")) {
59
+ return target.pathname
60
+ }
61
+
62
+ if (target.pathname.startsWith("/design/human-interface-guidelines")) {
63
+ return normalizeHigPath(target.pathname)
64
+ }
65
+
66
+ const videoMatch = target.pathname.match(VIDEO_PATH_RE)
67
+ if (videoMatch) {
68
+ return `/videos/play/${videoMatch[1]}/${videoMatch[2]}`
69
+ }
70
+
71
+ throw new Error(`Unsupported developer.apple.com URL path: ${target.pathname}`)
72
+ }
73
+
74
+ return `/external/${trimmed}`
75
+ }
76
+
77
+ if (trimmed.startsWith("/documentation/")) {
78
+ return trimmed
79
+ }
80
+
81
+ if (trimmed.startsWith("/design/human-interface-guidelines")) {
82
+ return normalizeHigPath(trimmed)
83
+ }
84
+
85
+ if (trimmed.startsWith("/videos/play/")) {
86
+ const videoMatch = trimmed.match(VIDEO_PATH_RE)
87
+ if (!videoMatch) {
88
+ throw new Error("Invalid video path. Expected /videos/play/COLLECTION/VIDEO_ID")
89
+ }
90
+ return `/videos/play/${videoMatch[1]}/${videoMatch[2]}`
91
+ }
92
+
93
+ if (trimmed.startsWith("/external/")) {
94
+ return trimmed
95
+ }
96
+
97
+ return `/documentation/${normalizeDocumentationPath(trimmed)}`
98
+ }
99
+
100
+ export function resolveSearchEndpoint(query: string): string {
101
+ const trimmed = query.trim()
102
+ if (!trimmed) {
103
+ throw new Error("Search query cannot be empty")
104
+ }
105
+ return `/search?q=${encodeURIComponent(trimmed)}`
106
+ }