├── test ├── fixtures │ ├── rick.tile │ ├── tmp │ │ └── rick.tile │ └── rick │ │ ├── img │ │ └── rick.jpg │ │ ├── manifest.json │ │ └── index.html ├── data.js ├── writing.js └── reading.js ├── lib └── rel.js ├── todo.md ├── loader-experiment ├── index.html ├── mothership.js └── tile-loader.js ├── loader ├── public │ ├── index.html │ ├── shuttle.js │ └── worker.js ├── server.js └── loader.js ├── package.json ├── README.md ├── PROBLEMS.md ├── car-reader.js ├── .gitignore ├── writer.js ├── tile-loader.js └── LICENSE /test/fixtures/rick.tile: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darobin/dasl-tiles/main/test/fixtures/rick.tile -------------------------------------------------------------------------------- /test/fixtures/tmp/rick.tile: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darobin/dasl-tiles/main/test/fixtures/tmp/rick.tile -------------------------------------------------------------------------------- /test/fixtures/rick/img/rick.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darobin/dasl-tiles/main/test/fixtures/rick/img/rick.jpg -------------------------------------------------------------------------------- /test/fixtures/rick/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "sizing": { 3 | "width": 600, 4 | "height": 450 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /lib/rel.js: -------------------------------------------------------------------------------- 1 | 2 | // call with makeRel(import.meta.url), returns a function that resolves relative paths 3 | export default function makeRel (importURL) { 4 | return (pth) => new URL(pth, importURL).toString().replace(/^file:\/\//, ''); 5 | } 6 | -------------------------------------------------------------------------------- /todo.md: -------------------------------------------------------------------------------- 1 | 2 | # Plan 3 | 4 | ## Loading 5 | - Finish the server, make this safely loadable 6 | - Deploy to webtil.es 7 | - Make loaders for: 8 | - CAR 9 | - WebXDC 10 | - AT 11 | 12 | ## Execution Contexts 13 | - Wishes 14 | - Indexing them 15 | - Calling one another 16 | - WebXDC 17 | - Telepath/AI 18 | - Web3/Dapp/Radicle Launcher 19 | -------------------------------------------------------------------------------- /loader-experiment/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Loader Everything 7 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /loader/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | shuttle 7 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /loader/server.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { argv, exit } from 'node:process'; 4 | import express from 'express'; 5 | import { createTileLoadingRouter } from './loader.js'; 6 | 7 | const baseServer = argv[2]; 8 | const port = argv[3] || 1503; 9 | if (!baseServer) { 10 | console.error(`Missing base server parameter.`); 11 | exit(1); 12 | } 13 | 14 | const app = express(); 15 | app.set('trust proxy', 'loopback'); // need this 16 | app.use(createTileLoadingRouter(baseServer)); 17 | app.listen(port, () => console.log(`Tile loader listening at http://load.${baseServer}:${port}/`)); 18 | -------------------------------------------------------------------------------- /test/data.js: -------------------------------------------------------------------------------- 1 | 2 | export const rickMeta = { 3 | name: "First Tile", 4 | resources: { 5 | "/": { 6 | src: { 7 | $link: "bafkreidcmg66nzp5ldng52laqfz23h2kf6h3ftp2rv2pwnuprih2yodz4m" 8 | }, 9 | "content-type": "text/html" 10 | }, 11 | "/img/rick.jpg": { 12 | src: { 13 | $link: "bafkreifn5yxi7nkftsn46b6x26grda57ict7md2xuvfbsgkiahe2e7vnq4" 14 | }, 15 | "content-type": "image/jpeg" 16 | } 17 | }, 18 | description: "This is a very basic tile with no interactivity, but it won't let you down." 19 | }; 20 | 21 | export const rickMetaRaw = { 22 | ...rickMeta, 23 | roots: [], 24 | version: 1, 25 | } 26 | -------------------------------------------------------------------------------- /test/fixtures/rick/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Never Gonna Give You Up 7 | 26 | 27 | 28 | Rick 29 |
Never Gonna Give You Up
30 | 31 | 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@dasl/tiles", 3 | "type": "module", 4 | "version": "0.1.0", 5 | "description": "All-purpose web tiles library", 6 | "author": "Robin Berjon ", 7 | "license": "Apache-2.0", 8 | "scripts": { 9 | "release": "release-it", 10 | "test": "mocha" 11 | }, 12 | "bin": { 13 | "tiles-loader": "loader/server.js" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/darobin/dasl-tiles.git" 18 | }, 19 | "eslintConfig": { 20 | "env": { 21 | "browser": true, 22 | "mocha": true, 23 | "es2021": true 24 | }, 25 | "extends": "eslint:recommended", 26 | "overrides": [], 27 | "parserOptions": { 28 | "ecmaVersion": "latest", 29 | "sourceType": "module" 30 | }, 31 | "rules": {} 32 | }, 33 | "devDependencies": { 34 | "eslint": "^8.26.0", 35 | "mocha": "^11.7.3" 36 | }, 37 | "dependencies": { 38 | "@atcute/car": "^3.1.1", 39 | "@atcute/cbor": "^2.2.5", 40 | "@atcute/cid": "^2.2.3", 41 | "@atcute/varint": "^1.0.2", 42 | "express": "^5.2.1", 43 | "nanoid": "^5.1.6", 44 | "tmp-promise": "^3.0.3" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Web Tiles 3 | 4 | Web Tiles are a DASL technology. They basically make content addressed resource work well 5 | in a web context, and make web content available safely and in privacy-friendly 6 | ways in non-web contexts such as social, commercial, or agentic protocols. 7 | 8 | This library is a toolbox for tiles. 9 | 10 | ## Writing 11 | 12 | ```js 13 | import TileWrite from '@dasl/tiles/writer.js'; 14 | 15 | const tw = new TileWriter({ 16 | name: `My Cat`, 17 | description: `This basic tile is a picture of my cat.`, 18 | }); 19 | tw.addResource('/', { 'content-type': 'text/html' }, { path: '/path/to/index.html') }); 20 | tw.addResource('/img/kitsu.jpg', { 'content-type': 'image/jpeg' }, { path: '/path/to/rick.jpg') }); 21 | await tw.write('/path/to/output.tile'); 22 | ``` 23 | 24 | ## Reading from a CAR 25 | 26 | The CAR reader indexes the whole tile and resolves paths to return the headers 27 | and the means of creating a stream that reads just that segment of the CAR. 28 | Note that the path resolution will correctly ignore query strings, hashes, etc. 29 | 30 | ```js 31 | const ctr = new CarTileReader('/path/to/a/car-based.tile'); 32 | await ctr.open(); 33 | const nf = ctr.resolvePath('/not/exists'); 34 | // nf.ok = false, nf.status = 404 35 | const root = ctr.resolvePath('/'); 36 | // root.ok = true, root.status = 200 37 | sendHeaders(root.headers); // { 'content-type': 'text/html' } 38 | root.createReadStream().pipe(res); // sends the content 39 | ``` 40 | -------------------------------------------------------------------------------- /test/writing.js: -------------------------------------------------------------------------------- 1 | 2 | import { deepStrictEqual, equal } from 'node:assert'; 3 | import { join } from 'node:path'; 4 | import { mkdir, rm } from 'node:fs/promises'; 5 | import { createReadStream } from 'node:fs'; 6 | import { Readable } from 'node:stream'; 7 | import { toString as stringifyCID, toCidLink } from '@atcute/cid'; 8 | import { CarReader } from '@atcute/car/v4'; 9 | import TileWriter from "../writer.js"; 10 | import makeRel from "../lib/rel.js"; 11 | import { rickMetaRaw } from './data.js'; 12 | 13 | const rel = makeRel(import.meta.url); 14 | const rickDir = rel('./fixtures/rick'); 15 | const tmpDir = rel('./fixtures/tmp'); 16 | const rickTile = join(tmpDir, 'rick.tile'); 17 | 18 | before(async () => { 19 | await rm(tmpDir, { recursive: true, force: true }); 20 | await mkdir(tmpDir); 21 | }); 22 | describe('Writing tiles', () => { 23 | it('writes a tile correctly', async () => { 24 | const tw = new TileWriter({ 25 | name: `First Tile`, 26 | description: `This is a very basic tile with no interactivity, but it won't let you down.`, 27 | }); 28 | tw.addResource('/', { 'content-type': 'text/html' }, { path: join(rickDir, 'index.html') }); 29 | tw.addResource('/img/rick.jpg', { 'content-type': 'image/jpeg' }, { path: join(rickDir, '/img/rick.jpg') }); 30 | await tw.write(rickTile); 31 | 32 | const rs = createReadStream(rickTile); 33 | const car = CarReader.fromStream(Readable.toWeb(rs)); 34 | const { data: meta } = await car.header(); 35 | Object.keys(meta.resources).forEach(k => { 36 | meta.resources[k].src = toCidLink(meta.resources[k].src).toJSON(); 37 | }); 38 | deepStrictEqual(rickMetaRaw, meta, 'metadata is written correctly'); 39 | const cids = [ 40 | 'bafkreidcmg66nzp5ldng52laqfz23h2kf6h3ftp2rv2pwnuprih2yodz4m', 41 | 'bafkreifn5yxi7nkftsn46b6x26grda57ict7md2xuvfbsgkiahe2e7vnq4' 42 | ]; 43 | for await (const entry of car) { 44 | equal(cids.shift(), stringifyCID(entry.cid), 'CID is correct'); 45 | } 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /PROBLEMS.md: -------------------------------------------------------------------------------- 1 | 2 | ## Approach 004 3 | 4 | - load.webtil.es redirects to a random origin 5 | - random origin has the loader code 6 | - but all of that code is generic, it just talks back to the mothership, which is 7 | where all the smarts are 8 | - steps: 9 | - ✅ Caddy mapping for webtiles.bast, *.webtiles.bast 10 | - ✅ server: 11 | - ✅ / on load.webtiles redirects to a random subdomain 12 | - ✅ loading random.webtiles/.wk... is just the right kind of static site with the right content 13 | - client: 14 | - it's just the mothership (with experiment code for now) 15 | - shuttle index needs to just have an iframe that takes up all the space 16 | - start building real mothership, with initial loader pluging being synthetic content 17 | - NOTE THAT mothershipd should load from an http-server thing, not from the 18 | loader site. We're still in sandbox experiment (maybe move). 19 | 20 | 21 | # ABANDONED 22 | 23 | It's impossible to use origin sandboxing: 24 | - If you start in a sandbox, the SW can't be loaded (HTTP will fail SO check, non-HTTP fails) 25 | - If you don't start in a sandbox, you can't dynamically sandbox (even if you reload 26 | the SW will be on the root origin) 27 | 28 | ## Approach 001 29 | 30 | - Mothership loads shuttle with sandboxed frame 31 | - Shuttle loads SW 32 | - SW intercepts iframe in shuttle 33 | 34 | ⛔️ FAIL: Shuttle can't load SW because it has a different origin. 35 | 36 | WAIT: 37 | - This is failing even without the sandboxing… 38 | - ✅ try flattening the files (this works but we changed a lot of stuff) 39 | - ✅ try loading the worker in shuttle init 40 | - ⛔️ try reintroducing sandboxing 41 | - try unflattening the files 42 | - CONFIRM: can't load a worker into an origin-sandboxed environment 43 | 44 | ## Approach 002 45 | 46 | Same as 001 but the sandboxing happens dynamically. 47 | 48 | - first, test that dynamic sandboxing works at all 49 | 50 | ## Approach 003 51 | 52 | Same as 001 but the SW is loaded as a blob. 53 | ⛔️ FAIL: SW has to be on HTTP scheme. 54 | 55 | ## Approach 00X 56 | 57 | Something with srcdoc. 58 | -------------------------------------------------------------------------------- /loader/loader.js: -------------------------------------------------------------------------------- 1 | 2 | import express from 'express'; 3 | import { customAlphabet } from 'nanoid'; 4 | import makeRel from '../lib/rel.js'; 5 | 6 | const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz', 20); 7 | const rel = makeRel(import.meta.url); 8 | 9 | // The baseHost is the host under which the subdomains for loading will be 10 | // created. Lowercase, no leading dot. 11 | export function createTileLoadingRouter (baseHost) { 12 | const router = express.Router(); 13 | baseHost = baseHost.toLowerCase().replace(/^\./, ''); 14 | router.use((req, res, next) => { 15 | if (req.hostname === `load.${baseHost}`) { 16 | const host = `${nanoid()}.${baseHost}`; 17 | res.redirect(303, `${req.protocol}://${host}${req.originalUrl || '/'}`); 18 | return; 19 | } 20 | next(); 21 | }); 22 | router.use('/.well-known/web-tiles/', express.static(rel('./public'), { 23 | setHeaders (res) { 24 | res.set({ 25 | 'service-worker-allowed': '/', 26 | 'origin-agent-cluster': '?1', 27 | 'referrer-policy': 'no-referrer', 28 | 'permissions-policy': 'interest-cohort=(), browsing-topics=()', 29 | // 'cross-origin-embedder-policy': 'require-corp', 30 | 'cross-origin-resource-policy': 'cross-origin', 31 | 'cross-origin-opener-policy': 'same-origin', 32 | 'x-content-type-options': 'nosniff', 33 | 'x-dns-prefetch-control': 'off', 34 | 'content-security-policy': [ 35 | `default-src 'self' blob: data:`, 36 | `script-src 'self' blob: data: 'unsafe-inline' 'wasm-unsafe-eval'`, 37 | `script-src-attr 'none'`, 38 | `style-src 'self' blob: data: 'unsafe-inline'`, 39 | `form-src 'self'`, 40 | `manifest-src 'none'`, 41 | `object-src 'none'`, 42 | `base-uri 'none'`, 43 | `sandbox allow-downloads allow-forms allow-modals allow-same-origin allow-scripts allow-top-navigation-by-user-activation`, 44 | ].join('; '), 45 | 'tk': 'N', 46 | 'x-robots-tag': "noai, noimageai", 47 | }); 48 | }, 49 | })); 50 | return router; 51 | } 52 | -------------------------------------------------------------------------------- /car-reader.js: -------------------------------------------------------------------------------- 1 | 2 | import { open } from 'node:fs/promises'; 3 | import { Readable } from 'node:stream'; 4 | import { CarReader } from '@atcute/car/v4'; 5 | import { toCidLink, toString as stringifyCID } from '@atcute/cid'; 6 | 7 | // XXX IMPORTANT 8 | // Note that resolvePath() returns a stream and doesn't verify. 9 | // Maybe we should use the fact that open() reads the bytes anyway to verify the content. 10 | export default class CarTileReader { 11 | #path; 12 | #fh; 13 | #meta = {}; 14 | #cidOffsets = {}; 15 | constructor (path) { 16 | this.#path = path; 17 | } 18 | get meta () { 19 | return this.#meta; 20 | } 21 | async open () { 22 | this.#fh = await open(this.#path); 23 | const car = CarReader.fromStream(Readable.toWeb(this.#fh.createReadStream({ autoClose: false }))); 24 | const { data: meta } = await car.header(); // also returns headerEnd if we want to read ourselves 25 | delete meta.version; 26 | delete meta.roots; 27 | Object.keys(meta.resources).forEach(k => { 28 | meta.resources[k].src = toCidLink(meta.resources[k].src).toJSON(); 29 | }); 30 | this.#meta = meta; 31 | // This isn't efficient in that it reads the bytes and we throw them away. 32 | // We could replace it with a version that skips, but that'd be copying a 33 | // lot of work from @atcute; we can optimise later. 34 | for await (const entry of car) { 35 | const { cid, bytesStart, bytesEnd } = entry; 36 | this.#cidOffsets[stringifyCID(cid)] = [bytesStart, bytesEnd - 1]; 37 | } 38 | } 39 | close () { 40 | this.#fh?.close(); 41 | } 42 | // Use this when paths are mapped into the tile. 43 | resolvePath (path) { 44 | path = (new URL(`fake:${path}`)).pathname; // remove QS, etc. 45 | if (!this.#meta.resources?.[path]) return { ok: false, status: 404, statusText: 'Not found' }; 46 | const headers = { ... this.#meta.resources[path] }; 47 | const cid = headers.src.$link; 48 | delete headers.src; 49 | return { 50 | ok: true, 51 | status: 200, 52 | statusText: 'Ok', 53 | headers, 54 | createReadStream: () => this.#fh.createReadStream({ start: this.#cidOffsets[cid][0], end: this.#cidOffsets[cid][1], autoClose: false }), 55 | }; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /test/reading.js: -------------------------------------------------------------------------------- 1 | 2 | import { deepStrictEqual, equal } from 'node:assert'; 3 | import { readFile } from 'node:fs/promises'; 4 | import { Buffer } from 'node:buffer'; 5 | import { create, CODEC_RAW, toString as stringifyCID } from '@atcute/cid'; 6 | import CarTileReader from '../car-reader.js'; 7 | import makeRel from "../lib/rel.js"; 8 | import { rickMeta } from './data.js'; 9 | 10 | const rel = makeRel(import.meta.url); 11 | const rickTile = rel('./fixtures/rick.tile'); 12 | 13 | describe('Reading tiles from CAR', () => { 14 | it('reads a tile correctly', async () => { 15 | const ctr = new CarTileReader(rickTile); 16 | await ctr.open(); 17 | deepStrictEqual(rickMeta, ctr.meta, 'metadata is read correctly'); 18 | // 404 19 | const nf = ctr.resolvePath('/not/exists'); 20 | equal(nf.ok, false, 'not found not ok'); 21 | equal(nf.status, 404, 'not found 404'); 22 | // root 23 | const idx = await readFile(rel('./fixtures/rick/index.html'), 'utf8'); 24 | const root = ctr.resolvePath('/'); 25 | equal(root.ok, true, 'root ok'); 26 | equal(root.status, 200, 'root 200'); 27 | deepStrictEqual(root.headers, { 'content-type': 'text/html' }, 'root headers'); 28 | const stream = root.createReadStream(); 29 | const got = await slurpString(stream); 30 | equal(got, idx, 'resource is correct'); 31 | // ? 32 | const qs = ctr.resolvePath('/?with=a;query=string'); 33 | equal(qs.ok, true, 'qs ok'); 34 | equal(qs.status, 200, 'qs 200'); 35 | deepStrictEqual(qs.headers, { 'content-type': 'text/html' }, 'qs headers'); 36 | // image 37 | const img = ctr.resolvePath('/img/rick.jpg'); 38 | equal(img.ok, true, 'img ok'); 39 | equal(img.status, 200, 'img 200'); 40 | deepStrictEqual(img.headers, { 'content-type': 'image/jpeg' }, 'img headers'); 41 | const imgStream = img.createReadStream(); 42 | const buf = await slurpBuffer(imgStream); 43 | const cid = await create(CODEC_RAW, buf); 44 | equal(stringifyCID(cid), ctr.meta.resources['/img/rick.jpg'].src.$link, 'CID matches image'); 45 | ctr.close(); 46 | }); 47 | }); 48 | 49 | async function slurpBuffer (stream) { 50 | const chunks = []; 51 | return new Promise((resolve, reject) => { 52 | stream.on('data', (chunk) => chunks.push(Buffer.from(chunk))); 53 | stream.on('error', reject); 54 | stream.on('end', () => resolve(Buffer.concat(chunks))); 55 | }); 56 | } 57 | 58 | async function slurpString (stream) { 59 | const buf = await slurpBuffer(stream); 60 | return buf.toString('utf8'); 61 | } 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Snowpack dependency directory (https://snowpack.dev/) 45 | web_modules/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Optional stylelint cache 57 | .stylelintcache 58 | 59 | # Optional REPL history 60 | .node_repl_history 61 | 62 | # Output of 'npm pack' 63 | *.tgz 64 | 65 | # Yarn Integrity file 66 | .yarn-integrity 67 | 68 | # dotenv environment variable files 69 | .env 70 | .env.* 71 | !.env.example 72 | 73 | # parcel-bundler cache (https://parceljs.org/) 74 | .cache 75 | .parcel-cache 76 | 77 | # Next.js build output 78 | .next 79 | out 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and not Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # vuepress v2.x temp and cache directory 95 | .temp 96 | .cache 97 | 98 | # Sveltekit cache directory 99 | .svelte-kit/ 100 | 101 | # vitepress build output 102 | **/.vitepress/dist 103 | 104 | # vitepress cache directory 105 | **/.vitepress/cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # Firebase cache directory 120 | .firebase/ 121 | 122 | # TernJS port file 123 | .tern-port 124 | 125 | # Stores VSCode versions used for testing VSCode extensions 126 | .vscode-test 127 | 128 | # yarn v3 129 | .pnp.* 130 | .yarn/* 131 | !.yarn/patches 132 | !.yarn/plugins 133 | !.yarn/releases 134 | !.yarn/sdks 135 | !.yarn/versions 136 | 137 | # Vite logs files 138 | vite.config.js.timestamp-* 139 | vite.config.ts.timestamp-* 140 | scratch/ 141 | -------------------------------------------------------------------------------- /loader/public/shuttle.js: -------------------------------------------------------------------------------- 1 | 2 | /* 3 | ████████╗██╗██╗ ███████╗███████╗ 4 | ╚══██╔══╝██║██║ ██╔════╝██╔════╝ 5 | ██║ ██║██║ █████╗ ███████╗ 6 | ██║ ██║██║ ██╔══╝ ╚════██║ 7 | ██║ ██║███████╗███████╗███████║ 8 | ╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝ 9 | •--~~~## SHUTTLE ##~~~--• 10 | 11 | This script loads in the context that the mothership creates, sets up the 12 | worker, and triggers the iframe that the worker operates on. 13 | 14 | Other than that, it passes messages between the worker and the mothership. 15 | */ 16 | 17 | let worker; // instance of the worker 18 | const { promise: readyToLoad, resolve: resolveReadyToLoad } = Promise.withResolvers(); 19 | 20 | const PFX = 'tiles-shuttle-'; 21 | const RCV_LOAD = `${PFX}load`; // mothership tells us to start loading 22 | const SND_READY = `${PFX}ready`; // tell mothership we're loaded and ready 23 | 24 | // XXX TODO 25 | // - If there's a way to pass references around, get the worker and mothership 26 | // to talk directly instead of through us. But this is an optimisation, only 27 | // do it once this fully works. 28 | // - One thing that is worth implementing here (unless the mothership has 29 | // direct script access to our window — this depends on which arrangement of 30 | // sandboxing we use) is setting the title and icon (for the windowed case). 31 | // - We should receive instructions from the mothership to distinguish 32 | // between loading the active tile and rendering its card. This would means 33 | // that we need to communicate readiness to the mothership and receive 34 | // local instructions, it also probably means that we need to be able to 35 | // convey size instructions of our own based on card rendering. 36 | 37 | // --- Communicating with worker and mothership 38 | // - mothership -> worker 39 | window.addEventListener('message', async (ev) => { 40 | const { action, id } = ev.data; 41 | if (action?.startsWith(PFX)) { 42 | if (action === RCV_LOAD) { 43 | await loadWorker(); 44 | window.parent.postMessage({ id, action: SND_READY }, '*'); 45 | } 46 | } 47 | else { 48 | await readyToLoad; 49 | worker.postMessage(ev.data); 50 | } 51 | }); 52 | // - worker -> mothership 53 | navigator.serviceWorker.onmessage = (ev) => { 54 | window.parent.postMessage(ev.data, '*'); 55 | } 56 | 57 | // Let's goooo 58 | async function loadWorker () { 59 | // social justice worker 60 | let curSWReg; 61 | try { 62 | curSWReg = await navigator.serviceWorker.getRegistration(); 63 | } 64 | catch (err) { 65 | console.warn(`Failed to get worker registration`, err); 66 | } 67 | if (!curSWReg) { 68 | curSWReg = await navigator.serviceWorker.register('worker.js', { scope: '/' }); 69 | await navigator.serviceWorker.ready; 70 | } 71 | navigator.serviceWorker.onmessage 72 | worker = curSWReg.active; 73 | worker.onerror = (ev) => console.error(`SW Error:`, ev); // XXX probably push up 74 | resolveReadyToLoad(); 75 | renderWorkerFrame(); 76 | } 77 | 78 | function renderWorkerFrame () { 79 | setTimeout( 80 | () => { 81 | const ifr = document.createElement('iframe'); 82 | ifr.setAttribute('src', '/'); 83 | ifr.setAttribute('frameborder', '0'); // oh hell yeah 84 | document.body.appendChild(ifr); 85 | }, 86 | 0 87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /writer.js: -------------------------------------------------------------------------------- 1 | 2 | import { readFile, open } from 'node:fs/promises'; 3 | import { file as tmpFile } from 'tmp-promise'; 4 | import { create, CODEC_RAW, toString as stringifyCID, toCidLink } from '@atcute/cid'; 5 | import { encode as encodeVarInt } from '@atcute/varint'; 6 | import { encode as encodeDRISL } from '@atcute/cbor'; 7 | 8 | // manifest: 9 | // - [ ] background_color 10 | // - [ ] categories 11 | // - [ ] description 12 | // - [ ] icons 13 | // - [ ] id 14 | // - [ ] name 15 | // - [ ] screenshots 16 | // - [ ] short_name 17 | // - [ ] theme_color 18 | // tiles: 19 | // - [ ] resources 20 | // - [ ] prev 21 | // - [ ] sizing 22 | // car (ignore on read, force on write): 23 | // - [ ] version 24 | // - [ ] roots 25 | // HTTP: 26 | // - [ ] content-disposition 27 | // - [ ] content-encoding 28 | // - [ ] content-language 29 | // - [ ] content-security-policy 30 | // - [ ] content-type 31 | // - [ ] link 32 | // - [ ] permissions-policy 33 | // - [ ] referrer-policy 34 | // - [ ] service-worker-allowed 35 | // - [ ] sourcemap // verify that this points in the resource map 36 | // - [ ] speculation-rules // verify that this points in the resource map 37 | // - [ ] supports-loading-mode 38 | // - [ ] x-content-type-options 39 | 40 | export const supportedHTTPHeaders = new Set([ 41 | // 'content-disposition', 42 | // 'content-encoding', 43 | // 'content-language', 44 | // 'content-security-policy', 45 | 'content-type', 46 | // 'link', 47 | // 'permissions-policy', 48 | // 'referrer-policy', 49 | // 'service-worker-allowed', 50 | // 'sourcemap', 51 | // 'speculation-rules', 52 | // 'supports-loading-mode', 53 | // 'x-content-type-options', 54 | ]); 55 | export default class TileWriter { 56 | #masl; 57 | #resMap = {}; 58 | constructor (masl) { 59 | this.setMASL(masl); 60 | } 61 | setMASL (masl) { 62 | if (masl.src) throw new Error(`The MASL metadata contains an 'src', it is intended for single-resource metadata, not a tile`); 63 | // XXX 64 | // - we should probably ignore resources here 65 | // - icons and screenshots need to be resources (later) 66 | this.#masl = masl; 67 | } 68 | addResource (path, headers, src) { 69 | if (!this.#masl) this.#masl = {}; 70 | if (!this.#masl.resources) this.#masl.resources = {}; 71 | path = (new URL(`fake:${path}`)).pathname; 72 | Object.keys(headers).forEach(k => { 73 | if (!supportedHTTPHeaders.has(k)) throw new Error(`Unsupported header '${k}'`); 74 | }); 75 | this.#masl.resources[path] = { ...headers }; 76 | // this could be multiple things: 77 | // - { path: '/path/to file' } 78 | this.#resMap[path] = src; 79 | } 80 | async write (out) { 81 | const seenCIDs = new Set(); 82 | const { path: tmpPath, cleanup } = await tmpFile(); 83 | const tmp = await open(tmpPath, 'w+'); 84 | // for now, just sort / first but there will likely be other priorities 85 | const paths = Object.keys(this.#masl.resources).sort((a, b) => { 86 | if (a === '/') return -1; 87 | if (b === '/') return 1; 88 | return 0; 89 | }); 90 | for (const p of paths) { 91 | const buf = await readFile(this.#resMap[p].path); 92 | const cid = await create(CODEC_RAW, buf); 93 | this.#masl.resources[p].src = toCidLink(cid); 94 | const cidString = stringifyCID(cid); 95 | if (seenCIDs.has(cidString)) continue; 96 | seenCIDs.add(cidString); 97 | const size = []; 98 | encodeVarInt(36 + buf.length, size); 99 | await tmp.write(new Uint8Array(size)); 100 | await tmp.write(cid.bytes); 101 | await tmp.write(buf); 102 | } 103 | await tmp.close(); 104 | const outh = await open(out, 'w'); 105 | this.#masl.version = 1; 106 | this.#masl.roots = []; 107 | const meta = encodeDRISL(this.#masl); 108 | const size = []; 109 | encodeVarInt(meta.length, size); 110 | await outh.write(new Uint8Array(size)); 111 | await outh.write(meta); 112 | const tmpRead = await open(tmpPath); 113 | const w = outh.createWriteStream(); 114 | tmpRead.createReadStream().pipe(w); 115 | await new Promise((resolve, reject) => { 116 | w.on('close', resolve); 117 | w.on('error', reject); 118 | }); 119 | cleanup(); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /loader-experiment/mothership.js: -------------------------------------------------------------------------------- 1 | 2 | /* 3 | ████████╗██╗██╗ ███████╗███████╗ 4 | ╚══██╔══╝██║██║ ██╔════╝██╔════╝ 5 | ██║ ██║██║ █████╗ ███████╗ 6 | ██║ ██║██║ ██╔══╝ ╚════██║ 7 | ██║ ██║███████╗███████╗███████║ 8 | ╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝ 9 | •--~~~## MOTHERSHIP ##~~~--• 10 | 11 | The tile-loading architecture has three levels that all communicate together: 12 | 13 | - At the top, the MOTHERSHIP. This has access to things in the real world like 14 | fetching from the internet or reading from the file system. It's the interface 15 | to tile loading, it gets configured in ways that are appropriate for its 16 | context. This is the entry point: you give it a URL and it'll instantiate that 17 | tile. To the extent possible, this should contain all the intelligence and all 18 | the configurability so that the other components can be deployed in entirely 19 | generic ways. 20 | - The mothership instantiates tiles by creating insulated contexts (a sandboxed 21 | iframe, an incognito window…) and loading a SHUTTLE in it. The role of the 22 | shuttle is to set up a service worker and an iframe to load the root of the 23 | tile into. It only exists because you need something to carry a service worker 24 | in. The only other thing that it does is (*drumroll*) shuttle messages back 25 | and forth between the worker and the mothership. 26 | - The WORKER is dispatched on a shuttle to handle resource loading for a tile. 27 | Apart from allow-listing some paths for itself and the shuttle, it passes all 28 | requests up, which the shuttle then hands over to the mothership. 29 | */ 30 | 31 | 32 | // WHAT WE'RE DOING HERE 33 | // Note: this is experimental and only an approximation of how we want the final 34 | // product to work. 35 | // But the idea is to get this working bottom up: we get the worker to DTRT, 36 | // then the shuttle, and then the mothership. If this experiment can run 37 | // correctly, then we can start having it load real tiles. 38 | // Before trying real tiles we should make attempts to break containment. 39 | 40 | const SHUTTLE_PFX = 'tiles-shuttle-'; 41 | const SND_SHUTTLE_LOAD = `${SHUTTLE_PFX}load`; // tell worker to roll 42 | const RCV_SHUTTLE_READY = `${SHUTTLE_PFX}ready`; // worker ready 43 | const WORKER_PFX = 'tiles-worker-'; 44 | const SND_WORKER_LOAD = `${WORKER_PFX}load`; // tell worker to roll 45 | const RCV_WORKER_READY = `${WORKER_PFX}ready`; // worker ready 46 | const RCV_WORKER_REQUEST = `${WORKER_PFX}request`; // worker requested something 47 | const SND_WORKER_RESPONSE = `${WORKER_PFX}response`; // respond to a worker 48 | const WORKER_WARNING = `${WORKER_PFX}warn`; // worker warngs 49 | 50 | const id2shuttle = new Map(); 51 | function sendToShuttle (id, action, payload) { 52 | console.warn(`sendToShuttle`, id, action, payload); 53 | const ifr = id2shuttle.get(id); 54 | if (!ifr) return console.error(`No shuttle for ID ${id}`); 55 | ifr.contentWindow.postMessage({ id, action, payload }, '*'); 56 | } 57 | window.addEventListener('message', async (ev) => { 58 | const { action } = ev.data || {}; 59 | if (action === WORKER_WARNING) { 60 | const { msg, id } = ev.data; 61 | console.warn(`[W:${id}]`, ...msg); 62 | } 63 | else if (action === RCV_SHUTTLE_READY) { 64 | const { id } = ev.data; 65 | console.info(`[W:${id}] shuttle ready!`); 66 | sendToShuttle(id, SND_WORKER_LOAD, { id }); 67 | } 68 | else if (action === RCV_WORKER_READY) { 69 | const { id } = ev.data; 70 | console.info(`[W:${id}] worker ready!`); 71 | } 72 | else if (action === RCV_WORKER_REQUEST) { 73 | const { type, id, payload } = ev.data; 74 | if (type === 'resolve-path') { 75 | const { path, requestId } = payload; 76 | let status = 200; 77 | let headers = {}; 78 | let body; 79 | // if (path === '/') { 80 | if (path === '/') { 81 | headers['content-type'] = 'text/html'; 82 | body = `Tile! 83 |

hi!

`; 84 | } 85 | else if (path === '/style.css') { 86 | headers['content-type'] = 'text/css'; 87 | body = `body { background: ${id}; }\n`; 88 | } 89 | else { 90 | status = 404; 91 | headers['content-type'] = 'text/plain'; 92 | body = `BOOM — not found…\n`; 93 | } 94 | sendToShuttle(id, SND_WORKER_RESPONSE, { requestId, response: { status, headers, body: (new TextEncoder()).encode(body) } }); 95 | } 96 | } 97 | }); 98 | 99 | [ 100 | 'oklch(69.3% 0.151 180)', 101 | 'oklch(79.3% 0.136 270)', 102 | 'oklch(54.3% 0.091 270)', 103 | 'oklch(74.3% 0.143 0.31)', 104 | 'oklch(89.3% 0.121 90.3)', 105 | ].forEach(c => { 106 | const ifr = document.createElement('iframe'); 107 | ifr.setAttribute('width', '300'); 108 | ifr.setAttribute('height', '300'); 109 | ifr.setAttribute('data-colour', c); 110 | // ifr.setAttribute('sandbox', 'allow-scripts'); 111 | document.body.appendChild(ifr); 112 | id2shuttle.set(c, ifr); 113 | ifr.onload = () => sendToShuttle(c, SND_SHUTTLE_LOAD, { id: c }); 114 | ifr.setAttribute('src', 'https://load.webtiles.bast/.well-known/web-tiles/'); 115 | // ifr.setAttribute('src', 'loader.html'); 116 | } 117 | ); 118 | -------------------------------------------------------------------------------- /loader/public/worker.js: -------------------------------------------------------------------------------- 1 | 2 | /* 3 | ████████╗██╗██╗ ███████╗███████╗ 4 | ╚══██╔══╝██║██║ ██╔════╝██╔════╝ 5 | ██║ ██║██║ █████╗ ███████╗ 6 | ██║ ██║██║ ██╔══╝ ╚════██║ 7 | ██║ ██║███████╗███████╗███████║ 8 | ╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝ 9 | •--~~~## SERVICE WORKER ##~~~--• 10 | 11 | This is a very simple Service Worker, almost all complexity that it might have 12 | had has been removed because life is too short to debug badly designed APIs. 13 | 14 | After being instantiated in the context that will load tile content (either the 15 | full tile or its card rendering), the following steps are expected: 16 | 17 | - It waits for a `action: tiles-worker-load` event with an associated unique `id` 18 | that we'll use to communicate up in case we need to be disambiguated. 19 | - It responds with `action: tiles-worker-ready`. 20 | - It can then communicate to the shuttle by messaging it with 21 | `action: tiles-worker-request`, with associated `type` for the request type and 22 | `payload` with whatever needed payload. 23 | - The shuttle responds with `action: tiles-worker-response` that also contains 24 | `payload` (if successful) or `error` (a string, if not). 25 | - Occasionally, an `action: tiles-worker-warn` message is sent, with an attached `msg` array 26 | of strings and worker `id`. This is so the container can warn, for debugging 27 | purposes. 28 | 29 | There is only one request type at this point, the type of which is `resolve-path` 30 | and data for which is the `path` being resolved (which may include a query string). 31 | 32 | It's worth noting that anything starting with `/.well-known/web-tiles/` the SW 33 | will treat as passthrough. This is the path that we load all support content from 34 | (including the index.html that loads us). 35 | */ 36 | 37 | 38 | let id; // keep track of our id so the shuttle knows who we are when we talk 39 | let shuttle; // hold on to the source so we can initiate sending up 40 | const { promise: readyToLoad, resolve: resolveReadyToLoad } = Promise.withResolvers(); 41 | 42 | // All of the below are described as communicating with the mothership, but it's 43 | // mediated by the shuttle. 44 | const PFX = 'tiles-worker-'; 45 | const RCV_LOAD = `${PFX}load`; // mothership tells us to start loading 46 | const SND_READY = `${PFX}ready`; // tell mothership we're loaded and ready 47 | const SND_REQUEST = `${PFX}request`; // request something from mothership 48 | const RCV_RESPONSE = `${PFX}response`; // mothership responds to a request 49 | const SND_WARNING = `${PFX}warn`; // warn mothership 50 | 51 | self.skipWaiting(); 52 | 53 | // --- Communicating with the shuttle 54 | const requestMap = new Map(); 55 | let currentRequest = 0; 56 | async function request (type, payload) { 57 | currentRequest++; 58 | warn(`[SW] current request ${currentRequest}`); 59 | const p = new Promise((resolve, reject) => { 60 | requestMap.set(currentRequest, { resolve, reject }); 61 | }); 62 | warn(`[SW] promise ready`, p); 63 | shuttle.postMessage({ action: SND_REQUEST, id, type, payload: { requestId: currentRequest, ...payload } }); 64 | warn(`[SW] posted to source…`); 65 | return p; 66 | } 67 | self.addEventListener('message', async (ev) => { 68 | warn(`[SW] MESSAGE`, ev.data); 69 | const { action } = ev.data || {}; 70 | if (!action) return; 71 | if (action === RCV_LOAD) { 72 | id = ev.data.id; 73 | resolveReadyToLoad(); 74 | shuttle = ev.source; 75 | ev.source.postMessage({ action: SND_READY, id }); 76 | } 77 | else if (action === RCV_RESPONSE) { 78 | const { payload, error } = ev.data; 79 | const { requestId } = payload; 80 | warn(`[SW] WORKER GOT RESPONSE ${requestId}`); 81 | if (!requestMap.has(requestId)) return console.error(`No response ID for "${requestId}".`); 82 | warn(`[SW] - had response ID`, requestMap.get(requestId)?.resolve?.toString()); 83 | const { resolve, reject } = requestMap.get(requestId); 84 | warn(`[SW] - have functions, will delete`) 85 | requestMap.delete(requestId); 86 | warn(`[SW] - error? ${error}`); 87 | if (error) return reject(error); 88 | warn(`[SW] - resolving`, payload); // XXX I think this nests response 89 | resolve(payload.response); 90 | } 91 | }); 92 | 93 | self.addEventListener('fetch', async (ev) => { 94 | warn('~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~'); 95 | warn(`FETCH of "${ev.request.url}"`); 96 | const url = new URL(ev.request.url); 97 | // IMPORTANT 98 | // We have to let this through since we do need to load the loader. But it means that tiles 99 | // can themselves load anything in loader space. 100 | if (/^\/\.well-known\/web-tiles\//.test(url.pathname)) return; 101 | warn('waiting to be ready to load…'); 102 | await readyToLoad; 103 | warn(`ready — has id? ${id}`); 104 | if (!id) return ev.respondWith(new Response('Not in a loaded state.', response())); 105 | // IMPORTANT: Here we have to be careful not to have a nested await (of a fetch at least). 106 | warn(`respondWith`); 107 | const { promise, resolve, reject } = Promise.withResolvers(); 108 | ev.respondWith(promise); 109 | warn(`making request`); 110 | try { 111 | const r = await request('resolve-path', { path: url.pathname }); // XXX this may be a nested await, delete this comment if it works 112 | warn(`got r `, r); 113 | warn(`res`, response(r.status, r.headers)); 114 | warn(`bod`, bodify(r.body)); 115 | // warn(`• fetch ${res.src.$link} got ${r.status}`) 116 | resolve(new Response(bodify(r.body), response(r.status, r.headers))); 117 | } 118 | catch (err) { 119 | reject(err); // XXX should get the error message out of this 120 | } 121 | }); 122 | 123 | function response (status = 200, headers = { 'content-type': 'text/plain' }) { 124 | return { 125 | status, 126 | headers, 127 | }; 128 | } 129 | 130 | // Tauri seems to turn Uint8Array into an Array, which isn't good. 131 | function bodify (body) { 132 | return Array.isArray(body) ? new Uint8Array(body) : body; 133 | } 134 | 135 | async function warn (...msg) { 136 | console.warn(...msg); 137 | if (!shuttle) return; 138 | shuttle.postMessage({ action: SND_WARNING, msg, id }); 139 | } 140 | -------------------------------------------------------------------------------- /tile-loader.js: -------------------------------------------------------------------------------- 1 | 2 | /* 3 | ████████╗██╗██╗ ███████╗███████╗ 4 | ╚══██╔══╝██║██║ ██╔════╝██╔════╝ 5 | ██║ ██║██║ █████╗ ███████╗ 6 | ██║ ██║██║ ██╔══╝ ╚════██║ 7 | ██║ ██║███████╗███████╗███████║ 8 | ╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝ 9 | •--~~~## MOTHERSHIP ##~~~--• 10 | 11 | The tile-loading architecture has three levels that all communicate together: 12 | 13 | - At the top, the MOTHERSHIP. This has access to things in the real world like 14 | fetching from the internet or reading from the file system. It's the interface 15 | to tile loading, it gets configured in ways that are appropriate for its 16 | context. This is the entry point: you give it a URL and it'll instantiate that 17 | tile. To the extent possible, this should contain all the intelligence and all 18 | the configurability so that the other components can be deployed in entirely 19 | generic ways. 20 | - The mothership instantiates tiles by creating insulated contexts (a sandboxed 21 | iframe, an incognito window…) and loading a SHUTTLE in it. The role of the 22 | shuttle is to set up a service worker and an iframe to load the root of the 23 | tile into. It only exists because you need something to carry a service worker 24 | in. The only other thing that it does is (*drumroll*) shuttle messages back 25 | and forth between the worker and the mothership. 26 | - The WORKER is dispatched on a shuttle to handle resource loading for a tile. 27 | Apart from allow-listing some paths for itself and the shuttle, it passes all 28 | requests up, which the shuttle then hands over to the mothership. 29 | */ 30 | 31 | export class TileLoader { 32 | #loaders = []; 33 | // Adds a loader that will handle matching requests to load a tile. 34 | // - `loader` is an object that knows how to load a tile for a specific scheme 35 | // (and types) 36 | addLoader (loader) { 37 | this.#loaders.push(loader); 38 | } 39 | // Remove using same reference. 40 | removeLoader (loader) { 41 | this.#loaders = this.#loaders.filter(ldr => ldr !== loader); 42 | } 43 | // Load a tile. 44 | async loadTile (url) { 45 | let tile = false; 46 | for (const ldr of this.#loaders) { 47 | tile = await ldr.load(url); 48 | if (tile) break; 49 | } 50 | return tile; 51 | } 52 | } 53 | 54 | export class Tile { 55 | #url; 56 | #manifest; 57 | #pathLoader; 58 | constructor (url, manifest, pathLoader) { 59 | this.#url = url; 60 | this.#manifest = manifest; 61 | this.#pathLoader = pathLoader; 62 | } 63 | get url () { 64 | return this.#url; 65 | } 66 | get manifest () { 67 | return this.#manifest; 68 | } 69 | async resolvePath (path) { 70 | return this.#pathLoader.resolvePath(path); 71 | } 72 | } 73 | 74 | // ############################################ 75 | // ########################################## 76 | // #### NEXT STEPS ######################## 77 | // ########################################## 78 | // ############################################ 79 | // 80 | // - have the mothership change to being an experiment loading actual tiles 81 | // - support renderCard (pluggable too) 82 | // - implement the loaders one by one 83 | // - try each, stick to browser environments for now (we can add e.g. Tauri later) 84 | // - when it works, refactor 85 | // - publish to npm with the right metadata 86 | // - make a website with demo 87 | // - DASL spec 88 | // - WAG meeting 89 | 90 | 91 | 92 | // ----- specific loaders (refactor later) 93 | export class ATTileLoader { 94 | async load (url) { 95 | const u = new URL(url); 96 | if (u.protocol !== 'at:') return false; 97 | // XXX 98 | // - also check that the collection is correct 99 | // - load the record 100 | // - fail if it's not a valid tile record 101 | // - create a Tile with the right manifest, the url, and a way to load a path 102 | } 103 | } 104 | 105 | export class ATPathLoader { 106 | #did; 107 | #manifest; 108 | constructor (did, manifest) { 109 | this.#did = did; 110 | this.#manifest = manifest; 111 | } 112 | async resolvePath (path) { 113 | // XXX 114 | // - use the manifest to find the CID (make sure to ignore search & hash) 115 | // - use the DID to know where to load it from 116 | // - call for the blob 117 | // - make the right response with the media type and all 118 | } 119 | } 120 | 121 | // Here the idea is that you can load from multiple schemes, but you might not 122 | // want to. 123 | export class ContentSchemeTileLoader { 124 | #schemes; 125 | constructor (schemes = ['http', 'file']) { 126 | this.#schemes = new Set(schemes); 127 | } 128 | async load (url) { 129 | const u = new URL(url); 130 | if (u.protocol === 'https:' || u.protocol === 'http:') { 131 | if (!this.#schemes.has('http')) return false; 132 | // XXX 133 | // - get the data 134 | // - if it's not a zip file, return false 135 | // - give it to processContent 136 | } 137 | if (u.protocol === 'file:') { 138 | if (!this.#schemes.has('file')) return false; 139 | // XXX 140 | // - get the data 141 | // - if it's not a zip file, return false 142 | // - give it to processContent 143 | } 144 | } 145 | // async processZip (zipData) { 146 | // // XXX 147 | // // - generate a synthetic manifest 148 | // // - make a path loader that will point to the right part 149 | // } 150 | } 151 | 152 | export class WebXDCTileLoader extends ContentSchemeTileLoader { 153 | constructor (schemes) { 154 | super(schemes); 155 | } 156 | async processContent (zipData, scheme) { 157 | // XXX 158 | // - generate a synthetic manifest 159 | // - make a path loader that will point to the right part 160 | // - scheme doesn't matter because we always do this in memory 161 | } 162 | } 163 | 164 | export class CARTileLoader extends ContentSchemeTileLoader { 165 | constructor (schemes) { 166 | super(schemes); 167 | } 168 | async processContent (car, scheme) { 169 | // XXX 170 | // - for http we assume that we have the file in memory (we could do range 171 | // requests but let's not right now) 172 | // - for file, we scan and save offsets 173 | // - extract the manifest 174 | // - make a path loader that will point to the right part depending on scheme 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /loader-experiment/tile-loader.js: -------------------------------------------------------------------------------- 1 | 2 | /* 3 | ████████╗██╗██╗ ███████╗███████╗ 4 | ╚══██╔══╝██║██║ ██╔════╝██╔════╝ 5 | ██║ ██║██║ █████╗ ███████╗ 6 | ██║ ██║██║ ██╔══╝ ╚════██║ 7 | ██║ ██║███████╗███████╗███████║ 8 | ╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝ 9 | •--~~~## MOTHERSHIP ##~~~--• 10 | 11 | The tile-loading architecture has three levels that all communicate together: 12 | 13 | - At the top, the MOTHERSHIP. This has access to things in the real world like 14 | fetching from the internet or reading from the file system. It's the interface 15 | to tile loading, it gets configured in ways that are appropriate for its 16 | context. This is the entry point: you give it a URL and it'll instantiate that 17 | tile. To the extent possible, this should contain all the intelligence and all 18 | the configurability so that the other components can be deployed in entirely 19 | generic ways. 20 | - The mothership instantiates tiles by creating insulated contexts (a sandboxed 21 | iframe, an incognito window…) and loading a SHUTTLE in it. The role of the 22 | shuttle is to set up a service worker and an iframe to load the root of the 23 | tile into. It only exists because you need something to carry a service worker 24 | in. The only other thing that it does is (*drumroll*) shuttle messages back 25 | and forth between the worker and the mothership. 26 | - The WORKER is dispatched on a shuttle to handle resource loading for a tile. 27 | Apart from allow-listing some paths for itself and the shuttle, it passes all 28 | requests up, which the shuttle then hands over to the mothership. 29 | */ 30 | 31 | export class TileLoader { 32 | #loaders = []; 33 | // Adds a loader that will handle matching requests to load a tile. 34 | // - `loader` is an object that knows how to load a tile for a specific scheme 35 | // (and types) 36 | addLoader (loader) { 37 | this.#loaders.push(loader); 38 | } 39 | // Remove using same reference. 40 | removeLoader (loader) { 41 | this.#loaders = this.#loaders.filter(ldr => ldr !== loader); 42 | } 43 | // Load a tile. 44 | async loadTile (url) { 45 | let tile = false; 46 | for (const ldr of this.#loaders) { 47 | tile = await ldr.load(url); 48 | if (tile) break; 49 | } 50 | return tile; 51 | } 52 | } 53 | 54 | export class Tile { 55 | #url; 56 | #manifest; 57 | #pathLoader; 58 | constructor (url, manifest, pathLoader) { 59 | this.#url = url; 60 | this.#manifest = manifest; 61 | this.#pathLoader = pathLoader; 62 | } 63 | get url () { 64 | return this.#url; 65 | } 66 | get manifest () { 67 | return this.#manifest; 68 | } 69 | async resolvePath (path) { 70 | return this.#pathLoader.resolvePath(path); 71 | } 72 | } 73 | 74 | // ############################################ 75 | // ########################################## 76 | // #### NEXT STEPS ######################## 77 | // ########################################## 78 | // ############################################ 79 | // 80 | // - have the mothership change to being an experiment loading actual tiles 81 | // - support renderCard (pluggable too) 82 | // - implement the loaders one by one 83 | // - try each, stick to browser environments for now (we can add e.g. Tauri later) 84 | // - when it works, refactor 85 | // - publish to npm with the right metadata 86 | // - make a website with demo 87 | // - DASL spec 88 | // - WAG meeting 89 | 90 | 91 | 92 | // ----- specific loaders (refactor later) 93 | export class ATTileLoader { 94 | async load (url) { 95 | const u = new URL(url); 96 | if (u.protocol !== 'at:') return false; 97 | // XXX 98 | // - also check that the collection is correct 99 | // - load the record 100 | // - fail if it's not a valid tile record 101 | // - create a Tile with the right manifest, the url, and a way to load a path 102 | } 103 | } 104 | 105 | export class ATPathLoader { 106 | #did; 107 | #manifest; 108 | constructor (did, manifest) { 109 | this.#did = did; 110 | this.#manifest = manifest; 111 | } 112 | async resolvePath (path) { 113 | // XXX 114 | // - use the manifest to find the CID (make sure to ignore search & hash) 115 | // - use the DID to know where to load it from 116 | // - call for the blob 117 | // - make the right response with the media type and all 118 | } 119 | } 120 | 121 | // Here the idea is that you can load from multiple schemes, but you might not 122 | // want to. 123 | export class ContentSchemeTileLoader { 124 | #schemes; 125 | constructor (schemes = ['http', 'file']) { 126 | this.#schemes = new Set(schemes); 127 | } 128 | async load (url) { 129 | const u = new URL(url); 130 | if (u.protocol === 'https:' || u.protocol === 'http:') { 131 | if (!this.#schemes.has('http')) return false; 132 | // XXX 133 | // - get the data 134 | // - if it's not a zip file, return false 135 | // - give it to processContent 136 | } 137 | if (u.protocol === 'file:') { 138 | if (!this.#schemes.has('file')) return false; 139 | // XXX 140 | // - get the data 141 | // - if it's not a zip file, return false 142 | // - give it to processContent 143 | } 144 | } 145 | // async processZip (zipData) { 146 | // // XXX 147 | // // - generate a synthetic manifest 148 | // // - make a path loader that will point to the right part 149 | // } 150 | } 151 | 152 | export class WebXDCTileLoader extends ContentSchemeTileLoader { 153 | constructor (schemes) { 154 | super(schemes); 155 | } 156 | async processContent (zipData, scheme) { 157 | // XXX 158 | // - generate a synthetic manifest 159 | // - make a path loader that will point to the right part 160 | // - scheme doesn't matter because we always do this in memory 161 | } 162 | } 163 | 164 | export class CARTileLoader extends ContentSchemeTileLoader { 165 | constructor (schemes) { 166 | super(schemes); 167 | } 168 | async processContent (car, scheme) { 169 | // XXX 170 | // - for http we assume that we have the file in memory (we could do range 171 | // requests but let's not right now) 172 | // - for file, we scan and save offsets 173 | // - extract the manifest 174 | // - make a path loader that will point to the right part depending on scheme 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------