diff --git a/package-lock.json b/package-lock.json index f2ad6b83..77f590f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,6 @@ "@libp2p/logger": "^4.0.6", "@sgtpooki/file-type": "^1.0.1", "debug": "^4.3.4", - "mime-types": "^2.1.35", "multiformats": "^11.0.2", "react": "^18.2.0", "react-dom": "^18.2.0" @@ -15531,6 +15530,7 @@ }, "node_modules/mime-db": { "version": "1.52.0", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -15538,6 +15538,7 @@ }, "node_modules/mime-types": { "version": "2.1.35", + "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" diff --git a/package.json b/package.json index 2be24466..70f830b5 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,6 @@ "@libp2p/logger": "^4.0.6", "@sgtpooki/file-type": "^1.0.1", "debug": "^4.3.4", - "mime-types": "^2.1.35", "multiformats": "^11.0.2", "react": "^18.2.0", "react-dom": "^18.2.0" diff --git a/src/lib/heliaFetch.ts b/src/lib/heliaFetch.ts deleted file mode 100644 index 91b933b5..00000000 --- a/src/lib/heliaFetch.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { type VerifiedFetch } from '@helia/verified-fetch' -import { trace } from './logger.ts' - -export interface HeliaFetchOptions { - verifiedFetch: VerifiedFetch - verifiedFetchUrl: string - signal?: AbortSignal - headers?: Headers -} - -// Check for **/*.css/fonts/**/*.ttf urls */ -const cssPathRegex = /(?.*\.css)(?.*\.(ttf|otf|woff|woff2){1})$/ - -/** - * Maps relative paths to font-faces from css files to the correct path from the root. - * - * e.g. in a css file (like specs.ipfs.tech's /ipns/specs.ipfs.tech/css/ipseity.min.css), you will find lines like: - * ``` - * @font-face { - * font-family: 'Plex'; - * font-style: normal; - * font-weight: 100; - * src: local('IBM Plex Sans'), - * local('IBM-Plex-Sans'), - * url('/fonts/IBMPlexSans-Thin.ttf') format('opentype'); - * } - * ``` - * which results in a request to `/ipns/specs.ipfs.tech/css/ipseity.min.css/fonts/IBMPlexSans-Thin.ttf`. Instead, - * we want to request `/ipns/specs.ipfs.tech/fonts/IBMPlexSans-Thin.ttf`. - * - * /ipns/blog.libp2p.io/assets/css/0.styles.4520169f.css/fonts/Montserrat-Medium.d8478173.woff - */ -function changeCssFontPath (path: string): string { - const match = path.match(cssPathRegex) - if (match == null) { - trace(`changeCssFontPath: No match for ${path}`) - return path - } - const { cssPath, fontPath } = match.groups as { cssPath?: string, fontPath?: string } - if (cssPath == null || fontPath == null) { - trace(`changeCssFontPath: No groups for ${path}`, match.groups) - return path - } - - trace(`changeCssFontPath: Changing font path from ${path} to ${fontPath}`) - return fontPath -} - -/** - * Test files: - * bafkreienxxjqg3jomg5b75k7547dgf7qlbd3qpxy2kbg537ck3rol4mcve - text - https://bafkreienxxjqg3jomg5b75k7547dgf7qlbd3qpxy2kbg537ck3rol4mcve.ipfs.w3s.link/?filename=test.txt - * bafkreicafxt3zr4cshf7qteztjzl62ouxqrofu647e44wt7s2iaqjn7bra - image/jpeg - http://127.0.0.1:8080/ipfs/bafkreicafxt3zr4cshf7qteztjzl62ouxqrofu647e44wt7s2iaqjn7bra?filename=bafkreicafxt3zr4cshf7qteztjzl62ouxqrofu647e44wt7s2iaqjn7bra - * broken - QmY7fzZEpgDUqZ7BEePSS5JxxezDj3Zy36EEpWSmKmv5mo - image/jpeg - http://127.0.0.1:8080/ipfs/QmY7fzZEpgDUqZ7BEePSS5JxxezDj3Zy36EEpWSmKmv5mo?filename=QmY7fzZEpgDUqZ7BEePSS5JxxezDj3Zy36EEpWSmKmv5mo - * web3_storageLogo.svg - bafkreif4ufrfpfcmqn5ltjvmeisgv4k7ykqz2mjygpngtwt4bijxosidqa - image/svg+xml - https://bafkreif4ufrfpfcmqn5ltjvmeisgv4k7ykqz2mjygpngtwt4bijxosidqa.ipfs.dweb.link/?filename=Web3.Storage-logo.svg - * broken - bafybeiekildl23opzqcsufewlbadhbabs6pyqg35tzpfavgtjyhchyikxa - video/quicktime - https://bafybeiekildl23opzqcsufewlbadhbabs6pyqg35tzpfavgtjyhchyikxa.ipfs.dweb.link - * stock_skateboarder.webm - bafkreiezuss4xkt5gu256vjccx7vocoksxk77vwmdrpwoumfbbxcy2zowq - video/webm (147.78 KiB) - https://bafkreiezuss4xkt5gu256vjccx7vocoksxk77vwmdrpwoumfbbxcy2zowq.ipfs.dweb.link - * bafybeierkpfmf4vhtdiujgahfptiyriykoetxf3rmd3vcxsdemincpjoyu - video/mp4 (2.80 MiB) - https://bafybeierkpfmf4vhtdiujgahfptiyriykoetxf3rmd3vcxsdemincpjoyu.ipfs.dweb.link - * bugbunny.mov - bafybeidsp6fva53dexzjycntiucts57ftecajcn5omzfgjx57pqfy3kwbq - video/mp4 (2.80 MiB) - https://bafybeieekpb73vggby3m35mnpre3pngdcdnnu47u25ehsz4r3xbmqum6nu.ipfs.w3s.link/ipfs/bafybeidsp6fva53dexzjycntiucts57ftecajcn5omzfgjx57pqfy3kwbq?filename=BugBunny.mov - * ipfs.tech website - QmeUdoMyahuQUPHS2odrZEL6yk2HnNfBJ147BeLXsZuqLJ - text/html - https://QmeUdoMyahuQUPHS2odrZEL6yk2HnNfBJ147BeLXsZuqLJ.ipfs.w3s.link - */ - -/** - * heliaFetch should have zero awareness of whether it's being used inside a service worker or not. - * - * The `path` supplied should be either: - * * /ipfs/CID (https://docs.ipfs.tech/concepts/content-addressing/) - * * /ipns/DNSLink (https://dnslink.dev/) - * * /ipns/IPNSName (https://specs.ipfs.tech/ipns/ipns-record/#ipns-name) - * - * Things to do: - * * TODO: implement as much of the gateway spec as possible. - * * TODO: why we would be better than ipfs.io/other-gateway - * * TODO: have error handling that renders 404/500/other if the request is bad. - * - */ -export async function heliaFetch ({ verifiedFetch, verifiedFetchUrl, signal, headers }: HeliaFetchOptions): Promise { - const response = await verifiedFetch(verifiedFetchUrl, { - signal, - headers, - // TODO redirect: 'manual', // enable when http urls are supported by verified-fetch: https://github.com/ipfs-shipyard/helia-service-worker-gateway/issues/62#issuecomment-1977661456 - onProgress: (e) => { - trace(`${e.type}: `, e.detail) - } - }) - - return response -} - -export interface GetVerifiedFetchUrlOptions { - protocol?: string | null - id?: string | null - path: string -} - -export function getVerifiedFetchUrl ({ protocol, id, path }: GetVerifiedFetchUrlOptions): string { - if (id != null && protocol != null) { - return `${protocol}://${id}${path}` - } - - const pathParts = path.split('/') - - let pathPartIndex = 0 - let namespaceString = pathParts[pathPartIndex++] - if (namespaceString === '') { - // we have a prefixed '/' in the path, use the new index instead - namespaceString = pathParts[pathPartIndex++] - } - if (namespaceString !== 'ipfs' && namespaceString !== 'ipns') { - throw new Error(`only /ipfs or /ipns namespaces supported got ${namespaceString}`) - } - const pathRootString = pathParts[pathPartIndex++] - const contentPath = pathParts.slice(pathPartIndex++).join('/') - return `${namespaceString}://${pathRootString}/${changeCssFontPath(contentPath)}` -} diff --git a/src/sw.ts b/src/sw.ts index 059b1f30..f727e2f5 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -1,23 +1,54 @@ -import mime from 'mime-types' import { getVerifiedFetch } from './get-helia.ts' import { HeliaServiceWorkerCommsChannel, type ChannelMessage } from './lib/channel.ts' import { getSubdomainParts } from './lib/get-subdomain-parts.ts' -import { getVerifiedFetchUrl, heliaFetch } from './lib/heliaFetch.ts' import { error, log, trace } from './lib/logger.ts' import { findOriginIsolationRedirect } from './lib/path-or-subdomain.ts' import type { VerifiedFetch } from '@helia/verified-fetch' -declare let self: ServiceWorkerGlobalScope +/** + ****************************************************** + * Types + ****************************************************** + */ + +/** + * Not available in ServiceWorkerGlobalScope + */ +interface AggregateError extends Error { + errors: Error[] +} + +interface FetchHandlerArg { + path: string + request: Request +} +interface GetVerifiedFetchUrlOptions { + protocol?: string | null + id?: string | null + path: string +} + +/** + ****************************************************** + * "globals" + ****************************************************** + */ +declare let self: ServiceWorkerGlobalScope let verifiedFetch: VerifiedFetch +const channel = new HeliaServiceWorkerCommsChannel('SW') +const urlInterceptRegex = [new RegExp(`${self.location.origin}/ip(n|f)s/`)] +/** + ****************************************************** + * Service Worker Lifecycle Events + ****************************************************** + */ self.addEventListener('install', (event) => { // 👇 When a new version of the SW is installed, activate immediately void self.skipWaiting() }) -const channel = new HeliaServiceWorkerCommsChannel('SW') - self.addEventListener('activate', () => { // Set verified fetch initially void getVerifiedFetch().then((newVerifiedFetch) => { @@ -38,25 +69,75 @@ self.addEventListener('activate', () => { }) }) +self.addEventListener('fetch', event => { + const request = event.request + const urlString = request.url + const url = new URL(urlString) + + if (!isValidRequestForSW(event)) { + trace('helia-sw: not a valid request for helia-sw, ignoring ', urlString) + return + } else { + log('helia-sw: valid request for helia-sw: ', urlString) + } + + if (isRootRequestForContent(event)) { + // intercept and do our own stuff... + event.respondWith(fetchHandler({ path: url.pathname, request })) + } else if (isSubdomainRequest(event)) { + event.respondWith(fetchHandler({ path: url.pathname, request })) + } +}) + /** - * Not available in ServiceWorkerGlobalScope + ****************************************************** + * Functions + ****************************************************** */ +function isRootRequestForContent (event: FetchEvent): boolean { + const urlIsPreviouslyIntercepted = urlInterceptRegex.some(regex => regex.test(event.request.url)) + const isRootRequest = urlIsPreviouslyIntercepted + return isRootRequest // && getCidFromUrl(event.request.url) != null +} -interface AggregateError extends Error { - errors: Error[] +function isSubdomainRequest (event: FetchEvent): boolean { + const { id, protocol } = getSubdomainParts(event.request.url) + trace('isSubdomainRequest.id: ', id) + trace('isSubdomainRequest.protocol: ', protocol) + + return id != null && protocol != null +} + +function isValidRequestForSW (event: FetchEvent): boolean { + return isSubdomainRequest(event) || isRootRequestForContent(event) } function isAggregateError (err: unknown): err is AggregateError { return err instanceof Error && (err as AggregateError).errors != null } -interface FetchHandlerArg { - path: string - request: Request +function getVerifiedFetchUrl ({ protocol, id, path }: GetVerifiedFetchUrlOptions): string { + if (id != null && protocol != null) { + return `${protocol}://${id}${path}` + } + + const pathParts = path.split('/') + let pathPartIndex = 0 + let namespaceString = pathParts[pathPartIndex++] + if (namespaceString === '') { + // we have a prefixed '/' in the path, use the new index instead + namespaceString = pathParts[pathPartIndex++] + } + if (namespaceString !== 'ipfs' && namespaceString !== 'ipns') { + throw new Error(`only /ipfs or /ipns namespaces supported got ${namespaceString}`) + } + const pathRootString = pathParts[pathPartIndex++] + const contentPath = pathParts.slice(pathPartIndex++).join('/') + return `${namespaceString}://${pathRootString}/${contentPath}` } -const fetchHandler = async ({ path, request }: FetchHandlerArg): Promise => { +async function fetchHandler ({ path, request }: FetchHandlerArg): Promise { // test and enforce origin isolation before anything else is executed const originLocation = await findOriginIsolationRedirect(new URL(request.url)) if (originLocation !== null) { @@ -76,13 +157,21 @@ const fetchHandler = async ({ path, request }: FetchHandlerArg): Promise { + trace(`${e.type}: `, e.detail) + } + }) } catch (err: unknown) { const errorMessages: string[] = [] if (isAggregateError(err)) { @@ -103,88 +192,3 @@ const fetchHandler = async ({ path, request }: FetchHandlerArg): Promise { - return urlInterceptRegex.some(regex => regex.test(event.request.referrer)) // && getCidFromUrl(event.request.referrer) != null -} - -const isRootRequestForContent = (event: FetchEvent): boolean => { - const urlIsPreviouslyIntercepted = urlInterceptRegex.some(regex => regex.test(event.request.url)) - const isRootRequest = !isReferrerPreviouslyIntercepted(event) && urlIsPreviouslyIntercepted - return isRootRequest // && getCidFromUrl(event.request.url) != null -} - -function isSubdomainRequest (event: FetchEvent): boolean { - const { id, protocol } = getSubdomainParts(event.request.url) - trace('isSubdomainRequest.id: ', id) - trace('isSubdomainRequest.protocol: ', protocol) - - return id != null && protocol != null -} - -const isValidRequestForSW = (event: FetchEvent): boolean => - isSubdomainRequest(event) || isRootRequestForContent(event) || isReferrerPreviouslyIntercepted(event) - -self.addEventListener('fetch', event => { - const request = event.request - const urlString = request.url - const url = new URL(urlString) - - if (!isValidRequestForSW(event)) { - trace('helia-sw: not a valid request for helia-sw, ignoring ', urlString) - return - } else { - log('helia-sw: valid request for helia-sw: ', urlString) - } - - if (isReferrerPreviouslyIntercepted(event)) { - log('helia-sw: referred from ', request.referrer) - const destinationParts = urlString.split('/') - const referrerParts = request.referrer.split('/') - const newParts: string[] = [] - let index = 0 - while (destinationParts[index] === referrerParts[index] && index < destinationParts.length && index < referrerParts.length) { - newParts.push(destinationParts[index]) - index++ - } - newParts.push(...referrerParts.slice(index)) - - const newUrlString = newParts.join('/') + '/' + destinationParts.slice(index).join('/') - const newUrl = new URL(newUrlString) - - /** - * respond with redirect to newUrl - */ - if (newUrl.toString() !== urlString) { - log('helia-sw: rerouting request to: ', newUrl.toString()) - const redirectHeaders = new Headers() - redirectHeaders.set('Location', newUrl.toString()) - if (mime.lookup(newUrl.toString()) != null) { - redirectHeaders.set('Content-Type', mime.lookup(newUrl.toString())) - } - redirectHeaders.set('X-helia-sw', 'redirected') - const redirectResponse = new Response(null, { - status: 308, - headers: redirectHeaders - }) - event.respondWith(redirectResponse) - } else { - log('helia-sw: not rerouting request to same url: ', newUrl.toString()) - - event.respondWith(fetchHandler({ path: url.pathname, request })) - } - } else if (isRootRequestForContent(event)) { - // intercept and do our own stuff... - event.respondWith(fetchHandler({ path: url.pathname, request })) - } else if (isSubdomainRequest(event)) { - event.respondWith(fetchHandler({ path: url.pathname, request })) - } -})