├── .eslintrc.cjs ├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── README.md ├── dist ├── saturn-sw-core.js ├── saturn-sw.js └── widget.js ├── package-lock.json ├── package.json ├── public ├── index.html ├── mock-node.js └── test.mjs ├── src ├── constants.js ├── sw │ ├── controller.js │ ├── interceptor.js │ ├── saturn-sw-core.js │ └── saturn-sw.js ├── utils.js └── widget │ ├── widget-config.js │ └── widget.js ├── test ├── utils.spec.js └── widget-config.spec.js └── webpack.config.js /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | node: true, 5 | es6: true 6 | }, 7 | extends: 'eslint:recommended', 8 | rules: { 9 | 'comma-dangle': 0, 10 | indent: ['error', 4], 11 | 'linebreak-style': [ 12 | 'error', 13 | 'unix' 14 | ], 15 | 'max-len': ['error', { code: 100, ignoreUrls: true }], 16 | 'no-unused-vars': ['error', { 17 | varsIgnorePattern: '^_|cl', 18 | ignoreRestSiblings: true 19 | }], 20 | 'prefer-const': 1, 21 | 'quotes': ['error', 'single'], 22 | 'semi': ['error', 'never'] 23 | }, 24 | parser: '@babel/eslint-parser', 25 | parserOptions: { 26 | requireConfigFile: false 27 | }, 28 | globals: { 29 | cl: true, 30 | cljson: true, 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - "main" 6 | tags: ["*"] 7 | jobs: 8 | cicd: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | 13 | - uses: actions/setup-node@v2 14 | with: 15 | node-version: "16" 16 | 17 | - name: Install dependencies 18 | run: npm ci 19 | 20 | - name: Run Tests 21 | run: npm run test 22 | 23 | # Just checking if build succeeds. 24 | - name: Build 25 | env: 26 | STATIC_FILE_ORIGIN: https://saturn-test.network 27 | L1_ORIGIN: https://l1s.saturn-test.ms 28 | TRUSTED_L1_ORIGIN: https://saturn-test.ms 29 | LOG_INGESTOR_URL: https://p6wofrb2zgwrf26mcxjpprivie0lshfx.lambda-url.us-west-2.on.aws 30 | JWT_AUTH_URL: https://fz3dyeyxmebszwhuiky7vggmsu0rlkoy.lambda-url.us-west-2.on.aws/ 31 | ORCHESTRATOR_URL: https://orchestrator.strn-test.pl/nodes?maxNodes=100 32 | run: npm run build 33 | 34 | - name: Set Staging Environment Variables 35 | if: github.ref_type != 'tag' 36 | run: | 37 | echo "FLEEK_SITE_SLUG=saturn-staging" >> $GITHUB_ENV 38 | 39 | - name: Set Production Environment Variables 40 | if: github.ref_type == 'tag' 41 | run: | 42 | echo "FLEEK_SITE_SLUG=saturn-tech" >> $GITHUB_ENV 43 | 44 | # Trigger fleek homepage deploy, which will build and host the browser-client files. 45 | - name: Deploy 46 | run: | 47 | siteJson=$( 48 | curl -sS https://api.fleek.co/graphql -H "Authorization: ${{secrets.FLEEK_HOSTING_API_KEY}}" \ 49 | -H "Content-Type: application/json" \ 50 | -d '{ "query": "query { getSiteBySlug(slug: \"'"$FLEEK_SITE_SLUG"'\") { id publishedDeploy { id } }}" }' 51 | ) 52 | 53 | siteId=$(echo $siteJson | jq --raw-output '.data.getSiteBySlug.id') 54 | latestDeployId=$(echo $siteJson | jq --raw-output '.data.getSiteBySlug.publishedDeploy.id') 55 | 56 | echo "siteSlug=$FLEEK_SITE_SLUG" 57 | echo "siteId=$siteId" 58 | echo "latestDeployId=$latestDeployId" 59 | 60 | # Retry deploy will cause fleek to download the latest browser-client, while also 61 | # keeping the currently deployed homepage version. 62 | curl -sS -H "Authorization: ${{secrets.FLEEK_HOSTING_API_KEY}}" \ 63 | -H "Content-Type: application/json" \ 64 | -d '{ "query": "mutation { retryDeploy(siteId: \"'"$siteId"'\", deployId: \"'"$latestDeployId"'\") { id status } }" }' \ 65 | https://api.fleek.co/graphql | jq 66 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | branches: 5 | - main 6 | tags: 7 | - '*' 8 | pull_request: 9 | branches: 10 | - main 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | 17 | - uses: actions/setup-node@v2 18 | with: 19 | node-version: '18' 20 | 21 | - name: Install dependencies 22 | run: npm ci 23 | 24 | - name: Run Tests 25 | run: npm run test 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .local 3 | node_modules 4 | test-files 5 | 6 | dist/ 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Saturn Browser Client 2 | 3 | [![](https://img.shields.io/badge/made%20by-Protocol%20Labs-blue.svg?style=flat-square)](https://protocol.ai/) 4 | [![](https://img.shields.io/badge/project-Filecoin-blue.svg?style=flat-square)](https://filecoin.io/) 5 | 6 | The Saturn Browser Client is a service worker that serves websites' [CID](https://docs.ipfs.io/concepts/content-addressing/) requests with [CAR files](https://ipld.io/specs/transport/car/carv1/). CAR files are verifiable, which is 7 | a requirement when retrieving content in a trustless manner from community hosted 8 | [Saturn Nodes](https://github.com/filecoin-project/L1-node). 9 | 10 | ## Install 11 | 12 | `$ npm install` 13 | 14 | ## Development 15 | 16 | `$ npm run dev` 17 | 18 | ## Adding the Browser Client to your website 19 | 20 | 1. Add this script tag to the `` tag. This will install the service worker. 21 | 22 | ```html 23 | 6 | 29 | 30 | 31 | 32 |
33 | 34 | 35 | 36 | 37 | 43 |
44 | 45 | 46 | -------------------------------------------------------------------------------- /public/mock-node.js: -------------------------------------------------------------------------------- 1 | // Mock node that returns CAR files 2 | 3 | const http = require('http') 4 | const path = require('path') 5 | 6 | const express = require('express') 7 | const cors = require('cors') 8 | 9 | const cl = console.log 10 | const app = express() 11 | 12 | app.use(cors({ maxAge: 7200 })) 13 | 14 | app.get('/cid/:cid', (req, res, next) => { 15 | const { cid } = req.params 16 | const opts = { 17 | root: path.resolve(__dirname, 'test-files') 18 | } 19 | const filename = `${cid}.car` 20 | res.sendFile(filename, opts, err => { 21 | if (err) { 22 | next(err) 23 | } else { 24 | console.log('Sent:', filename) 25 | } 26 | }) 27 | }) 28 | 29 | const port = process.env.PORT || 8031 30 | const server = http.createServer(app) 31 | server.listen(port, () => cl(`Node running on port ${port}`)) 32 | -------------------------------------------------------------------------------- /public/test.mjs: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import { fileURLToPath } from 'url'; 3 | 4 | import path from 'path' 5 | import { CarBlockIterator } from '@ipld/car/iterator' 6 | import { CarReader } from '@ipld/car/reader' 7 | import { pack } from 'ipfs-car/pack' 8 | import { unpack, unpackStream } from 'ipfs-car/unpack' 9 | import { packToFs } from 'ipfs-car/pack/fs' 10 | import { FsBlockStore } from 'ipfs-car/blockstore/fs' 11 | import { MemoryBlockStore } from 'ipfs-car/blockstore/memory' // You can also use the `level-blockstore` module 12 | 13 | const cl = console.log 14 | const __filename = fileURLToPath(import.meta.url); 15 | const __dirname = path.dirname(__filename); 16 | 17 | const input = `${__dirname}/test2/QmRd1QQHxgiLefbPohzEFU8oMPXzYZg9Jc26JQsTaYMpKh` 18 | const output = `${__dirname}/test2/QmRd1QQHxgiLefbPohzEFU8oMPXzYZg9Jc26JQsTaYMpKh.car` 19 | 20 | // const { root, out } = await pack({ 21 | // input: fs.createReadStream(input), 22 | // blockstore: new FsBlockStore() 23 | // }) 24 | 25 | const { root, filename } = await packToFs({ 26 | input, 27 | output, 28 | blockstore: new FsBlockStore() 29 | }) 30 | 31 | console.log(`root CID`, root.code) 32 | 33 | // const carIterator = await CarReader.fromIterable(fs.createReadStream(output)) 34 | // cl(carIterator) 35 | // for await (const block of carIterator) { 36 | // // if (block.cid.toString() === 'bafybeievmr6edmvyob3yimrjy7jb3c2e34xcbqchfblxmbsuzyug3q54ju') { 37 | // // continue 38 | // // } 39 | // cl(block) 40 | // } 41 | 42 | for await (const file of unpackStream(fs.createReadStream(output))) { 43 | // Skip root dir 44 | if (file.type === 'directory') { continue } 45 | cl(file) 46 | // TODO: I guess here is where you slice the file to satisfy 47 | // range requests? 48 | const opts = {} 49 | for await (const chunk of file.content(opts)) { 50 | 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const DEDICATED_WORKER_PATH = '/dedicated-worker.js' 2 | export const SHARED_WORKER_PATH = '/shared-worker.js' 3 | export const SW_CORE_NAME = 'saturn-sw-core' 4 | export const SW_CORE_PATH = `/${SW_CORE_NAME}.js` 5 | export const SW_NAME = 'saturn-sw' 6 | export const SW_PATH = `/${SW_NAME}.js` 7 | -------------------------------------------------------------------------------- /src/sw/controller.js: -------------------------------------------------------------------------------- 1 | import createDebug from 'debug' 2 | import { Saturn, indexedDbStorage } from '@filecoin-saturn/js-client' 3 | import { v4 as uuidv4 } from 'uuid' 4 | import * as Sentry from '@sentry/browser' 5 | 6 | import { Interceptor } from './interceptor.js' 7 | import { findCIDPathInURL } from '../utils.js' 8 | 9 | const FILTERED_HOSTS = [ 10 | 'images.studio.metaplex.com', 11 | ] 12 | 13 | const debug = createDebug('sw') 14 | const cl = console.log 15 | 16 | const IS_PROD = process.env.NODE_ENV === 'production' 17 | if (!IS_PROD) { 18 | createDebug.enable('sw') 19 | } 20 | 21 | export class Controller { 22 | clientId = null 23 | listenersAdded = false 24 | 25 | constructor () { 26 | this.clientId = getRetrievalClientId() 27 | this.saturn = new Saturn({ 28 | cdnURL: process.env.L1_ORIGIN, 29 | logURL: process.env.LOG_INGESTOR_URL, 30 | orchURL: process.env.ORCHESTRATOR_URL, 31 | authURL: process.env.JWT_AUTH_URL, 32 | experimental: true, 33 | clientKey: getClientKey(), 34 | storage: indexedDbStorage() 35 | }) 36 | } 37 | 38 | start () { 39 | if (this.listenersAdded) { return } 40 | this.listenersAdded = true 41 | 42 | addEventListener('install', e => e.waitUntil(self.skipWaiting())) 43 | addEventListener('activate', e => e.waitUntil(self.clients.claim())) 44 | addEventListener('error', err => debug('sw err', err)) 45 | addEventListener('fetch', event => { 46 | if (!meetsInterceptionPreconditions(event)) { 47 | return 48 | } 49 | 50 | const { url } = event.request 51 | const cidPath = findCIDPathInURL(url) 52 | 53 | if (cidPath) { 54 | debug('cidPath', cidPath, url) 55 | event.respondWith(fetchCID(cidPath, this.saturn, this.clientId, event)) 56 | } 57 | }) 58 | } 59 | } 60 | 61 | // clientId is added as a query param to the sw registration url 62 | function getRetrievalClientId () { 63 | let clientId 64 | try { 65 | const urlObj = new URL(self.location.href) 66 | clientId = urlObj.searchParams.get('clientId') 67 | } catch { 68 | clientId = uuidv4() 69 | } 70 | return clientId 71 | } 72 | 73 | function getClientKey() { 74 | const urlObj = new URL(self.location.href) 75 | const clientKey = urlObj.searchParams.get('clientKey') 76 | return clientKey 77 | } 78 | 79 | async function fetchCID(cidPath, saturn, clientId, event) { 80 | let response = null 81 | const { request } = event 82 | 83 | try { 84 | const interceptor = new Interceptor(cidPath, saturn, clientId, event) 85 | response = await interceptor.fetch() 86 | } catch (err) { 87 | debug(`${request.url}: fetchCID err %O`, err) 88 | Sentry.captureException(err) 89 | response = await fetch(request) 90 | } 91 | 92 | return response 93 | } 94 | 95 | function meetsInterceptionPreconditions (event) { 96 | try { 97 | const { request } = event 98 | const { url, destination, mode } = request 99 | const isNavigation = mode === 'navigate' 100 | 101 | if (isNavigation) { 102 | checkURLFlagsOnNavigation(url) 103 | return false 104 | } 105 | 106 | if (matchFilteredHosts(new URL(url).hostname)) { 107 | return false 108 | } 109 | 110 | // range requests not supported yet. 111 | const isStreamingMedia = ['video', 'audio'].includes(destination) 112 | // HLS works fine, no range requests involved. 113 | const isHLS = url.includes('.m3u8') 114 | 115 | // TODO: Add check for range header, skip if present 116 | if (isStreamingMedia && !isHLS) { 117 | return false 118 | } 119 | 120 | // https://developer.mozilla.org/en-US/docs/Web/API/Request/mode 121 | // "If a request is made to another origin with this mode set, the 122 | // result is simply an error." 123 | const isModeSameOrigin = mode === 'same-origin' 124 | 125 | const interceptionPreconditionsMet = ( 126 | self.ReadableStream 127 | && request.method === 'GET' 128 | && !isModeSameOrigin 129 | ) 130 | 131 | if (!interceptionPreconditionsMet) { 132 | return false 133 | } 134 | 135 | return true 136 | } catch (err) { 137 | debug('meetsInterceptionPreconditions err %O', err) 138 | return false 139 | } 140 | } 141 | 142 | function checkURLFlagsOnNavigation (url) { 143 | const { searchParams } = new URL(url) 144 | if (searchParams.get('swDebug') === '1') { 145 | createDebug.enable('sw') 146 | debug(`Enabling debug. gitHash: ${process.env.COMMITHASH}`) 147 | } 148 | 149 | Interceptor.nocache = searchParams.get('nocache') === '1' 150 | Interceptor.bypasscache = searchParams.get('cachebypass') === '1' 151 | } 152 | 153 | function matchFilteredHosts(hostname) { 154 | return FILTERED_HOSTS.some(host => hostname === host) 155 | } 156 | -------------------------------------------------------------------------------- /src/sw/interceptor.js: -------------------------------------------------------------------------------- 1 | import toIterable from 'browser-readablestream-to-it' 2 | import createDebug from 'debug' 3 | import * as Sentry from '@sentry/browser' 4 | 5 | const debug = createDebug('sw') 6 | const cl = console.log 7 | 8 | export class Interceptor { 9 | static nocache = false // request/response skips L1 cache entirely 10 | static bypasscache = false // request skips L1 cache, response gets cached. 11 | 12 | constructor(cidPath, saturn, clientId, event) { 13 | this.cidPath = cidPath 14 | this.saturn = saturn 15 | this.clientId = clientId 16 | this.event = event 17 | this.numBytesEnqueued = 0 18 | this.isClosed = false 19 | } 20 | 21 | // TODO: How to handle response headers? 22 | // Remember svgs break without the header: 'content-type': 'image/svg+xml' 23 | get responseOptions() { 24 | return {} 25 | } 26 | 27 | async fetch() { 28 | const self = this 29 | 30 | const readableStream = new ReadableStream({ 31 | async start(controller) { 32 | try { 33 | const opts = { 34 | customerFallbackURL: self.event.request.url, 35 | raceNodes: true, 36 | firstHitDNS: true 37 | } 38 | const contentItr = await self.saturn.fetchContentWithFallback( 39 | self.cidPath, 40 | opts 41 | ) 42 | await self._streamContent(contentItr, controller) 43 | } catch (err) { 44 | self._debug('Error', err) 45 | Sentry.captureException(err) 46 | } finally { 47 | self._close(controller) 48 | } 49 | }, 50 | cancel() { 51 | self._close() 52 | }, 53 | }) 54 | 55 | return new Response(readableStream, this.responseOptions) 56 | } 57 | 58 | async _streamContent(contentItr, controller) { 59 | const start = Date.now() 60 | 61 | try { 62 | for await (const data of contentItr) { 63 | this._enqueueChunk(controller, data) 64 | } 65 | } finally { 66 | const duration = Date.now() - start 67 | this._debug(`Done in ${duration}ms. Enqueued ${this.numBytesEnqueued}`) 68 | } 69 | } 70 | 71 | async _streamFromOrigin(controller) { 72 | this._debug('_streamFromOrigin') 73 | 74 | const { request } = this.event 75 | const originRequest = new Request(request, { 76 | mode: 'cors', 77 | }) 78 | 79 | const res = await fetch(originRequest, { 80 | // https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS/Errors/CORSNotSupportingCredentials 81 | credentials: 'omit', 82 | }) 83 | for await (const chunk of asAsyncIterable(res.body)) { 84 | this._enqueueChunk(controller, chunk) 85 | } 86 | } 87 | 88 | _enqueueChunk(controller, chunk) { 89 | if (this.isClosed) return 90 | 91 | controller.enqueue(chunk) 92 | this.numBytesEnqueued += chunk.length 93 | } 94 | 95 | _close(controller = null) { 96 | if (this.isClosed) return 97 | 98 | controller?.close() 99 | this.isClosed = true 100 | } 101 | 102 | _debug(...args) { 103 | debug(this.event.request.url, ...args) 104 | } 105 | } 106 | 107 | function asAsyncIterable(readable) { 108 | return Symbol.asyncIterator in readable ? readable : toIterable(readable) 109 | } 110 | -------------------------------------------------------------------------------- /src/sw/saturn-sw-core.js: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/browser' 2 | import { Controller } from './controller.js' 3 | 4 | if (process.env.NODE_ENV === 'production') { 5 | Sentry.init({ 6 | dsn: 'https://2a6fc6930efa43dd32d3e56c92c0a7d2@o4504290295349248.ingest.sentry.io/4505902296858624', 7 | }) 8 | } 9 | 10 | const ctrl = new Controller() 11 | ctrl.start() 12 | -------------------------------------------------------------------------------- /src/sw/saturn-sw.js: -------------------------------------------------------------------------------- 1 | // Copyright _!_ 2 | // 3 | // License _!_ 4 | 5 | /* eslint-env worker */ 6 | 7 | import { 8 | SW_CORE_PATH, 9 | SHARED_WORKER_PATH, 10 | DEDICATED_WORKER_PATH 11 | } from '#src/constants.js' 12 | 13 | const origin = process.env.STATIC_FILE_ORIGIN 14 | 15 | if (typeof ServiceWorkerGlobalScope !== 'undefined') { 16 | const url = origin + SW_CORE_PATH 17 | importScripts(url) 18 | } else if (typeof SharedWorkerGlobalScope !== 'undefined') { 19 | const url = origin + SHARED_WORKER_PATH 20 | importScripts(url) 21 | } else if (typeof DedicatedWorkerGlobalScope !== 'undefined') { 22 | const url = origin + DEDICATED_WORKER_PATH 23 | importScripts(url) 24 | } 25 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import isIPFS from 'is-ipfs' 2 | 3 | /** 4 | * Fetch API wrapper that throws on 400+ http status. 5 | * 6 | * @param {string|Request} resource 7 | * @param {object} opts - Fetch API options. Also accepts a 'timeout' number. 8 | */ 9 | export async function wfetch (resource, opts = {}) { 10 | let id 11 | if (Number.isFinite(opts.timeout)) { 12 | const controller = new AbortController() 13 | opts.signal = controller.signal 14 | id = setTimeout(() => controller.abort(), opts.timeout) 15 | } 16 | 17 | const response = await fetch(resource, opts) 18 | clearTimeout(id) 19 | 20 | if (response.status >= 400) { 21 | const error = new Error(response.statusText || response.status) 22 | error.response = response 23 | throw error 24 | } 25 | 26 | return response 27 | } 28 | 29 | export function sleep (ms, value = undefined) { 30 | return new Promise(resolve => setTimeout(() => resolve(value), ms)) 31 | } 32 | 33 | export function promiseTimeout (promise, ms, timeoutErr) { 34 | let id 35 | const timeout = new Promise((resolve, reject) => { 36 | id = setTimeout(() => { 37 | const err = new Error('Promise timed out') 38 | reject(timeoutErr || err) 39 | }, ms) 40 | }) 41 | 42 | return Promise.race([promise, timeout]).finally(() => clearTimeout(id)) 43 | } 44 | 45 | export class Deferred { 46 | constructor () { 47 | this.promise = new Promise((resolve, reject) => { 48 | this.reject = reject 49 | this.resolve = resolve 50 | }) 51 | } 52 | } 53 | 54 | // Modified from https://github.com/PinataCloud/ipfs-gateway-tools/blob/34533f3d5f3c0dd616327e2e5443072c27ea569d/src/index.js#L6 55 | export function findCIDPathInURL(url) { 56 | let urlObj 57 | try { 58 | urlObj = new URL(url) 59 | } catch (err) { 60 | return null 61 | } 62 | 63 | let cid = null 64 | let path = null 65 | 66 | const { hostname, pathname, searchParams, href } = urlObj 67 | 68 | const searchStrings = [ 69 | hostname + pathname, // checks for path based or subdomain based cids. 70 | ...searchParams.values(), // params could contain cid URLs, e.g. ?url=ipfs.io/ipfs/ 71 | ] 72 | 73 | for (const str of searchStrings) { 74 | const result = findCIDPathInUrlComponent(str) 75 | 76 | // sanity check if parsed cid appears in URL 77 | if (result.cid && href.includes(result.cid)) { 78 | ({ cid, path } = result) 79 | break 80 | } 81 | } 82 | 83 | const cidPath = path ? `${cid}/${path}` : cid 84 | 85 | return cidPath 86 | } 87 | 88 | function findCIDPathInUrlComponent(str) { 89 | let cid = null 90 | let path = null 91 | 92 | const splitStr = str.replace(/https?:\/\//, '').split('/') 93 | // Heuristic to check if the first segment is a domain. 94 | const isMaybeHost = splitStr[0].includes('.') 95 | 96 | // Assumes the rest of the segments after the cid form the file path. 97 | const segmentsToPath = i => splitStr.slice(i).join('/') || null 98 | 99 | for (let i = 0; i < splitStr.length; i++) { 100 | const segment = splitStr[i] 101 | if (isIPFS.cid(segment)) { 102 | cid = segment 103 | path = segmentsToPath(i + 1) 104 | break 105 | } 106 | 107 | const splitOnDot = segment.split('.')[0] 108 | if(isIPFS.cid(splitOnDot)) { 109 | cid = splitOnDot 110 | if (isMaybeHost) { 111 | path = segmentsToPath(1) 112 | } 113 | break 114 | } 115 | } 116 | 117 | return { cid, path } 118 | } 119 | -------------------------------------------------------------------------------- /src/widget/widget-config.js: -------------------------------------------------------------------------------- 1 | 2 | // Retrieves widget config based on the script tag url. 3 | 4 | export const WIDGET_BASE_URL = `${process.env.STATIC_FILE_ORIGIN}/widget.js` 5 | 6 | const CLIENT_KEY_IDENTIFIER = 'integration' 7 | const INSTALL_PATH_KEY = 'installPath' 8 | 9 | export function isWidgetUrl (url) { 10 | const { href } = new URL(url) 11 | return href.startsWith(WIDGET_BASE_URL) 12 | } 13 | 14 | function getConf (urlObj, conf = {}) { 15 | const [_, queryStr] = urlObj.href.split(/#|[?]/) 16 | const searchParams = new URLSearchParams(queryStr) 17 | conf.clientKey = searchParams.get(CLIENT_KEY_IDENTIFIER) 18 | conf.installPath = searchParams.get(INSTALL_PATH_KEY) ?? '/' 19 | return conf 20 | } 21 | 22 | 23 | function urlToInheritedProtocolUrl (url) { 24 | const { host, pathname } = new URL(url) 25 | const inheritedProtocolUrl = `//${host}${pathname}` 26 | return inheritedProtocolUrl 27 | } 28 | 29 | export function findWidgetScriptTag () { 30 | const $widgetScript = document.querySelector(` 31 | script[src^="${WIDGET_BASE_URL}"], 32 | script[src^="${urlToInheritedProtocolUrl(WIDGET_BASE_URL)}"] 33 | `) 34 | 35 | return $widgetScript 36 | } 37 | 38 | export function widgetConfigFromScriptTag () { 39 | const $widgetScript = findWidgetScriptTag() 40 | 41 | // widgetConfigFromUrl expects an absolute url, so any relative 42 | // urls need to be converted. 43 | let src = $widgetScript.getAttribute('src') 44 | if (src.startsWith('//')) { 45 | src = window.location.protocol + src 46 | } else if (src.startsWith('/')) { 47 | src = window.location.origin + src 48 | } 49 | 50 | return widgetConfigFromUrl(src) 51 | } 52 | 53 | export function widgetConfigFromUrl (url) { 54 | const urlObj = new URL(url) 55 | return getConf(urlObj, {}) 56 | } 57 | -------------------------------------------------------------------------------- /src/widget/widget.js: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from 'uuid' 2 | 3 | import { SW_PATH } from '#src/constants.js' 4 | import { widgetConfigFromScriptTag } from './widget-config.js' 5 | 6 | const cl = console.log 7 | 8 | const MDN_SW_DOCS_URL = 'https://developer.mozilla.org/en-US/docs/Web' + 9 | '/API/Service_Worker_API/Using_Service_Workers' + 10 | '#Why_is_my_service_worker_failing_to_register' 11 | 12 | async function installSw (conf) { 13 | const { clientId, clientKey, installPath } = conf 14 | try { 15 | const url = new URL(window.origin + SW_PATH) 16 | if (installPath !== '/') { 17 | url.pathname = installPath + url.pathname 18 | } 19 | if (clientKey) { 20 | url.searchParams.set('clientKey', clientKey) 21 | } 22 | await navigator.serviceWorker.register(url) 23 | } catch (err) { 24 | console.warn( 25 | 'Failed to install Saturn\'s Service Worker.\n\n' + 26 | `For installation help, see ${MDN_SW_DOCS_URL}.\n\n`, 27 | err.name, err.message 28 | ) 29 | } 30 | } 31 | 32 | function getRetrievalClientId () { 33 | const key = 'saturnClientId' 34 | let clientId = localStorage.getItem(key) 35 | if (!clientId) { 36 | clientId = uuidv4() 37 | localStorage.setItem(key, clientId) 38 | } 39 | return clientId 40 | } 41 | 42 | function addHeadElement (tag, props) { 43 | const selector = `${tag}#${props.id}` 44 | let $el = document.head.querySelector(selector) 45 | if ($el) { // Element could already exist due to a prerender 46 | return $el 47 | } 48 | 49 | $el = document.createElement(tag) 50 | for (const key in props) { 51 | $el[key] = props[key] 52 | } 53 | document.head.appendChild($el) 54 | 55 | return $el 56 | } 57 | 58 | function initWidget () { 59 | if (!('serviceWorker' in navigator)) { 60 | return 61 | } 62 | 63 | const config = widgetConfigFromScriptTag() 64 | config.clientId = getRetrievalClientId() 65 | 66 | addHeadElement('link', { 67 | href: process.env.L1_ORIGIN, 68 | crossOrigin: '', 69 | rel: 'preconnect', 70 | id: 'saturn-preconnect' 71 | }) 72 | 73 | installSw(config) 74 | } 75 | 76 | initWidget() 77 | -------------------------------------------------------------------------------- /test/utils.spec.js: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert/strict' 2 | import { describe, it } from 'node:test' 3 | import { findCIDPathInURL } from '#src/utils.js' 4 | 5 | describe('controller', () => { 6 | it('finds the cid in the subdomain', () => { 7 | const cid = 'bafybeigt4657qnz5bi2pa7tdsbiobny55hkpt5vupgnueex22tzvwxfiym' 8 | const url = `https://${cid}.ipfs.dweb.link` 9 | 10 | assert.strictEqual(findCIDPathInURL(url), cid) 11 | }) 12 | 13 | it('finds the cidPath in the subdomain', () => { 14 | const cid = 'bafybeidrf56yzbkocajbloyafrebrdzsam3uj35sce2fdyo4elb6zzoily' 15 | const path = 'test/cat.png' 16 | const cidPath = `${cid}/${path}` 17 | const url = `https://${cid}.ipfs.dweb.link/${path}` 18 | 19 | assert.strictEqual(findCIDPathInURL(url), cidPath) 20 | }) 21 | 22 | it('finds the cid in the url path', () => { 23 | const cid = 'QmS29VtmK7Ax6TMmMwbwqtuKSGRJTLJAmHMW83qGvBBxhV' 24 | const url = `https://ipfs.io/ipfs/${cid}` 25 | 26 | assert.strictEqual(findCIDPathInURL(url), cid) 27 | }) 28 | 29 | it('finds the cidPath in the url path', () => { 30 | const cidPath = 'QmS29VtmK7Ax6TMmMwbwqtuKSGRJTLJAmHMW83qGvBBxhV/cat.png' 31 | const url = `https://ipfs.io/ipfs/${cidPath}` 32 | 33 | assert.strictEqual(findCIDPathInURL(url), cidPath) 34 | }) 35 | 36 | it('finds the cid in an encoded query param', () => { 37 | const cid = 'bafybeidrf56yzbkocajbloyafrebrdzsam3uj35sce2fdyo4elb6zzoily' 38 | const url = `https://proxy.com/?url=ipfs.io%2Fipfs%2F${cid}/` 39 | 40 | assert.strictEqual(findCIDPathInURL(url), cid) 41 | }) 42 | 43 | it('finds the cidPath in an encoded query param', () => { 44 | const cidPath = 'bafybeidrf56yzbkocajbloyafrebrdzsam3uj35sce2fdyo4elb6zzoily/test/cat.png' 45 | const url = `https://proxy.com/?url=https%3A%2F%2Fipfs.io%2Fipfs%2F${cidPath}` 46 | 47 | assert.strictEqual(findCIDPathInURL(url), cidPath) 48 | }) 49 | 50 | it('finds the subdomain cid in an encoded query param', () => { 51 | const cid = 'bafybeidrf56yzbkocajbloyafrebrdzsam3uj35sce2fdyo4elb6zzoily' 52 | const param = `${cid}.ipfs.dweb.link` 53 | const url = `https://proxy.com/?url=${param}` 54 | 55 | assert.strictEqual(findCIDPathInURL(url), cid) 56 | }) 57 | 58 | it('finds the subdomain cidPath in an encoded query param', () => { 59 | const cid = 'bafybeidrf56yzbkocajbloyafrebrdzsam3uj35sce2fdyo4elb6zzoily' 60 | const path = 'dog/cow/cat.png' 61 | const cidPath = `${cid}/${path}` 62 | const param = `https%3A%2F%2F${cid}.ipfs.dweb.link/${path}` 63 | const url = `https://proxy.com/?url=${param}` 64 | 65 | assert.strictEqual(findCIDPathInURL(url), cidPath) 66 | }) 67 | 68 | it('finds the plain cid (no /ipfs/ prefix) in an encoded query param', () => { 69 | const cid = 'bafybeidrf56yzbkocajbloyafrebrdzsam3uj35sce2fdyo4elb6zzoily' 70 | const url = `https://proxy.com/?cid=${cid}` 71 | 72 | assert.strictEqual(findCIDPathInURL(url), cid) 73 | }) 74 | 75 | it('finds the plain cidPath (no /ipfs/ prefix) in an encoded query param', () => { 76 | const cidPath = 'bafybeidrf56yzbkocajbloyafrebrdzsam3uj35sce2fdyo4elb6zzoily/test/cat.png' 77 | const url = `https://proxy.com/?cid=${cidPath}` 78 | 79 | assert.strictEqual(findCIDPathInURL(url), cidPath) 80 | }) 81 | 82 | it('returns null if cid not found', () => { 83 | const url = 'https://example.com/hello/world.png' 84 | 85 | assert.strictEqual(findCIDPathInURL(url), null) 86 | }) 87 | }) 88 | -------------------------------------------------------------------------------- /test/widget-config.spec.js: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert/strict' 2 | import { describe, it } from 'node:test' 3 | import { widgetConfigFromUrl } from '#src/widget/widget-config.js' 4 | 5 | describe('widget-config', () => { 6 | it('should get default config from URL', () => { 7 | const clientKey = 'abc123' 8 | const url = `https://portal.saturn.tech/widget.js#integration=${clientKey}` 9 | 10 | const config = widgetConfigFromUrl(url) 11 | assert.strictEqual(config.clientKey, clientKey) 12 | assert.strictEqual(config.installPath, '/') 13 | }) 14 | 15 | it('should get config from URL', () => { 16 | const clientKey = 'abc123' 17 | const installPath = '/test' 18 | const url = `https://portal.saturn.tech/widget.js#integration=${clientKey}&installPath=${installPath}` 19 | 20 | const config = widgetConfigFromUrl(url) 21 | assert.strictEqual(config.clientKey, clientKey) 22 | assert.strictEqual(config.installPath, installPath) 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { fileURLToPath } from 'url' 3 | 4 | import ESLintPlugin from 'eslint-webpack-plugin' 5 | import { GitRevisionPlugin } from 'git-revision-webpack-plugin' 6 | import HtmlWebpackPlugin from 'html-webpack-plugin' 7 | import webpack from 'webpack' 8 | 9 | import { SW_NAME, SW_CORE_NAME } from './src/constants.js' 10 | 11 | const gitPlugin = new GitRevisionPlugin({ 12 | commithashCommand: 'rev-parse --short HEAD' 13 | }) 14 | 15 | const __dirname = fileURLToPath(path.dirname(import.meta.url)) 16 | const abspath = p => path.resolve(__dirname, p) 17 | const cl = console.log 18 | 19 | const devServerPort = 8030 20 | 21 | export default (env, { mode }) => { 22 | // Switch to .env files once this gets unwieldy 23 | const e = process.env 24 | const STATIC_FILE_ORIGIN = e.STATIC_FILE_ORIGIN ?? `http://localhost:${devServerPort}` 25 | const L1_ORIGIN = e.L1_ORIGIN ?? 'https://l1s.saturn-test.ms' 26 | const TRUSTED_L1_ORIGIN = e.TRUSTED_L1_ORIGIN ?? 'https://saturn-test.ms' 27 | const LOG_INGESTOR_URL = e.LOG_INGESTOR_URL ?? 'https://p6wofrb2zgwrf26mcxjpprivie0lshfx.lambda-url.us-west-2.on.aws' 28 | const JWT_AUTH_URL = e.JWT_AUTH_URL ?? 'https://fz3dyeyxmebszwhuiky7vggmsu0rlkoy.lambda-url.us-west-2.on.aws' 29 | const ORCHESTRATOR_URL = e.ORCHESTRATOR_URL ?? 'https://orchestrator.strn-test.pl/nodes?maxNodes=100' 30 | 31 | return { 32 | // Uncomment snapshot for webpack to detect edits in node_modules/ 33 | snapshot: { 34 | managedPaths: [], 35 | }, 36 | entry: { 37 | widget: abspath('src/widget/widget.js'), 38 | [SW_NAME]: abspath('src/sw/saturn-sw.js'), 39 | [SW_CORE_NAME]: abspath('src/sw/saturn-sw-core.js'), 40 | }, 41 | devServer: { 42 | client: { 43 | logging: 'warn' 44 | }, 45 | static: abspath('dist'), 46 | port: devServerPort, 47 | // hot: false, 48 | // liveReload: false, 49 | webSocketServer: false 50 | }, 51 | output: { 52 | path: abspath('dist'), 53 | clean: true, 54 | publicPath: STATIC_FILE_ORIGIN + '/', 55 | }, 56 | plugins: [ 57 | new webpack.EnvironmentPlugin({ 58 | COMMITHASH: JSON.stringify(gitPlugin.commithash()), 59 | STATIC_FILE_ORIGIN, 60 | L1_ORIGIN, 61 | TRUSTED_L1_ORIGIN, 62 | LOG_INGESTOR_URL, 63 | JWT_AUTH_URL, 64 | ORCHESTRATOR_URL, 65 | }), 66 | new ESLintPlugin({ 67 | emitError: false, 68 | emitWarning: false, 69 | }), 70 | new HtmlWebpackPlugin({ 71 | filename: 'index.html', 72 | template: abspath('public/index.html'), 73 | // chunks = [] disables script injection, the script tag is 74 | // already present in the html template with an absolute url 75 | chunks: [], 76 | templateParameters: { 77 | // Arc prod client key 78 | CLIENT_KEY: '1205a0fe-142c-40a2-a830-8bbaf6382c3f', 79 | STATIC_FILE_ORIGIN, 80 | } 81 | }) 82 | ], 83 | } 84 | } 85 | --------------------------------------------------------------------------------